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.71.6.271.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 并构建出恶意镜像,直接运行镜像即可成功利用。

/images/cve-2024-21626-escape-via-crafted-image.gif

  • 先启动一个容器,并在容器中为 /proc/self/fd/8 创建符号链接 /foo。此处使用 debian:bookworm 镜像。

  • 接着在新的窗口中使用 docker exec 命令在新创建的容器中执行 sleep 命令,并设置工作目录为 /foo

  • 在容器中找到 sleep 命令的 PID,然后通过其 /proc/<PID>/cwd 即可访问到宿主机文件系统。

/images/cve-2024-21626-escape-via-exec.gif

漏洞分析

首先简要描述当执行 docker run 命令创建并运行一个容器时,几个组件之间的整体调用关系是怎样的;然后通过 runc run 命令复现漏洞,并说明漏洞的形成原理;接着解释为什么通过 docker run 命令创建的容器中 /sys/fs/cgroup 目录对应的文件描述符为 8,而不是 7。最后对官方修复代码进行分析。

当执行 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-v2Create 方法创建一个容器时,containerd-shim-runc-v2 会执行 runc create 命令创建容器。当 containerd 调用 Run 方法运行一个容器时,containerd-shim-runc-v2执行 runc start 命令运行指定的容器

另外,containerd 把对 runc 命令的调用封装成了一个单独的库 go-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

/images/cve-2024-21626-reproduce-via-runc.gif

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 文件。

相关函数调用链为:

runc 在 2020 年 12 月 4 日的代码中引入了 openat(2) 的支持,即 1.0.0-rc93 版本。简言之,使用 openat(2) 可以避免在容器的 mount 命名空间中挂载宿主机文件系统的目录时潜在的逃逸风险,详细问题不在此处展开,可以查看相关文章以及 openat(2) 的文档。

这个问题与 Golang 的运行时有关。首先 0、1、2 三个文件描述符毫无疑问代表标准输入、标准输出和标准错误;打开命令行参数指定的日志文件(3);Golang 程序刚启动时运行时会创建一个 epoll 文件描述符(4)和一个管道(5、6);接着初始化 cgroup 管理模块时打开 /sys/fs/cgroup 目录(7)。

  • 打开日志文件在前,Golang 运行时创建 epoll 文件描述符和管道在后的原因与 Golang 运行时的实现有关,由于篇幅限制不展开,后续会新开一篇文章讲解。

从前述调用链可知,最终执行 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 两个进程的文件描述符关系。

/images/rpc-socket-passed-to-runc.png

由于在 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。

/images/sys-fs-cgroup-with-fd-8.png

上图是 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 的实现机制有关。

在前面使用 runc 复现漏洞时,如果不指定 --log 参数的话 /sys/fs/cgroup 目录的文件描述符会变成 3,此时是无法传递给子进程的,原因是与标准库 os/execexec.(*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 次代码 8e1cd2f2f16289c93dee7309,其中后三次都是在产生子进程前关闭不需要的文件描述符或者给文件描述符设置 O_CLOEXEC 标志位,第一次提交的代码对当前工作目录作校验,看其是否属于容器中的目录。

漏洞检测

根据漏洞利用过程,可以总结出漏洞利用过程中几条进程行为特征:

  • 在容器中会产生当前工作目录(cwd)形如 /proc/self/fd/<fd> 的进程。
  • 在容器中会产生目标目录为形如 /proc/self/fd/<fd>symlink(2)symlinkat(2) 系统调用。
  • 在容器中会产生 open/openat/openat2 系统调用,且文件名具有 /proc/\d+/cwd/.* 的正则表达式特征。

synk 官方提供了一个基于 eBPF 的检测程序 leaky-vessels-dynamic-detector。

根据上述漏洞利用特征编写如下 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)

/images/cve-2024-21626-detect-via-falco.gif

参考资料