vatt'ghern jaskier's ballads

V8 sandbox 的整個設計前提是:就算攻擊者完全拿下了 renderer、能在沙箱區域裡任意讀寫記憶體,他也只是被關在一個更小的籠子裡。CVE-2026-6307 用一個 TurboFan 編譯器的相等判斷 bug,讓這個「就算淪陷也還被關著」的假設,在同一顆漏洞裡連著兩道邊界一起垮掉。

一個漏洞穿透 Chrome 兩層防線

Nebula Security 在 2026 年 6 月 29 日公開的這篇 write-up,講的是一個他們命名為 Longinus 的漏洞——標題直接把賣點寫出來:「2 Boundaries in One Bug, Piercing Chrome's Renderer and V8 Sandbox with a Single Vulnerability」。值得記下的不是「又一個 V8 type confusion」,而是它同時穿透了兩道本來應該獨立的防線:先拿下 renderer,再逃出 V8 sandbox,全程不需要第二顆 bug。這個漏洞從 Chrome 106 引入、活了大約 4 年,任何介於 106 與 147 之間的正式版都受影響;發現它的是 Nebula Security,透過他們的 Vega 工具找出來的。

這條 chain 沒有 spray、沒有 heap grooming、沒有一連串串接不同 bug 的疊疊樂。它就是一個編譯器優化階段的相等判斷寫錯了,讓兩個描述不同 Wasm 回傳型別的節點在 graph optimizer 眼裡看起來一模一樣。下面這個分階 walkthrough 把整條 chain 攤成五格——每一格的重點不是「畫了哪些框」,而是「攻擊者這一步握到了什麼 primitive、正踩在哪一道邊界上」。

setup · renderer

兩個看起來相等的 getter

攻擊者準備兩個 Wasm 函式——參數列表相同、回傳型別不同:一個回 externrefkSig_r_v),一個回 i64kSig_l_v)。原文的條件是「the same optimized JavaScript function can call two Wasm getters with matching parameter lists but different return types」。

兩個 JS-to-Wasm continuation 的 FrameState 節點在 graph optimizer 看來等價,但它們描述的是不同的 Wasm 回傳型別。此刻還沒有 primitive——只是把地雷埋進了 TurboFan 的優化圖。

primitive · renderer

把 tagged pointer 讀成 i64:addrof

最後那次呼叫實際走的是 externref getter,但值被當成 i64 重建。原文:「This exposes the full tagged pointer(64 bits) of target as a i64, giving us an addrof primitive」。

一個物件的完整 64-bit tagged pointer 就這樣以整數形式流出來。primitive #1:addrof——知道任意物件在哪。

primitive · renderer

把 i64 讀回 reference:fakeobj

反過來走:回傳的 i64 被當成 tagged reference 重建。原文:「The returned i64 is reconstructed as a tagged reference, allowing an attacker-controlled address to be treated as a JavaScript object」。

攻擊者控制的位址被當成一個真正的 JS 物件。primitive #2:fakeobj——把任意位址偽裝成物件。addrof 與 fakeobj 湊齊,等於在 V8 heap sandbox 區域內拿到任意讀寫。

write · V8 sandbox

用 in-object property store 寫進去

只要偽造出的位址前面擺著一個合法的 map 與 properties 欄位,這個假物件就能通過 layout check,接著的 in-object property store 就會寫到那個 i64 指向的位址。原文:「the object passes the required layout checks and the in-object property store reaches the address pointed to by the i64」。

到這裡為止,讀寫都還在沙箱設計預期之內——原文明白說沙箱的威脅模型就是假設「an attacker can arbitrarily and concurrently read and write memory in the V8 heap sandbox region」。renderer 這一道已經破了;問題是下一步。

escape · V8 sandbox → RCE

偽造一個 map 在沙箱外,然後 RCE

關鍵一句:「the newly materialized reference can still be dereferenced normally, even when its underlying pointer lies outside the V8 sandbox」。攻擊者於是問「what if we can forge an object pointer with a valid map outside of sandbox?」——沙箱的邊界被繞過了。

接著把寫入導向 JIT 記憶體:原文說「a tagged SMI write is sufficient to place a short relative jump after one of the staged qwords and redirect execution into smuggled shellcode, allowing RCE in the renderer process」。同一顆 bug 走完兩道邊界,不需要第二個沙箱逃逸漏洞。

兩道邊界,一個一直沒被質疑的假設

