打破Docker:runC容器逃逸漏洞的深入分析及多种利用方法(CVE-2019-5736)

41yf1sh 漏洞 2019年2月25日发布
Favorite收藏

导语:本文将在runC容器逃逸漏洞基础上,深入分析这一漏洞,并探讨多种漏洞利用方法。

概述

2019年2月11日,runC的维护团队报告了一个新发现的漏洞,该漏洞最初由Adam Iwaniuk和Borys Poplawski发现。该漏洞编号为CVE-2019-5736,漏洞影响在默认设置下运行的Docker容器,并且攻击者可以使用它来获得主机上的root级访问权限。

runC维护团队成员Aleksa Sarai发现LXC存在同样的缺陷。但与Docker相反,只有特权LXC容器才容易受到攻击。runC和LXC都对漏洞进行了修复,并发布了新的版本。

该漏洞一经公布,就获得了非常多的关注,许多技术站点和商业公司都撰写专门的文章来说明这一漏洞。在Twistlock,我们的CTO John Morello撰写了一篇精彩的文章,其中包括Twistlock平台提供的所有相关细节和缓解措施。

最初,官方表示漏洞利用代码在2月18日之前不会公开发布,以防止恶意攻击者在用户还没有及时更新的时间段内进行该漏洞的武器化利用。但是,在漏洞披露后的几天内,有几个人决定发布他们自己的漏洞利用代码,这直接导致了runC团队在2月13日就公布了他们的漏洞利用代码。因为,他们已经无法阻止人们对这一漏洞的积极研究。

关于该漏洞的基本描述、发现过程和概念证明,请阅读《影响大量云服务厂商的严重漏洞:runC容器逃逸漏洞分析(CVE-2019-5736)》。本文将在此基础上,深入分析这一漏洞,并探讨多种漏洞利用方法。

关于RunC

RunC是一个容器运行时,最初是作为Docker的一部分开发的,后来作为一个单独的开源工具和库被提取出来。作为“低级别”容器运行时,runC主要由“高级别”容器运行时(例如Docker)用于生成和运行容器,尽管它可以用作独立工具。

像Docker这样的“高级别”容器运行时通常会实现镜像创建和管理等功能,并且可以使用runC来处理与运行容器相关的任务:创建容器、将进程附加到现有容器等。

Procfs

要了解漏洞,我们首先需要了解一些procfs的基础知识。Proc文件系统是Linux中的一个虚拟文件系统,主要提供有关进程的信息,通常安装在/proc上。它在某种意义上来说是虚拟的,因为它在磁盘上实际不存在。相反,内核会在内存中创建它。它还可以被认为是一个内存为文件系统公开的系统数据接口。每个进程在procfs中都有自己的目录,位于/proc/[pid]:

1.png

如上图所示,/proc/self是指向当前正在运行进程的目录的符号链接(在本例中为pid 177)。每个进程的目录中都包含几个文件和目录,其中包含有关该进程的信息。与本次研究的漏洞相关的几个信息是:

· /proc/self/exe – 进程正在运行的可执行文件的符号链接;

· /proc/self/fd – 包含进程打开的文件描述符的目录。

例如,我们运行ls /proc/self可以列出/proc/self下的文件,我们能够看到,/proc/self/exe指向“ls”可执行文件。

2.png

这是有道理的,因为访问/proc/self的正是Shell生成的“ls”进程。

漏洞分析

让我们回顾一下runC团队提供的漏洞概述:

该漏洞允许恶意容器(以最少的用户交互)覆盖主机runc二进制文件,从而在主机上蝴蝶root级别代码执行。用户交互的级别能够允许上下文中的任何一个容器以root身份运行任何命令:

1、使用攻击者控制的映像创建新的容器;

2、将(docker exec)附加到攻击者之前具有写入权限的已有容器中。

这两种情况看起来可能不同,但都需要runC来启动容器中的新进程,并以类似的方式实现。在这两种情况下,runC的任务是在容器中运行用户定义的二进制文件。在Docker中,这个二进制文件是启动新容器时映像的入口点,或者是附加到现有容器时的docker exec参数。

