performance.now() 被刻意限制精度以阻止 Spectre 式计时攻击。限制机制如何运作、跨域隔离为何能恢复精度、以及这背后的代价。
2026 年 8 月,Cloudflare 发布了一篇重新审视 Workers 上 Spectre 攻击的文章,其中有一个令人不安的发现:在纯 CPU 执行期间,Date.now() 和 performance.now() 完全被冻结——根本没有持续走动的时钟——但只需一条连接到外部服务器、专门推送时间戳的 WebSocket,就足以稳定地把亚毫秒级的计时精度找回来。一道本地防线,被一条借来的网络时钟绕了过去。浏览器早在多年前就撞上过一模一样的墙,原因也完全相同,而且同样没有彻底解决。这就是 performance.now() 是如何被模糊化的、"模糊"具体对应哪些数字,以及为什么你的 JavaScript 读到的这个时钟,本身也是一个可以被用来做指纹识别的信号。
核心要点
performance.now()是被刻意粗化的,不是出了 bug——它是针对 Spectre 类缓存计时侧信道攻击的一道防线,而这类攻击恰恰需要一个精确的时钟才能奏效。- W3C 高精度时间规范定义了一套双层精度模型:普通页面大约 100 微秒精度,页面处于跨域隔离状态时大约 5 微秒。
- 跨域隔离正是解锁
SharedArrayBuffer的同一道 COOP/COEP 门槛——这两项防护是绑在一起的,因为在紧凑循环里轮询一块共享缓冲区,本身就是另一种时钟。 - 这个时钟的行为如今本身就是一种指纹识别信号:你拿到哪一档精度、上面叠加了多少抖动、以及某种反指纹模式是否在进一步粗化时间,这些都会因浏览器和配置不同而不同。
- 阻止缓存攻击的同一道限制,也给合法的浏览器内测量设下了一个实实在在的下限——Cloudflare 在 Workers 上的发现,和浏览器里被限制的
performance.now(),是同一笔权衡账,只是发生在技术栈的不同层。
浏览器为什么要限制时钟精度
performance.now() 一开始并不模糊。根据它的 MDN 参考文档,最初的设计目标就是亚毫秒级精度:一个不受系统时钟调整影响的单调时钟,足够精确到给单个函数调用计时。有好几年时间,它确实做到了这一点。
然后 Spectre 出现了。推测执行类攻击靠的是给内存访问计时:一次缓存命中只需要几纳秒,一次缓存未命中则要多花几十纳秒,而这个微小的差距足以让攻击者一点一点地泄露出本不该被看到的数据——每次泄露一比特,测量上千次。这种攻击不需要直接读取内存,它只需要一个足够精确、能分辨命中和未命中的时钟。拿走这个时钟,或者让它变得足够嘈杂,原本能干净泄密的同一段代码,现在泄露出来的就只剩噪声。
这正是 hr-time-3 规范直接给出的理由。规范的安全章节点名了"缓存攻击、统计学指纹识别和微架构攻击"这一类威胁:恶意站点可以靠给浏览器的普通操作计时,把某一个具体用户从人群里挑出来,或者读出同进程内本不该交给它的数据。规范给出的应对方案是一套明确定义的 "粗化时间"算法——任何实现该规范的浏览器都必须降低原始高精度时间戳的精度,还可以在此基础上叠加抖动、并对连续调用做节流。各浏览器厂商在 Spectre 于 2018 年披露之后的几周内就上线了这套机制,一夜之间把 performance.now() 从亚微秒级的跳变粗化成几十乃至几百微秒的步长——某些引擎干脆退到整整一毫秒——此后它就一直保持这种被限制的状态。
双层模型:跨域隔离能换回什么
如果永远把所有页面都限制在同一档粗糙精度上,会破坏一些真正需要精确计时的正当用例——浏览器里的 WebAssembly 音频处理、视频编解码、科学计算,都依赖 performance.now() 提供真正管用的计时。规范给出的折中方案,是为那些主动选择隔离的页面解锁第二档、更精细的精度。
一个文档要成为跨域隔离状态,需要同时返回两个响应头:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
这一对响应头会切断该页面与跨域打开者、弹出窗口之间的联系,并限制它能嵌入哪些未主动授权的跨域资源。作为交换,window.crossOriginIsolated 会变成 true,而按照"粗化时间"算法自身给出的精度档位,这道限制也会随之放松:普通页面大约 100 微秒精度,一旦隔离生效则大约 5 微秒。这两个数字都不是硬性的通用常量——规范把两者都表述为一个最低精度,"或更高",因此各个引擎完全可以选择限制得更严格,实际上也经常这么做。
同一个隔离标志也控制着 SharedArrayBuffer 的开关。这并不是为了方便而顺带捆绑的巧合:一块可以被两个线程在紧凑循环里反复轮询的共享可变缓冲区,本身就是一种计时原语——Spectre 研究者们就曾用它演示过,即便 performance.now() 已被限制,依然能重建出一个高精度时钟。隔离页面会同时关上两扇门——直接的时钟和这个被临时凑出来的时钟——这也是为什么它们由同一对响应头统一控制,而不是分成两个独立的开关。
Date.now() 完全在这套体系之外。它在这一切发生之前就已经被限制在一毫秒精度,追踪的是墙钟时间而不是单调计数器,而且在隔离状态下也不会变得更精确——"粗化时间"算法和隔离带来的精度加成,两者都专门作用于高精度计时器,与最初的 Date 时钟无关。
指纹识别的角度:时钟本身就是一种信号
一旦精度取决于隔离状态、引擎和配置,这个时钟就不再是中立的基础设施,而变成了页面可以读取到的又一项关于你的信息。一段脚本可以测量自己调用 performance.now() 产生的抖动——在紧凑循环里连续调用,观察相邻两次读数之间的间隔——从而推断出自己拿到的是哪一档精度,进而推测该页面是否真如预期那样处于隔离状态,以及是哪个引擎特有的舍入方式生成了这种模式。
一些浏览器还会更进一步、刻意为之。Firefox 的 privacy.resistFingerprinting 首选项——在 Tor Browser 中始终开启——会在规范要求的基础 Spectre 防护之上,刻意把计时器舍入到更粗的步长,走的正是我们此前在 RFP 深度解析里讲过的那套"统一化"思路:让每一台受保护的浏览器都汇报同样生硬的数值,这样任何一台具体实例的计时器抖动都不会从人群中冒出来。一个依赖亚毫秒级计时噪声做指纹的页面,从一台开启 RFP 的浏览器上能拿到的信号,会远远少于一台普通浏览器——而这种"信号变少"本身也是一种可被检测出来的差异。
这也是我们在浏览器指纹识别完全指南里讲到的同一个道理:一个信号不必是唯一标识符,也能有用。单看计时精度和抖动,熵值都很低——它们大多只能说明"这是 Chromium"或者"这是开了 RFP 的 Firefox",而不能从人群里精确指认出某一个具体访客——但把它叠加进几十个其他信号里,恰恰是我们在指纹熵与匿名集那篇文章里量化的那种贡献:单个看很小,累积起来就不是零了。
与其读文章,不如直接看一眼这堆信号本身:BrowserInsight 的指纹检测完全在你自己的浏览器里运行,把一个页面能读到的属性并排列出来——canvas、WebGL、字体、硬件提示——这才是判断计时器行为这类低熵信号究竟给你的画像贡献了多少的老实办法。
检测的角度:仿真环境瞒不过计时
不管有没有被限制,performance.now() 依然精确到足以抓出另一类问题:一项工作看起来完成了,但实际发生的方式和它自称的不一样。真实的 GPU 渲染、真实的 JIT 编译、真实的硬件解码,都带着一种特有的成本——不是单次测量,而是重复多次操作之后呈现出的一种耗时分布,由真实的芯片一边预热缓存、一边跨真实的多个核心调度任务共同塑造出来。一个用软件光栅化冒充 GPU 的实现,或者一个跑在重度插桩环境里的浏览器,往往能把计算结果伪装得很像那么回事,却很难复现出那种耗时分布——要么太整齐,要么太快或太慢,和任何一台真实设备都对不上。
这正是CreepJS 的谎言检测模型所依赖的"可信来源与不可信来源"比对之一:在主线程和 Worker 里分别跑同一段计算,或者把某项能力自称达到的水平,和它理应产生的计时特征做对比,一旦两者不一致就标记出来。这同样也是为什么无头和自动化浏览器会被识破——即便那些显眼的破绽(缺失的 navigator.webdriver 标志、可疑的 User-Agent)早已被打过补丁修掉了。伪造一个数值的形态是可以做到的。而在这个被粗化、又叠加了抖动的时钟之下,去伪造产生这个数值所需要的耗时分布,则是一个难得多的目标——因为负责抓包的这个时钟,恰恰就是 Spectre 缓解措施留下来的那个又粗又吵的时钟。
代价:找不回来的精度
Cloudflare 在 Workers 上的发现,和浏览器里 performance.now() 的限制,其实是同一笔权衡账,发生在技术栈的不同位置:冻结或粗化时钟、堵住一条侧信道,同时接受合法测量会因此变得更嘈杂这个副作用。Cloudflare 的研究者证明,一次到外部时钟的网络往返,就能替攻击者把想要的精度找回来。而你浏览器里跑的页面,在做它自己的内部测量时,没有这条同样的退路——任何一段脚本用 performance.now() 计的时长,包括浏览器内网络诊断背后的那些计时,都逃不开这道"粗化时间"的底线,以及叠加在它之上的一切抖动。
这道底线在绝对数值上很小——隔离之外大约 100 微秒,隔离之内接近 5 微秒——对任何以整秒为单位的测量来说几乎无关紧要。但它仍是叠加在测量一条真实网络路径本身固有的波动之上的又一个噪声来源,而后者正是我们在为什么测速结果每次都不一样那篇文章里从网络侧详细讲过的内容。时钟并不是这种抖动的主要来源,它只是设计上永远不会完全安静——正是这同一套设计,让一个页面无法一比特一比特地读出你的缓存。
常见问题
浏览器为什么要降低 performance.now() 的精度?
为了堵住一条 Spectre 类侧信道。缓存计时攻击需要一个足够精确的时钟,才能分辨出缓存命中和未命中——两者的差距只有几纳秒。按照 W3C 规范的"粗化时间"算法的要求,把时钟粗化并叠加抖动,能让这种区分变得不可靠,同时又不至于让计时器在日常使用中完全失效。
这是不是意味着我没法在浏览器里做精确的性能测量了?
对于人类能感知的正常时间尺度来说不会。这道限制大概会让你损失隔离之外约 100 微秒、隔离之内约 5 微秒的精度——用来给一次网络请求或一次渲染计时完全可以忽略不计,但如果你想给单次内存访问计时,这道限制就真实存在了,而这恰恰是它存在的目的。
我怎么判断一个页面是否处于跨域隔离状态?
在控制台里查看 window.crossOriginIsolated——只有当页面同时返回了 Cross-Origin-Opener-Policy: same-origin 响应头和 Cross-Origin-Embedder-Policy 响应头时,它才会是 true,详见 MDN 说明。大多数普通页面并不会去做这件事,因为隔离会限制它能嵌入哪些跨域内容。
Firefox 的 resistFingerprinting 会进一步改变计时精度吗?
会。在每个浏览器都会应用的、由 Spectre 驱动的基础限制之上,RFP 会把计时器舍入到更粗、更一致的步长,目的正是让基于计时器抖动的指纹识别拿到更少的信号——用的是我们在 RFP 深度解析里介绍过的同一套统一化思路。


