研究者靠一條沒斷過的 WebSocket 心跳,把一個原本只能活 30 秒的 Cloudflare Workers isolate 撐到 20 多個小時——躲過的正是設計來抓這種行為的偵測系統。
從 120 bit/h 到 12 bit/s,側信道回來了
研究團隊在 Cloudflare Workers 上重新示範了一次 Spectre 側信道攻擊,作者是愛丁堡大學的 Haocheng Xiao、他的指導教授 Sam Ainsworth 與 Nigel Topham,加上 Albert Pedersen、Martin Schwarzl——Cloudflare 部落格原句列出的名單是「Albert Pedersen, Haocheng Xiao, Sam Ainsworth, Nigel Topham, and Martin Schwarzl」,並特別點出「Haocheng Xiao from University of Edinburgh and his supervisors, Sam Ainsworth and Nigel Topham」。2021 年那篇論文的洩漏速率是「leaking 120 bit/h」;這次同一組漏洞在正式 production 環境裡跑出「leaking up to 12 bit/s with a 99% accuracy in the production environment」。換算成同一單位,120 bit/h 大約是 0.033 bit/s——兩個數字放在同一張圖上才看得出差距有多大。攻擊鏈分成五段:先靠分支誤預測讓 CPU 投機執行一段跨型別的指標存取,再用快取的樹狀替換結構把單一位元放大成看得見的時間差,接著用 WebSocket 心跳撐住 isolate 的存活時間,繞過 Cloudflare 原本設下的資源上限,最後靠著讓自己看起來像普通的 I/O 密集流量,躲過專門抓這類行為的偵測系統。Cloudflare 自己把這篇研究定位成回頭重新示範,不是全新漏洞——同一組分支預測與快取放大的老手法,換上新的資源上限繞過技巧與偵測規避手法,重新在正式環境裡跑出一次可用的攻擊。往下五段,分別對應攻擊鏈的五個環節。
切換線性/對數尺度看差距有多大 · 2 組資料
這張圖預設用對數尺度畫,是故意的——如果拉成線性尺度,2021 年那根長條會薄到幾乎貼在座標軸上,兩個數字之間的落差反而看不出層次;對數尺度把每個十倍數當成等距的一格,才看得出這不是漸進式的進步,是研究者換了一套新技巧之後的一次跳躍。切換尺度這個動作本身就是在示範一件事:同一組資料,尺度選不對,讀者對「差多少」的直覺會完全走樣。
12 bit/s 這個數字前面有個「up to」——研究者自己標的是上限,不是穩定可以維持的速率;真實攻擊會受目標機器負載、網路路徑雜訊、快取替換演算法實作細節影響,實際能拿到的速率通常會比峰值低。但即使打對折,跟 2021 年的 120 bit/h 相比,仍然是完全不同量級的東西。
投機型別混淆:obj.ptr[0] 怎麼洩漏出界的位元
這次研究的核心 gadget 建立在一個型別檢查上:程式先用合法的 ObjP 物件反覆呼叫同一段程式碼,把 CPU 的分支預測器訓練成「這個位置的 obj instanceof ObjP 幾乎永遠成立」。訓練夠久之後,攻擊者換上一個記憶體佈局不同、但型別檢查會失敗的物件——CPU 還是照著訓練好的預測,投機執行型別檢查通過之後才該執行的那條路徑,也就是讀取 obj.ptr[0]。等到型別檢查真正算完、發現預測錯了,CPU 會撤銷這條路徑上暫存器層級的結果,但投機執行期間對快取造成的副作用不會被撤銷——這是所有 Spectre 系列攻擊共用的底層漏洞。
return probeArray[
obj instanceof ObjP
? PROBEARRAY_OFFSET + ((obj.ptr[0] >> bit) & 1) * 0x800
: 0x400
];
投機執行讀到的 obj.ptr[0] 是攻擊者原本不該碰到的指標值;程式碼把它其中一個位元拿來當索引,決定 probeArray 裡哪一個位置被讀進快取。等真正的型別檢查完成、投機執行被撤銷,快取狀態不會跟著撤銷——攻擊者事後只要量測 probeArray 每個位置的存取時間,就能反推出那個位元是 0 還是 1。單一位元一次讀不快,但反覆對指標的每一位元重複整套流程,就能一位元一位元拼出完整的 64 位元指標值。
分支預測器會這樣被騙,是因為 CPU 為了效能,本來就允許在還沒確定一個分支該往哪走之前,先猜一個方向往下執行——猜對了賺到時間,猜錯了撤銷結果,代價只在流水線層級。Spectre 系列漏洞的共通點是:撤銷會清乾淨暫存器與架構狀態,但清不乾淨微架構層級的副作用,快取狀態正是其中最容易被攻擊者讀出來的一種。這也是為什麼這次研究挑的 gadget 要繞著 obj.ptr 打轉——只要 JavaScript 物件的欄位裡還能摸到一段真正的 64 位元指標,投機執行就有東西可以讀、可以編碼進快取,後面第五段會看到 V8 Sandbox 怎麼直接把這個前提拿掉。
樹狀 pseudo-LRU:把一個位元放大成看得見的時間差
L1 快取的替換策略採用樹狀 pseudo-LRU(PLRU)近似——每個快取 set 對應一棵二元樹,樹上每個節點紀錄「上次存取偏向哪一側」。研究者利用的正是這棵樹的結構性弱點,Cloudflare 引述的說法是:「If it is not cached, the access pattern leads to a lot of cache hits. If it is present, it occupies one node in the tree, and subsequently four cache lines try to fit into three nodes, which results in a lot of L1 misses.」——拆開看:如果目標行不在快取裡,攻擊者設計的存取模式大多命中;一旦目標行被載入快取,它會佔住樹上的一個節點,接下來攻擊者準備的四條快取線要塞進只剩三個的節點,結果是大量的 L1 miss。命中和大量失誤之間的時間差,就是可以從遠端量測的訊號,而且比起單純比較一次命中與一次失誤的傳統做法,這個放大讓單一位元的訊號在雜訊很大的網路環境下也撐得住量測。
這個放大有其必要:單一次快取命中和快取失誤之間的時間差落在奈秒等級,遠端隔著網路量測時,這點差距會被路由、排隊、傳輸協定本身的抖動整個蓋過去。研究者要的不是一次命中和一次失誤的比較,而是像下面互動圖那樣,把整組探測重複很多次,讓「目標行在不在快取裡」這一個位元,反映成命中次數和失誤次數兩個群體之間拉開的統計差距——差距夠大,就算單次量測雜訊很大,多次平均後還是分得出來。
播放看四條快取線互相擠掉彼此 · 3 個樹節點 / 4 條線
切換「目標行」狀態,看同一組探測存取從大多命中翻成大量失誤——那正是 pseudo-LRU 樹被四條線擠爆時的行為
四條快取線擠進三個樹節點,目標行一旦載入就佔住一個位置,命中與失誤的落差就是能隔著網路量到的時間訊號。
上面這個模擬把「目標行未快取」和「目標行已快取」兩種狀態分開跑,兩邊用的是同一組探測邏輯,差別只在那一個位元。切到「已快取」,四條線擠三個節點,失誤次數會明顯竄升;切回「未快取」,探測大多命中,失誤次數幾乎不動——命中和失誤的比例,就是攻擊者用來讀出那個位元的訊號來源。
位元一個一個抽,抽滿 64 個位元才等於偷到一個完整指標;12 bit/s 這個速率換算下來,抽完一根指標大概要 5 到 6 秒——這還只是單一根指標,如果目標是把整個記憶體佈局摸清楚,需要的指標數量會遠不只一根,這也是為什麼撐住 isolate 存活時間這一段會這麼關鍵。
Durable Objects 心跳:讓 isolate 撐過 30 秒上限
Cloudflare Workers 對單次 invocation 的資源上限,原文寫得很明確:「The relevant limits were 30 seconds of CPU time and 1,000 subrequests per invocation.」——單一次呼叫最多只給 30 秒 CPU 時間,超過就會被強制終止,這原本足以讓任何需要長時間量測的側信道攻擊做不下去。研究者的解法是開一條到 Durable Objects 的 WebSocket 連線,讓對方定期送出 keep-alive 訊息,每一則訊息都觸發一次新的 invocation,把 CPU 時間預算重新算過。但這裡有個容易忽略的細節:「During synchronous code the runtime never sees the keep-alive, so it never resets the CPU time.」——如果攻擊程式碼是一路跑到底的同步區塊,執行環境根本沒有機會處理進來的 keep-alive 訊息,CPU 時間自然不會被重置。研究者因此得把量測工作拆成規律 yield 的小段,讓事件迴圈有空檔去處理 WebSocket 訊息。這個技巧的效果,原句是「yielding regularly between bursts lets us keep an isolate alive from five to more than 20 hours.」——同一個 isolate 從原本最多 30 秒,被撐到 5 個小時到 20 多個小時不等。
這個技巧真正的關鍵不是 WebSocket 本身,而是 Durable Objects 提供的長駐連線讓 invocation 可以被外部訊號反覆重新觸發——原本 30 秒 CPU 時間與 1,000 次 subrequest 的上限,設計初衷是假設一次 invocation 對應一次短暫的請求處理,沒有把「同一個邏輯任務,靠外部心跳串起一長串 invocation」算進去。對任何在 serverless 或 edge isolate 架構上維護長駐 WebSocket、SSE 或類似串流連線的工程師來說,這是一個值得記住的提醒:資源上限是按單次 invocation 算的,不代表整條邏輯連線的存活時間也有等量的上限。
按播放看心跳怎麼重複重置 CPU 時間預算 · 4 次心跳
播放上面這個模擬,會看到兩條軌道:沒有心跳的話,CPU 時間預算一路耗盡就到頂;有心跳的話,每一次 WebSocket 訊息送達,預算就重新灌回滿格。研究者實測到的結果是同一個 isolate 能撐 5 到 20 多個小時,換算下來,一個原本設計成「跑完一次請求就該結束」的執行環境,被硬生生變成一個長駐的量測平台。
DyPrIs 為什麼看不見:iTLB 正規化的死角
Cloudflare 原本就部署了一套叫 DyPrIs(Dynamic Process Isolation)的偵測系統,專門用硬體效能計數器抓側信道攻擊的行為特徵,但它有兩個假設,這次研究剛好都踩中。第一個假設是攻擊會在一次 invocation 內做完——「DyPrIs isolates a script only after its invocation finishes」,但上一段靠 WebSocket 心跳撐住的 isolate 根本不會結束,DyPrIs 等不到那個事後隔離的時機。第二個假設在計算方式上:「DyPrIs normalizes branch mispredictions by the number of iTLB accesses.」——DyPrIs 不是直接看分支誤預測的絕對次數,而是拿誤預測次數除以指令 TLB 存取數做正規化,避免把單純執行量大的正常程式也判成可疑。研究者的計時器本身就是一個不斷收送資料的大迴圈,原句是「Our remote timer is one large I/O loop, and that WebSocket traffic inflates iTLB activity. The normalized ratio drops below the detection threshold.」——分支誤預測的絕對次數並沒有變少,但 WebSocket 流量把分母(iTLB 存取數)推高了,正規化之後的比值就掉到偵測門檻以下。
用 iTLB 存取數當分母,原本是合理的工程判斷:指令多、分支多的正常程式(例如大量迴圈或字串處理)本來就會有比較多分支誤預測,直接看絕對次數容易誤殺這類正常負載,除以 iTLB 存取數是把誤預測密度標準化,避免正常忙碌的程式被錯誤隔離。問題是這個標準化假設了分母不會被攻擊者操縱——而這次研究剛好證明了它可以被操縱:攻擊者只要讓自己的計時器背後長得像一個吃 I/O 的正常程式,分母就會被推高,跟分支誤預測次數完全脫鉤。一個原本用來降低誤判的設計,變成攻擊者可以直接拉低比值的槓桿。
按按鈕灌入 WebSocket 流量,看正規化比值怎麼掉到門檻以下
按一下按鈕,圖裡的長條會從門檻之上掉到門檻之下——分子(分支誤預測次數)沒有變,變的是分母。這正是這種「用比值代替絕對值」的偵測設計共同的弱點:只要分母是攻擊者摸得到、能操縱的量,比值就不再是可靠的訊號。
MPK 與 V8 Sandbox:兩層防線各自守住哪一段
Cloudflare 沒有等到這篇論文發表才動手。原句寫得直接:「In September 2025, we deployed in-process isolation for Workers using Memory Protection Keys (MPK).」——2025 年 9 月,Cloudflare 已經用 MPK 幫每個 Workers isolate 的 heap 加上邊界。這條邊界的性質跟軟體層的存取控制不一樣:每個 isolate heap 拿到的是「hardware-enforced access boundary」,未授權的記憶體存取「denied by hardware」——擋下來的動作發生在硬體層,不需要等軟體先偵測到異常再介入。這層邊界直接卡住這次攻擊鏈的最後一步:就算投機執行讀到了越界的位元,MPK 邊界外的記憶體本來就不該被這個 isolate 存取到。第二層防線動的是攻擊鏈更前面的環節——指標本身。「The V8 memory sandbox's final goal is to remove raw 64-bit pointers from large parts of the JavaScript heap」,而這次研究用到的 gadget 依賴的正是 obj.ptr 這種可以直接讀到原始指標的欄位;V8 Sandbox 部署之後,「Typed-array backing stores no longer expose the same raw pointer structure」,這篇論文用的具體 gadget 因此失效——即使分支誤預測和 pseudo-LRU 放大這兩段機制原理上還成立,也沒有指標可讀。DyPrIs 本身也在調整:Cloudflare 表示偵測不能只發生在 script 結束之後,並把「long-lived executions and I/O-heavy workloads」列為一等安全案例——直接對應這篇論文利用的兩個弱點:長存活的 isolate、被 WebSocket 流量灌高的 iTLB 活動;具體的新偵測時機與機制,原文沒有進一步說明。
這裡有兩個措辭上的細節值得留意。Cloudflare 說的是「in-process isolation」,MPK 做的邊界劃在同一個作業系統行程內部,不是把每個 isolate 各自丟進一個獨立行程——那樣隔離性更強,但每個 isolate 多開一個行程的成本,在 Workers 這種高密度多租戶場景下負擔不起,MPK 等於是用硬體權限位元在行程內部再切一層,成本遠低於行程等級隔離。另外,V8 Sandbox 那句「final goal」用的是目標,不是完成式——移除原始指標是一個還在推進中的工程方向,這次研究能讓那個特定 gadget 失效,不代表 JS heap 裡已經沒有任何原始指標可讀,後續類似的研究大概率還會繼續在這個邊界上找縫。
「被硬體拒絕」和「被軟體攔截」的差別,在攻擊者的時間視窗上很關鍵:軟體層的防禦通常要先讓一段可疑的存取執行到某個檢查點,靠監控或例外處理事後補救;硬體邊界則是存取指令本身在執行的當下就被擋下來,不需要任何額外的判斷邏輯介入。這也是為什麼 MPK 這類硬體輔助隔離,被視為比純軟體沙箱更難繞過的一層——它不依賴先偵測到異常再反應這個時間差,投機執行本身想讀取邊界外的位元組,權限檢查在硬體層就已經生效。這次揭露的時間軸也有意思:MPK 是 2025 年 9 月上線的,這篇研究公開在 2026 年 8 月,防禦先於揭露將近一年。合理的推測是,縱深防禦本來就該假設總會有下一個側信道,不必等某一篇論文點名才動手,而不是 Cloudflare 提前知道這個特定 gadget。
DyPrIs 原本的設計,等於是把整個判斷押注在 invocation 結束後那一刻——只要攻擊者想辦法讓 invocation 不結束,這個判斷時機就永遠等不到。Cloudflare 的調整方向是把長存活與高 I/O 這兩個特徵本身當成一等安全案例,不再只靠事後那一次判斷;至於新的偵測時機具體怎麼觸發,原文沒有攤開講。
五段攻擊鏈,兩層防線
把五段攻擊鏈攤開排一次:分支誤預測讓 CPU 讀到不該讀的指標位元,樹狀 pseudo-LRU 把單一位元放大成看得見的時間差,WebSocket 心跳把 isolate 的存活時間從 30 秒撐到 5 個小時到 20 多個小時不等,DyPrIs 的 iTLB 正規化在這種長存活、高 I/O 的流量模式下失靈,最後靠著這一整條鏈子,洩漏速率從 2021 年的 120 bit/h 推進到 12 bit/s、99% 準確率。MPK 與 V8 Sandbox 兩層防線不是分別針對五段裡的某一段單獨補洞,而是分別卡在鏈子的頭尾:V8 Sandbox 讓第一段沒有指標可讀,MPK 讓就算讀到了、也存取不到邊界外的記憶體。中間三段——快取放大、心跳續命、正規化失靈——這次研究揭露之後,Cloudflare 也表示 DyPrIs 不會再只靠 invocation 結束後才做的事後隔離,把長存活與高 I/O 負載列為一等安全案例。
對照著看,這篇研究對任何自己在維運多租戶執行環境——不管是 V8 isolate、gVisor sandbox 還是其他形式的行程內隔離——的工程師,實際能抄的作業不是某一段程式碼,而是這套排查順序:先問投機執行的邊界有沒有硬體背書,再問長駐連線會不會被拿來繞過資源上限,最後問偵測系統用的統計量本身能不能被流量模式操縱。三個問題分別對應這次研究鏈子裡的頭、中、尾。
The unlock:MPK 擋住越界存取本身,V8 Sandbox 拿掉可以被投機執行讀出的原始指標——兩層防線分別堵住攻擊鏈的頭尾,任何一層被繞過都還有另一層擋著,Cloudflare 不必再賭沒有人會寫出下一個 gadget。