CVE-2019-5736浅析
本文的封面图来源于bang_dream_gbp在X(前Twitter)上的推文,版权归原作者所有。
背景
容器架构
Docker等容器采用分层架构,大致可以分为三层:
- dockerd(API层):处理高层逻辑。负责镜像管理、网络分配及存储卷挂载。
- containerd(管理层):容器生命周期守护进程。负责拉取镜像、利用OverlayFS准备根文件系统(rootfs),并调用底层的运行时。
- runc(执行层):OCI标准运行时。它是宿主机上的一个轻量级二进制文件(
runc,对于Docker而言为docker-runc),通过clone、pivot_root等系统调用配置Namespace、Cgroups等,创建隔离环境,并最终在隔离区内执行目标命令。每当执行docker exec时,宿主机会启动一个新的runc实例进入已有的隔离空间。
(关于更多内容,参见《Journey From Containerization To Orchestration And Beyond》)
ETXTBSY
根据POSIX标准(IEEE Std 1003.1):
[ETXTBSY]The file is a pure procedure (shared text) file that is being executed and oflag is O_WRONLY or O_RDWR.
如果二进制文件正在被进程执行,那么无法以写入模式(O_WRONLY或O_RDWR)打开并修改它。 内核会直接抛出ETXTBSY错误。
runc是沟通宿主机和容器的桥梁。当其进入容器命名空间执行指令时,runc本身会暴露在容器的视角中。但在正常逻辑中,内核的ETXTBSY机制会禁止任何进程修改正在运行的二进制镜像,这能够防止容器内进程对宿主机实体的反向篡改。
攻击流程
简而言之,旧版本的runc在容器内重新执行自身时,意外暴露了宿主机可执行文件在物理层面的写入路径。Dragon Sector指出了该漏洞的攻击流程:
- 攻击者在容器内,把某个常用命令(如
/bin/sh)替换成符号链接,直接指向/proc/self/exe。 - 宿主机的管理员执行
docker exec -it <Container ID> /bin/sh。宿主机上的/usr/bin/runc进程启动,配置好Namespace等隔离机制后,进入容器内部。 runc执行容器中的/bin/sh,顺着软链接执行/proc/self/exe,即重新执行了自己一次。- 此时
runc已处于容器的文件系统中,因此会加载容器内部相应的动态链接库。而攻击者已将容器里相关的链接库替换,其带有恶意全局构造函数,会在runc的main函数之前运行。 - 恶意代码先以只读模式(
O_RDONLY)打开/proc/self/exe,拿到其文件描述符后,启动另一个独立的恶意脚本,并向之传递文件描述符。 - 当原本的
runc进程退出后,独立运行的恶意脚本可以以写入模式(O_WRONLY)重新打开它,并将之覆写为恶意程序。只要宿主机再调用runc,恶意代码就会在宿主机执行。
他们还提到,也可以不采用替换动态链接库的方式。当runc调用execve系统调用,去执行#!/proc/self/exe时,其dumpable标志位被重置为1。这意味着其他进程可以读取该进程的信息。因此,攻击者可以在容器中执行恶意程序,判断/proc/<runc PID>/exe是否可访问;当其可访问时,可将之打开,拿到指向宿主机/usr/bin/runc的文件描述符,随后不断尝试写入。下面的复现流程即采用该方法。
复现
该仓库提供了漏洞的PoC。其首先覆写了/bin/sh文件,然后不断查找runc对应的PID,最终尝试将恶意代码写入该文件。考虑到演示效果,若攻破宿主机,则会在/tmp中创建tomorin_was_here文件,并写入内容MyGO!!!!!。为避免链接库版本差异而带来的问题,我采用静态编译(即传入CGO_ENABLED=0)。
为了避免污染宿主机,我使用Vagrant搭建虚拟机环境,并采用Libvirt作为后端。虚拟机中使用Metarget构建环境。对于该漏洞,使用Ubuntu 18.04可以搭建相关环境。
首先创建后台运行的普通容器,名为victim:
1 | sudo docker run -d --name victim ubuntu:18.04 sleep infinity |
随后将生成的二进制文件复制到该容器中,并执行:
1 | sudo docker cp /home/vagrant/shared_code/CVE-2019-5736/poc/exploit victim:/exploit |

该容器中的/bin/sh即被替换成了指向/proc/self/exe的软链接,该容器已被污染。
当管理员尝试打开该容器时:
1 | sudo docker exec -it victim /bin/sh |
可以看到容器中的恶意程序旋即打开了宿主机上的/usr/bin/docker-runc,并将其覆写为恶意程序。同时containerd可能是为了清理残留状态,再次调用了runc,恶意代码得以执行。因此可以观察到/tmp/tomorin_was_here文件被创建,并写入了MyGO!!!!!:
1 | ls -l /tmp/tomorin_was_here |

修复与后续
runc于该commit中修复了该漏洞。其核心是通过memfd_create系统调用切断容器到物理文件的连接。
修复后的runc在进入容器前,会在内存中创建独立的匿名文件,将自身的二进制代码拷贝至此,并执行密封操作。随后,程序转为执行该内存副本。即使容器内进程通过/proc/self/exe等方式获取到fd,也无法触及宿主机的/usr/bin/runc物理文件。此外,由于内存中的可执行文件被密封,因此也不会遭到修改。
LXC开发者Christian Brauner在其博文中批评了包括Docker在内的特权容器,称这种容器内部的root(UID 0)等同于宿主机的root,因此是「不安全的组件」,无法实现所谓的「root安全性」(即即便容器里的用户获得了root权限,也无法对宿主机安全构成威胁)。因此,他极力倡导将非特权容器作为默认标准。