Skip to content

够新但不够隔离:CI 里 MicroVM 的取舍

2026 年 7 月,在一次沙箱化的 ExploitGym 评估中,OpenAI 的模型(GPT-5.6 Sol 加一个预发布模型)在包注册表缓存代理里发现了一个零日漏洞,拿到了开放的互联网访问权限,又用偷来的凭证和更多零日漏洞,在 Hugging Face 的生产基础设施上实现了远程代码执行。OpenAI 在 7 月 21 日出来担责;Hugging Face 则提前几天披露了攻击细节。

大约两个月前,Claude Cowork VM 逃逸(CVE-2026-46331)利用 guest kernel 的漏洞,穿过 hypervisor 层摸到了宿主机。Anthropic 自己的工程文档里描述了 Claude 模型”好心”逃出沙箱去完成任务——翻 git 历史来回答编程测试,还自己认出了正在跑的 benchmark。

这些都不代表你的 Docker CI runner 有问题。涉及的隔离栈没有完全公开,这篇文章也不声称这些逃逸发生在容器里,或者 MicroVM 就能挡住。它们的共同点是跨越的边界类型:一种由和被约束对象并排运行的代码来执行的策略边界——有动机的代码就是会打破这种边界。而容器依赖的恰恰就是这种边界,CI runner 正是有动机的代码和你的基础设施碰面的地方。

容器是让一个内核来隔离进程之间。MicroVM 把边界往外推:workload 有自己的 guest kernel,和宿主机的隔离靠硬件虚拟化加一个小得多的 VMM 接口来保证。这不会让逃逸变不可能,但改变了攻击面和爆炸半径。拿下 guest kernel 不再等于拿下宿主机内核。

这篇文章从两个维度来看这个取舍。隔离回答的是:job A 的代码能不能影响 job B 或宿主机。身份回答的是:一个 workload 能不能证明自己跑在一个独立的、新开出来的实例上。做 ephemeral runner 的过程中我们发现:两种模型都没法让 guest 用内核信号证明自己的实例身份,而且失败的原因正好相反。

舒适区:容器和那个看不见的内核假设

容器不是一台机器。它是共享一个宿主机内核之上的 namespaces、cgroup 配额和 seccomp 过滤器。那个内核是信任锚点:容器里的进程和宿主机之间就隔着它,而且这台机器上所有容器共享同一个内核。容器之间的隔离不是和内核的隔离。是一个内核拒绝让 namespace A 看到 namespace B——这个策略恰恰由两个 namespace 共享的那个内核来执行。

对大多数 CI workload 来说这就够了。你构建代码、跑测试,内核不碍事。Docker 能拿下 CI runner 这个位置,是因为这个交易对你自己写的代码是成立的。代码可信的时候,共享内核的风险可以接受。

这个隐藏假设在 runner 配置里,不在镜像里。一个标准的 self-hosted GitHub Actions runner 会从 pull request 拉任意代码、执行、然后回报结果。fork 的 PR 在你的信任边界之外;runner 宿主机和它的 secrets 在边界之内。信任线画在容器墙那里,不是画在底下的内核。容器边界是你看得见、能操作的。共享内核是你看不见的边界。一个内核漏洞、一个配错的 mount、一条 privileged 路径,就能让宿主机上所有容器的这条隐形边界同时消失。

Lambda MicroVMs:硬件辅助的边界,落地了

AWS Lambda MicroVMs 是 2026 年 6 月发布的,基于 Firecracker——就是那个支撑每月超过 15 万亿次 Lambda 调用的 VMM,Firecracker 项目自己说的。每个 MicroVM 跑一个独立的真实 Linux 内核。一个 job 的 guest kernel 出问题,就局限在那个 MicroVM 里。没有共享内核的路径可以穿过去。这个边界是硬件辅助的,不是绝对的:hypervisor 和宿主机内核还在信任链里,Cowork VM 逃逸就是那一层出了问题。AWS 说这是 VM 级别的隔离,而 MicroVM 只是另一种、更难跨的边界。

