容器逃逸与 K8s 攻击面的最大来源不是漏洞,而是平台为了让 Agent 容器”能干活”而主动下发的特权配置——privileged、rw 挂载、宿主 socket、高权 ServiceAccount,每一条都是一条现成的接管路径。
1. 容器到底裁剪了什么
容器不是虚拟机:进程共享宿主内核,隔离只是”裁剪”。裁剪分四类:
- namespace——裁剪视图:看不到宿主进程(pid)、网络(net)、挂载(mnt)、主机名(uts)、用户(user)等;
- capabilities + seccomp——裁剪权限:capability 是内核里按位授予的特权(CAP_SYS_ADMIN 等),seccomp 是 syscall 级门禁;
- cgroup——裁剪资源与设备:cpu/memory 配额,以及 devices 控制器这个”设备访问门禁”;
- mount——裁剪可见性:把宿主文件系统/设备/socket 挂进容器 = 把宿主对象直接递到攻击者手上。
逃逸的定义因此很干净:把被裁剪的视图或权限恢复到宿主级。
被裁剪后剩什么,Linux 全部用文件暴露。这是容器自检工具能”站在容器里看穿配置”的根本原因——自检不需要先逃逸,读几个 /proc 与挂载表就能把可用的攻击面枚举完:
| 自检项 | 读什么 | 回答什么问题 |
|---|---|---|
| capabilities | /proc/self/status 的 CapEff/CapBnd | 我手里有哪几位 cap |
| mounts | /proc/self/mountinfo | 有没有 rw 的 lxcfs/procfs、宿主盘 |
| cgroups | /proc/self/cgroup + cgroupfs | cgroup v1/v2、是否在 root cgroup |
| namespace isolation | /proc/self/ns/* 的 inode | 六个 namespace 是否真隔离 |
| network namespace | /proc/net/unix | 能否看见宿主 socket(hostNetwork) |
| K8s 身份 | SA token + apiserver | 当前工牌/匿名通道对集群有什么权限 |
| 云元数据 | 169.254.169.254 等 | 云实例角色是否可达 |
| 内核 | 内核版本 | 跑 linux-exploit-suggester 给候选 CVE |
CapEff 是一串位掩码。以一个典型值 0x00000000a80425fb 为例,逐位解码后恰好是 Docker 默认下发的 14 个 cap:
| 位 | cap | 位 | cap |
|---|---|---|---|
| 0 | CHOWN | 8 | SETPCAP |
| 1 | DAC_OVERRIDE | 10 | NET_BIND_SERVICE |
| 3 | FOWNER | 13 | NET_RAW |
| 4 | FSETID | 18 | SYS_CHROOT |
| 5 | KILL | 27 | MKNOD |
| 6 | SETGID | 29 | AUDIT_WRITE |
| 7 | SETUID | 31 | SETFCAP |
- 默认没有 SYS_ADMIN(21)、SYS_PTRACE(19)、DAC_READ_SEARCH(2)、SYS_MODULE(16)、NET_ADMIN(12)——大多数逃逸手法的第一前置条件,就是这些位里的某一位被平台多加出来。
- 默认就有 MKNOD(27)。也就是说”造设备节点”默认是允许的,真正拦住容器访问宿主盘的不是 cap,而是 cgroup 的 devices 控制器。这把”设备门禁”变成设备型逃逸的唯一枢纽,第二类所有路径都在绕它。
高危位速查:
| 位 | cap | 拿到后意味着 |
|---|---|---|
| 21 | SYS_ADMIN | mount、namespace、cgroup 配置面全开——逃逸总开关 |
| 19 | SYS_PTRACE | 注入宿主可见进程,借宿主进程的手 |
| 2 | DAC_READ_SEARCH | open_by_handle_at 绕过路径权限读任意文件 |
| 16 | SYS_MODULE | 直接加载内核模块 = 宿主 root |
| 12 | NET_ADMIN | 宿主网络面(iptables/tun/raw socket) |
| 27 | MKNOD | 造设备节点(配合门禁放开) |
先破一个常见误解:seccomp 并不负责挡 mount。Docker 默认 profile 是白名单制(defaultAction=SCMP_ACT_ERRNO,放行约 410 个 syscall),mount、umount2、setns、unshare 都在放行名单里——seccomp 这层根本不拦挂载。普通容器真正挂不上宿主盘,是因为没有 CAP_SYS_ADMIN:mount 在内核 capability 检查处就直接返回 EPERM。–privileged 之所以一次拆三道——给满 capabilities、绕过 seccomp、放开 devices cgroup——是因为隔离本来就是这几道闸叠出来的,只拆一道到不了宿主级。
2. 逃逸分类 & 前置条件矩阵
把攻击面手法按机制分成五类:
第一类是可写内核对象:cgroup、procfs、lxcfs 都不是普通文件系统,写这些”文件”等于写内核对象;拿到 rw 挂载或能挂载,就等于拿到内核配置面的写入权,逃逸只是挑一个”内核会替你执行”的入口。
cgroup v1 release_agent。机制链:
1 | 挂载 cgroup → mkdir 子 cgroup → 写 notify_on_release=1 |
release_agent 是内核的”回调注册”:cgroup 清空时内核替你执行一个路径。关键限制是 v1 里 release_agent 只有 root cgroup 可写,所以普通容器即使能挂载也写不了——逃逸 = 先拿到 root cgroup 的写权,两条路:容器本身 privileged,或用非特权 user namespace(见下)。
两个位置容易混:release_agent 文件本身只存在于该控制器树的根节点(子 cgroup 目录里没有),此”根”是 cgroup 树的顶,不是宿主文件系统的根目录;而写入的路径值由宿主内核在宿主自己的 mount namespace 里以 root 执行,不认容器路径——所以 payload 要先以宿主视角落盘(overlay 的 upper 层路径,可经 /etc/mtab 的 perdir 换算),再写 release_agent 指向它。
非特权 userns 组合。多数发行版默认允许非特权用户创建 user namespace(kernel.unprivileged_userns_clone=1,5.10+ 常见),且 Docker 默认 seccomp 放行 unshare。于是:
1 | unshare -UrmC(新 user+mount+cgroup ns)→ 在新 userns 里你是 root |
在新 user namespace 内获得 CAP_SYS_ADMIN 后,mount 的权限检查针对的是持有该 mount namespace 的 userns,因此能挂上本不该挂的 cgroup 层级。社区对 CVE-2022-0492 的分析(Unit42)讨论的正是这条滥用面。该链只在 cgroup v1 成立——v2 没有 release_agent 文件。
这条链还有两个常被忽略的前提:容器内身份要能映射成新 userns 的 root(root 容器天然满足,非 root 则把自己的 euid 映射为 0);容器得恰好挂在某控制器的根层级——/proc/self/cgroup 里路径为 / 的控制器才可用,常见命中无人使用的 rdma,5.13+ 有 misc。payload 落盘仍按上段规则。
procfs core_pattern。rw 的 procfs 挂载下,写 /proc/sys/kernel/core_pattern 为 |/宿主路径脚本,此后任意进程崩溃内核就以 root 执行该脚本。与 release_agent 同族:注册一个”内核将来替你执行”的路径,触发条件从”cgroup 清空”换成”进程崩溃”。
lxcfs rw 的 cgroup 变体:rw 挂载的 lxcfs 提供了非特权写 release_agent 的入口(为何等于拿到真实 cgroup 写权,在第二类展开),落点仍是这条链。
第二类绕的是设备门禁:默认 CapEff 里就有 MKNOD,所以造节点不是问题,让节点可用才是问题。门禁分两代:
- cgroup v1:/sys/fs/cgroup/devices/ 下的 devices.allow、devices.list、devices.deny 三个文件;
- cgroup v2:改为 eBPF 程序(BPF_PROG_TYPE_CGROUP_DEVICE)attach 到 cgroup。
Docker 默认放行面大致是”允许对块设备做 mknod,但只允许读写少数几个字符设备(null/zero/tty 等)”——所以默认容器 mknod /dev/sda 能成功,open 却拿 EPERM。逃逸的三种绕法:
- 找到可写的 devices.allow。某些 runtime/编排会把子 cgroup 以 rw 委派进容器(自检的做法是遍历 cgroup 挂载,避开 root 那个、专找”可写的那一个”),此时往 devices.allow 追加 a(全部放行),mknod 宿主盘即通。
- 摘掉 eBPF 门禁。特权容器能调 bpf(),且可以纯 syscall 实现、无任何库依赖:BPF_PROG_GET_NEXT_ID 遍历宿主全部 eBPF 程序 → BPF_PROG_GET_FD_BY_ID 拿 fd → BPF_PROG_DETACH 把 BPF_CGROUP_DEVICE 程序从当前 cgroup 摘掉。为什么能摘:cgroup 是共享层级对象,持有 cgroup fd 且有 attach 权限的内核就认为你有权 detach 它上面的程序——门禁程序本身不防”管理员摘门禁”。
- 经 lxcfs rw 开门,见下。
lxcfs 为什么是特殊攻击面。lxcfs 是一个 FUSE 文件系统,给容器伪造”好看”的 /proc 与 /sys/fs/cgroup 视图。若它以 rw 挂载,FUSE 会把写操作转发到真实 cgroup 文件——容器以为自己在写”自己那层 cgroup”,实际写的是宿主的 devices 门禁。路径:
1 | 读 devices.list 拿到宿主盘主次设备号 → 写 devices.allow 开门 |
挂载自检会专门扫”挂载源为 lxcfs 且选项含 rw”的条目,命中即提示走设备绕法。
第三类是直读直写:设备一旦可用,VFS 的文件权限模型就整体失效:
- 挂载宿主盘:特权容器 fdisk -l 枚举盘后逐个 mount 到 /tmp 下的随机目录。前提 = 特权或 CAP_SYS_ADMIN + 设备节点可见。
- 不挂载直接写盘:debugfs(ext2/3/4)与 xfs_db(xfs)是文件系统工具,直接以用户态打开块设备操作 inode/块,不需要 mount,也不经过文件权限与 LSM。按 fs 类型选 debugfs -w 或 xfs_db -x——“读盘但不能写盘”在这里不成立,-w 就是写。落盘后改 crontab、authorized_keys、替换 suid 即得宿主控制权。
- CAP_DAC_READ_SEARCH:不需要设备。该 cap 允许 open_by_handle_at 绕过路径 DAC 检查。技巧:拿一个从宿主 bind 进来的文件(默认 /etc/hostname)做锚点,statfs 识别 fs 类型,构造 root inode 的文件句柄(ext4 根 inode 恒为 2,xfs 为 128 + 12 字节 fid),open_by_handle_at + fchdir 直接落到宿主 fs 根,然后读 /etc/shadow 或 chroot(“.”) 起 shell。这个 cap 默认不下发,命中的场景多是”只读审计容器”之类被过度授权的角色。
第四类是借手:攻击者自己不需要任何宿主权限,只持有”一个宿主侧会替它执行的对象”。三个典型样本,按”借的是谁”排:
- 借 dockerd 的管理员身份(docker.sock / Docker API):/var/run/docker.sock(被挂进容器)或 2375(网络可达)暴露的是 dockerd 的无鉴权管理 API,等于把”宿主 docker 管理员”交到你手里。用法:让 dockerd 创建一个 privileged 且挂载宿主根目录的容器,新容器里 /host 就是宿主文件系统。整条链不需要容器内任何权限——命令是宿主 dockerd 替你下的。
- 借 shim 进程的宿主权限(CVE-2020-15257):每个容器旁边都有一个常驻的 shim,夹在 containerd 与容器之间做转接;它对外提供无鉴权的 ttrpc 接口,socket 是抽象命名空间里的 unix socket(@/containerd-shim/*.sock)——不占文件路径,只有同处一个网络命名空间才连得到。普通容器在独立 netns 里摸不到;hostNetwork 容器与宿主共享 netns,于是能连到同宿主其他容器的 shim,调 CreateTask 让 shim 以自己(宿主侧)的身份起进程。
- 借 runc”以 root 重开自己”(CVE-2019-5736):runc 是创建/进入容器的宿主程序,docker exec、stop 都会让它以 root 重新打开自己的二进制。容器刚创建时,pid 1 的 /proc/self/exe 正指向这个文件——容器内唯一摸得到宿主可执行文件的窗口。攻击者握住 fd,等宿主下次重开时覆写内容,宿主 runc 从此是后门。变体:把 /bin/sh 首行写成 shebang /proc/self/exe,再定位宿主 runc 进程经 /proc/PID/exe 拿 fd。
这类攻击在容器里留下的信号都很显眼:能看到 docker.sock、2375 端口、宿主进程、抽象 socket——自检分别对应挂载检查、敏感服务探测与网络命名空间检查。
第五类是 K8s 特供:K8s 以”功能”名义把宿主路径(hostPath)挂进 pod,逃逸点不在内核而在挂载本身。日志采集类 pod 常挂 hostPath /var/log;kubelet 的 /logs/ HTTP 端点(10250)会跟随 /var/log 下的 symlink 提供日志。若 pod 能在 /var/log 里写 symlink,指向 /etc/kubernetes、/var/lib/kubelet 乃至任意宿主路径,kubelet 读日志时就把宿主文件递了出来;rw 挂载时写面同样成立(社区多轮披露的 log-mount symlink 逃逸即此思路)。
把上文的每条路径压成一行,就是攻击者的”可用性清单”,也是平台的反制清单。注意”前置信号”列——每一条都能在进容器后先自检到,说明这类攻击在发生前就是可枚举、可扫描的:
| 逃逸路径 | 前置条件 | 类型 | 前置信号(可自检) | 根除 |
|---|---|---|---|---|
| 挂载 cgroup v1 写 release_agent | privileged/SYS_ADMIN + cgroup v1 | 配置 | cgroup 层级自检 | 去特权 |
| 非特权 userns 组合 | 非特权 userns 开启 + seccomp 放行 unshare + 容器 root/可映射 + cgroup v1 且存在根层级控制器 | 配置/特性 | seccomp + cgroup 层级自检 | 视发行版收紧 userns |
| rw procfs 写 core_pattern | rw procfs 挂载 | 配置 | 挂载自检 | 禁 rw procfs |
| rw lxcfs(release_agent/devices) | lxcfs rw 挂载 | 配置 | 挂载自检(专扫 lxcfs) | lxcfs 只读 |
| 可写 devices.allow | runtime 把子 cgroup 以 rw 委派 | 配置/runtime | cgroup 自检 | runtime 不委派 |
| 摘 eBPF devices 门禁 | privileged(能调 bpf)+ cgroup v2 | 配置 | cap/seccomp 自检 | 去特权 |
| 挂载宿主盘 | privileged + 设备可见 | 配置 | 挂载自检 | 去特权/设备过滤 |
| debugfs/xfs_db 直写 | 块设备可访问 | 配置 | 设备可达性 | devices 门禁收紧 |
| CAP_DAC_READ_SEARCH | 该 cap 被下发 | 配置 | cap 自检 | 不下发该 cap |
| docker.sock / Docker API | sock 挂载或 2375 可达 | 配置 | 挂载/敏感服务自检 | 不挂 socket、egress 收敛 |
| containerd-shim | hostNetwork | CVE-2020-15257 | 网络 ns 自检 | 禁 hostNetwork + 升级 |
| runc 覆写 | 能执行容器 + 旧 runc | CVE-2019-5736 | 内核/组件版本自检 | 升级 runc |
| kubelet /var/log symlink | hostPath /var/log | 特性 | 挂载自检 | 准入控制拦 hostPath |
| AF_ALG/splice 页缓存写原语(CVE-2026-31431) | 能读目标文件:默认容器内 suid;宿主文件需 bind 可达 | CVE-2026-31431 | 内核版本自检 | 内核补丁 |
3. K8s 控制面攻击面
先放模型:Pod 跑在 Node 上,控制面在集群侧,关系如下:
graph TD
P["Pod(含 Agent 容器)"] -->|"SA token 自动挂载"| A["apiserver :6443"]
P -.->|"逃逸"| N["Node / kubelet :10250"]
N -->|"kubeconfig / etcd 证书"| E["etcd :2379"]
A --> E
S["shadow-apiserver :9444"] -->|"AlwaysAllow + 直连同一 etcd"| E
- Pod 跑在 Node 上,Pod 内进程的身份是 ServiceAccount(SA),token 自动挂载在 /var/run/secrets/kubernetes.io/serviceaccount/token,权限由 RBAC 决定;
- apiserver 是唯一入口,etcd 是唯一存储;
- kubelet 在 Node 上以 10250 提供 pod 管理 API,是绕过 apiserver 的第二入口。
对一个 pod 而言,”能对集群干什么” = RBAC(SA) ∪ 网络可达性 ∪ 逃逸后的 Node 权限。K8s 侧的清单正好按这三项分组。
身份探测分两条线:
- 匿名通道:不带任何 token 请求 /apis、/api/v1/namespaces。集群若开了匿名通道(–anonymous-auth 且 RBAC 给 system:anonymous 授了权),匿名请求能列 namespace。
- ServiceAccount 工牌:读 pod 自动挂载的 SA token,做同样的请求,回答”当前工牌能干什么”。
两条线的判定信号一致:响应里出现 kube-system 字样。为什么拿它当信号:列 namespace 是最小可观测的特权样本,一个匿名/默认 SA 能列 namespace,通常意味着集群 RBAC 整体宽松或集群很老——足够进入利用阶段。
用”能建 pod”换目标 SA:若当前工牌有创建 pod 的权限,就在 kube-system 里造一个挂载目标高权限 SA 的 pod,把 token 外带——这是用建 pod 权限交换更高身份权限的典型手法。
为什么这种场合一般没有 kubectl:容器里通常没有现成的 kubectl,也没有可解析的 kubeconfig。最短路径是带一个静态编译的 kubectl(或直接裸 HTTP),以三种身份之一(default / anonymous / 指定 token 路径)对 apiserver GET/POST。
kubelet 路可以绕过 apiserver:kubelet 的 10250 端口暴露 /pods 与 exec 子资源。匿名或凭据可访问时,list 枚举全 Node 的 pod,exec 在任意 pod 里执行——不需要任何 apiserver 权限。这是”拿到 Node 网络可达性即集群内代码执行”的典型路径;配合第五类的 var-log 逃逸,kubelet 同时是逃逸入口和横向出口。
逃到 Node 后,攻击面从”容器内凭据”升级为”宿主机上所有凭据”:
- 目录扫密钥:扫 AKIA[0-9A-Z]{16}、client_secret、GCP service_account 等正则,kubeconfig、云密钥、CI 缓存都是目标;
- etcd 是终局:apiserver 只是代理,所有 secret 明文(除静态加密外)都在 etcd。拿到 apiserver manifest / kubeconfig 里的 etcd 客户端证书后,直接走 etcd v3 API /v3/kv/range 读 /registry/secrets/kube-system/;
- secret/configmap 直拉:RBAC 允许时直接用 SA token 拉 secret/configmap,不需要逃逸;
- 镜像仓库弱口令:容器镜像里常躺着部署密钥;
- 内网服务探测:扫 2375/2379/10250 等服务、识别服务网格与旧策略——PSP 在 1.25 已移除,还能读到基本等于”老集群且通常没 seccomp/没审计”;
- route_localnet:读 /proc/sys/net/ipv4/conf/all/route_localnet,该值若开启,集群内流量可打到 Node 回环上监听的服务(对应 kubernetes#92315 的讨论),构成 pod→宿主本地服务的直达通道。
影子 apiserver是控制面侧最”优雅”的一种做法:把真实 apiserver 的启动参数复制一份,正则改写三处——删掉所有 –audit-log-*、–authorization-mode=AlwaysAllow、–secure-port=9444,保留 etcd 客户端证书参数,起一个影子控制面直连同一个 etcd。效果:任意 RBAC 操作,且原集群的审计日志完全不可见——audit 是防御方唯一可信日志,影子绕开它而不动原 apiserver,连告警都省了。
把上述步骤串成一条完整攻击链:
1 | Agent pod |
4. 回到 Agent Runtime
为什么这篇要挂在 Agent Runtime 名下:企业云 Agent 几乎必然跑在 K8s 的 Pod 里,而 Agent 平台为了业务会主动下发第 3 章的东西——SA(甚至高权)、云实例角色(metadata 可达)、任务密钥。凭据收集与身份探测这两类手法,在经典攻击链里是”逃逸之后才摸得到”的,在 Agent 场景里是平台默认发到注入可达进程手里的。
对应上文矩阵,平台侧该做的是把每一行前置条件删掉:
- 容器层:禁 privileged / hostPath / hostNetwork / docker.sock,caps 白名单 + 保持默认 seccomp,lxcfs 一律只读;
- 身份层:SA 最小 RBAC + 短期凭据/Workload Identity,metadata 与 6443 走 egress 收敛;
- 检测层:audit 全开,并盯”新 apiserver 端口、新 DaemonSet、异常 etcd 连接”——这三样正好对应影子 apiserver、后门、secret 倾销的指纹。