关键词:容器逃逸痕迹、特权容器、hostPath、Docker API 暴露、containerd-shim、runtimeClass、静态 Pod、DaemonSet 持久化
难度:进阶
前置知识:Linux 命名空间与 capability 机制、cgroup v1/v2、Docker overlay2 存储驱动、容器编排基础
一、概述
在案件里,"容器里出现了宿主机的东西"往往比"容器里跑着恶意程序"更能定性。
因为容器逃逸类案件的定罪关键从来不是"容器内有什么恶意文件",而是"攻击者是否离开了容器原有的隔离边界"。一旦突破,宿主机上的所有数据都进入攻击范围,后续的横向与持久化痕迹也才得以解释。
本篇把这类案件拆成三个层次:
| 层次 |
要回答的问题 |
主要证据面 |
| ① 隔离状态 |
事发时这个容器本来是不是被削弱了隔离? |
docker inspect 的 HostConfig、Pod spec、审计日志 requestObject |
| ② 突破痕迹 |
宿主机上有没有容器不该有的访问痕迹? |
/proc 下的 namespace/capability 差异、宿主机新文件、cgroup 异常 |
| ③ 持久化 |
攻击者如何在重建后活下来? |
DaemonSet、静态 Pod、镜像层改写、/etc/systemd |
本篇最需要反复强调的一点:容器内改动默认只存在于 upperdir,随 docker rm 一起消失(见 Docker 取证工件地图)。
因此对逃逸类案件,取证时机的意义比任何一条命令都重要——容器一停一删,最关键的"突破时刻"就永远拿不到了。
必须先内存、后状态、最后文件系统。
在取证流程中的位置:本篇属于分析环的判定层,跨采集与分析两环:它既要抢易失证据(第 1 步),又要在固定下来的配置与状态里做判定。
它与前面几篇的分工是明确的——镜像层与 overlay2 解析 告诉你层里有什么,Volume 与绑定挂载取证 告诉你数据落在哪,本篇告诉你这些配置组合起来危不危险、运行时是不是真的被削弱了。
涉及的证据形态(四道隔离边界,每道都有可观测点):
| 边界 |
机制 |
观察点 |
| 命名空间 |
mount / pid / net / uts / ipc / user |
/proc/<pid>/ns/* 的 inode 号;mountinfo 里是否出现宿主机路径 |
| Capability |
裁剪进程能力集 |
/proc/self/status 的 CapEff——0000003fffffffff = 全集 |
| cgroup |
CPU / 内存 / PIDs / 设备 |
/proc/<pid>/cgroup 路径层级,v1 与 v2 格式完全不同 |
| 只读根 + Seccomp + LSM |
rofs、seccomp、AppArmor/SELinux |
mountinfo 中 / 的挂载选项;Seccomp: 0 与 NoNewPrivs: 0 |
CapEff 是最有价值的一行:正常应用容器的 capability 集合通常只有十几项,看到 0000003fffffffff 就说明这个进程拥有全部能力。Linux 引入 capability 机制后,判断"是否特权"不再需要猜。
高危配置清单(本篇最需要交付给委托方的一张表):这些是"把隔离关掉"的配置选择,不是漏洞。判定依据全在 HostConfig 与 Pod spec 里——--privileged(所有隔离全部关闭,★★★★★)、hostPath 挂载(挂 / 即交出根权限,★★★★★)、挂载 docker.sock(持有 Docker API 完全控制权,★★★★★)、挂载宿主 /etc/kubernetes(可读 kubelet 凭据与 PKI,★★★★★)、--device=/dev/sda(可直读宿主机磁盘,★★★★★)、--cap-add=SYS_ADMIN、--pid=host(可读 /proc/*/environ 取凭据)、--net=host(直连 localhost 上的数据库)、kind: DaemonSet(每个节点都跑一份的持久化载体)、静态 Pod(绕过 API Server 与调度)。
Docker API 暴露(2375 端口)必须单独说:2375 是明文、无认证的 TCP 端口。只要它在监听且可达,任何人都能创建特权容器、挂载宿主机根目录、读取任意文件——不需要任何漏洞。 daemon.json 里的 hosts 字段是直接证据,比推断"是否被利用"更可靠。
能回答什么:事发时这个容器的隔离配置是什么、哪些边界被削弱了、宿主机上有没有容器不该有的访问痕迹、攻击者用哪种方式活了下来。
不能回答什么:配置高危不等于被利用——这是本领域最高频的误判方向。--privileged 可能是一开始就为调试而开,DaemonSet 可能是运维部署的。配置说明的是"具备条件",突破痕迹才说明"实际发生了",两者必须分开表述。另一个边界是:/proc 侧的观察需要容器仍在运行,已删除容器的隔离状态只能从残留配置推断。
内容边界(本篇的红线):只写防守侧的检测与排除误报——如何识别高危配置、如何判断突破痕迹、如何区分正常运维与攻击行为。不提供任何逃逸实现、不涉及提权方法、不给出突破隔离的具体操作步骤。 涉及 Docker API、capability、namespace 时,止步于"这个配置意味着什么、怎么观察到"。
读者前提:需要 Linux 的 namespace / capability / cgroup 机制(见 proc 虚拟文件系统取证)、容器运行时基础,以及 Docker 取证工件地图 与 镜像层与 overlay2 解析 的前置知识。如果主机还在线,强烈建议先读 3.1 第 1 步——逃逸类案件丢的不是命令,是时间。
二、核心原理
2.1 容器隔离的四道边界
理解"逃逸"必须先理解容器究竟靠什么把进程关住。这四道边界任意一道被配置削弱或被绕过,就可能脱离容器。
| 边界 |
机制 |
取证时的观察点 |
| 命名空间 |
mount / pid / net / uts / ipc / user |
/proc/<pid>/ns/* 的 inode 号;/proc/self/mountinfo 里是否出现宿主机路径 |
| Capability |
裁剪进程能力集 |
** /proc/self/status 的 CapEff**(0000003fffffffff = 全集,含 CAP_SYS_ADMIN) |
| cgroup 资源限制 |
CPU / 内存 / PIDs / 设备 |
/proc/<pid>/cgroup 路径层级 |
| 只读根 + Seccomp + LSM |
rofs、seccomp、AppArmor/SELinux |
/proc/self/mountinfo 中 / 的挂载选项;/sys/kernel/security/apparmor |
# 容器内执行:CapEff 全为 f 即拥有全部 capability
grep -E 'Cap(Inh|Prm|Eff|Bnd|Amb)|NoNewPrivs|Seccomp' /proc/self/status
# CapInh: 0000000000000000
# CapPrm: 0000003fffffffff ← ★ 全集
# CapEff: 0000003fffffffff ← ★ 全集 = 特权运行
# NoNewPrivs: 0
# Seccomp: 0 ← ★ 0 = 未启用 seccomp 过滤
⚠️ Seccomp: 0 与 NoNewPrivs: 0 同时出现要单独记录。 默认的非特权容器通常有 Seccomp: 2(filter 模式)。seccomp 被关闭会让 syscall 面大幅放开,这是"配置本应安全、实际被削弱"的直接证据。
2.2 cgroup 路径:判断"我在哪"与"我是谁"
/proc/self/cgroup 是区分"进程在容器内"与"在宿主机上"最省事的手段,因为容器运行时必然把它写进 cgroup 路径。
# 容器内:路径里带容器 ID
cat /proc/self/cgroup
# 12:memory:/docker/6d4f7b2c1a9b... ← ★ cgroup v1 风格
# 0::/system.slice/docker-6d4f7b2c1a9b...scope ← cgroup v2 风格
# kubelet 管理的容器:
# 0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/...
# 宿主机上:路径里没有容器标识
cat /proc/self/cgroup | grep -qE 'docker|kubepods|containerd|crio' && echo "在容器内" || echo "在宿主机"
| 观察结果 |
含义 |
路径含 docker-<id> 或 /kubepods |
该进程在容器内 |
路径含 system.slice 且无容器标识 |
在宿主机系统作用域 |
| 容器内却读到宿主机根 cgroup 路径 |
★ 强烈提示 hostPID/hostcgroup 被共享,或该进程已在宿主机上下文运行 |
cgroup 版本差异必须注意:cgroup v1 有 memory:、cpu: 等十余条层级,v2 统一为 0:: 单行。v2 下 docker-<id>.scope 结尾的容器 ID 是 64 位十六进制,可用于与 docker inspect 的容器 ID 前缀比对。
2.3 mountinfo:能不能看到宿主机,是最直观的判据
/proc/self/mountinfo 列出当前 mount namespace 内所有挂载点。普通容器里它只有二十几行;逃逸到宿主机的进程会看到宿主机真实文件系统。
wc -l < /proc/self/mountinfo # ★ 行数是最快的分界参考
# 找宿主机特有的挂载点(容器内不应出现)
grep -E ' / /(ext4|xfs|btrfs) | /boot | /etc/kubernetes | /var/lib/docker | /home ' /proc/self/mountinfo
# 检查是否挂载了宿主机目录
grep -E 'docker\.sock|containerd\.sock|/var/lib/kubelet|/etc/kubernetes' /proc/self/mountinfo
# 是否看到宿主机块设备
ls /dev/ | head -20
lsblk 2>/dev/null
⚠️ /proc/self/mountinfo 里出现 /var/lib/docker 或 /etc/kubernetes,几乎可以断定是 hostPath 挂载或已逃逸到宿主机。 但要注意区分两种情况:配置层面的 hostPath(docker inspect 里有 HostConfig.Binds)与运行结果的挂载(mountinfo 里有)要分别取证,前者证明"被配置成高危",后者证明"确实被访问过"。
2.4 高危配置清单:哪些参数等于"等于没隔离"
这是本文最需要交付给委托方的一张表。这些是"把隔离关掉"的配置选择,不是漏洞。
| 配置 |
在哪里看 |
隔离后果 |
风险等级 |
--privileged |
HostConfig.Privileged=true |
所有隔离全部关闭:cap 全集、禁用 seccomp、启用所有设备、AppArmor 转 unconfined |
★★★★★ |
--cap-add=SYS_ADMIN |
HostConfig.CapAdd |
获得可挂载、管理 cgroup、改 namespace 的大部分能力 |
★★★★☆ |
--pid=host |
HostConfig.PidMode="host" |
可见宿主机全部进程(可读 /proc/*/environ 取环境变量与凭据) |
★★★★☆ |
--net=host |
HostConfig.NetworkMode="host" |
直接使用宿主机网络栈,可访问 localhost 上的数据库/管理端口 |
★★★★☆ |
--ipc=host |
HostConfig.IpcMode="host" |
共享宿主机 IPC,可与宿主机进程交换数据 |
★★★☆☆ |
--uts=host |
HostConfig.UTSMode="host" |
共享主机名等 UTS 信息,影响有限但仍是配置失当 |
★★☆☆☆ |
hostPath 挂载 |
HostConfig.Binds / Pod volumes[].hostPath |
等于把宿主机目录的读写权交给容器;挂 / 即等于交出根权限 |
★★★★★ |
| 挂载 docker.sock |
Binds 含 /var/run/docker.sock |
持有 Docker API 的完全控制权,可创建任意特权容器 |
★★★★★ |
挂载宿主 /etc/kubernetes |
hostPath.path |
可读 kubelet 凭据与 PKI,集群控制面失守 |
★★★★★ |
--security-opt seccomp=unconfined |
HostConfig.SecurityOpt |
关闭系统调用过滤 |
★★★☆☆ |
--device=/dev/sda |
HostConfig.Devices |
可直读宿主机磁盘块设备 |
★★★★★ |
--user 0 + root |
Config.User 为空或 0 |
容器内即 root,配合任何上述能力都会放大后果 |
★★★☆☆ |
K8s 侧对应项:
| Pod 字段 |
等价含义 |
取证路径 |
spec.containers[].securityContext.privileged: true |
--privileged |
审计日志 requestObject;/var/lib/kubelet/pods/<uid>/config.yaml |
spec.hostNetwork / hostPID / hostIPC: true |
同上三项 |
审计日志 requestObject |
spec.volumes[].hostPath.path |
把宿主机路径交给容器 |
审计日志;configmap/静态 Pod 清单 |
spec.volumes[].hostPath.path: /var/run/docker.sock |
Docker API 控制权 |
同上 |
kind: DaemonSet |
每个节点都跑一份的持久化载体 |
kubectl get ds -A |
静态 Pod(/etc/kubernetes/manifests/) |
kubelet 直接拉起,绕过 API Server 与调度 |
见第 04 篇 |
spec.nodeName 被手工指定且带 hostPID |
直接落地到具体节点 |
审计日志 |
runtimeClassName 指向自定义 runtime |
可能使用非标准隔离配置 |
kubectl get runtimeclass |
serviceAccount + automountServiceAccountToken: true |
SA token 泄露影响面(见第 05 篇) |
Pod spec |
2.5 Docker API 暴露:暴露即等于宿主机 root
2375 是 Docker 的明文、无认证 TCP 端口;2376 是 TLS 版本。只要 2375 在监听且可达,任何人都能创建特权容器、挂载宿主机根目录、读取任意文件——不需要任何漏洞。
# 宿主机侧:是否监听
ss -lntp | grep -E ':2375|:2376'
# ★ 出现 0.0.0.0:2375 即为高危配置
# 配置来源
grep -nE '"hosts"' /etc/docker/daemon.json
systemctl show docker | grep -i execstart
# 是否有 TLS 校验
ls -l /etc/docker/certs.d/ 2>/dev/null
取证意义:daemon.json 里的 hosts 字段是直接证据,比推断"是否被利用"更可靠。若审计显示有来自外部的 create/start 容器请求,则说明该端口曾被实际使用。
⚠️ 区分"配置了"与"被利用了":daemon.json 里写了 0.0.0.0:2375 只能证明配置失当;证明被利用需要网络侧日志(防火墙/IDS/云厂商流量审计)、容器创建记录(/var/lib/docker/containers/*/config.v2.json)与镜像拉取记录三者至少两项相互印证。
2.6 运行时痕迹:containerd-shim 与 runc 状态
从 Docker 迁移到 containerd 的环境里,宿主机上会残留大量 containerd-shim 与 runc 相关痕迹,它们是容器"曾经运行过"的时间证据。
| 痕迹 |
位置 |
取证意义 |
containerd-shim 进程 |
ps / /proc/*/cmdline |
★ 每个运行中的容器对应一个 shim,父进程是 containerd;shim 数量 ≈ 该节点运行过的容器数上限 |
| shim 的 cwd 与 rootfs |
/proc/<shim-pid>/cwd、/proc/<shim-pid>/root |
指向 /run/containerd/io.containerd.runtime.v2.task/<ns>/<id>,可反查容器 ID |
| runc 状态目录 |
/run/containerd/io.containerd.runtime.v2.task/<ns>/<id>/ |
含 log.jsonl(该容器的完整生命周期日志) |
| cgroup 残留 |
/sys/fs/cgroup/.../docker-<id>.scope |
容器删除后 cgroup 可能仍在,说明删除不彻底 |
| 运行时 socket |
/run/docker.sock、/run/containerd/containerd.sock、/var/run/crio/crio.sock |
存在即可被挂载进容器 |
| namespace 残留 |
/proc/<pid>/ns/* 的 inode |
同一 inode = 同一 namespace,共享 namespace 的容器即可互相看到 |
# 运行中的容器对应的 shim(★ 父进程是 containerd)
ps -ef | grep '[c]ontainerd-shim' | head -20
# 由 shim 反查容器 ID 与 rootfs
for p in $(pgrep -f containerd-shim); do
echo "shim $p cwd=$(readlink /proc/$p/cwd 2>/dev/null)"
done
# runc 生命周期日志(★ 记录 create/start/delete 的完整过程)
ls -l /run/containerd/io.containerd.runtime.v2.task/*/*/log.jsonl 2>/dev/null
# 残留 cgroup
find /sys/fs/cgroup -maxdepth 4 -name 'docker-*.scope' 2>/dev/null | head
# ★ 运行时 socket 是否存在
ls -l /run/docker.sock /run/containerd/containerd.sock 2>/dev/null
⚠️ log.jsonl 与容器生命周期日志容易被运维清理。 在 /run 下(tmpfs,重启即失)必须第一时间固定;/var/lib/docker/containers/<id>/<id>-json.log(json-file 驱动)虽在磁盘上,但会随 docker rm 删除。这是容器取证的"双重时间窗"(详见第 01 篇)。
2.7 K8s 层的异常形态
| 异常 |
为什么高危 |
检查方式 |
| 特权 Pod |
见 2.4 |
审计日志 requestObject;kubectl get pod -A -o json |
hostPath 挂载 / |
宿主机根目录写权限 |
同上,grep "path":"/" |
| DaemonSet |
每个节点都跑一份,是最常见的 K8s 持久化载体 |
kubectl get ds -A -o wide |
| 异常 ClusterRoleBinding 指向 cluster-admin |
提权后行为的容器 |
kubectl get clusterrolebinding -o json;见第 05 篇 |
| 静态 Pod |
绕过 API Server 与调度器,kubelet 直接拉起;API 审计可能记录不全 |
/etc/kubernetes/manifests/*.yaml |
Pod 指定了 nodeName 但无对应控制器 |
手工落地的"影子 Pod" |
审计日志中 create pods 的 requestObject.spec.nodeName |
| 镜像来源异常 |
私库直连 IP、:5000、可变 tag latest |
审计日志 requestObject.spec.containers[].image |
容器内无 imagePullPolicy: Always 却用 latest |
节点缓存导致"实际运行版本≠声明版本" |
Pod spec 与节点缓存镜像 digest 比对 |
imagePullSecrets 异常 |
拉取凭证被滥用 |
Secret 审计(见第 05 篇) |
三、操作步骤
3.1