Chrome 的縱深防禦是這樣分層的:renderer process 跑不受信任的 JavaScript 與 WebAssembly,被 OS 層的 process sandbox 關住;在 renderer 內部,V8 又疊了一層自己的 heap sandbox。這第二層的定位很明確——原文說它是「an in-process software fault isolation, that confines memory corruption bugs originating from untrusted JavaScript or WebAssembly code」。它不假設 renderer 不會淪陷,它假設的是:淪陷之後也還關得住。

這種「in-process」的隔離跟 OS process sandbox 是兩種東西。process sandbox 靠的是核心強制的位址空間分離,跨越它得走一次真正的權限升級;V8 heap sandbox 是同一個 process 內、用軟體約定畫出來的一塊區域,它沒有硬體邊界可倚靠,只能靠「所有沙箱內的指標都表示成相對 cage 的偏移、任何解參考都先被夾回區內」這套 pointer compression 規則來維持。它便宜、對每次記憶體存取的額外成本低,代價是它的完整性完全建立在「沒有任何一條路徑能生出繞過這套規則的指標」這個前提上。這也是為什麼一個編譯器內部的旁路——而不是對邊界檢查本身的正面攻擊——就足以讓它失守。

這個假設有多強?強到直接把攻擊者的能力寫進威脅模型裡。原文設定的前提是「an attacker can arbitrarily and concurrently read and write memory in the V8 heap sandbox region with primitives from typical traditional V8 vulnerabilities (addrof and fakeobj)」。換句話說,addrof 與 fakeobj 這種傳統 V8 漏洞給的任意讀寫,被視為「已經發生、但仍被容納」的常態——沙箱的存在,就是為了讓這種讀寫的破壞半徑停在沙箱邊緣。

沙箱的物理形狀決定了這道邊界在哪。原文描述:「The V8 sandbox is currently a 1 TB large region, containing all V8 heaps (located inside the 4GB V8 pointer compression cage at the start of the sandbox), ArrayBuffer backing stores and Wasm backing buffers」。一個 1 TB 的位址空間,開頭是 4 GB 的 pointer compression cage,裝著所有 V8 heap,後面接著 ArrayBuffer 與 Wasm 的 backing store。沙箱內的指標被壓縮成偏移量、任何越界存取被限制在這 1 TB 之內——這就是「關得住」的機制。下面這張圖可以點各區塊,看它各自裝什麼、以及那個假設落在哪裡。

click a region to read what it holds · 4 regions

V8 sandbox:1 TB region(點區塊看內容) 1 TB sandbox boundary pointer compression cage · 4 GB all V8 heaps ArrayBuffer backing stores Wasm backing buffers 沙箱外記憶體 region outside sandbox 沙箱外——本應無法從沙箱內的指標抵達

pointer compression cage · 4 GB

位於沙箱最前端的 4 GB 區,裝著所有 V8 heap。沙箱內的物件指標被壓縮成相對這個 cage 的偏移量,越界存取因此被夾在區內。

假設:這裡的任意讀寫(addrof / fakeobj 帶來的)被視為常態,破壞半徑應停在 1 TB 邊界內。

ArrayBuffer backing stores

JavaScript 的 ArrayBuffer 實際資料緩衝區也放在這 1 TB 區裡,同樣受沙箱邊界約束。

用意:即使攻擊者能任意寫這些緩衝,也寫不出沙箱。

Wasm backing buffers

WebAssembly 的 backing buffer 與 V8 heap、ArrayBuffer 共用這同一個 1 TB region。

用意:Wasm 與 JS 一樣是不受信任的輸入,其記憶體也被關在同一道邊界內。

沙箱外的記憶體

原文列出的 1 TB 沙箱只裝 V8 heap、ArrayBuffer 與 Wasm backing store。要在 renderer 達成 RCE,得寫到沙箱外的可執行記憶體——合理的推測是 JIT 產生的機器碼區,原文未逐一點名這塊的位置。

破口:Longinus 偽造一個 map 落在沙箱外的位址,讓 dereference 正常穿出邊界——原文「its underlying pointer lies outside the V8 sandbox」。

這道邊界設計得很認真:它甚至事先承認自己會被打穿一半。renderer 淪陷不算沙箱失效,任意讀寫沙箱記憶體也不算——只有「破壞半徑越過 1 TB 邊界」才算。Longinus 之所以值得單獨寫一篇,是因為它讓「越過邊界」這件本應需要第二顆漏洞的事,變成同一顆 bug 的下一行。