运行这个用户二进制文件时,它必须已经被限制在容器内,否则可能会威胁主机安全。为了实现这一点,runC创建了一个名为“runC init”的子进程,它将所有需要的限制放在其自身(例如:输入、设置命名空间),并有效地将其自身放置在容器中。然后,现在在容器中的runC init进程调用execve系统调用,使用用户请求的二进制文件覆盖自身。

3.png

这是runC用于创建新容器以及将进程附加到现有容器的方法。

4.png

研究人员发现,攻击者可以通过要求runC运行/proc/self/exe来欺骗runC执行其自身,这是一个指向主机上runC二进制文件的符号链接。

5.png

然后,容器中具有root访问权限的攻击者可以使用/proc/[runc-pid]/exe作为对主机上runC二进制文件的引用,并对其进行覆盖。由于runC二进制文件由root拥有,因此需要在容器中进行root访问后才能执行此攻击。在下次执行runC时,攻击者将在主机上实现代码执行。由于runC通常以root身份运行(例如:通过Docker守护程序),因此攻击者将获得主机上的root访问权限。

* 为什么不使用runC init?

上面的图片可能会误导一些读者,让人以为漏洞(即欺骗runC执行其自身)是多余的。也就是说,为什么攻击者不能简单地覆盖/proc/[runc-init-pid]/exe呢?

针对类似runC漏洞的修补程序CVE-2016-9962可以缓解此类攻击。CVE-2016-9962漏洞具体来说,是runC init进程拥有来自宿主机的打开文件描述符,容器中的攻击者可以利用它来遍历主机的文件系统,从而打开容器。这个漏洞的部分修补程序在进入容器之前就将runC init进程设置为“不可转储”(Non-dumpable)。

在CVE-2019-5736的上下文中,“不可转储”标志拒绝其他进程解除引用/proc/[pid]/exe,因此缓解了通过/proc/[runc-init-pid]/exe [1]覆盖runC二进制文件的问题。调用execve将会丢弃此标志,因此新的runC进程/proc/[runc-pid]/exe将可以访问。

* 符号链接问题

该漏洞似乎与Linux中实现符号链接的方式相矛盾。

符号链接只是保持其目标的路径。对于runC进程,/proc/self/exe中应该包含类似/usr/sbin/runc的内容。

当进程访问符号链接时,内核使用链接中存在的路径在访问进程的根目录下查找目标。

这就引出了一个问题——当容器中的进程打开runC二进制文件的符号链接时,为什么内核不会在容器根目录中搜索runC路径?

答案是,/proc/[pid]/exe不遵循符号链接的正常语义。从技术上讲,这可能算作违反POSIX,但正如我之前所提到的,procfs是一个特殊的文件系统。当进程打开/proc/[pid]/exe时,没有正常的读取和跟踪符号链接内容的过程。相反,内核只是让用户直接访问打开的文件条目。

漏洞利用

