你在 Cloudflare 的 Workers AI 上调一个长上下文请求,模型返回的回复里,夹着别人十几分钟前的对话内容。文档编号、代码片段、甚至半截没写完的句子,都不是你的。
这不是科幻设定。2026 年 8 月 27 日到 9 月 9 日之间,Cloudflare 托管在 Workers AI 上的 GLM-5.3 出现了跨租户泄漏,一共 155 条响应受影响,全部来自超过 681,280 token 的超长提示。9 月 8 日深夜由安全研究员 Gadi Evron(Knostic)经 HackerOne 报入,Cloudflare 次日复现、下架、修配置、打引擎补丁,当天恢复。10 月 26 日,Cloudflare 和 SGLang 团队做了协同披露。
我把它这份复盘通读了一遍,还顺手把引用的上游 PR 逐个查了合入状态。这篇不讲「AI 又出安全事故了」,讲一件更具体的事:一个把推理成本压到极致的共享缓存设计,是怎么在配置和状态判断两个小裂缝上漏出别人数据的。
155 条,全部超过 68 万 token
先把事件本身说清楚,因为它的边界条件非常干净。
泄漏只在提示长度超过 KV 缓存池容量时发生。Cloudflare 给 GLM-5.3 配的上下文窗口是 1,048,576 token,但他们预填充(prefill)GPU 上的 KV 缓存池只装得下 681,280 token。凡是超过 681,280 的提示,就会走进出问题的那条路径。
受影响时间这样算:8 月 27 日模型上线那天起就存在。这个洞从上线第一天就在,只是没人往里看。
Cloudflare 的说法是:排除掉他们自己的测试和研究员的复现请求之后,这些请求在时间和数量上都符合普通的长上下文使用,没有任何反复利用的痕迹,未发现蓄意利用证据。这一点重要。它说明这个洞是被正常用户无意间撞出来的,而不是被谁盯上了。真正让人后背发凉的地方就在这里。
还有一条:只有 GLM-5.3 受影响。Workers AI 上其它分离式部署的 KV 池都大于其上下文长度,没有请求能走到那条拒绝路径。
它到底是怎么漏的:一条七步的失败链
官方的复盘写得很好,但铺开在几十段里。我压成一张表,看这张表基本就懂了:
| 步 | 发生了什么 | 错在哪 |
|---|---|---|
| 1 引导 | 路由器告诉解码节点提示长度是 8 页,解码节点预留 8 页 KV | 正常 |
| 2 预填充准入 | 准入队列只检查 req.finished()。被拒绝的请求把错误记在 to_finish,finished() 仍返回 False,于是被放行 |
拿「是否完成」当「是否被拒」用 |
| 3 预填充前向 | 提示早被换成单 token 占位符,跑了一次几乎为空的前向 | 拒绝没有真的终止请求 |
| 4 KV 传输 | 预填充只发了 1 页 KV,并报了一条「传输完成」 | 收发两端对「完成」的理解不一致 |
| 5 解码准入 | 解码的准入队列同样只看 finished(),接受了这次移交 |
与第 2 步同因 |
| 6 解码注意力 | 对预留的 8 页做注意力。只有 P0 是本次请求写的,P1 到 P7 还留着上几个用户的提示和半截生成 | 页不擦除,且没有所有权校验 |
| 7 响应 | 客户端拿到 HTTP 200 而不是 400;prompt_tokens 对得上、回复连贯、还比正常 8 页预填充更快 |
错误被伪装成成功 |
第 7 步那个「更快」是个很妙的信号。正常的 8 页预填充要花时间,而这次只写了一页,所以快。这个 bug 留下了可观测的痕迹,只是当时没人去看。
两个 bug 叠在一起,缺一个都不会漏
复盘里有一句我认为是全篇的钥匙:这是两个重叠问题的结果。
第一个是配置错配。他们上线时就知道池子装不下上下文,但没当回事。理由是长上下文测试返回了成功的 HTTP 状态码,流式 body 里也没有错误帧,于是他们推断引擎会用流式或别的方式优雅处理这个差值。这里有一段我想原样引出来,因为它是一个极其典型的测试盲区:
我们只验证了长上下文下的 HTTP 响应码和引擎性能,没有验证模型输出和连贯性。
换句话说,他们测的是请求有没有成功,而不是回答是不是对的。性能指标全绿,内容里却在漏别人的数据。
第二个是 SGLang 的状态判断 bug。引擎在单机模式下处理超长提示是对的:拒绝、替换成占位符、跑完批次、给客户端报错。到了分离式 prefill/decode 模式(Cloudflare 的常规部署形态)就崩了,因为两边的准入队列都在问「这个请求完成了吗」,而没人问「这个请求被拒绝了吗」。
为什么页里会有别人的数据
要理解 P1 到 P7 里为什么会有别人的内容,得先接受一个反直觉的事实:推理引擎通常不擦除 KV 缓存页。
Cloudflare 在背景里写得很直白。GPU 里的 KV 缓存是一池固定大小的页,每个请求被接纳时分到页,结束时归还。页在请求之间不做清零,下一个拿到它的请求直接覆写。这个做法在推理引擎里很普遍,也是拿到最好性能所必需的。代价是引擎自己必须负责追踪这些内存页的归属和生命周期。
而归属追踪在这里不是一件轻松事,有几种同时成立的所有权:同一节点内,token 生成、卸载到主机内存、KV 传输接收方、以及中止与错误路径,可能同时持有同一页;跨节点上,远端预填充节点对同一页还握着一个可变的引用。任何一处提前归还,新请求就会拿到一个正在被读写的页。
还有一个值得所有做推理的人抄进 checklist 的细节:
这个池的大小不是一个配置值。SGLang 在启动时按加载完权重后剩余的显存自己算出来。模型对外宣称的上下文长度是另一个独立的配置值。没有任何机制强制两者一致,检查它们是我们自己的责任。
池子大小会随量化方式、张量并行度、CUDA graph 尺寸、甚至换一个模型版本而变,而且很容易被漏掉。Cloudflare 的原话是:光靠池子够大是脆弱的。
这类 bug 是一族,不是一例
Cloudflare 把这类 KV 缓存内存安全 bug 归成两种常见成因,理解这两类,比记住这一次的细节更有用。
第一类是单节点内的页生命周期。解码节点上可能有多个任务同时持有同一页:token 生成、卸载到主机内存、KV 传输接收方、以及中止与错误路径。任何一个在另一个还在读写时归还了页,新请求就会被分到这一页。他们之前修的 #24226 和 #30899(解码卸载要先等卸载完成再回收请求)以及 #30937(语法错误路径上的双重释放)都属于这一类。
第二类是跨节点的竞争。请求在预填充还在通过 RDMA 往解码写 KV 时被中止,已经发出的写无法召回。解码如果立刻释放这些页,页会被分给另一个请求,迟到的写就落进了别人的 KV 缓存。他们为此提过 #31357、#38961,还附了一个复现脚本 #32564。
这两类的结果都一样:输出被污染,或者一个用户的数据出现在另一个用户的响应里,而且不报错。本次事故的形态不同,被拒绝的请求每一次都会走完整个移交流程,不靠时序运气,但结果一样。
修复分两层:先把洞堵上,再把路基改硬
配置层的做法是把 GLM-5.3 的配置调成和 GLM-5.2 一致,腾出显存后预填充池现在能装约 204 万 token,约为 1,048,576 上下文的两倍。能放进上下文的提示就一定放得进池子,拒绝路径对合法请求不再可达。单这一步就足以让这个 bug 不被触发。
引擎层是 Cloudflare 不接受「靠池子够大」当答案,因为那是脆弱的。他们给引擎打了补丁,让带拒绝标记的请求在分离式准入队列就被退场、带着原本的错误返回,早于任何引导、分配或 KV 传输发生。同时解码端要校验实际传输的 token 数与它预留的数量是否一致。这份草案提了上游 #38704。
我把这些 PR 逐个查了一遍合入状态,结果如下:
| 类别 | PR | 版本 | 作用 | 我查到的状态 |
|---|---|---|---|---|
| 事故 | #36988 | v0.5.20 | 让被中止的预填充结果在到达解码前退场,本次泄漏就此止住 | 已合入 |
| 事故 | #38935 | v0.5.21 | 在准入阶段就拒绝,早于引导、分配与传输,采用了 Cloudflare 草案的思路 | 已合入 |
| 竞争 | #40645 / #41402 | v0.5.21 | drain 确认丢失时不再干等超时,按确认释放 | 已合入 |
| 竞争 | #41404 | v0.5.21 | 任何传输失败都保留页,不再只处理解码端发起的中止 | 已合入 |
| 草案 | #38704 | — | Cloudflare 自己那份「解码端校验 token 数」的补丁 | 已关闭、未合入,被 #38935 取代 |
| 观测 | #39500 | v0.5.21 | --disaggregation-enable-kv-checksum:解码生成前校验 KV 校验和 |
已合入 |
有个细节值得单拎出来。这个 bug 在 7 月 6 日就被另一个用户作为 SGLang issue #30233 报过,Cloudflare 是在查这次事故时才注意到的。而 #36988 早在 9 月 6 日就已合入 SGLang main,只是当时还没发版。也就是说,修复比泄漏的公开披露早了两个月,但没人把它发出去。
给所有跑推理的人的五条作业
Cloudflare 列了五条建议,我认为这是全文最有复制价值的部分,逐条抄下来:
- 量你的 KV 池,和上下文长度对一遍。在每一个预填充和解码节点上、在引擎的启动日志里读,并且在每次改动权重、量化、并行度或显存比例之后重测。池子小就把上下文长度降下来。不要指望代理层去拦,也不要假设引擎会拒绝放不下的东西。这就是他们没强制的那条检查。
- 升级到包含
#36988和#38935的版本(v0.5.22 或更高),这样超长请求在移交前就被拒,与池子大小无关。这是引擎侧的兜底。 - 两个角色都开 deferred KV release(
#41023之后默认开启),并给 Mooncake 传输批次设上限,避免一次超时的调用留下无界的写入尾巴。 - 在你的模型支持的前提下打开损坏检测。
--disaggregation-enable-kv-checksum和--kv-canary目前还没覆盖所有模型架构,先看日志确认请求真的在校验。 - 确保你的代理没有制造多余的 abort。把负载均衡配成让请求跑到第一个 token,避免触发需要跨节点协同的中止。
更长的那条路:让「读到别人的页」在运行时读不出来
这篇复盘我最欣赏的是它没有停在「我们修好了」。Cloudflare 把长期方案分成两条,名字起得很好。
一条叫 integrity by construction,构造即正确。配置检查和生命周期修复只能减少误用的路径,不能让误用变得不可能,而下一个 bug 的形状一定是没见过的。持久的答案是让引擎在读取时就发现「这个请求即将对一页它并不拥有的内存做注意力」,然后只让这一个请求失败,而不是把别人的数据返回出去。
他们的原型是这样:每个物理 KV 页带一个标签,页被重新分配时标签就变;每个请求记录它期望的标签;解码读缓存之前校验这个映射。测试里吞吐和尾延迟的代价不到 1%。他们 8 月中在 GLM-5.2 上测过这个原型,但很快就因可靠性问题撤了。这里我想给 Cloudflare 加一句实话:原型是主动撤下的,说明「读时校验」在工程上比它听起来难。而他们的 #39500 逐次传输校验和是第一小步,只覆盖 prefill 到 decode 那一次移交;页级标签走得更远,因为它检查每一次读,包括传输很久之后的读。
另一条叫 integrity by mathematical impossibility,数学上不可能。RDMA 是直接从一台机器的内存拷到另一台,两台机器必须对「数据写到哪、多大、什么时候算完」有一致认识,任何不一致都可能让接收方读到错误的数据。Cloudflare 说这本质上是一个分布式系统的一致性失败,两台机器对系统状态的看法不一致。他们打算把在 Topaz、Meerkat 的 QuePaxa 上用过的那套形式化验证方法搬到 KV 缓存传输协议上,先建模、再证明安全性,去追踪并修补任何偏离。他们的措辞很克制:现实里会存在证明器覆盖不到的空隙,但把系统其余部分证清楚,能让那些空隙被更仔细地审查和监控。
写在最后
如果只把这件事读成「Cloudflare 的 AI 服务漏了别人的数据」,药方会开错。它的根因和模型能力、和 prompt 注入、和「AI 安全」那套叙事都没关系。它是一个分布式系统里,两台机器对「这次传输完成了」这件事的理解不一致,加上一层共享内存的所有权跟踪漏了一处。
更值得记的是那个结构性张力。为了把推理成本压下来,引擎必须复用 KV 缓存页,而页在请求之间不清零;为了不擦页,引擎就必须把所有权追踪做对,而这在有本地任务、有远端可变引用、有中止和错误路径的情况下很难做全。也就是说,**把成本降下来的那个设计,和泄漏的成因是同一枚硬币的两面。**这一条对每一家做托管推理的厂商都成立,Cloudflare 只是第一个把复盘写出来的。
它给出的答案方向也值得抄:不要指望配置永远对,也不要指望引擎永远不乱,让引擎在读的那一刻发现越界,并让单个请求失败。以及,认真对待「池子大小不是配置项、和上下文长度没有任何机制强制一致」这句话。这类坑不挑厂商,只挑你测不测内容。只验状态码不验输出,是所有长上下文部署里最容易漏的一格。
相关阅读:
- 《DeepInfer 拆解》 — 推理框架内核、并行与确定性推理的源码级拆解
- 《LiteLLM 又被投毒了》 — 另一类推理基础设施安全事故:供应链投毒
- 《OpenCode 凭据泄露》 — 数据外泄的另一条路径:凭据管控