值得先記住這顆 bug 的存活範圍,因為它直接說明了「假設多久沒被驗證」。原文的判斷是:「The bug was introduced in Chrome 106, can affect 148 beta at the time of report, and fixed in Chrome 147.0.7727.101. Any released Chrome version between 106 and 147 is affected.」106 到 147,橫跨大約 4 年、四十多個正式版;回報當時連 148 beta 都還在射程內。一個活這麼久的沙箱逃逸漏洞不是因為藏得深不可測,而是因為它平時完全無害——只有攻擊者刻意把兩個 Wasm getter 構造成「參數相同、回傳不同」,那個少比一欄的相等判斷才會被撬開。正常網頁不會誤觸,fuzzer 也很難隨機湊出這個組合,於是它在明處活了四年。

那個一直看起來相等的 FrameState

bug 藏在一個相等運算子裡。TurboFan 在做 JS-to-Wasm 呼叫的 inlining 與去優化(deoptimization)時,會用 FrameState 節點記錄「如果這裡要 deopt,該怎麼把狀態還原回 interpreter」。原文一句話點出核心:「Two JS-to-Wasm continuation FrameState nodes can look equivalent to the graph optimizer even though they describe different Wasm return types」。兩個描述著不同 Wasm 回傳型別的節點,在 optimizer 眼裡等價——這就是型別混淆的根。

為什麼會等價?看 FrameStateFunctionInfo 的相等運算子就懂了。它逐一比 type()parameter_count()max_arguments()local_count()shared_info()bytecode_array()——每一個都比了,唯獨沒有比 Wasm 的 signature。

bool operator==(FrameStateFunctionInfo const& lhs,
                FrameStateFunctionInfo const& rhs) {
  return lhs.type() == rhs.type() &&
         lhs.parameter_count() == rhs.parameter_count() &&
         lhs.max_arguments() == rhs.max_arguments() &&
         lhs.local_count() == rhs.local_count() &&
         lhs.shared_info().equals(rhs.shared_info()) &&
         lhs.bytecode_array().equals(rhs.bytecode_array());
}

問題出在繼承。原文說得很清楚:「JSToWasmFrameStateFunctionInfo inherits from FrameStateFunctionInfo and adds the signature_ field described above. However, the equality operator accepted references to the base class and only compared fields from that base class. It never compared the Wasm signatures.」子類多加了一個 signature_ 欄位,但相等運算子收的是 base class 的 reference,只比 base class 的欄位——那個關鍵的 Wasm signature,從頭到尾沒進比較。

這是一種很安靜的錯誤。它不是誰寫了越界索引、也不是誰忘了檢查 null;它是一個型別系統邊界上的疏漏——一個為了 deopt 而存在的 metadata 節點,因為相等判斷少比了一個欄位,讓 optimizer 把兩個語意不同的東西當成同一個做了合併。相等運算子在編譯器裡是個不起眼的角色:optimizer 靠它判斷兩個節點能不能去重、能不能共用同一份 deopt metadata。一旦它對兩個「其實不同」的節點回傳 true,後續所有基於「這兩者可互換」的優化就都建立在錯的前提上。這裡漏掉的偏偏是決定「回傳值該被當成什麼型別」的 Wasm signature——去重的結果,就是讓一個回 externref 的呼叫與一個回 i64 的呼叫共用了同一套還原邏輯。

原文把攻擊利用的觸發條件講得很具體:「This becomes a problem when the same optimized JavaScript function can call two Wasm getters with matching parameter lists but different return types.」關鍵在「the same optimized JavaScript function」——兩個 getter 得被同一個經過優化的 JS 函式呼叫,TurboFan 才會在同一張 graph 裡看到這兩個 FrameState、才有機會把它們判為等價並合併。參數列表一樣、回傳型別不同,就是撬動這個等價判斷的槓桿。這也解釋了為什麼它能活 4 年——它在正常執行下什麼都不會壞,只有被刻意構造的兩個 getter 撞上去才會現形。

一顆 register,兩種讀法:primitive 就地成型

型別混淆本身不給你能力,能力來自「同一份 bits 被兩種型別各讀一次」。這條 chain 的巧妙之處在於,混淆發生在 deoptimizer 重建回傳值的那一刻。所謂 deopt,是 TurboFan 對某段程式碼做了樂觀假設、跑到一半發現假設不成立,於是把狀態退回到 interpreter 重新解釋執行;FrameState 就是那份「怎麼退回去」的說明書。既然相等運算子讓兩個回傳型別不同的節點共用了同一份說明書,deoptimizer 就會拿著錯的型別資訊去重建那個 Wasm 回傳值。deoptimizer 面對 Wasm 回傳時,會去讀機器的回傳暫存器;i64 與 reference 回傳讀的是同一個暫存器,差別只在「怎麼解讀那 64 個 bit」——混淆恰好卡在這個「同一份 bits、兩種讀法」的接縫上。