报告漏洞后不久,在还没有公开发布概念证明(PoC)时,我尝试根据LXC的修复补丁(https://github.com/lxc/lxc/commit/6400238d08cdf1ca20d49bafb85f4e224348bf9d)中给出的漏洞详细描述来自行开发PoC。大家可以在这里看到PoC的完整代码:https://github.com/twistlock/RunC-CVE-2019-5736/tree/master/exec_POC

接下来,让我们逐条分析LXC对漏洞的描述:

当runC附加到容器时,攻击者可以欺骗它执行其自身。这可以通过使用指向runC二进制文件本身的自定义二进制文件来替换容器内的目标二进制文件来完成。例如,如果目标二进制文件是/bin/bash,那么就可以用指定解释器路径#!/proc/self/exe的可执行脚本来替换。

#!语法被称为Shebang,在脚本中用于指定解释器。当Linux加载器遇到Shebang时,将会运行解释器,而不是可执行文件。

视频:https://asciinema.org/a/228389

如视频中所示,最终由加载程序执行的程序是:

interpreter [optional-arg] executable-path

当用户运行docker exec container-name /bin/bash之类的条目时,加载器将识别修改后的bash中的Shebang,并执行我们指定的解释器,也就是/proc/self/exe,这是runC二进制文件的符号链接。

我们可以通过/proc/[runc-pid]/exe从容器中的单独进程覆盖runC二进制文件。

6.png

然后,攻击者可以继续写入/proc/self/exe的目标,以尝试覆盖主机上的runC二进制文件。但是一般来说,这不会成功,因为内核不允许在runC执行时覆盖它。

基本上,如果我们不能在进程运行时覆盖runC二进制文件。然而,如果runC进程退出,那么/proc/[runc-pid]/exe也会随之消失,我们将丢失对runC二进制文件的引用。为了解决这一问题,在我们的进程中打开/proc/[runc-pid]/exe进行读取,这会在/proc/[our-pid]/fd/3中创建一个文件描述符。

然后我们等待runC进程退出,然后继续打开/proc/[our-pid]/fd/3进行写入,并覆盖runC。

下面是overwrite_runc的代码,为简短起见,我们进行了相应的缩短。

7.png

我们来看看!漏洞利用的输出显示了覆盖runC所要采取的步骤。我们可以看到,runC进程正在运行(PID 20054)。

视频:https://asciinema.org/a/228632

使用这种方法,只有一点不足之处,就是它需要一个额外的过程来运行攻击者的代码。由于容器只使用一个进程启动(即:Docker的映像入口点),因此无法使用此方法创建在运行时威胁到主机的恶意映像。

目前,还有其他一些实施与之类似方法的PoC,分别是由Frichettenfeexd提出的思路。

共享库方法

在runC维护团队发布的官方PoC中,使用了一种不同的利用方法,并且优于我提出的PoC,因为它可以通过两种不同的方法实现对主机的危害:

1、当用户将命令执行到现有攻击者控制的容器中时;

2、当用户运行恶意映像时。

我们现在将研究构建恶意映像,因为之前的PoC中已经展现了第一个场景。我认为这种方法写出来的PoC很大程度是基于q3k的PoC(https://github.com/q3k/cve-2019-5736-poc),据我所知,q3k是第一个发布的恶意映像PoC。大家可以在这里看到完整的PoC代码:https://github.com/twistlock/RunC-CVE-2019-5736/tree/master/malicious_image_POC

让我们来看看用于构建恶意图像的Dockerfile。首先,将映像的入口点设置为/proc/self/exe,以便在运行映像时欺骗runC执行其自身。

# Create a symbolic link to /proc/self/exe and set it as the image entrypoint
RUN set -e -x ;\
   ln -s /proc/self/exe /entrypoint
ENTRYPOINT [ "/entrypoint" ]

RunC在运行时动态链接到多个共享库,我们可以使用ldd命令列出。

8.png

当在容器中执行runC进程时,动态链接器会将这些库加载到runC进程中。我们可以使用恶意版本替换其中一个库,这将在加载到runC进程时覆盖runC二进制文件。

我们的Dockerfile构建了libseccomp库的恶意版本:

# Append the run_at_link function to the libseccomp-2.3.1/src/api.c file and build libseccomp
ADD run_at_link.c /root/run_at_link.c
RUN set -e -x ;\
   cd /root/libseccomp-2.3.1 ;\
   cat /root/run_at_link.c >> src/api.c ;\
   DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -b -uc -us ;\
   dpkg -i /root/*.deb

Dockerfile将run_at_link.c的内容附加到libsecomp的源文件之中。随后,将构建恶意libsecomp。

9.png

构造函数属性(GCC语法)表明,在动态链接器将库加载到runC进程之后,run_at_link函数将作为libseccomp的初始化函数执行。由于run_at_link将由runC进程执行,因此它可以访问/proc/self/exe中的runC二进制文件。

runC进程必须退出,才能使runC二进制文件可写。为了强制执行退出,run_at_link调用execve系统调用,来执行overwrite_runc。

由于execve不会影响进程打开的文件描述符,因此可以使用前一个PoC中相同的文件描述符技巧:

1、runC进程加载libseccomp库,并将执行转移到run_at_link函数。

2、run_at_link打开runC二进制文件,以读取/proc/self/exe。这会在/proc/self/fd/${runc_fd_read}创建一个文件描述符。

3、run_at_link调用execve来执行overwrite_runc。

4、该进程不再运行runC二进制文件,overwrite_runc打开/proc/self/fd/runc_fd_read进行写入,并覆盖runC二进制文件。

我们构建了一个恶意映像,使用一个简单的脚本覆盖runC二进制文件,该脚本将在2345端口产生反向Shell。

视频:https://asciinema.org/a/228625

Docker run命令将执行runC两次。一旦创建并运行容器,就将会执行PoC以覆盖runC,然后再次使用runc delete停止容器。

在第二次执行runC时,它就已经被覆盖,因此会执行反向Shell脚本。

漏洞修复

RunC和LXC都是用了相同的方法进行修复,这在LXC补丁提交中有明确说明:

为了防止这种攻击,LXC已经发布补丁,以便在启动或附加到容器时创建调用二进制文件本身的临时副本。为此,LXC使用memfd_create()系统调用创建一个匿名的内存文件,并将其自身复制到临时内存文件中,然后将其封装以防止进一步修改。随后,LXC执行该封装后的内存文件,而不是磁盘上原始的二进制文件。从特权容器到主机LXC二进制文件的任何妥协写入操作都将写入到临时内存中的二进制文件,而不是磁盘上的宿主机二进制文件,从而保证宿主机二进制文件的完整性。此外,由于临时的内存LXC二进制文件已经被封装,因此写入也会失败。

RunC已经使用了相同的方法进行修复。它在启动或附加到容器时,从其自身的临时副本重新执行。因此,/proc/[runc-pid]/exe现在指向临时文件,并且无法从容器内访问runC二进制文件。临时文件也被封装,以阻止任何写入操作,尽管对它的覆盖不会影响到宿主机。

这个补丁说明了一些问题。在runC init进程已经将容器的cgroup内存约束应用于其自身之后,临时runC副本将在内存中创建。在运行具有相对较低内存限制(例如10MB)的容器时,如果runC init进程连接到容器,可能会导致内核中的进程被破坏(内存不足)。

如果大家感兴趣,我们可以在后续详细讨论这一复杂问题,并重点讨论如何能实现修复并保证不再引入相同的问题。

CVE-2019-5736和特权容器

作为我们的一个经验,我们认为特权容器(特定的容器运行时)比非特权容器(同一运行时)更加不安全。

之前我们说过,漏洞将会影响所有Docker容器,但只影响LXC的特权容器。那么,为什么Docker非特权容器存在漏洞,而LXC非特权容器就不存在呢?这是因为,LXC与Docker是以不同方式来定义特权容器的。实际上,根据LXC的理念,Docker非特权容器实际上被认为是具有特权的。

特权容器的定义是,容器uid 0映射到宿主机uid 0的任何容器。

其主要区别在于,默认情况下LXC在单独的用户命名空间中运行了非特权容器,而Docker则没有。

用户命名空间是Linux的一项功能,可以用于将容器root和宿主机root区分开。容器内的root以及所有其他用户将映射到宿主机上的非特权用户。换而言之,进程可以对容器内的操作具有root访问权限,但对于其外部的操作没有特权。如果大家想了解更加深入的解释,我推荐大家阅读LWN的命名空间系列文章

10.png

那么,在用户命名空间中运行容器如何缓解此漏洞呢?

攻击者是容器内的root用户,但是映射到宿主机后就变为非特权用户。因此,当攻击者试图打开宿主机的runC二进制文件并进行写入时,内核会拒绝执行这一操作。

大家可能要问,为什么Docker默认情况下不会在单独的用户命名空间中运行容器呢?这是因为,用户命名空间在容器的上下文中确实存在一些缺点,这有点超出了本文的范围。如果大家感兴趣,Dockerrkt(另一个容器运行时)都列出了在用户命名空间中运行容器的限制。

结语

希望本篇文章能让各位读者对这个漏洞具有更加深入地了解。如果大家使用的是runC、Docker或LXC,请记得更新到最新版本。如果大家有任何问题,欢迎随时通过电子邮件与我们联系。

本文翻译自:https://www.twistlock.com/labs-blog/breaking-docker-via-runc-explaining-cve-2019-5736/如若转载,请注明原文地址: https://www.4hou.com/vulnerable/16361.html
点赞 0
  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论