├── README.md └── getlibs.py /README.md: -------------------------------------------------------------------------------- 1 | # Getlibs 2 | This is a simple script to pull the libraries of binaries for pwn challenges in CTFs from docker containers. 3 | 4 | It uses the Docker SDK for python, you can install it with `pip install docker`. 5 | 6 | Just enter the folder with the challenge files, where the Dockerfile and/or docker-compose.yml are stored, and run this script. 7 | 8 | You must tell it the port that the container exposes the challenge at, like this: `getlibs -p=1337`. 9 | 10 | It tries to detect whether pwn.red/jail is used from the Dockerfile, and automatically runs the container as privileged if so, and also adjusts the paths it takes the libraries from, but you can also run it with the `--privileged` flag 11 | -------------------------------------------------------------------------------- /getlibs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import docker 4 | import argparse 5 | import socket 6 | import time 7 | import os 8 | import subprocess 9 | import docker.errors 10 | import docker.models.containers 11 | import psutil 12 | import atexit 13 | import shlex 14 | 15 | def get_procs(container: docker.models.containers.Container) -> list[psutil.Process]: 16 | top = container.top() 17 | procs = top["Processes"] 18 | pids = [int(proc[1]) for proc in procs] 19 | return [psutil.Process(pid) for pid in pids] 20 | 21 | def choose_proc(before: list[psutil.Process], after: list[psutil.Process], args: argparse.Namespace) -> psutil.Process|None: 22 | new = list(set(after) - set(before)) 23 | print(f"New PIDs: {[proc.pid for proc in new]}") 24 | 25 | if len(new) == 1: 26 | return new[0] 27 | elif len(new) == 0: 28 | print("Warning: no new processes found, this might be because the challenge doesn't spawn a process for each connection; selecting from already existing processes") 29 | new = before 30 | 31 | if not args.no_proc_heuristics: 32 | """ 33 | could use a command blacklist for socat, socaz, nsjail, xinetd, ecc... 34 | but we can also bypass these using a more general solution of looking at parents and children 35 | neither of these work too well for a binary that spawns a child with different libs, and you might want to 36 | get both of their libs, but that's what this option is for: you can disable it and explicit the target process 37 | 38 | just getting the highest pid doesn't work because they wrap around after reaching a pid_max value (/proc/sys/kernel/pid_max) 39 | pid_max isn't necessarily that high; it's 4M~ on my pc, 32k~ & 64k~ for many other people 40 | i get 600k as pids right now and i've started my computer just a few hours ago :P 41 | it would be very rare for pids to wrap around HERE, but if it didn't happen and it wasn't handled, someone would have a very bad day if they didn't notice :/ 42 | """ 43 | 44 | print("Warning: the proc-heuristics option is set to TRUE (default); using heuristics to determine the target process. Disable with --no-proc-heuristics") 45 | leaves = [proc for proc in new if not proc.children()] 46 | if len(leaves) == 1: 47 | return leaves[0] 48 | 49 | print(f"Couldn't determine target process; multiple 'leaf' (childless) processes found: {leaves}") 50 | print("Reverting to manual selection") 51 | 52 | print("Multiple new processes detected, please choose the target PID:") 53 | print("\t-1: Stop the container") 54 | for proc in new: 55 | print(f"\t{proc.pid}: {' '.join(proc.cmdline())}") 56 | 57 | pid = int(input("PID: ")) 58 | if pid == -1: 59 | return None 60 | 61 | proc = next((proc for proc in new if proc.pid == pid), None) 62 | if proc is None: 63 | print("Invalid PID; stopping the container") 64 | 65 | return proc 66 | 67 | def is_pwnred() -> bool: 68 | if os.path.exists("./Dockerfile"): 69 | with open("./Dockerfile") as f: 70 | return "FROM pwn.red/jail" in f.read() 71 | 72 | return False 73 | 74 | def build_from_dockerfile(client: docker.DockerClient, args: argparse.Namespace) -> str: 75 | if is_pwnred(): 76 | args.privileged = True 77 | print("Detected pwn.red/jail Dockerfile, running as privileged") 78 | 79 | # run as subprocess so that the user can see the container buile 80 | subprocess.run(["docker", "build", "."]) 81 | # and then re run to get the image id 82 | img, _ = client.images.build(path=".") 83 | print(f"Image: {img.id}") 84 | 85 | container = client.containers.run(img, detach=True, ports={args.port: args.port}, privileged=args.privileged) 86 | return container.id 87 | 88 | def build_from_compose() -> str: 89 | subprocess.run(["docker", "compose", "up", "--build", "-d"]) 90 | # actually could get the container name from the docker compose subprocess stderr 91 | # i won't now cause this works and i think that might be 'unstable', 92 | # especially if the container immediately starts printing stuff 93 | result = subprocess.run(["docker", "compose", "ps", "-q"], capture_output=True) 94 | return result.stdout.decode().strip() 95 | 96 | def read_super(path: str) -> str: 97 | try: 98 | with open(path) as f: 99 | return f.read() 100 | except PermissionError: 101 | print("Error: Permission denied; trying again with sudo") 102 | return subprocess.run(["sudo", "cat", path], capture_output=True).stdout.decode() 103 | 104 | def get_libs(mapfile: str) -> list[tuple[str, int]]: 105 | # file structure is: 106 | # address perms offset dev inode pathname 107 | # pathname can be blank, for mmap()'d memory 108 | 109 | maps = [m.split() for m in mapfile.splitlines()] 110 | maps = [m for m in maps if len(m) == 6] # filter out mmap()'d memory 111 | libs = [(m[5], int(m[4])) for m in maps if not m[5].startswith('[')] # filter out [stack], [heap], [vdso], etc. 112 | libs = list(dict.fromkeys(libs)) # remove duplicates 113 | libs = libs[1:] # remove the executable itself (i hope this always works..) 114 | 115 | return libs 116 | 117 | def find_container_by_port(client: docker.DockerClient, port: int) -> docker.models.containers.Container: 118 | containers: list[docker.models.containers.Container] = client.containers.list() 119 | return next((c for c in containers if any(int(port_proto.split('/')[0]) == port for port_proto in c.ports)), None) 120 | 121 | def get_container(client: docker.DockerClient, args: argparse.Namespace) -> docker.models.containers.Container: 122 | is_compose = any(f.startswith("docker-compose") for f in os.listdir()) 123 | is_dockerfile = "Dockerfile" in os.listdir() 124 | 125 | if not is_compose and not is_dockerfile: 126 | print("No docker setup found; checking for live container") 127 | container = find_container_by_port(client, args.port) 128 | if container is None: 129 | print("Error: no live container found") 130 | exit(1) 131 | 132 | return container 133 | 134 | try: 135 | if is_compose: 136 | print("Detected docker-compose.yml") 137 | container_id = build_from_compose() 138 | elif is_dockerfile: 139 | print("Building Dockerfile") 140 | container_id = build_from_dockerfile(client, args) 141 | 142 | container = client.containers.get(container_id) 143 | atexit.register(lambda: container.kill()) 144 | print("Registered an atexit container killer") 145 | except docker.errors.APIError as err: 146 | if not err.is_server_error(): 147 | raise err 148 | elif "bind: address already in use" in err.explanation: 149 | print("Error: target port is already in use, most likely not by a container") 150 | exit(1) 151 | elif "port is already allocated" not in err.explanation: 152 | raise err 153 | 154 | print("Error: the target port is already allocated; finding target container") 155 | container = find_container_by_port(client, args.port) 156 | if container is None: 157 | print("Error: port is already allocated by a container, but it can't found") 158 | exit(1) 159 | 160 | return container 161 | 162 | def main(): 163 | parser = argparse.ArgumentParser() 164 | parser.add_argument("-p", "--port", help="Port to expose and connect to", type=int, required=True) 165 | parser.add_argument("--privileged", help="Run the container as privileged", action="store_true") 166 | parser.add_argument("--no-proc-heuristics", help="Disable usage of heuristics to determine the target process if unsure", action="store_true") 167 | args = parser.parse_args() 168 | 169 | client = docker.from_env() 170 | container = get_container(client, args) 171 | print(f"Container: {container.id}") 172 | 173 | before = get_procs(container) 174 | print(f"PIDs before connecting: {[proc.pid for proc in before]}") 175 | 176 | sleep_ms = 50 177 | print(f"Sleeping for {sleep_ms}ms to allow the container to start") 178 | time.sleep(sleep_ms / 1000) 179 | 180 | print(f"Connecting to localhost:{args.port}") 181 | sock = socket.socket() 182 | sock.connect(("localhost", args.port)) 183 | 184 | # Wait for the target process to start completely 185 | # Initially, this PID will be of the jail/socat/whatever process after it's fork()'ed 186 | # We want to wait for it to execve() our program before reading its maps, or we'll get the wrong libs 187 | sleep_ms = 50 188 | print(f"Sleeping for {sleep_ms}ms to allow the process to start completely") 189 | time.sleep(sleep_ms / 1000) 190 | 191 | after = get_procs(container) 192 | print(f"PIDs after connecting: {[proc.pid for proc in after]}") 193 | 194 | target_proc = choose_proc(before, after, args) 195 | if target_proc is None: 196 | return 197 | print(f"Target process: {target_proc.name()}") 198 | 199 | print("Reading process maps") 200 | maps = read_super(f"/proc/{target_proc.pid}/maps") 201 | libraries = get_libs(maps) 202 | lib_names = [lib[0].split('/')[-1] for lib in libraries] 203 | print(f"Libraries: {lib_names}") 204 | 205 | print("Copying libraries") 206 | inode_mismatches = [] 207 | for i in range(len(libraries)): 208 | path, inode = libraries[i] 209 | name = lib_names[i] 210 | 211 | print(f"Copying {name}") 212 | full_path = f"/proc/{target_proc.pid}/root{path}" 213 | 214 | # i guess the inode could be wrong if like /lib is mounted weird or something idk 215 | real_inode = os.stat(full_path).st_ino 216 | if real_inode != inode: 217 | print(f"ERROR: the inode for {name} is {inode}, but the inode of {full_path} is {real_inode}. Will resort to searching by inode with `find`") 218 | inode_mismatches.append(i) 219 | continue 220 | 221 | os.system(f"cp {full_path} .") 222 | 223 | if not inode_mismatches: 224 | return 225 | 226 | # sudo find / \( -inum -o -inum ... \) -printf '%i %p\n' 227 | # finds files with one of these inodes and printf first the inode and then the path; i want the inode too so i can avoid duplicates (hard links) 228 | cmd = ["sudo", "find", "/", "("] + " -o ".join(f"-inum {libraries[i][1]}" for i in inode_mismatches).split() + [")", "-printf", "%i %p\\n"] 229 | 230 | print(f"Ran into {len(inode_mismatches)} inode mismatches; searching with `{shlex.join(cmd)}` (slow)") 231 | files = {int(l.split()[0]) : l.split()[1] for l in subprocess.run(cmd, capture_output=True).stdout.decode().splitlines()} 232 | 233 | for i in inode_mismatches: 234 | print(f" - {lib_names[i]} found @ {files[libraries[i][1]]}") 235 | os.system(f"sudo cp {files[libraries[i][1]]} {lib_names[i]}") 236 | 237 | if __name__ == "__main__": 238 | main() 239 | --------------------------------------------------------------------------------