回傳 kind 讀哪個暫存器 如何解讀那 64 bits 攻擊者拿到
kI64 kReturnRegister0 cast 成 int64_t,box 成 BigInt tagged pointer 以整數流出 → addrof
kRef kReturnRegister0 把暫存器值 wrap 成 Tagged<Object> 整數位址被當成物件 → fakeobj
kRefNull kReturnRegister0 把暫存器值 wrap 成 Tagged<Object> 同上,reference 語意
三種回傳 kind 讀的是同一個 kReturnRegister0,差別只在解讀方式。原文:「For both kI64 and reference returns, it reads the same machine return register, kReturnRegister0」——這句話就是整條 chain 的物理根源。

攻擊者用兩個 Wasm 函式製造這個條件,原文給了具體的宣告:一個是 builder.addFunction('return_ref', kSig_r_v),回 reference;另一個是 builder.addFunction('return_i64', kSig_l_v),回 64-bit 整數。kSig_r_vkSig_l_v 這兩個 signature 的差別,正是相等運算子沒去比的那一欄。當同一個被優化的 JS 函式先後呼叫這兩者、TurboFan 把兩個 continuation FrameState 判為等價,deopt 材料化就會拿其中一種型別的還原邏輯去處理另一種型別的回傳值。

表裡那句對照直接引自原文:「The only difference is how the bits are interpreted: kI64 casts the register value to an int64_t and boxes it as a BigInt, while kRef and kRefNull wrap the register value as a Tagged<Object>」。把一個 reference 用 kI64 路徑讀,就把物件的 tagged pointer 當成一個普通 BigInt 交到 JavaScript 手上——addrof 成型。反過來,把一個攻擊者控制的整數用 reference 路徑讀,V8 就把它當成一個真物件——fakeobj 成型。原文一句話收攏:「This supplies the addrof and fakeobj primitives」。

有了 fakeobj,任意寫只差一個 layout check。V8 存一個物件的 in-object 屬性時,會先信任這個物件的 map——map 描述了它的形狀、屬性擺在哪個 offset。fakeobj 給的是一個「攻擊者說了算」的物件,只要在它宣稱的位址前面把 V8 期待看到的東西擺好,這個檢查就過得去。原文描述得很直白:「As long as a valid map and properties field are present before the chosen write target, the object passes the required layout checks and the in-object property store reaches the address pointed to by the i64」。攻擊者在目標位址前面鋪好一個合法的 map 與 properties 欄位,假物件就通過檢查,接著一個普通的 in-object property store 就落在那個 i64 指的地方。原文還提到這個 property store「can then be used to patch any nearby instruction」——寫的能力,已經延伸到能改附近的指令。

表格那三行其實藏著整條 chain 為什麼「乾淨」的原因。傳統 V8 利用鏈常常要先做一連串 heap spray、把記憶體佈局調到某個機率性的狀態,再賭一個 use-after-free 或 out-of-bounds 落在對的位置。Longinus 不需要——原文列的第一條能力就是「Gain arbitrary memory read/write primitive with 100% success rate, without any spraying trick」。因為混淆發生在暫存器解讀這種確定性的位置:你要 addrof,就走 kI64 路徑讀那顆暫存器;你要 fakeobj,就走 reference 路徑讀同一顆。沒有機率、沒有佈局賭注,兩個 primitive 都是「呼叫一次、拿一次」。

到這一步為止,攻擊者手上的東西——任意讀、任意寫——恰好就是沙箱威脅模型假設「已經發生」的那組能力。沙箱的存在,本來就是為了讓故事停在這裡。它沒有停。

沙箱的核心假設,被自己的編譯器推翻

邊界的破口在一句話裡:「the newly materialized reference can still be dereferenced normally, even when its underlying pointer lies outside the V8 sandbox」。剛剛用型別混淆材料化出來的 reference,就算它底層的指標落在 V8 sandbox 之外,仍然能被正常 dereference。這一句直接抹掉了那道 1 TB 邊界的意義——沙箱靠的是「指標被夾在區內」,但這個 reference 是繞過材料化路徑生出來的,它不吃那套壓縮與夾限。