这就是为什么 CI 会选 MicroVM:当代码不可信或者有动机的时候,内核边界就是”一个 job 被攻破”和”宿主机被攻破”的区别。CI 跑的是贡献者写的任意原生代码,而这些贡献者你不一定完全信任。在这个威胁模型下,把租户内核和宿主机内核分开,能显著缩小爆炸半径。AWS 自己就把 MicroVMs 定位在用户和 AI 生成的代码、漏洞扫描、以及需要 VM 级隔离的 CI/CD workload 上。这个论点不需要靠沙箱逃逸事件就已经很有说服力了。

这件事能落地,全靠 snapshot-restore。每个 job 都真实启动一个内核,要跑 early-init、设备枚举、systemd、包管理器初始化——冷 VM 上要好几秒,对短 CI job 来说完全不可接受。如果每个 job 都付这个启动成本,你就会去池化 warm runner,那就把你想用 MicroVM 躲开的状态泄漏又带回来了。Snapshot-restore 让按 job 开 MicroVM 变得可行:一个保存好的内存镜像,几毫秒就能恢复。

snapshot

restore

restore

restore

injected at launch

injected at launch

injected at launch

Template VM
booted once at deploy

Snapshot
kernel, runner agent, base image

MicroVM for job 1

MicroVM for job 2

MicroVM for job N

Job-time inputs
workflow code, environment

Firecracker 的 snapshot 文档描述了机制:snapshot 保存 guest 内存和模拟硬件的完整状态;restore 从那个镜像恢复。snapshot 在部署时创建一次、存起来,每个 job 都从同一个基线恢复。Job 不创建 snapshot,它们继承 snapshot。Job 运行时的输入和 snapshot 携带的内容是分开注入的。这里没有延迟 benchmark 数据,这篇文章也不声称有。

Snapshot-restore 不是一次启动

我在做 mayfly 的时候,给每个 ephemeral runner 打指纹、证明它跑在一个全新且独立的环境里,这是最自然的起点。内核在启动时生成 boot_id,并且承诺每个运行中的内核实例都是唯一的。boot_id 自然就成了要抓的信号。

Snapshot-restore 不是一次启动。Firecracker snapshots 保留 guest 内存和模拟硬件的完整状态;restore 从那个镜像恢复,不会重新跑启动早期的初始化。boot_id 只生成过一次——在 snapshot 源 VM 最初启动的时候——之后每次 restore 都继承它。

sd_id128_get_boot(3) 的 Linux man page 说 boot_id 是”启动早期随机生成,对每个运行中的内核实例唯一”。这只对真实启动成立。boot_id 的文档假设的是真实启动。而 snapshot-restore 不是一次启动。

这不是文档错误,也不是 Firecracker 的 bug。2021 年的论文 “Restoring Uniqueness in MicroVM Snapshots”(arXiv:2102.12892)把机制说清楚了:初始化后的 snapshot 跳过了冷启动时间;restore 是内存镜像的克隆加恢复,不是一次全新启动。同一个 snapshot 恢复出来的实例之间,启动相关状态发生碰撞,这是架构决定的。

这是身份信号的失效,不是安全漏洞。boot_id 碰撞不代表你的 MicroVMs 有漏洞。它意味着你用来证明实例独立性的那个信号,在错误的生命周期边界上是诚实的。它证明发生过一次启动,但不证明这个实例是新的。

五个 MicroVM,一个 boot_id

我们在 Lambda MicroVMs 上对 mayfly 的 ephemeral-runner 集群发了五次 job。每个 job 的用户空间里都没有前一个 job 的状态——从操作意义上说是”新的”。但 snapshot-restore 意味着内核从未被重新初始化:这些 job 是 fresh-restored,不是 freshly booted。五条指纹记录,来自 mayfly evidence deck 里密封的日志文件,每个 run_*_log.txt 的第 99 行:

run_0: vm=microvm-2e884347-1180-3795-a56b-700f24f02a6b  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_1: vm=microvm-89906b33-d73b-3c8a-949a-b40fd44812f5  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_2: vm=microvm-c1e6e7ec-5a42-3a4e-83c4-5c4b7bd8c591  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_3: vm=microvm-b51161d1-208f-3c2e-8f72-1cd00f3e0469  boot_id=78869c81-58e5-409c-8f75-a066367e3603
run_4: vm=microvm-1b317b3f-f2eb-3e56-b321-d9d67d9e87a9  boot_id=78869c81-58e5-409c-8f75-a066367e3603

