如有错误,望大佬斧正。也希望大家可以快乐调试。
前言
The PWNer (二进制手) 苦调试 docker 中的进程久矣。恰好本人前几周学习、了解了 docker 大致的框架设计。并且看到了一些比较新的 docker 调试方式,例如:
而我想从 docker 的大致框架设计,来简要理解新老调试技术是如何对抗 docker 的反调试的。
闲言少叙,书归正传。让我们正是进入这场调试旅程吧。
Docker 的基本框架设计
从开发者视角来说,Docker 分为:Dockers Client、Docker Daemon、Containerd、Docker Shim、Docker runc。每一层各司其职,而与我们用户而言最长打交道的就是 Docker CLI。例如当我执行 docker run
、docker ps
等命令时,实际上是在和 Docker CLI 交互。Docker Client 解析命令,构造 HTTP 请求(如 POST /containers/create
),发送给 dockerd
(Docker Daemon) 的 API 接口。
而 Docker Daemon 是管理镜像、网络、卷等核心对象,协调下游组件的核心管理引擎。因此当其接收到 Docker Client 发送的请求时会解析 Client 的 API 请求。并根据请求决定是否将任务委托给 containerd 进程。为了方便说明,你只需要知道 docker build
、docker login
等指令 Daemon 进程是不会下发任务的,而我们对于 container 的操作基本上是交由或委托 containerd 完成。为什么说存在委托现象呢?
为了理解上一段落留下的问题,我想我们应该先了解 containerd 在框架设计中的定位是什么。containerd 是容器生命周期管理者。其管理容器执行、镜像分发、存储等底层操作,是容器运行时的核心中介。因此像 docker exec
、docker run
等指令毫无疑问是交由 containerd 完成的。但是并非所有的指令是一个团队可以完成的。正如现实生活和工作中也存在必然的合作。最好的例子是 docker pull
,其是完成镜像的下载、解压和存储。从直觉上来说 image 的存储任务应当由 Daemon 自身完成。事实上,在早期的版本中 image 的存储任务确实由 Daemon 完成。但随着2016年containerd从Docker剥离捐赠给CNCF,镜像存储自然就下沉了。这种解耦带来了三个关键优势:
1. 稳定性与进程隔离 (Stability & Isolation)
- 镜像操作是 I/O 密集型且易阻塞:拉取大镜像、解压层、校验哈希等操作耗时且可能阻塞进程。
- 将镜像管理放入独立守护进程 (containerd) 提升了 Docker 系统的整体稳定性:
- 即使 containerd 的镜像操作出现临时阻塞或崩溃,也不会直接导致 dockerd 崩溃(用户仍能执行 docker network ls 等非容器操作)。
- dockerd 可以独立重启或升级,而 containerd 管理的镜像和运行中容器不受影响(通过 containerd-shim 机制)。
2. 生态系统复用与 Kubernetes 集成 (Ecosystem & Kubernetes)
- containerd 是 Kubernetes CRI 的默认运行时之一:
- Kubernetes 通过 CRI (Container Runtime Interface) 直接调用 containerd 管理容器和镜像。
- dockerd 将镜像存储委托给 containerd 实现了关键复用:
- 当同一主机上同时运行 Docker 和 Kubernetes 时,它们可以共享同一个镜像存储库 (/var/lib/containerd),避免重复拉取和存储镜像,节省磁盘空间和带宽。
- 用户用 docker pull nginx 拉取的镜像,Kubernetes Pod 可以直接使用,反之亦然。
3. 性能优化 (Performance)
- containerd 实现了高效的镜像处理流水线:
- 并发拉取镜像层。
- 延迟解压(如使用 stargz snapshotter)或使用快照叠加(如 overlayfs)。
- 内容寻址存储避免重复数据。
最后由 containerd 调用 shim 创建、启动容器并接管孤儿容器。至于 runc,它才是真正的基层干部。因此大致的关系图如下:
+------------+ +------------+ +-------------+ +-----------------+ +-------+| Docker | HTTP | Docker | gRPC | Containerd | gRPC | Containerd-shim | Exec | Runc || Client |-----> | Daemon |-----> | |-----> | |------> | || (`docker`) | | (`dockerd`)| | | | (per container) | | |+------------+ +------------+ +-------------+ +-----------------+ +-------+ | | | | (镜像管理) | (镜像存储) | (系统调用) v v v Registry /var/lib/containerd Linux Kernel
如果你想要验证上述观点,你可以使用 ps -aux --forest
来查看进程树:
此外,出于安全性的考量 docker 在默认情况下会禁用 ptrace 系统调用。但由于实际生产中的调试需求是真切存在的,因此 docker 将 ptrace 作为可选项保留而不是彻底移除。
如何调试 Docker 中的进程
去 Docker 里面看看
去到 docker 里面也就是最传统的方法。对于传统做法你完全不需要关注 docker 在开发者视角的构成。你只需要知道 docker 将 ptrace 作为可选项供用户选择。而 gdb 等 linux 调试几乎都使用 ptrace 实现。那么现在就是对抗最基础的反调试技术 — 禁用 ptrace。而这个开关是我们可以掌握的,因此你需要在启动命令中增加 ptrace 命令即可。
[sudo] docker run -d --privileged -p <host port>:<docker port> --security-opt seccomp=unconfined --cap-add sys_ptrace --name=<container's name> image_name:latest
然后就是在 docker 中安装 gdb + pwndbg 了。但是因为 pwndbg 难装,而且本人又是 pwndbg 和 GEF 都用。因此我在 docker 里面更喜欢安装 GEF,GEF 真比 pwndbg 好装很多…^_^。但是这对于长期使用 pwndbg 的师傅会不友善。
到 Docker 外面瞅瞅
现在让我们到 docker 外面看看。想必细心的小伙伴已经从我此前给出的进程树中发现了端倪。即便是 docker 内的进程,但是不影响 Linux Kernel 给他分配一个 PID。说白了就是在 Linux Kernel 眼中,你 Docker 内的进程只不过是 docker-containerd-shim-runc
的“儿子”/“孙子”罢了。
于是我们可以尝试以下是否可以利用 gdb 的 attach 功能附加调试!不是不知道一试吓一跳。例如,我所启动的 docker container 中的 httpd 进程 Linux Kernel 发放了 38388 做它的 PID。我们直接 pwndbg attach 38388
一手。
我们直接通过外部就可以调试程序了。值得一提的是,我并没有赋予 docker container 容器 ptrace 权限。如果单从外部的视角,我觉得没毛病。gdb 确实可以调用 ptrace 来让内核帮助自己调试进程。但是这好像和 docker 设计里面的隔离性有违背。按理说应该是里不能访外、外不能访内。
而我恰好发现有师傅也这样尝试过,而且我看 ZLSF 师傅在搭建调试容器的时候是赋予 ptrace 的。而我直接拿了一些题目现成的作为尝试,并没有赋予也能成功:
from pwn import *
container_name = ""ps_name = ''dtop = process(f"docker top {container_name}", shell=True)output = dtop.recvall().decode()print(f"docker top output:\n{output}")
pid = Nonefor line in output.splitlines(): if ps_name in line: # Filter by process name pid = line.split()[1] # Get PID break
if pid: # If we get pid. print(f"We get {container_name}/{ps_name}'s PID: {pid}")else: print("No matching process found.")
dtop.close() # close the docker top pipe
# ================== debug target process ==================gdbscript = '''break *$rebase(0x3333)info break'''if pid : io = process(f"pwndbg attach {pid}", shell=True) io.send(gdbscript) io.interactive()
后来问了一手 AI 发现,如果不加 ptrace 直接附加可能会出现如下告警:
1.”target:/proc/38388/exe”: could not open as an executable file: Input/output error.
- 原因:GDB 无法通过 /proc/<pid>/exe 访问目标进程的可执行文件
- 根本问题: 容器文件系统隔离导致主机 GDB 无法访问容器内的二进制文件,容器可能已终止或进程路径不可访问
2.Target and debugger are in different PID namespaces…
- 原因:主机 GDB 和容器进程位于不同的 PID 命名空间
- 根本问题:容器隔离机制导致进程信息不可靠