一、背景

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)

  • 使用 TIOCGPTPEER ioctl 无竞态获取 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 执行写入,而非基于路径

openat2RESOLVE_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 而非路径引用对象(TIOCGPTPEERopenat2+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 版本升级至修复版本,并结合自身环境采取相应的纵深防御措施。