重新审视针对 Cloudflare Workers 的远程 Spectre 攻击

2021 年,我们评估了针对 Cloudflare Workers 的远程 Spectre 攻击,并基于评估结果在生产环境中部署了名为动态进程隔离(Dynamic Process Isolation,DyPrIs)的防御机制——该机制能够识别行为可疑的脚本,并将其隔离至独立进程。此后,用于稳定 Spectre 攻击的新技术不断涌现。为评估这些技术是否对 Workers 生产环境构成威胁,我们决定在内部对远程 Spectre 攻击进行重新评估。通过在生产环境中构建更新版的概念验证,我们能够在实际生产负载下对 Spectre 攻击风险进行实证评估。
在生产环境中发动成功的侧信道攻击,外部攻击者还需克服额外障碍,包括共享硬件资源上的活动、中断、上下文切换以及粗粒度计时器等。我们的研究发现了 DyPrIs 实现中的一处缺陷,并成功在 Cloudflare Workers 生产环境中演示了一次远程 Spectre 攻击,可靠地以 99% 的准确率泄露最高达 12 bit/s 的数据。基于这项研究,我们改进了 DyPrIs,集成了 V8 Sandbox 和进程内隔离机制,以进一步降低内存泄露攻击的风险。
今天,我们发表了一篇论文,详细描述了上述研究发现,论文由 Albert Pedersen、Haocheng Xiao、Sam Ainsworth、Nigel Topham 和 Martin Schwarzl 共同撰写,涵盖 2024 年至 2025 年初的研究工作。
请注意,由于 Cloudflare Workers Runtime 团队已部署相应对策,文中所描述的攻击在生产系统中已得到缓解。过去三年间,我们未发现任何主动利用的迹象。
Cloudflare Workers 安全模型
Cloudflare Workers 在边缘节点上运行不受信任的 JavaScript 代码。借助 V8 隔离区提供的语言层隔离,数以万计的租户可共享同一操作系统进程。每个 Worker 拥有各自独立的 JavaScript 堆。这一设计既能降低启动延迟,又能相比完整进程隔离方案更高效地运行大量租户。在运行时层面,我们部署了多层防御机制,例如自动化 V8 补丁管道、由 Linux 命名空间和 seccomp 过滤器构成的双层沙箱、Cap'n Proto RPC,以及将特定脚本调度至独立进程沙箱的功 能。尽管如此,Worker 进程内的单个任意读取漏洞仍然 可能导致跨租户数据泄露。其中一种漏洞非常难以缓解,它利用了推测执行的特性,即进程内 Spectre 漏洞。
Spectre

推测执行的原理可以用登山来类比。在某个路口,您需要预判前行方向。若预判正确,您节省了时间,可以在山间小屋享受阳光和冷饮。然而,若预判方向错误,您不得不折返。山路看起来完好如初,但您的脚印仍留在泥土里。
CPU 的推测执行与此类似。分支预测(branch prediction)会提前对分支结果作出预判,CPU 随即推测性地执行该分支。若预测正确,推测执行节省了时间;若预测错误,CPU 则需丢弃结果、回滚并执行另一分支。由于这些推测执行的指令仅在 CPU 流水线中短暂存在,从未被永久退休或提交,学术界将其称为瞬态指令(transient instructions),并将该概念概括为瞬态执行(transient execution)。
然而,瞬态执行仍会在微架构状态中留下痕迹,例如残留在 CPU 缓存中的数据。因此,攻击者可利用 Spectre 以瞬态方式越界访问内存,将单个比特的信息编码至缓存状态,并通过测量重新访问数据的延迟来推断该比特是否被置位。
为缓解进程内 Spectre 攻击,Cloudflare Workers 冻结本地计时器,禁止多线程和共享内存,并主动检测可疑脚本、定期对内存进行随机化,同时将行为可疑的脚本隔离至独立进程。
攻击原语

