容器逃逸与运行时痕迹

在案件里,"容器里出现了宿主机的东西"往往比"容器里跑着恶意程序"更能定性。

最后更新 2026-10-09版本 v1.0维护 DigiForensics查看历史 0

关键词:容器逃逸痕迹、特权容器、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

安全验证 当前请求需要先完成一次滑块验证。