├── .gitignore ├── README.md ├── demo ├── alpine │ ├── Dockerfile │ ├── build_docker.sh │ └── demo.py ├── demo ├── demo.c ├── redpwn_jail │ ├── Dockerfile │ ├── build_docker.sh │ └── demo.py ├── run_all.sh └── ubuntu │ ├── Dockerfile │ ├── build_docker.sh │ └── demo.py ├── setup.py └── src └── docker_dbg ├── __init__.py ├── binaries ├── frpc ├── frps └── gdbserver └── docker_dbg.py /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | docker_dbg.egg-info 4 | frps.toml 5 | frpc.toml -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # docker-dbg 2 | 3 | Python package to effortlessly debug any process running in a x86_64 Linux docker container 4 | 5 | 6 | ## Demo 7 | 8 | https://github.com/junron/docker-dbg/assets/26194273/b2ed4956-8007-4035-8e01-92ab52f18369 9 | 10 | Checkout the [`demo`](./demo) directory for more demos. 11 | 12 | ## Installation 13 | ```shell 14 | pip install git+https://github.com/junron/docker-dbg 15 | ``` 16 | 17 | ## Usage 18 | 19 | 20 | 21 | 22 | 23 | Docker-dbg mirrors the `pwntools` [GDB module](https://docs.pwntools.com/en/stable/gdb.html)'s `debug` and `attach`: 24 | 25 | ```python 26 | from docker_dbg import * 27 | 28 | # Execute and debug the `/demo` binary in `container`: 29 | p, docker_process = docker_debug("/demo", container="container") 30 | 31 | # Attach to an existing process `demo` in `container`: 32 | docker_process = docker_attach(proc="demo", container="container") 33 | ``` 34 | 35 | `docker_process.gdb` provides access to the `pwntools` GDB module. The `docker_process.libc` provides access to the libc executing in the container. 36 | 37 | Checkout [`ubuntu/demo.py`](./demo/ubuntu/demo.py) for an example of `docker_debug` and [`redpwn_jail/demo.py`](./demo/redpwn_jail/demo.py) for `docker_attach`. 38 | 39 | 40 | ## How it works 41 | Docker-dbg copies `gdbserver` into the docker container, then uses [fast reverse proxy](https://github.com/fatedier/frp/) to proxy the `gdbserver` port out of the docker container. 42 | 43 | ## Dependencies 44 | 45 | ### On the host 46 | - Python 3 with `pwntools` 47 | - gdb 48 | - Docker (current user should be added to the `docker` group) 49 | 50 | ### In the container 51 | - `root` user 52 | - Commands: `cat`, `cp`, `chmod`, `ps`, `rm` 53 | - Optional: `killall`, `kill`, `ip`, `awk` 54 | - A functional `/proc` filesystem 55 | - `/` must be writable by `root` 56 | 57 | These requirements shouldn't be a problem for most Linux docker containers. 58 | 59 | 60 | ## Packaged binaries 61 | - Statically compiled `gdbserver` (13.2, compiled with `./configure CXXFLAGS="-fPIC -static" --disable-inprocess-agent `) 62 | - Statically compiled `frps` and `frpc` (v0.58.1, from https://github.com/fatedier/frp/releases/tag/v0.58.1) 63 | -------------------------------------------------------------------------------- /demo/alpine/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3 AS app 2 | 3 | RUN apk add gcc musl-dev 4 | 5 | COPY ./demo.c / 6 | 7 | RUN gcc /demo.c -o /demo 8 | 9 | CMD [ "sh", "-c", "while true; do sleep 30; done;" ] -------------------------------------------------------------------------------- /demo/alpine/build_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker kill demo-alpine 4 | cp ../demo.c . 5 | docker build . -t demo_alpine 6 | rm demo.c 7 | docker run --rm -d --name demo-alpine demo_alpine -------------------------------------------------------------------------------- /demo/alpine/demo.py: -------------------------------------------------------------------------------- 1 | from docker_dbg import * 2 | from pwn import * 3 | 4 | p, dp = docker_debug("/demo", container="demo-alpine") 5 | 6 | ld = dp.fetch_lib("/lib/ld-musl-x86_64.so.1") 7 | 8 | dp.brpt(ld.sym.printf) 9 | 10 | dp.gdb.wait() 11 | res = dp.gdb.execute("x/s $rdi", to_string=True) 12 | assert "What's your name?" in res, res 13 | dp.gdb.continue_nowait() 14 | 15 | p.sendlineafter(b">", b"World") 16 | dp.gdb.wait() 17 | 18 | res = dp.gdb.execute("x/s $rsi", to_string=True) 19 | assert "World" in res, res 20 | dp.gdb.continue_nowait() 21 | 22 | print(p.recvall().decode()) -------------------------------------------------------------------------------- /demo/demo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junron/docker-dbg/80e9e89ae0c126f94675137d0e182e7af0d5cd01/demo/demo -------------------------------------------------------------------------------- /demo/demo.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | 5 | void setup(){ 6 | setvbuf(stdout, NULL, _IONBF, 0); 7 | setbuf(stdin, NULL); 8 | setbuf(stderr, NULL); 9 | } 10 | 11 | 12 | int main(){ 13 | setup(); 14 | 15 | char username[0x100]; 16 | printf("What's your name?\n> "); 17 | 18 | fgets(username, sizeof(username), stdin); 19 | username[strcspn(username, "\n")] = 0; 20 | 21 | printf("Hello, %s!\n", username); 22 | 23 | return 0; 24 | } 25 | 26 | 27 | -------------------------------------------------------------------------------- /demo/redpwn_jail/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 AS app 2 | 3 | FROM pwn.red/jail 4 | COPY --from=app / /srv 5 | COPY ./demo /srv/app/run 6 | 7 | ENV JAIL_TIME=0 -------------------------------------------------------------------------------- /demo/redpwn_jail/build_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker kill demo-jail 4 | cp ../demo . 5 | docker build . -t demo_jail 6 | rm demo 7 | docker run --rm --privileged -d --name demo-jail -p 34569:5000 demo_jail 8 | -------------------------------------------------------------------------------- /demo/redpwn_jail/demo.py: -------------------------------------------------------------------------------- 1 | from docker_dbg import * 2 | from pwn import * 3 | 4 | p = remote("localhost", 34569) 5 | dp = docker_attach(container="demo-jail", proc="run") 6 | 7 | libc = dp.libc 8 | dp.brpt(libc.sym.printf) 9 | 10 | p.sendlineafter(b">", b"World") 11 | dp.gdb.wait() 12 | 13 | res = dp.gdb.execute("x/s $rsi", to_string=True) 14 | assert "World" in res, res 15 | dp.gdb.continue_nowait() 16 | 17 | print(p.recvall().decode()) -------------------------------------------------------------------------------- /demo/run_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd redpwn_jail 4 | ./build_docker.sh 5 | python3 demo.py 6 | cd .. 7 | 8 | 9 | cd ubuntu 10 | ./build_docker.sh 11 | python3 demo.py 12 | cd .. 13 | 14 | cd alpine 15 | ./build_docker.sh 16 | python3 demo.py 17 | cd .. 18 | 19 | 20 | docker kill demo-jail 21 | docker kill demo-ubuntu 22 | docker kill demo-alpine 23 | -------------------------------------------------------------------------------- /demo/ubuntu/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 AS app 2 | 3 | COPY ./demo /demo 4 | 5 | CMD [ "bash", "-c", "while true; do sleep 30; done;" ] -------------------------------------------------------------------------------- /demo/ubuntu/build_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker kill demo-ubuntu 4 | cp ../demo . 5 | docker build . -t demo_ubuntu 6 | rm demo 7 | docker run --rm -d --name demo-ubuntu demo_ubuntu -------------------------------------------------------------------------------- /demo/ubuntu/demo.py: -------------------------------------------------------------------------------- 1 | from docker_dbg import * 2 | from pwn import * 3 | 4 | p, dp = docker_debug("/demo", container="demo-ubuntu", gdbscript="catch load libc.so\nc\n") 5 | 6 | dp.gdb.wait() 7 | 8 | libc = dp.libc 9 | dp.brpt(libc.sym.printf) 10 | 11 | dp.gdb.wait() 12 | res = dp.gdb.execute("x/s $rdi", to_string=True) 13 | assert "What's your name?" in res, res 14 | dp.gdb.continue_nowait() 15 | 16 | p.sendlineafter(b">", b"World") 17 | dp.gdb.wait() 18 | 19 | res = dp.gdb.execute("x/s $rsi", to_string=True) 20 | assert "World" in res, res 21 | dp.gdb.continue_nowait() 22 | 23 | print(p.recvall().decode()) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup(name='docker_dbg', 4 | version='0.2.0', 5 | description='Tools for debugging processes running in docker containers', 6 | author='jro', 7 | install_requires=[ "pwntools"], 8 | package_data={'docker_dbg': ['binaries/*']}, 9 | package_dir={"": "src"}, 10 | packages=find_packages(where="src")) -------------------------------------------------------------------------------- /src/docker_dbg/__init__.py: -------------------------------------------------------------------------------- 1 | from .docker_dbg import docker_attach, docker_debug, DockerProcess -------------------------------------------------------------------------------- /src/docker_dbg/binaries/frpc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junron/docker-dbg/80e9e89ae0c126f94675137d0e182e7af0d5cd01/src/docker_dbg/binaries/frpc -------------------------------------------------------------------------------- /src/docker_dbg/binaries/frps: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junron/docker-dbg/80e9e89ae0c126f94675137d0e182e7af0d5cd01/src/docker_dbg/binaries/frps -------------------------------------------------------------------------------- /src/docker_dbg/binaries/gdbserver: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junron/docker-dbg/80e9e89ae0c126f94675137d0e182e7af0d5cd01/src/docker_dbg/binaries/gdbserver -------------------------------------------------------------------------------- /src/docker_dbg/docker_dbg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import six 4 | import pwnlib.gdb as pgdb 5 | from pwnlib.tubes.process import process 6 | import pwnlib 7 | import atexit 8 | import tempfile 9 | from typing import Tuple 10 | import functools 11 | 12 | def ps_grep(proc, container): 13 | results = os.popen(f"docker exec --user root '{container}' ps aux").read().splitlines() 14 | pid_index = results[0].split().index("PID") 15 | out = [] 16 | for line in results[1:]: 17 | if proc in line: 18 | pid = int(line.split()[pid_index]) 19 | out.append(pid) 20 | return out 21 | 22 | def exec_in_container(container, cmd, check_output=True): 23 | p = os.popen(f"docker exec --user root '{container}' {cmd}") 24 | if check_output: 25 | res = p.read() 26 | if "permission denied" in res: 27 | print("Please add this user to the docker group") 28 | exit(1) 29 | return res 30 | 31 | 32 | def setup_docker(args, container, exe=None) -> Tuple[int, str]: 33 | 34 | def killall(proc): 35 | if "executable file not found" in exec_in_container(container, f"killall {proc} 2>&1"): 36 | pids = ps_grep(proc, container) 37 | for p in pids: 38 | exec_in_container(container, f"kill {p}") 39 | 40 | 41 | def copy_to_container(f): 42 | return os.popen(f"docker cp {f} '{container}:/'").read() 43 | 44 | def random_port(): 45 | return random.randint(1024, 65535) 46 | 47 | def get_dir(): 48 | return os.path.join(os.path.dirname(__file__), "binaries") 49 | 50 | def cleanup(): 51 | killall("gdbserver") 52 | killall("frpc") 53 | atexit.register(cleanup) 54 | 55 | parts = args[0].split("/") 56 | if parts[0] != "": 57 | print("Executable path must be absolute") 58 | return 59 | 60 | if os.path.exists("./frps.toml") and exec_in_container(container, "ls /frpc 2>/dev/null").strip(): 61 | frpc = exec_in_container(container, "cat /frpc.toml") 62 | gdbserver_port = int(frpc.split("localPort = ")[1].split("\n")[0]) 63 | frps_port = int(frpc.split("serverPort = ")[1].split("\n")[0]) 64 | else: 65 | host_ip = exec_in_container(container, "ip route|awk '/default/ { print $3 }'").strip() 66 | if not host_ip: 67 | host_ip = "172.17.0.1" 68 | 69 | frps_port = random_port() 70 | 71 | gdbserver_port = random_port() 72 | 73 | f = tempfile.NamedTemporaryFile(mode="w") 74 | f.write(f"serverAddr = \"{host_ip}\"\n") 75 | f.write(f"serverPort = {frps_port}\n") 76 | f.write(f"[[proxies]]\n") 77 | f.write(f"name = \"gdbserver\"\n") 78 | f.write(f"type = \"tcp\"\n") 79 | f.write(f"localIP = \"127.0.0.1\"\n") 80 | f.write(f"localPort = {gdbserver_port}\n") 81 | f.write(f"remotePort = {gdbserver_port}\n") 82 | f.flush() 83 | 84 | copy_to_container(f.name) 85 | f.close() 86 | exec_in_container(container, f"cp /{f.name.split('/')[-1]} /frpc.toml") 87 | copy_to_container(f"{get_dir()}/frpc") 88 | copy_to_container(f"{get_dir()}/gdbserver") 89 | exec_in_container(container, "chmod +x /frpc") 90 | 91 | f = tempfile.NamedTemporaryFile(mode="w") 92 | f.write(f"bindPort = {frps_port}\n") 93 | f.flush() 94 | 95 | frps = process([f"{get_dir()}/frps", "-c", f.name], level='error') 96 | frps.recvuntil(b"started successfully") 97 | 98 | exec_in_container(container, "/frpc -c /frpc.toml", check_output=False) 99 | if exe is None: 100 | temp = tempfile.NamedTemporaryFile(delete=False) 101 | os.popen(f"docker cp '{container}:{args[0]}' {temp.name}").read() 102 | exe = temp.name 103 | atexit.register(lambda: os.unlink(temp.name)) 104 | 105 | return gdbserver_port, exe 106 | 107 | class DockerProcess: 108 | pid: int 109 | gdb: pgdb.Gdb 110 | executable: str 111 | container: str 112 | 113 | def libs(self): 114 | maps_raw = exec_in_container(self.container, f"cat /proc/{self.pid}/maps") 115 | if not maps_raw.strip(): 116 | return {} 117 | maps = {} 118 | for line in maps_raw.splitlines(): 119 | if '/' not in line: continue 120 | path = line[line.index('/'):] 121 | if path not in maps: 122 | maps[path]=0 123 | 124 | for lib in maps: 125 | for line in maps_raw.splitlines(): 126 | if line.endswith(lib): 127 | address = line.split('-')[0] 128 | maps[lib] = int(address, 16) 129 | break 130 | return maps 131 | 132 | @functools.lru_cache() 133 | def fetch_lib(self, path): 134 | from pwnlib.elf import ELF 135 | temp = tempfile.NamedTemporaryFile(delete=False) 136 | container_exe_name = random.randbytes(8).hex() 137 | container_path = f"/proc/{self.pid}/root{path}" 138 | exec_in_container(self.container, f"cp {container_path} /{container_exe_name}") 139 | os.popen(f"docker cp '{self.container}:/{container_exe_name}' {temp.name}").read() 140 | exec_in_container(self.container, f"rm /{container_exe_name}") 141 | atexit.register(lambda: os.unlink(temp.name)) 142 | e = ELF(temp.name) 143 | e.address = self.libs()[path] 144 | return e 145 | 146 | @property 147 | def libc_path(self) -> str: 148 | for lib, address in self.libs().items(): 149 | if 'libc.so' in lib or 'libc-' in lib: 150 | return lib 151 | 152 | @property 153 | def libc(self) -> pwnlib.elf.ELF: 154 | from pwnlib.elf import ELF 155 | lib = self.libc_path 156 | if not lib: 157 | return lib 158 | return self.fetch_lib(self.libc_path) 159 | 160 | def download_libc(self): 161 | path = self.libc.path 162 | os.system(f"cp {path} ./libc.so.6") 163 | 164 | @property 165 | def address(self) -> int: 166 | libs = self.libs() 167 | cmdline = exec_in_container(self.container, f"cat /proc/{self.pid}/cmdline") 168 | exe_name = cmdline.split()[0] 169 | if exe_name in libs: 170 | return libs[exe_name] 171 | for lib, address in libs.items(): 172 | if "lib" not in lib and ".so" not in lib: 173 | return address 174 | 175 | @property 176 | def elf(self): 177 | import pwnlib.elf.elf 178 | e = pwnlib.elf.elf.ELF(self.executable) 179 | e.address = self.address 180 | return e 181 | 182 | 183 | def breakpoint(self, address: int, block=False): 184 | # probably PIE 185 | if address < 0x10000: 186 | pie_base = self.address 187 | # Almost definitely PIE 188 | if pie_base > address: 189 | address += pie_base 190 | self.gdb.Breakpoint(f"*{hex(address)}") 191 | if block: 192 | self.gdb.continue_and_wait() 193 | else: 194 | self.gdb.continue_nowait() 195 | 196 | def brpt(self: pwnlib.tubes.process, address: int, block=False) -> pwnlib.gdb.Gdb: 197 | return self.breakpoint(address, block) 198 | 199 | def docker_debug(args, container, gdbscript=None, exe=None) -> Tuple[process, DockerProcess]: 200 | if isinstance(args, (bytes, six.text_type)): 201 | args = [args] 202 | 203 | gdbserver_port, exe = setup_docker(args, container, exe) 204 | 205 | gdbserver_args = ["docker", "exec", "-i", "--user", "root", container] 206 | 207 | gdbserver = process(gdbserver_args + ["/gdbserver", "--no-disable-randomization", f"localhost:{gdbserver_port}", *args], level="error") 208 | 209 | gdbserver.executable = exe 210 | _, gdb = pgdb.attach(("127.0.0.1", gdbserver_port), exe=exe, gdbscript=gdbscript, api=True) 211 | gdbserver.gdb = gdb 212 | dp = DockerProcess() 213 | dp.gdb = gdb 214 | dp.executable = exe 215 | dp.container = container 216 | 217 | gdbserver.recvuntil(b"pid = ") 218 | dp.pid = int(gdbserver.recvline()) 219 | 220 | # Some versions of gdbserver output an additional message 221 | garbage2 = gdbserver.recvline_startswith(b"Remote debugging from host ", timeout=2) 222 | 223 | return gdbserver, dp 224 | 225 | 226 | def docker_attach(proc, container, gdbscript=None) -> DockerProcess: 227 | pids = ps_grep(proc, container) 228 | if not pids: 229 | print("Process not found!") 230 | return None 231 | pid = max(pids) 232 | exe_path = f"/proc/{pid}/exe" 233 | container_exe_name = random.randbytes(8).hex() 234 | os.popen(f"docker exec --user root '{container}' cp {exe_path} /{container_exe_name}").read() 235 | gdbserver_port, exe = setup_docker([f"/{container_exe_name}"], container) 236 | os.popen(f"docker exec --user root '{container}' rm /{container_exe_name}") 237 | 238 | gdbserver_args = ["docker", "exec", "-i", "--user", "root", container] 239 | 240 | process(gdbserver_args + ["/gdbserver", "--no-disable-randomization", "--attach", f"localhost:{gdbserver_port}", str(pid)], level="error") 241 | 242 | _, gdb = pgdb.attach(("127.0.0.1", gdbserver_port), exe=exe, gdbscript=gdbscript, api=True) 243 | 244 | dp = DockerProcess() 245 | dp.pid = pid 246 | dp.gdb = gdb 247 | dp.executable = exe 248 | dp.container = container 249 | return dp 250 | 251 | 252 | 253 | --------------------------------------------------------------------------------