一、背景
runc 是 OCI(Open Container Initiative)运行时规范的参考实现,是 Docker、containerd、CRI-O、Kubernetes 等所有上层容器运行时的底层底座。无论是 docker run 还是 K8s 调度一个 Pod,最终都由 runc 完成 namespace 创建、rootfs 设置、设备节点生成与容器 init 进程拉起。可以说,runc 是整个云原生世界"容器隔离"的最后一道闸门。
2025 年 11 月 5 日,runc 同一天发布三个高危安全公告,合计 20+ 个补丁 commit。三个漏洞"殊途同归"——通过不同的方法绕过 runc 对 /proc 文件写入的限制,实现完整的容器逃逸(container escape)。三者共享同一设计盲区:runc 信任了"本该是它自己控制的文件路径"是真实的、未被篡改的。
本文从 runc 架构入手,逐一拆解三个 CVE 的根因、利用面、补丁思路,并给出链式利用与防御方案。
二、runc 架构与信任模型
runc 的核心代码位于 libcontainer/ 目录。一个容器的创建流程大致如下:
┌─────────────────────────────────────────────────────────────────┐
│ runc create / run 流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 配置解析 (config.json → libcontainer.Config) │
│ ↓ │
│ 2. namespace 创建 (mnt/uts/ipc/pid/user/net) │
│ ↓ │
│ 3. rootfs 设置 ── pivot_root(2) ── 切换根目录 │
│ ↓ │
│ 4. mount 配置 (maskedPaths / readonlyPaths / mounts) │
│ ↓ │
│ 5. 设备创建 (/dev/null /dev/console /dev/pts/* ...) │
│ ↓ │
│ 6. init 进程启动 (runc init) │
│ │
└─────────────────────────────────────────────────────────────────┘
其中最关键的时间窗口是 pivot_root(2) 之后、容器进程启动之前。在这个窗口内,runc 需要在容器 rootfs 上做大量 mount/设备操作。runc 的安全假设是:在这段窗口内它自己创建/挂载的文件 inode 是可信的。
maskedPaths 实现机制
maskedPaths 是 runc 保护宿主机敏感文件的核心机制,用于遮蔽 /proc/kcore、/proc/sysrq-trigger、/proc/sys/kernel/addr 等不应被容器读取的路径。实现方式分两类:
┌──────────────────────────────────────────────────────────────┐
│ maskedPaths 覆盖策略 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 目录路径 (如 /proc/sys) │
│ └── 挂载一层只读 tmpfs 覆盖整个目录 │
│ │
│ 文件路径 (如 /proc/kcore) │
│ └── bind-mount /dev/null 覆盖该文件 │
│ 容器读 /proc/kcore 实际读到的是 /dev/null (EOF) │
│ │
│ 信任假设:容器内的 /dev/null 是真实的字符设备 (1,3) │
│ │
└──────────────────────────────────────────────────────────────┘
这套机制的安全性完全建立在"容器内 /dev/null 是真实的、未被篡改的字符设备"这一假设上。三个 CVE 正是从不同角度击穿了这个假设。
三、三个 CVE 全景概览
| CVE | GHSA | 攻击的 bind-mount 点 | 根因 | CVSS v4 |
|---|---|---|---|---|
| CVE-2025-31133 | GHSA-9493-h29p-rfm2 | maskedPaths 的 /dev/null |
bind-mount /dev/null 时对 ENOENT 静默跳过,未验证源 inode |
7.3 High |
| CVE-2025-52565 | GHSA-qw9x-cqr3-wc7r | /dev/console → /dev/pts/$n |
创建 /dev/console bind-mount 时对 /dev/pts/$n 验证不足 |
7.3 High |
| CVE-2025-52881 | GHSA-cgrx-mc8f-2prm | procfs LSM label / sysctl 写入 | runc 写 procfs 时验证不足,可诱导写入重定向 | 7.3 High |
三者共享同一设计盲区:runc 信任了"本该是它自己控制的文件路径"是真实的、未被篡改的。
┌─────────────────────────────────────────────────────────────────┐
│ 三个 CVE 的共同攻击范式 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ runc 期望路径 攻击者篡改 后果 │
│ ───────────── ────────── ──── │
│ /dev/null (真实) → 符号链接/删除 → bind-mount │
│ /dev/pts/$n (真实) → 符号链接替换 → 掉包挂载 │
│ /proc/self/attr/* → no-op procfs 文件 → 写入重定向 │
│ │
│ 核心模式:TOCTOU + 信任未验证的 inode │
│ │
└─────────────────────────────────────────────────────────────────┘
四、CVE-2025-31133 深入:拆穿 /dev/null 信任
漏洞原理
maskedPaths 用 /dev/null 做 bind-mount 覆盖敏感路径(/proc/kcore、/proc/sysrq-trigger 等)。漏洞在于:runc 没有充分验证 bind-mount 的源(容器的 /dev/null)确实是真实的 /dev/null inode。攻击者可通过共享 mount(shared mount)的竞态,在容器创建过程中篡改 /dev/null。
两个攻击面
Attack 1:任意挂载 gadget
攻击者把 /dev/null 替换为指向攻击者控制路径的符号链接,runc 随即把"任意源路径"bind-mount 到容器内路径。
┌────────────────────────────────────────────────────────────────┐
│ CVE-2025-31133 Attack 1:挂载 gadget │
├────────────────────────────────────────────────────────────────┤
│ │
│ 攻击者: rm /dev/null && ln -s /proc/sys/kernel/core_pattern │
│ /dev/null │
│ │
│ runc maskPath("/proc/kcore"): │
│ mount("/dev/null", "/proc/kcore", MS_BIND) │
│ ↓ 解析符号链接 │
│ 实际执行: mount("/proc/sys/kernel/core_pattern", │
│ "/proc/kcore", MS_BIND) │
│ │
│ 宿主机 DoS: bind-mount /proc/sysrq-trigger → echo c 触发 │
│ 容器逃逸: bind-mount /proc/sys/kernel/core_pattern │
│ → 重配 coredump helper │
│ → 内核 upcall 不受 namespace 隔离 │
│ → 以宿主机 root 运行 │
│ │
└────────────────────────────────────────────────────────────────┘
Attack 2:绕过 maskedPaths
攻击者在 runc 做 bind-mount 前删除 /dev/null,runc 因 ENOENT 静默跳过 maskedPaths,导致 /proc/kcore 等敏感文件对容器可读,泄露宿主机内核内存。
漏洞代码
// libcontainer/rootfs_linux.go (修复前)
func maskPath(path string, mountLabel string) error {
if err := mount("/dev/null", path, "", unix.MS_BIND, ""); err != nil && !errors.Is(err, os.ErrNotExist) {
if errors.Is(err, unix.ENOTDIR) {
return mount("tmpfs", path, "tmpfs", unix.MS_RDONLY, ...)
}
return err
}
return nil
}
问题在于 !errors.Is(err, os.ErrNotExist) 这个判断:当源路径 /dev/null 不存在(被攻击者删除)时,mount 返回 ENOENT,被静默忽略,maskedPaths 直接失效。同时,对源 inode 的真实性完全没有验证——只要路径存在就 bind-mount,不校验它是不是真正的字符设备 (1,3)。
补丁分析(4 个 commit)
核心补丁 8476df8 新增 /dev/null inode 验证:
func isDevNull(st *unix.Stat_t) bool {
return st.Mode&unix.S_IFMT == unix.S_IFCHR && st.Rdev == unix.Mkdev(1, 3)
}
func verifyDevNull(f *os.File) error {
return sys.VerifyInode(f, func(st *unix.Stat_t, _ *unix.Statfs_t) error {
if !isDevNull(st) {
return errors.New("container's /dev/null is invalid")
}
return nil
})
}
补丁 1a30a8f 修正了 ENOENT 的处理语义:ENOENT 只在 mount 目标侧(被覆盖的 path)忽略,不再对源侧(/dev/null)静默跳过。即如果源 /dev/null 不存在,应直接报错而非静默放行。
五、CVE-2025-52565 深入:/dev/console 的竞态掉包
漏洞原理
runc 把宿主机分配的 /dev/pts/$n bind-mount 到容器内 /dev/console,用于容器标准输入输出。问题在于 runc 分配 /dev/pts/$n 与实际执行 bind-mount 之间存在一个时间窗口。攻击者可在这个窗口内把 /dev/pts/$n 替换为符号链接,使 runc 把链接目标文件挂进容器。
┌────────────────────────────────────────────────────────────────┐
│ CVE-2025-52565:/dev/console 竞态掉包 │
├────────────────────────────────────────────────────────────────┤
│ │
│ T0: runc 分配 pty → 得到 /dev/pts/3 │
│ │
│ T1: [竞态窗口] 攻击者 goroutine 循环执行: │
│ os.Remove("/dev/pts/3") │
│ os.Symlink("/proc/sys/kernel/core_pattern", │
│ "/dev/pts/3") │
│ │
│ T2: runc mount("/dev/pts/3", "/dev/console", MS_BIND) │
│ ↓ 解析符号链接 │
│ 实际: mount("/proc/sys/kernel/core_pattern", │
│ "/dev/console", MS_BIND) │
│ │
│ 结果: 宿主机 /proc/sys/kernel/core_pattern 挂进容器可写 │
│ → 重配 coredump helper → 宿主机 root 命令执行 │
│ │
└────────────────────────────────────────────────────────────────┘
伪代码攻击逻辑
// 攻击者在独立 goroutine 中循环竞态
for {
os.Remove(fmt.Sprintf("/dev/pts/%d", n))
os.Symlink("/proc/sys/kernel/core_pattern", fmt.Sprintf("/dev/pts/%d", n))
}
这个竞态窗口虽然窄,但循环尝试成功率可观,且攻击者完全控制触发时机(只要能诱导宿主机执行 docker run / K8s 调度 / CI 构建即可)。
补丁分析(8 个 commit)
- 使用
TIOCGPTPEERioctl 无竞态获取 pty peer fd(Linux 4.13+ 支持),直接通过已打开的 fd 拿到 peer,绕过路径解析,从根上消除 TOCTOU。 - 对
/dev/pts/$n、/dev/ptmx做 inode 验证(与 CVE-2025-31133 的verifyDevNull思路一致)。 - 禁止
os.Create等不安全调用(新增 lint 规则),避免代码中再次出现基于路径的竞态写。
核心思路:用 fd 而非路径来引用对象,从语义上消除路径解析与对象操作之间的时间差。
六、CVE-2025-52881 深入:procfs 写入重定向
漏洞原理
CVE-2025-52881 是 CVE-2019-19921 的变体,攻击 runc 对 procfs 的写入逻辑。runc 在设置 LSM 标签(AppArmor/SELinux)和 sysctl 时需要写 /proc/self/attr/<label> 等 procfs 文件。漏洞有两条路径:
路径 A:no-op procfs 文件伪装
攻击者让 /proc/self/attr/<label> 引用一个真实的 procfs 文件,但属于 no-op(无副作用)的文件,例如 /proc/self/sched。这样能通过 runc 的"is a procfs file"检查,却不会真正设置 LSM 标签,导致 LSM 防护被静默绕过。
路径 B:共享 mount 竞态重定向
通过共享 mount 竞态,用 tmpfs 上的符号链接把 runc 对 /proc 的写入重定向到其他 procfs 文件。
┌────────────────────────────────────────────────────────────────┐
│ CVE-2025-52881:procfs 写入重定向 │
├────────────────────────────────────────────────────────────────┤
│ │
│ runc 期望写入: /proc/self/attr/current (设置 LSM 标签) │
│ │
│ 路径 A: 攻击者把目标替换为 /proc/self/sched (真实 procfs) │
│ → 通过 "is procfs" 检查 │
│ → 写入是 no-op,LSM 标签未设置 │
│ → AppArmor/SELinux 防护被绕过 │
│ │
│ 路径 B: 共享 mount 竞态 │
│ /proc/self/attr/current → symlink → /proc/sys/... │
│ → sysctl 写入重定向 │
│ │
│ 最危险场景: │
│ sysctl 写入重定向到 /proc/sys/kernel/core_pattern │
│ → 容器逃逸 (kernel upcall 以宿主机 root 运行) │
│ │
└────────────────────────────────────────────────────────────────┘
最危险场景
sysctl 写入被重定向到 /proc/sys/kernel/core_pattern,进而触发内核 upcall 以宿主机 root 身份执行任意命令,完成容器逃逸。这是三个 CVE 中唯一能独立完成逃逸的路径。
补丁分析(16 个 commit)
这是补丁量最大的一个,核心是引入基于 fd 的安全 procfs API(依赖 securejoin v0.5.0):
- 用
openat2+O_PATH打开目标后fstat确认st_dev对应 procfs。 fstatfs确认文件系统魔法数为PROC_SUPER_MAGIC。- 所有 procfs 写操作改用基于 fd 的安全 API,杜绝路径解析竞态。
// 补丁思路伪代码
fd, err := unix.Openat2(dirfd, path, &unix.OpenHow{
Flags: unix.O_PATH | unix.O_CLOEXEC,
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS,
})
// ...
var st unix.Stat_t
unix.Fstat(fd, &st)
var sfs unix.Statfs_t
unix.Fstatfs(fd, &sfs)
if sfs.Type != unix.PROC_SUPER_MAGIC {
return errors.New("not a procfs file")
}
// 基于 fd 执行写入,而非基于路径
openat2 的 RESOLVE_NO_SYMLINKS 从内核层面禁止符号链接解析,RESOLVE_BENEATH 限制路径不能逃逸出指定目录,两者结合把 TOCTOU 攻击面压缩到几乎为零。
七、链式利用分析
三个 CVE 并非孤立,它们可以组合成更强的攻击链。关键在于 CVE-2025-52881 能绕过 LSM 标签,而 LSM 是 CVE-2025-31133 和 CVE-2025-52565 在某些发行版上的缓解措施。
┌─────────────────────────────────────────────────────────────────┐
│ 链式利用组合矩阵 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ① CVE-2025-52881 单独 │
│ → 绕过 LSM 标签 (AppArmor 默认 unconfined 时直接生效) │
│ → 或通过 sysctl 写入重定向独立逃逸 │
│ │
│ ② CVE-2025-52881 + CVE-2025-31133 │
│ → 52881 绕过 LSM,使 31133 的 AppArmor 缓解失效 │
│ → 31133 的挂载 gadget 不再受 LSM 拦截 │
│ │
│ ③ CVE-2025-52881 + CVE-2025-52565 │
│ → 52881 绕过 LSM,使 52565 的 SELinux 缓解失效 │
│ → 52565 的 console 掉包不再受 SELinux 拦截 │
│ │
│ ④ CVE-2025-52881 独立逃逸 │
│ → sysctl 写入重定向到 core_pattern │
│ → 不依赖其他 CVE │
│ │
└─────────────────────────────────────────────────────────────────┘
完整攻击链流程
┌─────────────────────────────────────────────────────────────────┐
│ 完整攻击链 (端到端) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 阶段0: 攻击者立足容器内 │
│ (恶意镜像 / 应用层 RCE / 供应链投毒) │
│ │ │
│ ↓ │
│ 阶段1: 布置符号链接 │
│ rm /dev/null && ln -s /proc/sysrq-trigger /dev/null │
│ (或 /dev/pts/$n / /proc/self/attr/* 竞态循环) │
│ │ │
│ ↓ │
│ 阶段2: 触发 runc 创建容器 │
│ (docker run / K8s 调度 / CI docker build) │
│ │ │
│ ↓ │
│ 阶段3: runc bind-mount 被掉包路径 │
│ → 宿主机敏感文件挂进容器 │
│ │ │
│ ↓ (可选: CVE-2025-52881 绕过 LSM) │
│ 阶段4: 逃逸到宿主机 root │
│ ├─ 读: /etc/shadow / kubelet 凭证 / SA token │
│ ├─ 写A: echo c > /proc/sysrq-trigger (宿主机崩溃) │
│ └─ 写B: 改 /proc/sys/kernel/core_pattern │
│ → 宿主机 root 命令执行 │
│ │
└─────────────────────────────────────────────────────────────────┘
值得强调的是 core_pattern 逃逸是经典路径:内核在处理进程 coredump 时会执行 core_pattern 指定的程序,这个 upcall 不受 namespace 隔离,且以 root 身份运行。只要能把 /proc/sys/kernel/core_pattern 挂进容器并写入,就等同于宿主机 root 命令执行。
八、受影响版本
| 分支 | 受影响版本 | 修复版本 |
|---|---|---|
| 1.2.x | <= 1.2.7 | 1.2.8 |
| 1.3.x | <= 1.3.2 | 1.3.3 |
| 1.4.x | <= 1.4.0-rc.2 | 1.4.0-rc.3 |
此外,CVE-2025-52881 还影响 opencontainers/selinux <= 1.12.0,需升级到 1.13.0。值得注意的是,crun 和 youki 等其他 OCI 运行时也存在类似缺陷,说明这不是 runc 独有的实现 bug,而是这一类"信任未验证 inode"设计模式的通病。
由于 runc 是底层底座,实际风险面要乘以所有上层:Docker、containerd、CRI-O、Kubernetes 各发行版、各云厂商的托管 K8s,都需要跟进底层 runc 版本升级。
九、防御方案
1. 升级 runc(首要)
升级到 1.2.8+ / 1.3.3+ / 1.4.0-rc.3+,同时升级 opencontainers/selinux 到 1.13.0+。这是最直接、最彻底的修复。
2. 使用 user namespace(核心缓解)
runc 强烈推荐的核心缓解措施。user namespace 把容器内的 root 映射为宿主机上的非特权 uid,即使容器逃逸,攻击者在宿主机上也只是非特权用户,无法读写 /proc/sys/kernel/core_pattern 等需要真实 root 的路径。
┌──────────────────────────────────────────────────────────────┐
│ user namespace 的缓解效果 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 无 user namespace: │
│ 容器 root == 宿主机 root (uid 0) │
│ → 逃逸后可直接写 /proc/sys/kernel/core_pattern │
│ │
│ 有 user namespace: │
│ 容器 root (uid 0) → 映射为宿主机 uid 100000 │
│ → 逃逸后对 /proc/sys/* 无写权限 │
│ → core_pattern 逃逸路径失效 │
│ │
└──────────────────────────────────────────────────────────────┘
3. Rootless 容器
Rootless 模式下 runc 本身以非特权用户运行,从根本上限制逃逸后的权限上限。
4. 非 user namespace 容器的加固
对于无法启用 user namespace的场景:使用非 root 用户运行容器进程 + noNewPrivileges,限制逃逸后的横向移动能力。
5. 运行时监控
监控容器内对 /dev/null、/dev/pts/*、/proc/*/attr/* 的异常操作(删除、符号链接替换、非常规写入),这些是三个 CVE 利用过程中的共性特征,可作为检测信号。
十、关键启示
1. 同一设计盲区的三次暴露。 三个 CVE 表面上攻击不同的 bind-mount 点,本质都是 runc 信任了"本该由它自己控制的文件路径"是真实、未被篡改的。一个设计盲区被打了三次补丁,说明这类"信任未验证 inode"的模式在容器运行时中具有普遍性。
2. 防护机制变成攻击通道。 maskedPaths 本是为了保护宿主机不被容器读取敏感文件,结果因为信任 /dev/null,反而成了把宿主机敏感文件挂进容器的"挂载 gadget"。安全机制本身成为攻击面,是值得警惕的反模式。
3. 竞态窗口是核心利用面。 三个漏洞都依赖"路径解析"与"对象操作"之间的时间差。修复方向高度一致:用 fd 而非路径引用对象(TIOCGPTPEER、openat2+O_PATH、基于 fd 的 procfs API),从语义上消灭 TOCTOU。
4. LSM 不是银弹。 CVE-2025-52881 专门绕过 LSM 标签,使得 CVE-2025-31133/52565 在依赖 AppArmor/SELinux 缓解的发行版上重新变得可利用。纵深防御的每一层都可能被单独击穿,不能把 LSM 当作唯一防线。
5. core_pattern 逃逸是经典路径。 三个 CVE 中两个都把 /proc/sys/kernel/core_pattern 作为逃逸目标,因为内核 upcall 不受 namespace 隔离且以 root 运行。这再次印证:跨 namespace 边界的内核 upcall 是容器逃逸的高价值目标,任何能控制该文件写入的路径都应被视为高危。
┌─────────────────────────────────────────────────────────────────┐
│ 修复范式的统一性 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CVE-2025-31133 → verifyDevNull (fstat 校验 Rdev == (1,3)) │
│ CVE-2025-52565 → TIOCGPTPEER + inode 验证 (fd 直取 peer) │
│ CVE-2025-52881 → openat2+O_PATH + fstatfs (PROC_SUPER_MAGIC)│
│ │
│ 共同范式: │
│ 1. 用 fd 而非路径引用对象 │
│ 2. 操作前验证对象的内核身份 (inode/设备号/魔法数) │
│ 3. 禁止对源侧 ENOENT 静默放行 │
│ │
└─────────────────────────────────────────────────────────────────┘
三个 CVE 的修复范式高度统一,反映出容器运行时安全正在从"基于路径的信任"向"基于 fd + 内核身份验证的零信任"演进。这对其他 OCI 运行时(crun、youki)乃至更广义的"操作不可信 rootfs"场景都有方法论上的参考价值。
免责声明
本文仅用于安全技术研究和学习交流,旨在帮助安全研究人员、运维工程师和开发人员理解漏洞原理并采取相应的防御措施。文中涉及的漏洞利用方法、攻击链描述均基于公开的安全公告和补丁分析,不包含可直接用于攻击的完整 exploit 代码。
读者应严格遵守所在国家和地区的网络安全法律法规,未经授权对任何系统进行漏洞测试或攻击均属违法行为。因不当使用本文信息造成的任何后果,作者不承担任何法律责任。
请尽快将受影响的 runc 版本升级至修复版本,并结合自身环境采取相应的纵深防御措施。
浙公网安备 33010602011771号