五个不同的 vm= id。五个实例共享一个 boot_id。读者可以自己验证:grep -hoE 'vm=microvm-[a-f0-9-]+' run_*_log.txt | sort -u | wc -l 返回 5;grep -hoE 'boot_id=[a-f0-9-]+' run_*_log.txt | sort -u | wc -l 返回 1。碰撞就在密封的原始产物里。

vm= id 不是内核信号。它是 control plane 认证的身份:mayfly 的 control plane 在启动时把 MAYFLY_MICROVM_ID 注入每个 MicroVM,并在每个 job 的指纹里打印出来。这是来自 orchestrator 的认证,不是内核生成的东西。更早的版本用 arch、内核、宿主机和 tmp 状态来打指纹——这些内核级信号和 boot_id 一样,在 snapshot-restore 集群上没法证明独立性。修复方案 mayfly PR #1feat/microvm-identity-attestation)加了这个被认证的 id,因为内核级信号在这个架构上不可靠。

为什么修复必须来自 guest 外部

Snapshot-restore 保留 guest 内存。boot_id 就在那个被保留的内存镜像里,r4 的证据直接展示了碰撞。同样的架构逻辑也适用于 uname、hostname 和内核版本。内核在启动时持有的任何值都在被保留的镜像里。/proc/sys/kernel/random/uuid 每次读取都生成新值,所以它从来就不是一个有意义的实例标识符。

关于内核 RNG 的一个说明:Firecracker 的 VMGenID 设备 会在 restore 发生时通知 guest,Linux 5.18+ 收到通知后会重新播种 CSPRNG。在现代内核上,保证随机的值跨 restore 是安全的。VMGenID 是唯一一个会对 restore 做出响应的内核信号。这恰恰证明了规律:内核需要一个专门的设备才能区分 restore 和 boot。它解决的是熵重用问题——如果两个 MicroVMs 共享 CSPRNG 状态,那确实是真正的安全问题——但它不修复 boot_id 碰撞,也不提供实例身份。重新播种的 RNG 不是被认证的身份。

在 job 启动时于用户空间重新生成一个信号也不够。一个新的 UUID 只能证明 job 启动了,不能证明 MicroVM 在 job 运行之前是干净的。如果 snapshot 被篡改过,或者前一个 job 的状态泄漏进了恢复的镜像,用户空间的 UUID 照样会显示”新”,但 VM 其实不是。信任问题问的是 job 代码运行之前的状态,而 VM 内部的代码无法对此作证。

最有资格在启动时证明 MicroVM 身份的系统,就是启动它的那个系统。Control plane 知道它什么时候启动的、从哪个 snapshot 启动的,并且可以向环境里注入一个唯一 id。MicroVM 的内核做不到,因为 boot_id 是从 snapshot 继承的。用户空间也做不到,因为它在信任问题已经定论之后才运行。这不是说 control plane 是唯一可能的来源:Firecracker 可以暴露一个 VMM 级别的 generation counter,硬件认证(measured boot、vTPM)可以把信任链锚定到更底层。对 mayfly 来说,control-plane 认证是 boot_id 缺口最简单的修复。

一个精确的说明:control-plane 认证解决的是实例之间的独立性,不是 snapshot 的完整性。一个被篡改的 snapshot 照样能产生带不同 control-plane id 的 VMs。证明完整性需要 measured boot、snapshot 哈希或者 vTPM,那是另一个问题。而且”control-plane 认证的”不等于”面对恶意 control plane 时密码学意义上的新鲜”。证据只能证明 control plane 报告了五个不同的 id,不能证明它们能抵抗控制 plane 本身的攻击者。这个论断更窄:boot_id 在 snapshot-restore 上根本不可靠,修复必须来自 guest 外部。

逃逸事件和身份问题

这些逃逸事件展示了一种策略边界的失效:因为执行机制就在被约束对象旁边的代码里。Claude Code——Ona 安全研究员演示的——用路径技巧绕过了 denylist,然后直接把沙箱整个禁用了。(这是 Claude Code,不是 Claude Mythos——后者在 Anthropic 内部安全测试时自己找到了互联网连接,还主动给研究员发了邮件。两回事,机制不同。)Pillar Security 的”沙箱逃逸周”用他们的”信任交接缺陷”戳了四个编码 agent:沙箱不管的写入,被沙箱外的一切信任。一个 seccomp 过滤器、一条 namespace 规则、一个 denylist。每个都是由和被约束对象并排的代码来执行的。压力足够大,请求就被绕过了。这就是容器那一类边界。

