空闲空间并不是空的
9 月 24 日,Cloudflare 与 Accomplish 的 Oren Yomtov 公开了 Cloudflare Containers 与 Sandboxes 中一个跨租户的缺陷。在一个新开的沙箱里写入 4 KiB,再读原始磁盘,同一个块剩下的 60 KiB 就会带着前一个租户的 .env 文件、凭证文件和 Chromium 配置目录回来。没有任何东西逃出任何东西:每一道活着的边界都守住了,漏的地方在于一个块的主人用完它之后发生了什么。Cloudflare 卖 Sandboxes,就是为了跑语言模型生成的代码。Bromure Agentic Coding 把那份代码跑在你自己 Mac 上的一个镜像文件里,而它取用的空闲块是你的。
沙箱有两份工作。关住里面在跑的东西,以及忘掉之前在里面跑过的东西。第二份是一个 配置选项。
你把智能体的测试套件送进云端沙箱,因为把没审过的代码跑在自己笔记本上,大概从去年
某个时候起就不再显得聪明了。沙箱大约一秒就起来,带着一个完整的 Linux 文件系统。
智能体写了一个小文件。在那底下,平台递给它一个 64 KB 的磁盘块,而其中 60 KB 还装着
别人的 .env。
你不需要从任何地方逃出来,就走到了那里。
写入四 KiB,读出六十
9 月 24 日,Cloudflare 与研究者一起发表了一份报告, 谈 Cloudflare Containers 里一个跨租户的缺陷。Accomplish 的 Oren Yomtov 在 9 月 4 日 通过 HackerOne 上报,并在同一周 发表了他自己的记述。 The Hacker News 隔天做了报道。
Containers 用 Linux 的 device-mapper 精简配置,从一个共享池里给每个客户一块磁盘,
块大小是 64 KiB。那个池带着 skip_block_zeroing 选项,而它做的就是名字说的事:
dm-thin 在把新分配的块交给容器之前,不会先清掉它。容器被删掉时,它的块会带着原本
的内容回到池里,而那个池是跨越客户账号的。
从那里开始,手法只有四步,而且没有任何漏洞利用:
- 找出容器自己的 ext4 文件系统认为是空闲的、对齐到 64 KiB 的区域。
- 在每一个里面写入单独一个 4 KiB 的块。那就足以让 dm-thin 从共享池里分配一个物理 的 64 KiB 块。
- 读原始设备,而不是文件系统。
- 留下另外那 60 KiB。
Cloudflare 自己描述这个原语的那句话,就是整个 bug:“覆盖整个块的写入会替换它先前 的内容,但更小的写入只会改动被写到的那一部分。”
捡回来的块里带着可用的结构。Cloudflare 报告了“目录结构、数据库页,以及结构上完整
的 SQLite 数据库”,并在六个生产放置点上数出2,700 个不同的、属于他人的目录
inode,24 个放置点中有 18 个留有残留材料。The Hacker News 报道,横跨四个大洲的
22 台底层机器中,有 20 台捡得到数据。Yomtov 列出他从那些块上读到的东西:“目录列表、
SQLite 数据库、Chromium 配置目录、.env 文件,以及凭证文件。”
Cloudflare 说明了两个框住这起事件的界限。攻击者“无法挑选特定的受害者,也无法访问 一块正在被挂着的磁盘”,所以你拿到的是曾经待在那台机器上的随便某人,而不是你挑的 目标。而且 Cloudflare 用研究者的概念验证做出检测特征,拿去跑历史上的磁盘活动遥测, 结果“没有找到任何证据显示这个特定的攻击向量曾被其他人利用”。
Cloudflare 动得很快。9 月 4 日 15:26 UTC 上报;18:45 确认生产环境配置错误;21:27 合并运行时修复;22:03 合并池配置;23:15 开始推送。这些全都在同一天之内。推送在 9 月 7 日完成,研究者在 9 月 14 日确认他们的概念验证已死,Cloudflare 在 9 月 19 日 清掉了最后一批缓解前的磁盘快照。客户没有任何动作要做,也没有办法从沙箱里面确认 自己的块是不是干净地送到。
每一道活着的边界都守住了
Yomtov 那篇报告的标题,“Escaping the Cloudflare Sandbox”,低估了这个 bug 有多安静。
他没有找到内存安全的 bug,没有权限提升,也没有容器逃逸。在整起事件期间,围着一个 正在运行的容器的每一道边界,都按设计守住了。客户 B 读不到客户 A 活着的容器,而 Cloudflare 也指出,沙箱同样碰不到一块正在被挂着的磁盘。
漏的地方坐在交接那一段。Cloudflare 从一个租户手上释放一个块,再把它发给下一个,而 站在这两个客户之间的东西,是池配置里的一个布尔值。
研究者把注意力花在沙箱里的代码现在能碰到什么,花在上一个住户留下了什么的则少得多。 后面那个问题大多数时候有个无聊的答案:平台会把块清零。这个答案一直成立,直到某个 工程师为了吞吐量把清零关掉。
Accomplish 把这件算成自 2026 年 7 月以来公开的第六个沙箱逃逸。前五个是:Claude Cowork 的 SharedRoot、Claude Code 的 Beltdown、Cursor CLI 的 Beltdown2、 Docker VMM 里的一个,以及 OpenAI Codex 沙箱里的一个。这六个里有五个,跑的是智能体写 出来的代码。Yomtov 自己的结论只有一行:“隔离可以用很多种方式失败。”
这正是智能体的代码原本该去的地方
Cloudflare Sandboxes 跑在 Containers 上,所以继承了这个 bug。Sandboxes 也正是 Cloudflare 为这个博客一直在写的那件事所卖的产品。照 Cloudflare 自己的文档,这个 SDK 的存在 是为了“在隔离的环境里安全执行不受信任的代码”,它用于“安全而可靠地执行大语言模型 生成的代码”,并且适合“需要执行不受信任代码的 AI 智能体、代码助手与自主系统”。每个 沙箱都跑“在它自己的隔离容器里,带着完整的 Linux 环境,在保持性能的同时提供强固的 安全边界”。
这里面每一个子句都是合理想要的东西,而它描述 Bromure 的工作区 VM,大致跟描述一个 Cloudflare Sandbox 一样贴切。差别在于机器上还有谁。
把智能体的执行搬离你的笔记本,是把一个信任决定换了位置,而不是把它移除。你现在信任 的是一个你看不到的池分配器,在一个你没有挑的放置点里,旁边是没有人会替你指名的租户。 那可以是对的交换。要刻意做这个交换,并且先问残留的问题:我的空闲块是从哪里来的, 里面原本有什么。
磁盘是你 Mac 上的一个文件
Bromure Agentic Coding 回答这个问题的方式,是没有池。
每个工作区对应到刚好一个 VM。两个工作区永远不共享一个 VM,一个工作区也永远不会有
两个。VM 从两块虚拟磁盘启动,而它们是你 Mac 上那个工作区自己目录里的普通文件。
disk.img 是系统磁盘,第一次启动时作为本机 Ubuntu 基础镜像的 APFS 写时复制克隆
建立出来:瞬间完成,而且在客户机写入之前不占空间。home.img 是一个私有的稀疏 ext4
镜像,装着 /home/ubuntu,以第二个 virtio-blk 设备挂上。
那四步的手法在这里没有地方可以落地。客户机里的智能体可以读原始的块设备而不是文件 系统,因为它在里面是 root,那没关系。它碰到的是属于一个工作区的一个镜像文件。那个 文件长大时会长进去的块,来自你自己 Mac 的 APFS 卷,而你 Mac 上一个空闲块的上一个 住户就是你。
工作区之间有一个共享的东西,而“什么都不共享”会是错的说法,所以把它指名出来:每一块
工作区磁盘都是从 base.img 克隆出来的。Ubuntu 24.04,逻辑 24 GB、物理 6 到 8 GB,
对着一份签名过的镜像目录获取,那份目录带着用同一把给应用更新签名的密钥所做的
ed25519 签名,覆盖镜像的身份、它的 SHA-256 以及每一道 postinstall 命令,有防回滚
保护,也会拒绝没有签名的目录。它在每一台安装上都逐字节相同,并且不含任何属于你的
东西。那正是适合共享的那种东西:一个工作就是要处处相同的公开产物。
而那些残留一文不值
块的排布方式是答案的一半。另一半是一个写代码的智能体往磁盘上写了什么,而那正是让
这个 bug 值得被上报的原因:.env 文件、凭证文件、Chromium 配置目录。
在 Bromure 的工作区里,那些文件存在,读它们的工具也照常工作,只是里面的值是假的。
VM 里没有任何文件、环境变量或进程,曾经持有真的 API 密钥、OAuth token、AWS 密钥或
SSH 私钥。客户机拿到的是保住结构的占位符:sk-ant-api03-brm-…、一个长得像 ghp_
的 GitHub token、brm_…,每台安装各自确定性地生成,所以客户端的校验器会满意,也
不会有工具以为某把密钥在两次会话之间轮转过。真正的值住在 Mac 上宿主代理的内存内置换
表里,每一个都绑到它所属的目标主机,并在送出时被换上,范围是完全匹配或子域,绝不是
子串匹配。AWS 请求在宿主端重新签名:客户机用一个假的密钥签,宿主把那个签名剥掉,再
重新签一次。SSH 是通过 vsock 桥接的纯签名,私钥的字节完全不会进到客户机里。
Chromium 配置目录,Bromure 另外处理。智能体操作的那个浏览器窗格,跑在它自己的短命 Alpine VM 里,是临时目录中的一个写时复制克隆,你把窗格收起来时应用就会删掉它。保留 登录状态是选择加入的,而那会把配置目录放到一块每个工作区各自的加密磁盘上。
如果未来某个缺陷暴露了一个 Bromure 客户机的块,读的人拿走的会是一棵目录树、你已经 有了的源代码,以及一个在任何地方都花不出去的凭证文件。
把可丢弃当成动词
工作区是刻意持久的。写代码需要持久的状态:克隆下来的仓库、包缓存、工具链、shell 历史。所以 Bromure 控制的是 VM 被允许持有什么,而不是每晚把它毁掉,而擦掉一块 磁盘是你要求的事。应用对每一种要求的方式都讲得很明白:
重置回基础
删掉 disk.img,让下一次启动重新克隆一份新的基础镜像。对话框会指名哪些东西会
消失:你 apt install 过的一切,以及你对 /etc、/var、/usr、/opt 的
修改。home 会留着。
擦掉 home、删除工作区
擦掉 home 会丢掉 /home/ubuntu 和生成出来的密钥对。删除工作区会移走整个配置
目录、两个镜像、加密的密钥文件、每一个检查点以及宿主端的 SSH 材料,然后释放它
保留的 MAC 地址。
用完就丢与检查点
bromure-cli vm run --rm 给你一个 docker 风格的工作区,VM 停下来时就被删掉。
每一次成功启动都会留下一个磁盘与 home 的写时复制检查点,而且只在一次产生出可用
终端的启动之后才取,所以每一个都被证明得以启动。
强制擦除
如果代理抓到一个占位凭证正前往它不该去的地方,Bromure 会把那个工作区标成已被 入侵,而它在你擦掉它的磁盘与 home 之前不会再启动。你的设置、token 与 SSH 密钥 都保住,而擦除永远不会碰到共享的宿主文件夹。
这一组动词,就是你要求过的擦除与你假设有发生的擦除之间的差别。
问问上一个房客留下了什么
Cloudflare 找到了配置错误,在九小时内修好,清掉了池与快照,做出检测特征去检查过去, 并公开了块大小与那个标志的名字。把这件事读成一家厂商把糟糕的一天处理得不错。
“沙箱”为一个关于边界的希望取了名字,而这道边界结果是一个资源的生命周期,而不是一道 墙。业界对于智能体执行没审过的代码,给出的答案一直是把执行搬到别的地方,并且相信那 个别的地方会干净地送到。它大多数时候是干净的。你没办法从沙箱里面检查这件事,这周 不行,任何一周都不行。
把代码跑在你看得到的基础设施上,残留的问题就换成由你自己回答:每个工作区一个镜像 文件、来自你自己卷的空闲块、只有一个共享的东西而那是一份签名过的公开基础镜像,而且 就算那块磁盘真的漏了,上面也没有什么值得读。擦掉它是一个菜单项。