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 深度解析裡介紹過的同一套統一化思路。