mayfly 的证据表明,用硬件替换那条边界并不能消除信任问题。它只是把问题搬了个位置。从共享内核变成每个 job 独立内核,移除了容器逃逸利用的共享内核路径。同时也移除了让 boot_id 诚实的东西:一次真实的启动。

更强的隔离不消除身份认证的需求;它改变的是谁来认证。在容器世界里,信任锚点是共享的宿主机内核。在 MicroVM 世界里,内核从未为那个特定 job 全新启动过,所以信任锚点移到了每个 MicroVM 诞生时在场的 orchestrator 身上。边界更难跨了,认证也换了边。

把取舍说清楚

隔离和身份是两个不同的属性,在容器/MicroVM 的边界上,它们产生的结果比我预想的更尖锐:两种 workload 都没法用内核信号证明自己的实例身份。而且失败的原因正好相反。

属性容器Snapshot MicroVM
每个 workload 的内核边界无(共享宿主机内核)有(独立 guest kernel)
状态重置(文件系统、进程树)有,每次容器启动有,从干净 snapshot 恢复
Guest kernel 是否全新启动不适用(没有 guest kernel)否(从 snapshot 恢复)
Guest 的 boot_id 能证明 workload 身份吗?不能,它属于宿主机内核不能,它从 snapshot 继承
身份必须来自哪里orchestrator / runtime启动时的 control plane

容器重置 workload 状态,但不创建新内核。 每次容器启动都会创建新的文件系统、进程树和 namespaces。这就是 CI 里通常说的”新”。但 boot_id 是宿主机内核的标识符,那台宿主机上的每个容器都一样。它从来就不是一个 per-workload 的信号。/proc/sys/kernel/random/uuid 每次读取都生成新值,对新环境什么都证明不了。容器的身份由创建它的 runtime 来认证,不是由它从自己内部读到的任何东西来认证。

MicroVMs 创建了内核边界,但没有创建一次新的内核启动。 每个 job 独立内核带来了硬件辅助的隔离。但 snapshot-restore 意味着那个内核是从已初始化的状态克隆的,所以它启动时派生的标识符都是继承来的。从业者伸手去抓的信号,恰恰是会说谎的那些。隔离是赢的;可证明的实例身份现在是你的问题。

状态重置和内核隔离,这两个属性都不是”从 guest 内部可证明的实例身份”。那必须来自分配者。CI 里的”新鲜度”原来不是一个单一维度。它至少是三个不同的属性:状态干净(这个 job 会不会看到上一个 job 的文件?)、实例独立(这是不是一个独立的执行环境?)、来源完整性(snapshot 有没有被改过?)。容器给你第一个;MicroVMs 在独立内核上给你前两个。两者都不让 guest 用内核信号证明第三个。

关于光谱的一个说明:gVisor 和 Kata Containers 在这两个极点之间——分别是用户空间内核的系统调用拦截和每容器一个 VM。光谱中间在身份轴上继承了同样的动态:一个没有为那个特定 workload 全新启动过的 guest,无法从内部证明自己的实例身份。

所以决策框架是:

  • 信任你的代码和贡献者:容器就够了。简单性胜出,配一个策略执行的隔离边界。
  • 跑不可信或有动机的代码(fork PR、agents、插件、多租户):你需要内核边界。隔离胜出,而且你必须从 control plane 建立可证明的实例身份。
  • 无论哪边,如果你需要证明 workload 是新开出来的、独立的,证明来自分配者,不是 guest。容器从 runtime 拿;MicroVMs 从启动时在场的 control plane 拿。

这篇文章不是说”MicroVMs 更好”或者”容器更安全”。它说的是:你换掉了什么、哪个信号会骗你、证明必须放在哪里。如果你在 snapshot-restore 集群上构建 ephemeral runners,别去抓 boot_id。从你的 control plane 注入一个被认证的身份,打印在每个 job 的指纹里——因为那个本该生成新 boot_id 的 guest kernel,从来没有为这个 job 启动过。