Docker 沙箱逃逸深度复盘:CVE-2026-103916 中 sbx cp 符号链接如何击穿 AI Agent 信任边界

深度剖析 CVE-2026-103916:Docker 沙箱逃逸如何经由 sbx cp 符号链接绕过 allowlist,从架构信任边界、四位置路径解析缺陷到 Node 与 openat2 内核级修复方案,给出可落地的文件通道安全加固与升级至 0.51.0 的完整工程实践。

导语:Docker 沙箱逃逸事件(CVE-2026-103916)再次证明了一条朴素的安全定律——沙箱的强度等于它最薄弱的那条出入口。Docker Sandboxes 用 microVM 把 Claude Code、Codex、Gemini CLI 这类编码智能体关进了独立内核,allowlist 机制本身没有失守,失守的是它旁边那条从未被安全模型纳入视野的 sbx cp 文件通道。本文以资深架构师视角,完整拆解这条侧信道的攻击链、修复为何需要两次、以及一条真正尊重边界的文件通道在代码层应该长什么样。

🎯 核心技术Docker Sandboxes / sbx cp / 符号链接解析
⚡ 架构难度资深架构级 (Level 3-4)
🚀 核心收益CVSS 8.2 高危漏洞闭环 / 侧信道全面收敛
🚨 严重隐患告警

CVE-2026-103916 的 CVSS 3.1 评分为 8.2(High)。若你正在使用 Docker Sandboxes 承载 AI 编码智能体,请立即升级至 0.51.0。在完成升级之前,任何跨工作区边界的 sbx cp 操作都应被视为潜在的越权写入。

一、Docker 沙箱逃逸的攻击面全景:主通道守住了,侧信道失守了

要理解这次 Docker 沙箱逃逸为什么成立,必须先理解 Docker Sandboxes 的安全模型设计意图。它把智能体放进一台拥有独立内核、独立文件系统、独立网络的 microVM 中,宿主只把用户显式授权的目录(通常是项目工作区)共享进 VM。这些被授权目录构成一份 allowlist,是智能体理论上唯一能够触达的宿主区域。

这套设计在「共享文件夹」这条主通道上是成立的:路径校验、权限检查、测试覆盖都围绕它展开。问题出在架构师最容易忽略的地方——所有代表操作者(operator)执行的文件搬运动作,都会天然继承操作者的信任级别。复制、导出、下载、制品上传,这些动作在安全模型里被默认为「用户自己的行为」,因此不经过 allowlist 校验。而当被搬运的路径由智能体控制时,这份信任就被花在了智能体想指向的任何地方。

📐 Docker 沙箱逃逸攻击链与信任边界全景
企业级架构规范

flowchart TD
    subgraph 沙箱层 ["隔离执行层 (microVM Sandbox Tier)"]
        A["AI Agent (Claude Code / Codex / Gemini CLI)"] --> B["工作区读写权限 (Workspace RW)"]
        B --> C["植入符号链接 (Symlink Plant)"]
    end
    subgraph 通道层 ["文件搬运侧信道 (Side Channel Tier)"]
        C --> D["sbx cp 命令 (Operator 权限执行)"]
        D --> E["路径解析 (resolve symlinks)"]
        E --> F["-L 校验仅覆盖末位 (Last Position Only)"]
    end
    subgraph 宿主层 ["宿主文件系统 (Host Filesystem Tier)"]
        F --> G["越界写入 / 越界读取 (Out-of-Bounds Access)"]
        G --> H[("工作区外敏感文件")]
    end
    subgraph 修复层 ["边界加固 (Boundary Enforcement Tier)"]
        I["realpath 逐级解析父目录"] --> J["O_NOFOLLOW 拒绝末位链接"]
        J --> K["openat2 RESOLVE_BENEATH 内核级约束"]
    end
  
图 1:Docker 沙箱逃逸攻击链全景,展示智能体如何借 sbx cp 侧信道穿越信任边界,以及内核级修复的收敛路径。

