Agent Runtime 容器逃逸与 K8s 攻击面

容器逃逸与 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
  1. 默认没有 SYS_ADMIN(21)、SYS_PTRACE(19)、DAC_READ_SEARCH(2)、SYS_MODULE(16)、NET_ADMIN(12)——大多数逃逸手法的第一前置条件,就是这些位里的某一位被平台多加出来。
  2. 默认就有 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
2
3
挂载 cgroup → mkdir 子 cgroup → 写 notify_on_release=1
→ 写 release_agent=<宿主路径脚本> → 把某进程 pid 写入 cgroup.procs
→ 进程退出、子 cgroup 变空 → 内核以 root 执行 release_agent

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
2
3
unshare -UrmC(新 user+mount+cgroup ns)→ 在新 userns 里你是 root
→ 挂载 cgroup v1 的某个 root 层级(如 rdma)→ release_agent 可写
→ 走 release_agent 链

在新 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。逃逸的三种绕法:

  1. 找到可写的 devices.allow。某些 runtime/编排会把子 cgroup 以 rw 委派进容器(自检的做法是遍历 cgroup 挂载,避开 root 那个、专找”可写的那一个”),此时往 devices.allow 追加 a(全部放行),mknod 宿主盘即通。
  2. 摘掉 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 它上面的程序——门禁程序本身不防”管理员摘门禁”。
  3. 经 lxcfs rw 开门,见下。

lxcfs 为什么是特殊攻击面。lxcfs 是一个 FUSE 文件系统,给容器伪造”好看”的 /proc 与 /sys/fs/cgroup 视图。若它以 rw 挂载,FUSE 会把写操作转发到真实 cgroup 文件——容器以为自己在写”自己那层 cgroup”,实际写的是宿主的 devices 门禁。路径:

1
2
读 devices.list 拿到宿主盘主次设备号 → 写 devices.allow 开门
→ mknod b <major> <minor> → 直接 open 宿主块设备

挂载自检会专门扫”挂载源为 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
2
3
4
5
6
7
8
Agent pod
→ 自检:CapEff 无 SYS_ADMIN、无 docker.sock → SA token 却列得出 namespace
→ 裸 HTTP(不带 token / 带 SA token)GET /api/v1/nodes → 确认工牌权限
├─ 权限够 → 直接用 SA token 拉 secret / 建影子 apiserver
└─ 权限不够 → 逃逸(若平台发了 privileged/hostPath)→ Node
→ 扫宿主机找 kubeconfig / etcd 证书
→ etcd /v3/kv/range 拿全集群 secret
→ 影子 apiserver + DaemonSet 后门 → 集群已在手,审计无痕

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 倾销的指纹。