Cloudflare Workers 平台有意限制计时器的精度。在仅使用 CPU 执行期间,时间实际上处于冻结状态:Date.now() 和 performance.now() 无法提供持续推进的高精度时钟。由于没有共享内存且不支持多线程,经典的基于 SharedArrayBuffer 的计数线程计时器也无从使用。
要成功发动攻击,需要克服几个挑战:首先,Workers 运行时间有限,必须确保攻击者与受害者位于同一位置;其次,必须找到一个可靠的、理想情况下位于同一位置的远程计时器,以进行稳定的计时测量;其三,攻击在生产条件下运行,因此还需要额外的稳定性措施,例如可靠的 Spectre gadget 以实现瞬态 64 位越界访问、应对系统和网络噪声的稳健信号放大机制,以及可靠地将数据从缓存中驱逐的原语。
Spectre gadget
return probeArray[
obj instanceof ObjP
? PROBEARRAY_OFFSET + ((obj.ptr[0] >> bit) & 1) * 0x800
: 0x400
];推测类型混淆 Spectre gadget
借助正确的 Spectre gadget(如上方代码片段所示),攻击者可以瞬态方式越界访问内存,并将单个比特编码至缓存 (probeArray) 中。攻击者随后测量内存访问延迟,以确认数据是否已被缓存:访问较快意味着该缓存行已缓存,对应比特为 1;访问较慢则意味着未缓存,对应比特为 0。
在我们的攻击中,使用了两种不同类型的 Spectre gadget。第一种用于泄露压缩堆指针,例如隔离区的堆基地址(根地址);第二种则利用推测类型混淆,从攻击者精心构造的用户空间 64 位指针处读取数据。在开展研究时,V8 Sandbox 尚未在 Cloudflare Workers 上实现。在指针压缩模式下,大多数对象使用 32 位压缩指针,而 TypedArray 是少数仍存储原始 64 位指向其后备存储的指针的例外——这正是我们的 gadget 所利用的。
分支 obj instanceof ObjP 执行类型检查,即一次分支操作。为误训分支预测,我们多次以真实的 ObjP 实例调用该 gadget,然后以具有攻击者控制内存布局的不同对象 ObjI 调用它。CPU 随即推测性地执行该分支,从 obj.ptr[0] 读取数据,尽管该对象实际上是不同类型。为泄露单个比特,我们屏蔽出一个比特位,并用其选择 probeArray 中的两个缓存行之一,该缓存行是否被缓存即编码了该比特。
利用堆泄露 gadget,我们映射相邻对象并定位一个攻击者控制的数组。第二个 gadget 混淆两个跨越多个缓存行的大型对象,使类型字段落在与被读取字段不同的缓存行上。驱逐类型字段可打开推测窗口,同时目标字段保持缓存状态,瞬态读取随即跟随攻击者控制的 64 位值,从而将泄露转化为任意地址读取。该技术的更详细描述可参见论文原文。
本地演示:泄露任意 64 位地址。

信号放大
缓存命中与缓存未命中之间仅相差数纳秒,而远程计时器的噪声量级则在数微秒至数毫秒之间。因此,需要某种形式的信号放大才能区分缓存命中与未命中。Stephen Röttger 和 Artur Janc 发现了一种放大单次内存访问的方法,其原理是利用 L1 缓存中基于树的伪最近最少使用(PLRU)缓存替换策略。基于树的 PLRU 将每个缓存组织为一棵二叉树,树节点指向最近最少使用的一侧,CPU 通过沿指针方向驱逐数据。利用特定的访问模式,攻击者可以在指针转向目标时持续触及其树邻居,从而使目标缓存行无限期保持缓存状态。这一思路颇为精妙:借助该行为,单次缓存事件的时序差异可被任意放大——与反向情形(大量 L1 未命中)相比,正向情形将产生大量 L1 命中(访问更快)。
下图展示了内存地址 X 是否处于缓存状态的两种情况。若未缓存,该访问模式将产生大量缓存命中;若已缓存,它占据树中的一个节点,导致四条缓存行竞争三个节点,从而引发大量 L1 未命中。

远程计时器
只要信号能够被放大,嘈杂的远程计时器便足以区分编码的比特。例如,连接到提供高精度时间戳的外部服务器的 WebSocket 连接即可满足需求。该计时器可托管于 Cloudflare,或部署于与运行 Worker 的目标数据中心共同部署的数据中心。Worker 请求远程计时器为某一事件标记时间戳,并在事件结束后计算另一请求的时间差。
在论文中,我们评估了多种不同的计时器配置,即便在较大的拓扑距离下,仅凭少量样本便能稳定地实现中位数亚毫秒级精度。下图展示了使用基于树的 PLRU 放大后的缓存事件。