二、CVE-2026-103916 攻击链拆解:符号链接的四个位置,三个是敞开的

2.1 攻击前提:工作区被授予读写权限

Endor Labs 的 Peyton Kennedy 在 11 月 3 日公开了 CVE-2026-103916。攻击成立的前提极其普通:工作区对智能体是可读可写的。这意味着智能体可以在工作区内任意创建、替换目录与文件——包括把一个普通目录替换成一个指向宿主其他位置的符号链接。

关键细节在于:这个链接在 VM 内部指向「空」,因此单独植入它,智能体什么也得不到。它是一枚延时引信,只有当操作者执行一次「恰好经过该目录」的普通复制操作时才会引爆。Kennedy 演示了双向利用:

  • 复制出沙箱(copy out):覆盖工作区之外的宿主文件,实现越权写入;
  • 复制入沙箱(copy in):把宿主敏感文件拉进沙箱,实现越权读取。

2.2 根因:-L 标志只检查了路径的最后一个位置

命令并非毫无防护,它带有 -L 标志用于检查符号链接。但问题在于,一个路径上的符号链接可以出现在四个位置:路径中间的某个目录、路径末端的文件名、以及解析过程中的多级跳转。Kennedy 的测试表格给出了残酷结论——「四个位置中有三个是敞开的」。-L 只守住了末位,中间目录被替换成链接时,校验完全失效。

⚠️ 生产避坑风险

「只校验路径最后一段」是路径安全领域最经典的反模式。任何基于 path.basename 或字符串后缀匹配的边界检查,都无法防御中间目录被替换为符号链接的攻击。正确做法是对解析后的父目录做前缀归属校验。

2.3 修复为何需要两次:第一次补丁不完整

Endor Labs 记录显示,Docker 最初计划在 10 月 13 日的 0.48.0 版本修复,但「初始补丁上线后,Docker 团队判定修复不完整」。最终在 11 月 3 日的 0.51.0 版本发布说明中确认了完整修复:sbx cp 阻止挂载目录中的符号链接逃逸到宿主其他路径。

更值得警惕的是,这已经是 sbx cp 今年第二次逃逸。此前的 CVE-2026-17106 在 0.38.0 中修复,它检查的是被复制归档内部的路径,而这一次的缺陷在于——它没有检查复制动作起点与落点本身。两次漏洞,同一个命令,不同的检查盲区。

漏洞编号 受影响组件 检查盲区 修复版本 CVSS
CVE-2026-17106 sbx cp 归档解包 归档内部路径未校验 0.38.0 —
CVE-2026-103916 sbx cp 路径解析 起点/落点符号链接未校验 0.51.0 8.2 High

三、为什么侧信道总被漏掉:沙箱安全模型的系统性盲区

Kennedy 自己的总结最值得被写进架构评审清单:「隔离原语本身是有效的,失败的是通往宿主的第二条路径,而安全模型没有把它计算在内。」他在今年早些时候于另一个智能体沙箱 brig 中发现了完全相同的形态。

原因非常「普通」,普通到几乎每个团队都会踩:

  • 沙箱围绕主通道(共享文件夹)设计,allowlist 与测试都给了主通道;
  • 代表操作者搬运文件的侧信道(copy、export、download、artifact upload)看起来像操作者自己的动作,于是继承了操作者的信任;
  • 当智能体控制了路径,这份信任就被花在智能体指向的任何地方。

这不是 Docker 独有的问题,而是一类架构缺陷。任何让智能体在真实机器上产出文件的系统都存在这些侧信道:收集构建产物的构建步骤、保存附件的工具、把报告同步到共享存储的定时任务。每一个都需要在触碰磁盘的那一刻重新校验边界。

💡 架构核心决策

安全边界不应绑定在「通道」上,而应绑定在「系统调用」上。只要一次文件写入发生在宿主内核,边界就必须在该次 open 上被强制,而不是在调用它的 CLI 参数解析阶段被「约定」。