這一步是整條 chain 從「renderer 內的任意讀寫」升級到「沙箱逃逸」的分水嶺,而它之所以成立,是因為前面 addrof / fakeobj 那組能力恰好就是沙箱威脅模型主動承認的常態。攻擊者不必再去繞過任何邊界檢查,只需要用同一個材料化路徑,餵給它一個沙箱外的位址。沙箱設計原意是「就算你在區內為所欲為,你造出來的指標也只能指回區內」;但 deopt 材料化出來的 reference 不受這條規則管,於是「造一個指向沙箱外的物件」這件本該不可能的事,變成合法操作。

攻擊者於是問出那個致命的問題:「what if we can forge an object pointer with a valid map outside of sandbox?」——如果我在沙箱外偽造一個帶合法 map 的物件指標呢?答案是:可以。前面那套 fakeobj + layout check + in-object store 的機制,不再受沙箱邊界約束。任意讀寫的破壞半徑,從 1 TB 之內漫到了沙箱之外。

最後一步是把這個越界寫變成執行流劫持。原文:「a tagged SMI write is sufficient to place a short relative jump after one of the staged qwords and redirect execution into smuggled shellcode, allowing RCE in the renderer process」。一個 tagged SMI 寫入——SMI 是 V8 裡最不起眼的小整數表示——就足以在事先鋪好的 qword 後面塞一個 short relative jump,把執行流導進偷渡進來的 shellcode,在 renderer process 裡達成 RCE。

把這條路徑攤平看,它徹底顛倒了縱深防禦的成本結構。原文把這顆 bug 的能力列成三條:「Gain arbitrary memory read/write primitive with 100% success rate, without any spraying trick」、「Achieve V8 heap sandbox escape and RCE on its own, without any other bug」、「Found in Chrome 106, across 4 years」。100% 成功率、不用 spray、單一漏洞就完成沙箱逃逸與 RCE、潛伏 4 年——每一條都在攻擊 V8 sandbox 的立論基礎。整個逃逸過程完全走 deoptimization metadata 的混淆,不需要一個獨立的沙箱逃逸 bug;而那個「就算 renderer 全淪陷、攻擊者能任意讀寫沙箱記憶體,也仍被關住」的核心假設,就是被這條路徑推翻的。

這件事對正在維護 V8 sandbox 這類 in-process isolation 的人是一記提醒:邊界的完整性不只取決於邊界本身檢查得多嚴,還取決於「有沒有一條旁路能生出不吃邊界規則的物件」。Longinus 沒有攻擊 pointer compression 的夾限邏輯,它攻擊的是「材料化」這條 compiler 內部路徑——deoptimizer 重建一個 reference 時,那個 reference 沒有被要求證明自己落在沙箱內。對防守方而言,這改變了審計的重心:不能只盯著「解參考時有沒有夾限」,還得盯著「所有能生出 reference 的路徑」,包括 deopt、inlining、continuation 這些平時只被當成優化正確性問題、而非安全邊界問題的編譯器內部機制。相等運算子少比一欄,過去頂多是一個 miscompile 的正確性 bug;在有 in-process sandbox 的世界裡,它直接是一條沙箱逃逸路徑。

修補本身很快。原文的時間線是:「2026-03-31: Google identified the root cause and finished the fix.」——Google 定位 root cause 當天就完成修補,4 月 7 日隨 Chrome 147.0.7727.101 發佈;從 3 月 29 日回報算起,只隔兩天。修法從 bug 描述反推也很直接:讓 JSToWasmFrameStateFunctionInfo 的比較把那個被漏掉的 Wasm signature 補進去,兩個回傳型別不同的節點就不會再被判為等價。但這顆 bug 已經在正式版裡活了 4 年——修補的速度,跟假設被信任而未被驗證的時間長度,是兩個獨立的數字。

下面這條時間線可以拖動——把 handle 拉到各個節點,看每個日期當下這顆漏洞處於什麼狀態,從 106 埋下、到 147 補上。

drag the handle along the disclosure timeline · 5 milestones

CVE-2026-6307 揭露時間線(拖動查看每一刻的狀態) 2022
拖動 handle:查看這個 4 年壽命漏洞在每一刻的狀態。

What breaks:in-process 的 V8 sandbox 一直靠「就算 renderer 淪陷、攻擊者能任意讀寫沙箱記憶體,破壞半徑也停在 1 TB 邊界內」這個假設支撐——Longinus 用一個少比 Wasm signature 的相等運算子,讓 deoptimizer 材料化出一個能正常 dereference 到沙箱外的 reference,於是同一顆 bug 走完 renderer 淪陷與沙箱逃逸兩道邊界,把那個假設連根拔掉。