可重复测量
单次测量不足以可靠地区分时序编码数据。生产机器噪声较大,因此攻击者需要对每次测量至少重复若干次,并使用某种统计判别器。在我们的方案中,重复测量意味着每轮重置缓存状态。每轮开始前需驱逐两类数据:推测分支所依赖的值必须被驱逐,使分支解析停顿足够长的时间以打开推测窗口;编码泄露比特的探测缓存行也必须被驱逐,以便下一次瞬态访问能够重新将其缓存。
由于 JavaScript 中没有直接可用的驱逐指令,经典方法是构建驱逐集(eviction set)——一组映射到与目标相同缓存组的地址集合。以正确的模式访问这些地址可将目标从缓存中驱逐。Stephen Röttger 和 Artur Janc 在其攻击中使用驱逐列表,可靠地将数据至少驱逐至 L2 缓存。这种方法有效,但代价较高:构建精确的驱逐集需要大量时序测量,而我们的计时器是一个嘈杂的远程计时器。此前针对 Workers 的远程攻击绕过了这一搜索过程,改为在每轮中遍历一个大于 L1 和 L2 缓存之和的数组——这是一种可行方案,但速度更慢。
Dougall Johnson 在其关于可移植 JavaScript Spectre 利用的精彩博文中介绍了一种更优雅的方式,其思路直接源自鸽巢原理。若分配的数据量远超缓存容量,随机选取的缓存行几乎可以确定不在缓存中。对于 256 KB 的 L2 缓存,分配 64 MB 数据后,随机缓存行仍处于 L2 缓存的概率最多为 1/256。因此,无需驱逐特定缓存行,只需选取一个以压倒性概率已被驱逐的全新随机位置即可。频繁循环遍历该对象数组的副作用是产生自动驱逐效果。
为在 JavaScript 中利用这一特性,我们分配了一个超过末级缓存容量的攻击者-受害者对象对大型池。每轮测量选取一个全新的随机对,该对象的 map 指针——即推测类型检查所读取的隐藏类描述符——因此几乎可以确定已被驱逐。
实现攻击者与受害者隔离区位于同一位置
要使攻击奏效,攻击者与受害者的隔离区必须被调度在同一边缘服务器的同一进程中。直觉上,这似乎并不容易——毕竟 Cloudflare 运营着数以万计的边缘服务器。然但实际上在 Cloudflare Workers 上实现二者共位相当简单。 由于 Cloudflare Workers 设计为可在任意 Cloudflare 边缘服务器上执行,在攻击者脚本中通过 fetch("https://victim.example") 调用受害者脚本,在大多数情况下会促使调度器在完全相同的进程中启动受害者 Worker 的实例。通过以固定时间间隔持续向受害者发送 子请求,可保持受害者隔离区持续存活。
此外,由于攻击稳定性在很大程度上取决于运行 Worker 脚本的边缘服务器的 CPU 负载,攻击者可以策略性地选择在非高 峰时段的托管数据中心发动攻击(例如在欧洲工作时间选择澳大利亚的数据中心),此时流量水平相对较低。
突破隔离区资源限制
Cloudflare Workers 运行时对所有隔离区强制执行一组限制,以保护平台并防止滥用。就本次攻击而言,相关限制为每次调用 30 秒 CPU 时间和 1,000 次子请求。这些限制此后已提高,但以下原则仍然适用。
对于普通 Worker,每个 HTTP 请求(即 fetch 事件)都是一次新调用,会重置上述限制。难点在于如何让连续请求落在同一边缘服务器上——负载平衡和网络状况的变化使这一点难以保证。Durable Objects 为我们解决了这一问题。
Durable Objects 专为客户端间的实时协调而构建,运行时将每条传入的 WebSocket 消息视为一次新调用并重置 CPU 时间和请求限制。攻击者向一个 Durable Object Worker 建立持久 WebSocket 连接,并定期发送保活消息。这使单个隔离区持续存活,并为我们提供了一个持久的双向通道来执行攻击。
其中有一个细节耗费了我们不少时间。由于隔离区是单线程的,传入的 WebSocket 消息只有在脚本将控制权交还给事件循环时才会被处理。在同步代码执行期间,运行时不会感知到保活消息,因此不会重置 CPU 时间。若线程持续阻塞超过 30 秒,运行时将终止隔离区。这为单次同步执行突发中的放大量设定了上限。在各次突发之间定期让出控制权,使我们能够将隔离区保持存活长达 5 小时至 20 余小时。
综合运用
此前的攻击主要依靠重复来放大单次缓存访问,因此泄露速率较低,约为 120 比特/小时。我们将基于树的 PLRU 放大机制与测量循环相结合:每次迭代重建缓存状态,从而累积更大的时序差异。即便某次迭代中中断破坏了缓存状态,后续迭代也能将其抵消。这使信号强度足以通过远程 WebSocket 计时器对比特进行分类。总体思路如下:
for (let s = 0; s < SAMPLE_NUM; s++) {
timer.mark("mark S" + s);
for (let r = 0; r < OUTER_REP_NUM; r++) {
setup(); // branch mistraining and cache control
leak(secretBit); // transient access
PLRU(cacheSet, INNER_REP); // amplify
}
timer.mark("mark E" + s);
}
delta = fetchFromServer(SAMPLE_NUM);
return median(delta);我们在 Cloudflare Workers 生产环境中,针对我们自己控制的 Worker,完整演示了端到端攻击。我们首先从攻击者 Worker 泄露内存,继而从一个共同部署的受害者 Worker(我们事先在其中放置了秘密数据)中泄露数据。
第一步,在攻击者 Worker、我们拥有的受害者 Worker 和远程计时器之间建立共同部署关系。Durable Objects 为我们提供了长期执行上下文,WebSocket 消息提供了可重复的时序来源,/cdn-cgi/trace 端点通过查看 fl 字段帮助我们确认机器部署位置。
第二步,增加校准步骤,以推测可达的值探测计时器。这一步骤至关重要,因为生产机器噪声较大。逐次调用的校准使我们能够根据 0 分布和 1 分布之间的相对差异对比特进行分类,最终应产生两个可清晰区分的分布。

