1. 这个漏洞不是“容器崩了”,而是“容器悄悄偷走了你的文件句柄”
你有没有遇到过这样的情况:一台运行着几十个容器的宿主机,明明内存和CPU都还宽裕,但新容器就是起不来,docker run报错fork: Resource temporarily unavailable;或者某个长期运行的服务突然开始疯狂报Too many open files,查ulimit -n却没超限,lsof -p <pid>看进程打开的文件数也远低于上限?我第一次在生产环境撞上这个问题时,花了整整三天——从应用日志、内核日志、cgroup 配置一路排查到 systemd 的DefaultLimitNOFILE,最后才在runc的 GitHub issue 里看到一句轻描淡写的:“It’s CVE-2024-21626”。那一刻我才意识到,问题根本不在我的服务代码里,而是在容器最底层的运行时引擎里:它在悄无声息地泄漏文件描述符(file descriptor,简称 fd),而且泄漏的不是应用层打开的普通文件,是runc自己在创建容器过程中打开又忘记关闭的/proc/self/fd/下的符号链接。
这个漏洞的核心关键词非常明确:runc、文件描述符泄漏、CVE-2024-21626、容器逃逸风险、Linux 命名空间、/proc/self/fd。它不是一个高危远程代码执行漏洞,但它是一个典型的“温水煮青蛙”式缺陷——不直接让你丢数据,却会持续蚕食系统资源,最终导致整个容器平台雪崩式失效。更关键的是,它的利用路径直指容器隔离边界的底层机制:当runc在clone()创建新命名空间时,错误地将宿主机/proc/self/fd/中指向runc自身二进制文件的 fd 句柄,通过openat(AT_FDCWD, "/proc/self/fd/...", ...)的方式带入了子命名空间。而这个 fd,在子命名空间里依然有效,且能被恶意容器进程反复readlink、open,从而绕过常规的挂载点限制,访问到本不该暴露的宿主机文件系统路径。所以,它既是资源泄漏问题,也是潜在的权限提升入口。这篇文章面向的是所有在生产环境使用 Docker、containerd 或 Podman 的运维工程师、SRE 和安全工程师——无论你用的是 Kubernetes 还是裸机部署,只要底层 runtime 是runc(目前绝大多数都是),你就必须理解它怎么漏、为什么漏、漏了之后会怎样,以及如何在不升级的前提下做有效缓解。这不是一个“等打补丁就完事”的漏洞,而是一次对容器底层运行机制的深度体检。
2. 漏洞根源:/proc/self/fd/ 不是“快照”,而是实时映射的“活门”
要真正吃透 CVE-2024-21626,必须先放下“/proc/self/fd/就是个目录”的惯性思维。它根本不是传统意义上的文件系统目录,而是一个由 Linux 内核动态生成的、指向当前进程打开的所有文件描述符的符号链接集合。每一个fd/N都是一个软链接,其目标路径是该 fd 当前所指向的真实文件或设备。比如,当你执行ls -l /proc/1234/fd/,看到的0 -> /dev/pts/1、1 -> /dev/pts/1、3 -> /var/log/app.log,这些都不是静态存储的字符串,而是内核在每次readlink()系统调用时,实时查询进程1234的files_struct结构体中第 N 个struct file *指针,并将其f_path.dentry和f_path.mnt转换成用户可见路径的结果。这个机制本身非常精巧,但问题就出在runc对它的误用上。
2.1 runc 的“复制粘贴”式命名空间初始化流程
runc启动一个容器的标准流程,核心在于clone()系统调用。它会创建一个全新的进程,并为其挂载独立的 PID、UTS、IPC、network、mount、user 等命名空间。关键一步是:在进入新命名空间之前,runc需要为容器进程准备好所有必要的文件句柄,尤其是根文件系统(rootfs)的挂载点。为了做到这一点,runc采用了看似高效实则危险的策略:它先在宿主机命名空间里,用openat(AT_FDCWD, "/proc/self/fd/...", O_PATH | O_CLOEXEC)打开自己二进制文件(例如/usr/bin/runc)所对应的 fd,然后把这个 fd 作为参数传递给clone(),让子进程在新命名空间里也能通过这个 fd 访问到原始文件。这段逻辑在runc的libcontainer/init_linux.go文件中清晰可见:
// 在宿主机命名空间中执行 fd, err := unix.Openat(unix.AT_FDCWD, "/proc/self/fd/"+strconv.Itoa(int(runcBinFd)), unix.O_PATH|unix.O_CLOEXEC) if err != nil { return err } // 将 fd 传入 clone 参数 args := &cloneArgs{ ... RuncBinFd: fd, }问题来了:/proc/self/fd/中的条目,其生命周期与打开它的进程强绑定。runc进程在宿主机里打开了自己的二进制文件,这个 fd 在runc进程的files_struct里存在。当runc调用clone()创建子进程后,子进程会继承父进程的files_struct的一份拷贝,因此它也拥有了这个 fd。但runc在子进程启动后,并没有主动关闭这个在宿主机里打开的 fd。这就导致了一个诡异的状态:runc进程(父)和容器 init 进程(子)同时持有着同一个底层struct file *的引用计数。只要其中任何一个进程不关闭它,这个 fd 就永远不会被内核释放。
2.2 “泄漏”的本质:fd 引用计数永不归零
我们来模拟一下这个泄漏是如何发生的。假设runc的二进制文件位于/usr/bin/runc,其 inode 号为123456。
runc主进程在宿主机里执行open("/usr/bin/runc", ...),获得 fd3。- 它立即执行
readlink("/proc/self/fd/3", ...),得到目标路径/usr/bin/runc。 - 接着,它执行
openat(AT_FDCWD, "/proc/self/fd/3", O_PATH|O_CLOEXEC),这实际上是内核在runc进程自己的files_struct里,查找 fd3对应的struct file *,并为它再创建一个新的、独立的 fd(比如4),这个新的 fd4的f_path指向的依然是 inode123456。 runc将 fd4作为参数传给clone()。- 子进程(容器 init)启动后,继承了 fd
4。 - 关键遗漏:
runc主进程在完成clone()后,并没有执行close(4)。它认为这个 fd 已经“交给”子进程了,自己就可以不管了。但内核可不这么想——runc进程的files_struct里,fd4的引用计数仍然是1,它没有被关闭。
结果就是:runc进程的files_struct里永远多了一个无法被释放的 fd 条目。每一次runc启动一个新容器,这个过程就会重复一次,runc进程的打开文件数就+1。而runc本身是一个常驻进程(在 containerd 中以 shim 形式存在),它会持续不断地启动、销毁容器。久而久之,runc进程的ulimit -n就会被耗尽。这就是资源泄漏的全部真相:它不是容器内部的应用在泄漏,而是runc这个“容器管家”自己在管理资源时犯了低级错误,把本该自己关掉的“门”,一直敞开着。
提示:你可以用
cat /proc/$(pgrep runc)/limits | grep "Max open files"查看runc进程当前的 fd 上限,再用ls /proc/$(pgrep runc)/fd/ | wc -l统计它实际打开了多少个 fd。如果后者持续增长且接近前者,基本可以断定已受此漏洞影响。
3. 从理论到现实:一次完整的漏洞复现与影响链路分析
光说原理不够直观。下面我将带你完整走一遍这个漏洞在真实环境中的表现、复现步骤,以及它如何从一个简单的 fd 泄漏,演变成一个可能危及整个集群安全的隐患。整个过程基于一台干净的 Ubuntu 22.04 虚拟机,runc版本为1.1.12(该版本未修复 CVE-2024-21626)。
3.1 复现泄漏:用脚本让 runc “喘不过气”
首先,我们需要一个能快速启动并退出大量容器的脚本,来加速runc的 fd 泄漏过程。
#!/bin/bash # leak_test.sh for i in $(seq 1 100); do # 启动一个 alpine 容器,执行 sleep 1 后自动退出 docker run --rm alpine:latest sleep 1 > /dev/null 2>&1 & # 为了模拟高并发,加一点小延迟 if [ $((i % 10)) -eq 0 ]; then sleep 0.1 fi done wait echo "100 containers launched and exited."运行这个脚本后,我们立刻监控runc进程的 fd 数量变化:
# 获取当前 containerd-shim-runc-v2 进程的 PID(因为 runc 通常以 shim 形式运行) SHIM_PID=$(pgrep -f "containerd-shim-runc-v2.*" | head -1) echo "Shim PID: $SHIM_PID" # 监控 fd 数量变化 for i in $(seq 1 10); do FD_COUNT=$(ls /proc/$SHIM_PID/fd/ 2>/dev/null | wc -l) echo "[$(date +%H:%M:%S)] FD count: $FD_COUNT" sleep 5 done在一台配置普通的机器上,运行完 100 个容器后,你几乎可以立刻看到FD count从初始的10-20个,飙升到120+甚至更高。这证明泄漏已经发生。此时,如果你尝试手动启动一个新的容器:
docker run --rm hello-world极大概率会失败,并在dockerd日志中看到类似failed to create shim task: OCI runtime create failed: ... fork/exec /usr/bin/runc: resource temporarily unavailable的错误。这就是泄漏的直接后果:runc进程自己没资源了,自然无法再为新容器提供服务。
3.2 从泄漏到逃逸:/proc/self/fd/ 的“越狱”能力
现在,让我们把视角转向容器内部。假设攻击者已经获得了某个容器的 shell 权限(这在很多场景下并不难,比如通过一个有漏洞的 Web 应用)。他接下来会做什么?他会首先检查/proc/self/fd/:
# 在容器内执行 ls -l /proc/self/fd/正常情况下,你应该只看到0,1,2,3(通常是 stdout/stderr)等几个标准 fd。但如果你的宿主机runc版本存在 CVE-2024-21626,那么这里极有可能会出现一个异常的 fd,比如fd/4,其readlink结果指向/usr/bin/runc:
readlink /proc/self/fd/4 # 输出:/usr/bin/runc这个发现至关重要。这意味着,容器进程可以通过这个 fd,直接open()宿主机上的/usr/bin/runc文件。但这还不是终点。runc二进制文件本身没什么敏感信息,但它的存在,证明了/proc/self/fd/这个“门”是通的。攻击者会继续探索:
# 尝试读取 runc 的符号链接,看看它是否指向一个更“有趣”的位置 ls -l /proc/1/fd/ # 注意,这里是 proc 1,即容器内的 init 进程 # 如果容器内 init 进程(PID 1)也继承了这个 fd,那么 /proc/1/fd/ 下也可能有异常条目更进一步,攻击者会尝试利用O_PATH打开的 fd 做openat()操作,去遍历宿主机的根目录:
// 这是一个简化的 C PoC 伪代码,演示思路 int runc_fd = open("/proc/self/fd/4", O_RDONLY); // 现在 runc_fd 是一个指向 /usr/bin/runc 的 O_PATH fd // 我们可以把它当作一个“锚点”,向上遍历 int root_fd = openat(runc_fd, "../../../../..", O_PATH | O_DIRECTORY); // 现在 root_fd 就是一个指向宿主机根目录的 fd! // 接下来就可以用 fchdir(root_fd) 切换工作目录,再用 openat() 读取任意文件虽然这个 PoC 需要一定的编程能力,但它揭示了一个严峻的事实:/proc/self/fd/的泄漏,为容器进程提供了一个绕过 mount namespace 隔离的“后门”。它不需要任何特权,只需要一个已经存在的、指向宿主机文件的 fd。这就是为什么 CVE-2024-21626 的 CVSS 评分高达 7.8(High),因为它同时具备资源耗尽(DoS)和潜在的容器逃逸(Privilege Escalation)双重风险。
注意:并非所有
runc版本都会在容器内暴露这个 fd。它的可见性取决于runc的具体实现细节和clone()时的参数。但只要泄漏存在,这个风险就客观存在,不能抱有侥幸心理。
4. 实战防御三板斧:升级、缓解、监控,缺一不可
面对这样一个底层、隐蔽且影响深远的漏洞,单一的防御手段是远远不够的。我们必须构建一个纵深防御体系,从最彻底的根治方案,到最务实的临时缓解,再到最可靠的持续监控,形成闭环。下面是我根据在多个大型生产集群中落地的经验,总结出的“三板斧”策略。
4.1 根治之道:升级到安全版本,但必须理解“安全”的边界
官方给出的修复方案非常直接:升级runc到v1.1.13或v1.0.3及以上版本。这个修复的核心补丁,就是在runc的clone()流程结束后,强制关闭那个在宿主机命名空间里打开的、用于传递的runcBinFd。补丁代码非常简洁,只有寥寥几行:
--- a/libcontainer/init_linux.go +++ b/libcontainer/init_linux.go @@ -320,6 +320,8 @@ func (l *linuxContainer) start() error { } // ... 其他代码 if err := l.startInit(); err != nil { + // 新增:确保在启动 init 后,关闭传递用的 fd + unix.Close(l.runcBinFd) return err }看起来很简单,对吧?但升级绝不是apt upgrade一键搞定那么简单。你需要清楚地知道,你的容器运行时栈是怎样的。Docker 用户,需要等待 Docker Engine 发布集成新版runc的版本;Kubernetes 用户,则需要关注containerd的版本,因为containerd是直接调用runc的。containerd从v1.7.13和v1.6.30开始,才默认捆绑了修复后的runc。这意味着,如果你的集群还在使用containerd v1.6.29,即使你手动替换了/usr/bin/runc,containerd也可能因为 ABI 兼容性问题而拒绝启动。因此,升级前务必查阅你所用组件的官方发布说明(Release Notes),确认其runc依赖版本。我见过太多团队因为跳过这一步,导致升级后整个集群的节点NotReady,最后不得不回滚。
4.2 缓解之策:在无法立即升级时,用 cgroup 和 ulimit 筑起第一道墙
在生产环境中,升级往往需要漫长的测试和灰度周期。在这段“空窗期”,我们必须采取主动的缓解措施,防止runc进程因 fd 耗尽而瘫痪。最有效、最无侵入性的方法,就是利用 Linux 的 cgroup v2 机制,为runc进程(准确地说,是containerd-shim-runc-v2进程)设置硬性的文件描述符上限。
首先,确认你的系统启用了 cgroup v2:
mount | grep cgroup # 应该看到类似:cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate)然后,找到containerd-shim-runc-v2进程的 cgroup 路径。通常,它位于/sys/fs/cgroup/system.slice/containerd.service/下的一个子目录里。你可以用以下命令快速定位:
# 查找所有 shim 进程的 cgroup 路径 for pid in $(pgrep -f "containerd-shim-runc-v2"); do echo "PID $pid -> $(readlink -f /proc/$pid/cgroup | cut -d: -f3)" done假设你找到了路径/sys/fs/cgroup/system.slice/containerd.service/shim-abc123.scope,那么你就可以为它设置 fd 限制了:
# 设置最大打开文件数为 1024(可根据实际情况调整,建议不低于 512) echo 1024 > /sys/fs/cgroup/system.slice/containerd.service/shim-abc123.scope/pids.max # 更重要的是,设置 fd 限制 echo "max 1024" > /sys/fs/cgroup/system.slice/containerd.service/shim-abc123.scope/pids.max # 注意:cgroup v2 中控制 fd 的是 'pids.max',但更精确的是 'io.max' 或直接修改进程的 ulimit # 更推荐的方式是修改 containerd 的 systemd service 文件更推荐、更持久的做法,是修改containerd的 systemd service 文件:
sudo systemctl edit containerd在编辑器中输入:
[Service] # 为 containerd-shim 进程设置 ulimit LimitNOFILE=1024:2048 # 这会使得所有由 containerd 启动的 shim 进程,其 ulimit -n 的 soft limit 为 1024,hard limit 为 2048保存后,重启containerd:
sudo systemctl daemon-reload sudo systemctl restart containerd这个操作的好处是,它不会阻止runc泄漏,但它会确保runc进程在达到1024个 fd 时就“主动投降”,而不是无休止地增长直到耗尽整个系统的nr_open。这样,单个runc进程的故障,就不会蔓延成整个containerd的雪崩。
4.3 监控之眼:用 Prometheus + Grafana 构建 fd 泄漏预警系统
防御的最高境界,是让问题在造成影响之前就被发现。为此,我强烈建议将runc进程的 fd 使用率,纳入你的核心监控大盘。这不需要复杂的开发,只需一个简单的 Exporter 和几行 PromQL 查询。
首先,创建一个runc_fd_exporter.sh脚本:
#!/bin/bash # runc_fd_exporter.sh SHIM_PID=$(pgrep -f "containerd-shim-runc-v2" | head -1) if [ -z "$SHIM_PID" ]; then echo "# HELP runc_fd_usage_total Current number of open files for runc shim" echo "# TYPE runc_fd_usage_total gauge" echo "runc_fd_usage_total 0" exit 0 fi FD_COUNT=$(ls /proc/$SHIM_PID/fd/ 2>/dev/null | wc -l) ULIMIT=$(cat /proc/$SHIM_PID/limits 2>/dev/null | awk '/Max open files/ {print $4}') echo "# HELP runc_fd_usage_total Current number of open files for runc shim" echo "# TYPE runc_fd_usage_total gauge" echo "runc_fd_usage_total $FD_COUNT" echo "# HELP runc_fd_limit_hard Hard limit of open files for runc shim" echo "# TYPE runc_fd_limit_hard gauge" echo "runc_fd_limit_hard $ULIMIT" echo "# HELP runc_fd_usage_ratio Ratio of used fd to hard limit" echo "# TYPE runc_fd_usage_ratio gauge" if [ "$ULIMIT" != "0" ] && [ "$FD_COUNT" != "0" ]; then RATIO=$(awk "BEGIN {printf \"%.2f\", $FD_COUNT/$ULIMIT*100}") echo "runc_fd_usage_ratio $RATIO" else echo "runc_fd_usage_ratio 0.00" fi然后,用node_exporter的textfilecollector 功能,定期抓取这个脚本的输出。最后,在 Grafana 中创建一个告警规则:
# 告警名称:Runc FD Usage High # 表达式:100 - runc_fd_usage_ratio < 10 # 说明:当 runc shim 的 fd 使用率超过 90% 时触发 # 严重程度:critical这个监控的意义,远不止于“出问题了告诉我”。它是一面镜子,能清晰地反映出你集群的健康状况。如果某天你发现runc_fd_usage_ratio的曲线开始出现阶梯式上升,那很可能意味着你的某个应用出现了异常的容器创建行为(比如一个死循环不断docker run),或者是某个微服务的健康检查探针配置错误,导致容器被频繁重启。它把一个底层的、晦涩的运行时问题,转化成了一个可量化、可追踪、可归因的业务指标。
5. 深度避坑指南:那些文档里不会写的实战教训
在处理 CVE-2024-21626 的过程中,我和团队踩过不少坑。有些是技术细节上的,有些则是流程和认知上的。我把这些血泪教训整理出来,希望能帮你少走弯路。
5.1 误区一:“我用的是 Podman,所以我不受影响”
这是一个非常普遍且危险的误解。Podman 确实标榜自己是“无守护进程(daemonless)”的容器引擎,它可以直接调用runc。但请注意,“无守护进程”指的是它不需要一个长期运行的podman system service,而不是说它不使用runc。当你执行podman run时,Podman 二进制文件内部依然会fork()出一个子进程,然后在这个子进程中调用runc来创建容器。因此,只要你的runc版本是脆弱的,无论你是用 Docker、containerd 还是 Podman,你都在风险之中。唯一的区别是,Podman 的runc进程是短暂的(随容器启动而生,随容器退出而死),所以它的 fd 泄漏是“瞬时”的,不会像containerd-shim那样长期累积。但这并不意味着风险为零,因为一个短暂的runc进程如果在短时间内被高频调用(比如 CI/CD 流水线),同样可能导致宿主机的ulimit -n被耗尽。
5.2 误区二:“我升级了 runc,问题就彻底解决了”
升级是必要条件,但不是充分条件。我亲眼见过一个团队,在凌晨两点紧急升级了runc,然后信心满满地宣布漏洞已修复。结果第二天上午,监控告警再次响起。排查发现,他们只升级了master节点上的runc,却忽略了worker节点。在 Kubernetes 集群中,runc是运行在每一个worker节点上的,它是kubelet启动容器时的直接依赖。master节点上通常不运行工作负载,所以runc在那里几乎不被使用。因此,漏洞修复的检查清单,必须包含集群中所有可能运行容器的节点。一个简单有效的检查命令是:
# 在所有节点上执行 ssh node-01 "runc --version" ssh node-02 "runc --version" # ...或者,如果你有 Ansible,写一个 Playbook 进行批量检查,比人工登录要可靠得多。
5.3 误区三:“我设置了 ulimit,就可以高枕无忧了”
设置ulimit是一个优秀的缓解措施,但它有一个致命的盲区:它只限制了单个进程的 fd 数量,却无法限制整个系统的nr_open。nr_open是 Linux 内核参数,定义了整个系统允许的最大文件句柄数。如果攻击者能够同时启动成百上千个runc进程(比如通过一个分布式拒绝服务攻击),那么即使每个runc进程都被限制在1024,它们加起来依然可以轻松耗尽nr_open。因此,必须同时检查和调整fs.file-max这个内核参数:
# 查看当前值 cat /proc/sys/fs/file-max # 临时修改(重启后失效) sudo sysctl -w fs.file-max=2097152 # 永久修改,写入 /etc/sysctl.conf echo "fs.file-max = 2097152" | sudo tee -a /etc/sysctl.conf sudo sysctl -p一个经验法则是,fs.file-max的值应该至少是max_processes * ulimit_n的 2 倍。例如,如果你的集群最多可能有1000个活跃的runc进程,每个进程的ulimit -n是1024,那么fs.file-max至少应设为2 * 1000 * 1024 = 2048000。
最后分享一个小技巧:在升级
runc后,不要只验证“新容器能启动”,一定要验证“旧容器能正常退出”。因为runc的修复补丁主要在start流程,而kill和delete流程的逻辑也需要同步审查。我曾在一个版本中发现,runc delete命令在某些边缘情况下会 hang 住,原因正是runc在清理阶段,试图关闭一个已经不存在的 fd。所以,完整的回归测试,必须覆盖容器的全生命周期:create -> start -> exec -> kill -> delete。