四、工程落地:一条真正尊重边界的文件通道该怎么写

4.1 Node 侧参考实现:解析父目录 + O_NOFOLLOW

下面是一份宿主侧「向智能体工作区内写入」的参考实现(非 Docker 官方代码,为架构示范)。它修复了三个关键点:校验发生在解析后的父目录上(正是 -L 留下的缺口);最终 open 使用 O_NOFOLLOW 拒绝末位链接;函数返回真实路径供调用方打印。

import { constants, promises as fs } from "node:fs";
import path from "node:path";

// 仅允许写入工作区内的路径,否则拒绝
export async function writeInside(
  workspace: string,
  relPath: string,
  data: Uint8Array,
): Promise {
  const root = await fs.realpath(workspace);
  const target = path.resolve(root, relPath);

  // 逐级解析父目录,而不是只检查文件名
  const dir = await fs.realpath(path.dirname(target));
  if (dir !== root && !dir.startsWith(root + path.sep)) {
    throw new Error(`refused: ${relPath} resolves to ${dir}`);
  }

  const file = path.join(dir, path.basename(target));

  // O_NOFOLLOW:若文件本身是链接,直接失败
  const flags =
    constants.O_WRONLY |
    constants.O_CREAT |
    constants.O_TRUNC |
    constants.O_NOFOLLOW;

  const fh = await fs.open(file, flags, 0o644);
  try {
    await fh.writeFile(data);
  } finally {
    await fh.close();
  }

  return file; // 返回真实路径,让人看得见落点
}

注意第三个设计细节:函数返回真实路径。Kennedy 特别指出,一次成功的 sbx cp 什么也不打印,因此旁边盯着屏幕的人根本无从察觉异常。可观测性本身就是安全边界的一部分。

4.2 从「检查后写入」到「内核强制」:openat2 与 securejoin

上面的实现仍有 TOCTOU 窗口:路径检查与随后的写入是两个步骤,如果智能体在复制执行期间重写工作区,就能在两步之间把目录换成链接。真正的工程级方案必须把边界下沉到内核。

  • Linux:使用 openat2 配合 RESOLVE_BENEATH,让内核在 open 本身执行期间强制约束路径不得越出根目录;
  • Go 生态:可使用 cyphar/filepath-securejoin,Kennedy 指出该库其实早已被链接进 sbx 二进制——工具在手,只是没被用在正确的位置。
RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | O_NOFOLLOWKERNEL
点击展开:升级与临时缓解执行命令
# 1. 升级 Docker Sandboxes 至修复版本
docker sandboxes upgrade 0.51.0

# 2. 确认当前版本
docker sandboxes version

# 3. 临时缓解:复制前后手动解析真实路径
realpath -e ./workspace/target
# 若解析结果不在工作区根目录之下,立即中止复制

五、Docker 沙箱逃逸事件给出的架构级行动清单

把这次 Docker 沙箱逃逸当作一次免费的架构评审,以下清单可直接纳入团队的 Agent 安全基线:

  • 立即升级:Docker Sandboxes 升至 0.51.0;未升级前,自行解析路径后再执行任何跨工作区复制;
  • 清点全部通道:列出智能体与真实机器之间所有搬运文件的通道,而不只是共享文件夹;
  • 在写入时刻校验:每条通道都要在解析完路径的每一段之后、真正落盘的那一刻检查边界;
  • 打印真实路径:每次复制成功都输出解析后的落点,让操作者有机会发现异常;
  • 优先内核强制:能用 openat2 / securejoin 就不要停留在用户态字符串校验。
💡 架构核心决策

如果你的智能体从不向 VM 之外写入、且其产物从不被复制回宿主,本文所述风险不适用。但只要它的输出落到你使用的机器上,边界校验就必须存在——这不是可选项,而是准入条件。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注