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启动后继承了 fd4。关键遗漏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 21 # 为了模拟高并发加一点小延迟 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/runcreadlink /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.8High因为它同时具备资源耗尽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/runccontainerd也可能因为 ABI 兼容性问题而拒绝启动。因此升级前务必查阅你所用组件的官方发布说明Release Notes确认其runc依赖版本。我见过太多团队因为跳过这一步导致升级后整个集群的节点NotReady最后不得不回滚。4.2 缓解之策在无法立即升级时用 cgroup 和 ulimit 筑起第一道墙在生产环境中升级往往需要漫长的测试和灰度周期。在这段“空窗期”我们必须采取主动的缓解措施防止runc进程因 fd 耗尽而瘫痪。最有效、最无侵入性的方法就是利用 Linux 的 cgroup v2 机制为runc进程准确地说是containerd-shim-runc-v2进程设置硬性的文件描述符上限。首先确认你的系统启用了 cgroup v2mount | 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 LimitNOFILE1024:2048 # 这会使得所有由 containerd 启动的 shim 进程其 ulimit -n 的 soft limit 为 1024hard limit 为 2048保存后重启containerdsudo 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-max2097152 # 永久修改写入 /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。