文章总结: Cloudflare安全团队在2024至2025年间于生产环境重演远程Spectre攻击,以每秒12比特、99%以上准确率泄漏跨租户内存,并发现DyPrIs防御的两个盲区。攻击利用推测类型混淆gadget、树形PLRU信号放大及远程WebSocket定时器实现。修补方案包括改进检测逻辑、引入V8Sandbox及进程内隔离,目前未发现真实利用迹象。
综合评分: 88
文章分类: 漏洞分析,红队,云安全,安全建设
再探 Cloudflare Workers 的远程 Spectre 攻击
幻泉之洲
2026年9月21日 13:31
北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
Cloudflare 2021 年为 Workers 上线了 DyPrIs 防御。四年后,团队用新出现的攻击稳定化技术在自家生产环境重做了一遍远程 Spectre 攻击,成功以每秒 12 比特、99% 以上的准确率泄漏跨租户内存。他们同时挖出了 DyPrIs 的两个盲区,并给出 V8 Sandbox、MPK 进程内隔离、检测逻辑重写三项修补。攻击已缓解,三年内没有发现真实利用迹象。
2021 年,我们和格拉茨工业大学一起评估过远程 Spectre 对 Cloudflare Workers 的威胁。结论落地成了一套叫 Dynamic Process Isolation(DyPrIs,动态进程隔离)的生产防御:它盯住硬件性能计数器,一旦某个脚本看起来像在搞 Spectre,就把它扔进独立进程。
问题在于,这几年攻击者那边没闲着。Spectre 攻击的”稳定化”技术又出了新花样。这些新手法对 Workers 的生产环境还有没有威胁?我们干脆自己重做一遍,在真实生产环境里搭了个更新的 PoC。
结果不算好看:我们可靠地实现了远程 Spectre 攻击,泄漏速率最高 12 bit/s,准确率 99% 以上。顺带还发现了 DyPrIs 实现上的一个漏洞。攻击目标是我们自己控制的 Worker,没有碰任何真实用户数据。
放在生产环境里做这件事,难度和实验室完全不是一个量级。共享硬件上的其他活动、中断、上下文切换、粗糙的定时器,每一样都能把侧信道信号淹掉。能在这种条件下跑通,本身就说明攻击者的门槛比想象中低。
修补分三块:改进 DyPrIs 的检测逻辑,把 V8 Sandbox 接进来,再加上进程内隔离机制。论文已经挂出来了,可以在 arXiv 上看到全文[1],合作者包括 Albert Pedersen、Haocheng Xiao、Sam Ainsworth、Nigel Topham 和 Martin Schwarzl,覆盖的是 2024 年到 2025 年初的工作。
这套攻击在当前生产系统里已经被 Runtime 团队的缓解措施挡住了。过去三年我们没有发现任何活跃利用的迹象。
Cloudflare Workers 的安全模型
Workers 干的事是在边缘节点上跑不可信的 JavaScript。靠 V8 isolate 做语言级隔离,几万个租户可以共享同一个操作系统进程。每个 Worker 有自己独立的 JavaScript 堆。这套设计把启动延迟压得很低,跑租户的效率比完整的进程隔离高得多。
运行时周边还有好几层:自动化的 V8 补丁流水线、Linux namespace 加 seccomp 组成的双层沙箱、Cap’n Proto RPC,以及把特定脚本调度进独立进程沙箱的能力。
但这些都不足以让人放心。一个 Worker 进程里只要出现任意读漏洞,就能跨租户泄漏。而最棘手的一类漏洞利用的是推测执行的本质——进程内 Spectre。
Spectre 是什么
用爬山来类比推测执行挺好理解。走到一个岔路口,你得先猜往哪边走。猜对了,省下时间,可以在山屋里晒晒太阳喝口饮料。猜错了就得原路折返。路看起来跟没走过一样,可你的脚印已经留在泥里了。
CPU 的推测执行一模一样。分支预测器提前猜一个分支结果,CPU 就照着猜的方向先跑起来。猜对了省时间,猜错了就丢弃结果、回滚、走另一条路。这些被推测执行的指令只短暂存在于流水线里,从来不会被永久提交,文献里叫它们 transient instructions(瞬态指令),整个概念叫 transient execution(瞬态执行)。
麻烦在于,瞬态执行会在微架构状态里留下痕迹,比如 CPU 缓存。攻击者用 Spectre 临时越界访问内存,把一个比特的信息编码进缓存状态,再靠重新访问同一块数据的延迟差异,反推这个比特是 0 还是 1。
针对进程内 Spectre,Workers 的对策是冻结本地定时器、禁止多线程和共享内存、主动检测、周期性打乱内存布局,以及把可疑脚本隔离进独立进程。
攻击原语
上图是远程 Spectre 攻击的高层示意。攻击者需要一个远程定时器,来测量探测某个瞬态泄漏比特是 0 还是 1 所花的时间。
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
有了上面这段 gadget,攻击者就能瞬态越界访问内存,把一个比特编码进缓存(也就是 probeArray)。接下来测量内存访问延迟,就能判断数据有没有被缓存进去。访问快,说明缓存行命中,比特是 1;访问慢,说明没命中,比特是 0。
我们的攻击用了两类 gadget。第一种泄漏压缩后的堆指针,比如 isolate 的堆基址(root)。第二种利用推测类型混淆,从一个攻击者构造的用户态 64 位指针上读数据。做这项研究的时候,V8 Sandbox 还没在 Workers 上线。指针压缩之下,大多数对象用的是 32 位压缩指针。TypedArray 是少数例外,它仍然把指向后备存储的原始 64 位指针直接存着,我们的 gadget 就是冲着这一点去的。
obj instanceof ObjP 这个分支做了一次类型检查,本质上是一个分支。要搞坏分支预测,我们先用真实的 ObjP 实例反复调用这段 gadget 做误训练,再拿一个内存布局由攻击者控制的异类对象 ObjI 去调它。CPU 会在被取用的分支上继续推测,顺着 obj.ptr[0] 走下去,哪怕对象类型根本不对。要泄漏一个比特,就掩出一个位,用它从两条 probeArray 缓存行里选一条。这条行有没有被缓存,就编码了这个比特。
利用堆泄漏 gadget,我们先把相邻对象映射出来,定位到攻击者可控的数组。第二个 gadget 混淆两个横跨多条缓存行的大对象,让类型字段落在一行,而我们要读的字段落在另一行。把类型字段挤出去,推测窗口就打开了,同时目标字段还留在缓存里,瞬态读取就会跟着攻击者控制的 64 位值走。这样一来,泄漏就变成了任意地址读取。更详细的描述在论文里。
▲ 推测类型混淆 gadget 的内存布局。构造一个假的 typed-array 头,瞬态读取就会跟着攻击者指定的指针走
信号放大
缓存命中和缓存未命中之间,差的是几个纳秒。而远程定时器本身的抖动,在几微秒到几毫秒的量级。这中间差了三个数量级,不做信号放大根本区分不出来。
Stephen Röttger 和 Artur Janc 找到过一个放大单次内存访问的办法,利用的是 L1 缓存里基于树的伪最近最少使用(PLRU)替换策略。树形 PLRU 把每个缓存组组织成一棵二叉树,节点指向最近用得较少的那一侧,CPU 就顺着这些指针做淘汰。掌握正确的访问模式之后,攻击者只要在指针转向目标时去碰它的树邻居,就能让目标行一直留在缓存里。
不得不说,这招挺优雅。靠这个行为,单次缓存事件的时序可以被任意放大,让它在一种情况下变成大量 L1 命中(更快),在相反情况下变成大量 L1 未命中。
下图展示的就是某个内存地址 X 有没有被缓存。没被缓存时,访问模式会带来大量缓存命中。已经被缓存时,它占了树上的一个节点,接下来四条缓存行要挤进三个节点,结果就是大量 L1 未命中。
▲ 用 PLRU 访问模式放大单次缓存事件(命中/未命中)
远程定时器
只要信号能被放大,一个有噪声的远程定时器就够用了。说白了,一条连到外部服务器、由对方提供高分辨率时间戳的 WebSocket 连接就行。定时器可以架在 Cloudflare 上,也可以架在跟运行 Worker 的目标数据中心同址的机房。
Worker 让远程定时器给某个事件打个时间戳,事件结束后再发一次请求,算出差值。
论文里我们评估了好几种定时器方案,即使跨较大的拓扑距离,也能只用少量样本稳定拿到亚毫秒级的中位数分辨率。下图是用树形 PLRU 放大后的缓存事件。
▲ 50 组放大后缓存命中(实线)与未命中(虚线)时序测量的核密度估计。上:网络定时器因抖动出现重叠。下:真值呈现清晰分离。竖线为各自的中位数
可重复测量
一次测量不足以可靠地辨别时序编码的数据。生产机器噪声大,每个测量至少要重复几次,再用某种统计判别器来下结论。在我们的场景里,重复测量意味着要重置缓存状态。
每一轮开始前有两样东西必须处于未缓存状态。一是推测分支所依赖的那个值,它被挤出后,分支解析才会停顿得足够久,打开推测窗口。二是编码泄漏比特的那条探测行,它被挤出后,下一次瞬态访问才能重新把它缓存进来。
JavaScript 里没有现成的指令干这事,传统做法是搭一个驱逐集(eviction set):一组跟目标映射到同一缓存组的地址,按特定模式访问就能把目标顶出缓存。Röttger 和 Janc 在攻击里用驱逐列表,至少能把目标稳定挤到 L2。这可行,但代价高。构造精确驱逐集要大量计时测量,而我们的定时器偏偏是有噪声的远程定时器。
之前那次针对 Workers 的远程攻击绕开了这个搜索,办法是每轮遍历一个比 L1 和 L2 都大的数组。也能用,但更慢。
Dougall Johnson 在他那篇讲可移植 JavaScript Spectre 利用的博客里给了一个更漂亮的办法[2]。思路直接从鸽笼原理来:如果你分配的数据远多于缓存能容纳的量,那么随机挑一条缓存行,它几乎肯定不在缓存里。256 KB 的 L2,你分配 64 MB,随机一条行还留在 L2 的概率最多也就 1/256。
所以根本不用驱逐,干脆从不驱逐。你挑一个全新的随机位置,它十有八九本来就已经在外面了。频繁遍历这个大对象数组还有个副作用:会自然形成自动驱逐的效果。
落到 JavaScript 上,我们分配一个超出末级缓存容量的大池子,里面是攻击者对象和受害者对象配对。每一轮测量随机挑一对新的。对象的 map 指针——也就是推测类型检查要读的那个隐藏类描述符——因此几乎肯定已经被挤出去了。
让攻击者和受害者 isolate 同处一地
要让攻击成立,攻击者 isolate 和受害者 isolate 必须在同一台边缘服务器的同一个进程里。直觉上这应该很难,毕竟 Cloudflare 有上万台边缘服务器。但实际上在 Workers 上这事相当简单。
原因正是 Workers 的设计目标:代码要能在任意一台 Cloudflare 边缘服务器上执行。攻击脚本用 fetch(“https://victim.example”) 去调受害者脚本,大多数情况下调度器就会在完全相同的进程里起一个受害者 worker 实例。按固定间隔反复发子请求,就能让受害者 isolate 一直活着。
更妙的是,攻击稳定性高度依赖运行脚本那台边缘服务器的 CPU 负载,这就让攻击者可以有策略地挑低谷机房下手,比如在欧洲工作时段去打澳大利亚的机房,那里的流量相对低。
说实话,Workers 最核心的架构优势在这里反而变成了攻击者的便利。这是设计取舍的必然结果,不是配置疏忽。
绕过 isolate 资源限制
Workers 运行时对所有 isolate 都有一套限制来保护平台、防止滥用。做这次攻击时,相关的两个是 30 秒 CPU 时间和每次调用 1000 个子请求。这两个限制后来都放宽了,但下面说的原理依然适用。
普通 Worker 里,每个 HTTP 请求也就是一个 fetch 事件,就是一次新调用,限制会被重置。难点在于让连续的请求落到同一台边缘服务器上。负载均衡加上不断变化的网络状况,让这件事很不可靠。Durable Objects 帮我们解决了。
Durable Objects 本来就是为客户端之间的实时协调设计的,所以运行时把每一条进来的 WebSocket 消息都当成一次调用,会重置 CPU 时间和请求限制。攻击者向一个 Durable Object worker 开一条持久的 WebSocket,定期发心跳消息。这样单个 isolate 就一直活着,同时也给了我们一条持久的双向通道来跑攻击。
有一个细节让我们多花了不少时间。isolate 是单线程的,只有脚本把控制权交还给事件循环,进来的 WebSocket 消息才会被处理。代码同步执行期间,运行时看不到心跳,也就不会重置 CPU 时间。如果线程被堵超过 30 秒,运行时就会杀掉这个 isolate。这给单次同步突发能放大多少设了上限。好在突发之间定期让出控制权,我们就能让一个 isolate 活 5 到 20 个小时以上。
把一切拼起来
之前那次攻击主要靠重复来放大单次缓存访问,泄漏速度慢,只有 120 bit/h。我们把树形 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 泄漏 isolate root,再在另一个 Worker 里用带 64 位指针的推测类型混淆,从 isolate root 上读数据。
作为中间验证,我们用第二个 gadget 从 vDSO 区域读内存,确认了 64 位泄漏能成立。vDSO 是个好用靶子,因为里面有 gettimeofday 这种人类可读的字符串。
▲ 从 JavaScript 堆泄漏数据的演示视频
最后,我们在受害者 Worker 里放了一个 JWT token,逐位把它泄漏出来。第一个字节是字符 e,二进制是 0b01100101。下图展示了这个字节逐位的分类结果。分类用的是双侧检验,同时检验两种结果,再用多数投票加百分位阈值来推断比特。生产环境里我们做到了最高 12 bit/s 的泄漏速率,准确率超过 99%。代价嘛,如果追求更高的泄漏速率,准确率就得往下掉。
▲ 完整泄漏出的 token 截图
健壮性
机器利用率随一天中的时段变化很大。利用率越高,攻击越慢,因为要采更多样本。但即便在 CPU 高负载下,攻击依然可行。
为什么没被检测到
DyPrIs 盯着硬件性能计数器,一旦某个脚本看起来像在搞 Spectre,就把它隔离进独立进程。有两件事让这次攻击一直藏在雷达下面。
第一,DyPrIs 只在调用结束之后才隔离脚本,而我们在攻击里用的 Durable Object 保活技巧能跑几个小时甚至一天。WebSocket 心跳把一次调用的生命周期拉到几小时,泄漏早就完成了,隔离根本来不及触发。
第二,DyPrIs 会用 iTLB 访问次数来归一化分支误预测。我们的远程定时器就是一个大 I/O 循环,WebSocket 流量把 iTLB 活动抬得很高。归一化之后的比值就掉到检测阈值以下,整个攻击看起来就像一个普通的 I/O 密集型 Worker。
两个盲区其实指向同一个问题:DyPrIs 的检测模型假设攻击是短促的一次性行为。任何能拉长生命周期、又能往分母里灌噪声的手法,都能绕开它。
我们改了什么
重点放在三块:继续加固 V8,提供更强的进程内隔离,以及改进检测。
V8 Sandbox
V8 内存沙箱的最终目标是把原始 64 位指针从 JavaScript 堆的大部分区域里去掉,这会削弱很多内存破坏原语的威力。它也让本文里这类特定的推测类型混淆 gadget 更难复用,因为 typed-array 的后备存储不再暴露同样的裸指针结构。
但 V8 Sandbox 不是完整的 Spectre 缓解方案。本文展示的 64 位泄漏 gadget 确实失效了,可其他 Spectre 变体或 gadget 仍有可能用来实现任意越界内存访问。别把它当成终点。
硬件辅助的进程内隔离
2025 年 9 月,我们为 Workers 上线了基于内存保护键(Memory Protection Keys,MPK)的进程内隔离。MPK 让进程可以把内存划分为若干保护域,并以很低的成本切换访问权限。Workers 用它保护每个堆,让同一进程里的其他 isolate 访问不到。
这改变了 Spectre 的风险模型。每个 isolate 的堆现在都在一道硬件强制的访问边界后面。用错误的密钥去访问受保护的页,硬件直接拒绝。本文依赖的那种直接的跨 isolate 堆读取,被挡住了。
遗憾的是,MPK 也不是 Spectre 的完整答案,不过它确实收窄了泄漏面。它也有局限:硬件保护域的数量有限,保护键的状态需要小心维护。
改进版 DyPrIs
我们把 DyPrIs 改了,让长命执行和 I/O 密集型负载被当成一等安全问题来处理。检测不能只在脚本结束之后才发生。一个 Durable Object 或者 WebSocket 密集型的 Worker,跑的时间足够长,等到事后隔离时早就晚了。
我们正在研究能不能把远程计时行为作为 DyPrIs 的一个额外维度。虽然没法完全禁止跟攻击者控制的基础设施通信,但时序数据会暴露出很有意思的窃取比特模式。更好的做法是把计算密集段周围反复出现的定时器式 I/O 当作行为信号的一部分,而不是当作背景噪声。
致谢
特别感谢爱丁堡大学的 Haocheng Xiao,以及他的导师 Sam Ainsworth 和 Nigel Topham,他们为 JavaScript 中 Spectre 攻击的可靠性做出了重要贡献。
参考资料
[1] https://arxiv.org/pdf/2608.17043
[2] https://dougallj.wordpress.com/2021/03/16/another-approach-to-portable-javascript-spectre-exploitation/
[3] https://hackerone.com/cloudflare
[4] https://github.com/cloudflare/workerd/pull/4917
[5] https://github.com/cloudflare/workerd
[6] https://blog.cloudflare.com/revisiting-spectre-attacks-on-workers/
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:幻泉之洲 《再探 Cloudflare Workers 的远程 Spectre 攻击》