第一阶段,我们从一个 Worker 泄露了隔离区根地址;在另一个 Worker 中,我们使用基于 64 位指针的推测类型混淆,从该根地址处读取数据。

作为中间验证步骤,我们通过读取 vDSO 区域的内存确认了第二个 gadget 的 64 位泄露能力。vDSO 是一个便于验证的目标,因为其中包含 gettimeofday 等人类可读字符串。

演示视频:从 JavaScript 堆泄露数据
最终,我们在受害者 Worker 中放置了一个 JWT 令牌,并逐比特泄露。第一个字节为字符"e",二进制表示为 0b01100101。下图展示了该字节的逐比特分类结果。分类过程使用双侧检验同时测试两种结果,并通过多数投票和基于百分位的阈值推断比特值。在生产环境中,我们实现了最高 12 bit/s 的泄露速率,准确率超过 99%。需要注意的是,更高的泄露速率以牺牲准确率为代价。



鲁棒性
随着一天中时段的不同,机器利用率会显著上升,进而拖慢攻击速度——因为需要采样更多数据。然而即便在 CPU 利用率较高的情况下,攻击依然可行。

为何未被检测到?
DyPrIs 监测硬件性能计数器,一旦脚本行为疑似 Spectre 攻击,便将其隔离至独立进程。以下两点使本次攻击得以躲避检测。
其一,DyPrIs 仅在脚本调用结束后才会触发隔离,而我们在攻击中使用的 Durable Object 保活技巧可持续运行数小时乃至一天。WebSocket 保活消息使单次调用持续开放数小时,泄露早在隔离机制介入之前便已完成。
其二,DyPrIs 通过 iTLB 访问次数对分支预测错误进行归一化处理。我们的远程计时器是一个大型 I/O 循环,WebSocket 流量会显著增加 iTLB 活动量。归一化后的比率降至检测阈值以下,使该攻击看起来与普通的 I/O 密集型 Worker 无异。
我们的改进措施
我们重点从三个方面持续推进改进:V8 深度加固、提供更强的进程内隔离,以及改进检测机制。
V8 Sandbox
V8 内存沙箱的最终目标是从 JavaScript 堆的大部分区域移除原始 64 位指针,从而降低众多内存破坏原语的利用价值。这也使本研究中特定推测类型混淆 gadget 的复用难度大幅提升,因为 typed-array 后备存储不再暴露相同的原始指针结构。
V8 Sandbox 并非针对 Spectre 的完整缓解方案。尽管文中所述的 64 位泄露 gadget 不再适用,但仍可能存在其他 Spectre 变体或 gadget 可被利用,以实现任意越界内存访问。
硬件辅助进程内隔离
2025 年 9 月,我们为 Workers 部署了基于内存保护密钥(Memory Protection Keys,MPK)的进程内隔离机制。MPK 允许进程将内存划分为保护域,并以低成本切换访问权限。Workers 利用该机制保护每个堆,防止其被同一进程内的其他隔离区访问。
这从根本上改变了 Spectre 的风险模型。每个隔离区的堆现在受到硬件强制访问边界的保护:对受错误密钥保护的页面的内存访问,将在硬件层面被拒绝。这阻断了本研究所依赖的直接跨隔离区堆读取路径。
然而,MPK 并非缓解 Spectre 的完整答案,但它严格收窄了泄露面。其局限性包括:硬件域数量有限,以及需要谨慎管理保护密钥状态。
改进 DyPrIs
我们改进了 DyPrIs,将长期执行和 I/O 密集型工作负载作为一类安全问题来处理。检测不能仅在脚本结束后才触发。Durable Object 或 WebSocket 密集型 Worker 可能运行时间很长,以至于执行后隔离机制介入时为时已晚。
我们目前正在研究是否可将远程时序行为作为 DyPrIs 的额外检测维度。尽管我们无法阻断与攻击者控制基础设施的远程通信,但时序数据揭示了极具特征性的数据泄露比特模式。更好的做法是将计算密集型代码段周围重复出现的类计时器 I/O 纳入行为信号,而非将其视为背景噪声。
致谢
特别感谢爱丁堡大学的 Haocheng Xiao 及其导师 Sam Ainsworth 和 Nigel Topham,感谢他们在提升 JavaScript 中 Spectre 攻击可靠性方面所作的贡献。
参与邀请
我们始终欢迎您通过我们的漏洞赏金计划提交高质量报告。运行时内存安全漏洞是高价值目标。您可以在 GitHub 上找到 workerd 的 Fuzzilli 集成及 workerd 源代码。

