這算是我寫得最詳細的一個 v8 漏洞利用了,其中將一個越界讀漏洞轉變為越界寫的思路比較有意思。
1. 介紹
一只南美洲亞馬孫河流域熱帶雨林中的蝴蝶,偶爾扇動幾下翅膀,可能在美國德克薩斯引起一場龍卷風嗎?這我不能確定,我能確定的是程序中的任意一個細微錯誤經過放大后都可能對程序產生災難性的后果。在11月韓國首爾舉行的 PwnFest 比賽中,我們利用了 V 8的一個邏輯錯誤(CVE-2016-9651)來實現 Chrome 的遠程任意代碼執行,這個邏輯錯誤非常微小,可以說是一個品相比較差的渣洞,但通過組合一些奇技淫巧,我們最終實現了對這個漏洞的穩定利用。這個漏洞給我的啟示是:“絕不要輕易放棄一個漏洞,絕不要輕易判定一個漏洞不可利用”。
本文將按如下結構進行組織:第二節介紹 V8 引擎中”不可見的”對象私有屬性;第三節將引出我們所利用的這個細微的邏輯錯誤;第四節介紹如何將這個邏輯錯誤轉化為一個越界讀的漏洞;第五節會介紹一種將越界讀漏洞轉化為越界寫漏洞的思路,這一節是整個利用流程中最巧妙的一環;第六節是所有環節中最難的一步,詳述如何進行全內存空間風水及如何將越界寫漏洞轉化為任意內存地址讀寫;第七節介紹從任意內存地址讀寫到任意代碼執行。
2. 隱形的私有屬性
在 JavaScript 中,對象是一個關聯數組,也可以看做是一個鍵值對的集合。這些鍵值對也被稱為對象的屬性。屬性的鍵可以是字符串也可以是符號,如下所示:?
上述代碼片段先定義了一個對象 normalObject ,然后給這個對象增加了兩個屬性。這種可以通過 JavaScript 讀取和修改的屬性我把它們稱作公有屬性。可以通過 JavaScript 的 Object 對象提供的兩個方法得到一個對象的所有公有屬性的鍵,如下 JavaScript 語句可以得到代碼1中 normalObject 對象的所有公有屬性的鍵。
在 V8 引擎中,除公有屬性外,還有一些特殊的 JavaScript 對象存在一些特殊的屬性,這些屬性只有引擎可以訪問,對于用戶 JavaScript 則是不可見的,我將這種屬性稱作私有屬性。在 V8 引擎中,符號(Symbol)也包括兩種,公有符號和私有符號,公有符號是用戶 JavaScript 可以創建和使用的,私有符號則只有引擎可以創建,僅供引擎內部使用。私有屬性通常使用私有符號作為鍵,因為用戶 JavaScript 不能得到私有符號,所有也不能以私有符號為鍵訪問私有屬性。既然私有屬性是隱形的,那如何才能觀察到私有屬性呢?d8 是 V8 引擎的 Shell 程序,通過 d8 調用運行時函數 DebugPrint 可以查看一個對象的所有屬性。比如我們可以通過如下方法查看代碼1中定義的對 normalObject 的所有屬性:

從上示d8輸出結果可知,normalObject僅有兩個公有屬性,沒有私有屬性。現在我們來查看一個特殊對象錯誤對象的屬性情況。

對比一下 specialObject 對象的公有屬性和所有屬性可以發現所有屬性比公有屬性多出了一個鍵為 stack_trace_symbol 的屬性,這個屬性就是 specialObject 的一個私有屬性。下一節將介紹與私有屬性有關的一個 v8 引擎的邏輯錯誤。
3. 微小的邏輯錯誤
在介紹這個邏輯錯誤之前,先了解下 Object.assign 這個方法,根據 ECMAScript/262 的解釋[1]:
The assign function is used to copy the values of all of the enumerable own properties from one or more source objects to a target object
那么問題來了,私有屬性是 v8 引擎內部使用的屬性,其他 JavaScript 引擎可能根本就不存在私有屬性,私有屬性是否應該是可枚舉的,私有屬性應不應該在賦值時被拷貝,ECMAScript 根本就沒有做規定。我猜 v8 的開發人員在實現 Object.assign 時也沒有很周密的考慮過這個問題。私有屬性是供 v8 引擎內部使用的屬性,一個對象的私有屬性不應該能被賦給另一個對象,否則會導致私有屬性的值被用戶 JavaScript 修改。v8 是一個高性能的 JavaScript 引擎,為了追求高性能, 很多函數的實現都有兩個通道,一個快速通道和一個慢速通道,當一定的條件被滿足時,v8 引擎會采用快速通道以提高性能,因為使用快速通道出現漏洞的情況有不少先例,如 CVE-2015-6764[2]、 CVE-2016-1646 都是因為走快速通道而出現的問題。同樣,在實現 Object.assign 時,v8 也對其實現了快速通道,如下代碼所示[3]:
在 Object.assign 的快速通道的實現中,首先會判斷當前賦值是否滿足走快速通道的條件,如果不滿足,則直接返回失敗走慢速通道,如果滿足則會簡單的將源對象的所有屬性都賦給目標對象,并沒有過濾那些鍵是私有符號并且具有可枚舉特性的屬性。如果目標對象也具有相同的私有屬性,則會造成私有屬性重新賦值。這就是本文要討論的邏輯錯誤。Google 對這個錯誤的修復很簡單[4],給對象增加任何屬性時,如果此屬性是私有屬性,則給此屬性增加不可枚舉特性。現在蝴蝶已經找到了,那它如何扇動翅膀可以實現遠程任意代碼執行呢,我們從第一扇開始,將邏輯錯誤轉化為越界讀漏洞。
4. 從邏輯錯誤到越界讀
現在我們有了將對象的可枚舉私有屬性重賦值的能力,為了利用這種能力,我遍歷了 v8 中所有的私有符號[5],嘗試給以這些私有符號為鍵的私有屬性重新賦值,希望能能攪亂 v8 引擎的內部執行流程,令人失望的是我并沒有多大收獲,不過有兩個私有符號引起了我的注意,它們是 class_start_position_symbol 和 class_end_position_symbol ,從這兩個符號的前綴我們猜測這兩個私有符號可能與 JavaScript 中的 class 有關。于是我們定義了一個 class 來觀察它的所有屬性。

果不其然,新定義的 class 中確實存在這兩個私有屬性。從鍵的名字和值可以猜測這兩個屬性決定了 class 的定義在源碼中的起止位置。現在我們可以通過給這兩個屬性重新賦值來實現越界讀。

上圖是在 Chrome 54.0.2840.99 的 console 中的運行輸出結果,最后一行等同于 short.toString() 的結果,我們可以看到,最后一行的最后兩個字符不正常,它們是發生了越界讀的結果。可以通過 substr 方法得到越界字符串的一個子串,使這個子串完全是未初始化內存或者部分是初始化內存部分是未初始化化內存都是可行的。
5. 從越界讀到越界寫
在檢查了所有其他私有符號后,并沒有發現其他有意義的私有屬性重賦值可被利用,現在我們唯一的收獲是有了一個越界讀漏洞,那么一個越界讀漏洞可以轉換為越界寫嗎?聽起來匪夷所思,但在一定條件下是可以的。第四節的最后我們得到了一個可越界讀的字符串 short.toString() ,而在 JavaScript 中,字符串是不可變的,每次對它的修改(如append)都會返回一個新的字符串,那么如何使用這個可越界讀的字符串實現越界寫呢?首先我們需要了解這樣一個事實,因為這是一個越界的字符串,而在程序執行時垃圾回收,內存分配操作是隨機,所以越界部分的字符是不確定的,多次訪問同一個越界的字符串返回的字符串內容可能是不一樣的,這就間接使得字符串是可變的。然后需要了解 JavaScript 中的一組函數,escape 和 unescape,他們分別實現對字符串的編碼和解碼。unescape 在 v8 中的內部實現如下[6]:
?unescape 的 v8 內部實現可以分為三步,假設輸入參數 string 是我們前面構造的越界字符串,第一步是計算這個字符串解碼后需要的存儲空間大小;第二步分配空間用來存儲解碼后的字符串;第三步進行真正的解碼操作。第一步和第三步都掃描了整個輸入串,但因為輸入是一個越界串,第一步和第三步掃描的字符串的內容可能不一樣,從而導致第一步計算出的長度并不是第三步所需要的長度,從而使第三步解碼時發生越界寫。需要注意的是,這個函數的實現并沒有問題,根本原因是輸入的字符串是一個越界串,這個越界串的內容是不確定的。我們舉例來說明越界到底是如何發生的。因為v8新分配的對象都位于 New Space[7], New Space 采用的垃圾回收算法是 Cheney's algorithm[8] ,所以 New Space 中對象的分配是順序分配的。假設我們已經將 New Space 噴滿字符串 "%a" ,越界寫的執行流程示意如下:
a)下圖為初始內存狀態,全是未分配內存,內容為噴滿的”%a”字符串;

?b)下圖為在創建了越界串之后,在執行 unescape 之前的內存狀態,假設創建的越界串的內容為 "dd%a" ,其中 "dd" 位于已初始化的內存空間中, "%a" 位于未分配的內存中;

c)下圖為在執行了代碼片段3的第二步后的內存狀態,r 代表隨機值。分配的 RawOneByteString 為16字節,包括12字節的頭部和4字節的解碼后的字符(因為第一次訪問越界字符串時內容為 "dd%a" ,所以計算的解碼后的字符串應該是 "dd%a" ,為四個字節)

d)下圖為執行完代碼片段3的第三步后的內存狀態,也就是完成 unescape 后的內存狀態,因為在執行完第二步后越界字符串的內容已經變為 "ddrrrr" , r 是隨機值,一般不會是字符 '%' ,所以解碼后的字符串仍然是 "ddrrrr" ,導致兩個字符的越界寫。

6. 從越界寫到任意地址讀寫
從越界讀到越界寫是整個利用過程中最巧妙的一環,但從越界寫到任意地址讀寫卻是最難的一步。一個越界寫漏洞要能被利用必須有三個必要條件,長度可控,寫的源內容可控,被覆蓋的目的內容可控。對這個漏洞而言,前兩個條件很容易滿足,但要滿足第三個條件頗費周折。
從上一節的最后一個圖中可以看到,越界寫覆蓋的兩個字節是未分配的內存。因為 v8 中在 New Space 中分配對象是順序分配的,而在代碼片段3的第二步和第三步之間沒有分配任何對象,所有 RawOneByteString 后總是未分配的內存空間,改寫未分配的內存數據沒有任何意義。那么如何使 RawOneByteString 對象后的內容是有意義的數據就成了從越界寫到任意地址寫的關鍵。
首先想到的是能不能控制在分配 RawOneByteString 時觸發一次 GC ,使得分配的 RawOneByteString 被重新拷貝,從而使得它之后的內存是已分配的其它對象,經過深入分析后發現此路不通,因為一個新分配的對象的第一次 GC 拷貝只是在兩個半空間(from space 和 to space)之間移動,拷貝后還是在 New Space 內部,拷貝后 RawOneByteString 之后的內存依然是未分配的內存數據。
第二種思路是越界寫時寫過 New Space 的邊界,改寫非 New Space 內存的數據。這需要跟在 New Space 后的內存區間是被映射的內存并且是可寫的。New Space 的內存范圍是不連續的,它的基本塊的大小為1MB,最大可以達到16MB,所以越界寫時可以選擇寫過任意一個基本塊的邊界。我們需要通過地址空間布局將我們需要被覆蓋的內容被映射到一個 New Space 基本塊之后。將一個 Large Space[7] 的基本塊映射到 NewSpace 基本塊之后是一個比較好的選擇,這樣可以能覆蓋 Large Space 中的堆對象。不過這里有個障礙,我們應該記得,當第一個參數為 NULL 時,mmap 映射內存是總是返回 mm->mmap_base 到 TASK_SIZE 之間能夠滿足映射大小范圍的最高地址,也就是說一般多次 mmap 時返回的地址應該是連續的,這樣的特性很有利于操縱內存空間布局,但很不幸的是,chrome 在分配堆的基本塊時,第一個參數給的是隨機值,如下代碼所示[9]:

這使得 New Space 和 Large Space 分配的基本塊總是隨機的,Large Space 的基本塊剛好位于 New Space 之后后幾率很小。我們采取了兩個技巧來保證 Large Space 基本塊剛好分配在 New Space 基本塊之后。
第一個技巧是使用 web worker 繞開不能進行地址空間布局的情形;New Space 起始保留地址是 1MB,為一個基本塊,隨著分配的對象的增加,最大可以增加到 16MB,這16個基本塊是不連續的,但一旦增加到 16MB,它的地址范圍就已經確定了,不能再修改,如果此時 New Space 的內存布局如下圖所示:

即每一個 New Space 的基本塊后都映射了一個只讀的內存空間,這樣無論怎樣進行地址空間布局都不能在 New Space 之后映射 Large Space ,我們采用了 web worker 來避免產生這種狀態,因為 web worker 是一個單獨的 JS 實例,每一個 web worker 的 New Space 的地址空間都不一樣,如果當前 web worker 處于上圖所示狀態,我們將結束此次利用,重新啟動一個新的 webworker 來進行利用,期望新的 web worker 內存布局處于以下狀態,至少有一個 New Space 基本塊之后是沒有映射的內存地址空間:

現在使用第二個技巧,我將它稱為暴力風水,這與堆噴射不太一樣,堆噴是指將地址空間噴滿,但 chrome 對噴射有一定的限制,它對分配的 v8 對象和 dom 對象的總內存大小有限制,往往是還沒將地址空間噴滿, chrome 就已經自動崩潰退出了。暴力風水的方法如下:先得到16個 New Space 基本塊的地址,然后觸發映射一個 Large Space 基本塊,我們通過分配一個超長字符串來分配一個 Large Space 基本塊;判斷此 Large Space 基本塊是否位于某一 New Space 基本塊之后,若不是,則釋放此 Large Space 基本塊,重新分配一個 Large Space 基本塊進行判斷,直到條件滿足,記住滿足條件的 Large Space 基本塊之上的 New Space 基本塊的地址,在此 New Space 基本塊中觸發越界寫,覆蓋緊隨其后的 Large Space 基本塊。
當在 v8 中分配一個特別大(大于 kMaxRegularHeapObjectSize==507136)的 JS 對象時,這個對象會分配在 Large Space 中,在 Large Space 基本塊中,分配的 v8 對象離基本塊的首地址的偏移是 0x8100 ,基本塊的前 0x8100 個字節是基本塊的頭,要實現任意地址讀寫,我們只需要將 Large Space 中的超長字符串對象修改成 JSArrayBuffer 對象即可,但在改寫前需要保存基本塊的頭,在改寫后恢復,這樣才能保證改寫只修改了對象,沒有破壞基本塊的元數據。要精確的覆蓋 Large Space 基本塊中的超長字符串,根據 unescape 的解碼規則有個較復雜的數學計算,下圖是執行 unescap 前的內存示意圖:

假設 Large Space 基本塊的起始地址為 border address,border address 之上是 New Space ,之下是 Large Space , 需要被覆蓋的超長字符串對象位于 border+0x8100 位置,我們構造一個越界串,它的起始地址為 border-0x40000 ,結束地址為 border-0x2018 ,其中 border-0x40000 到 border-0x20000 范圍是已分配并已初始化的內存,存儲了編碼后的 JSArrayBuffer 對象和輔助填充數據 “a”, border-0x20000 到 border-0x2018 是未分配內存,存取的數據為堆噴后的殘留數據 “a”, 整個越界串的內容都是以 “%xxy” 的形式存在,y不是字符%,整個越界串的長度為 (0x40000-0x2018),所以 unescape 代碼片段3中第一步計算出的目的字符串的長度為 (0x40000-0x2018)/2 ,起始地址為 border-0x20000 ,執行完 unescape 后的內存示意圖如下:

在執行完代碼片段3第二步后, Write Point 指向 border-0x20000+0xc ,因為 NewRawOneByteString 創建的對象的起始地址為 border-0x20000 ,對象頭為12個字節。
我們將代碼片段3的第三步人為地再分成三步,第一步,解碼從 border-0x40000 到 border-0x20000 的內容,因為此區間的內容為 “%xxy” 形式,所以解碼后長度會減半,解碼后寫的地址范圍為 border-0x20000+0xc 到 border-0x10000+0xc ,解碼后的 JSArrayBuffer 位于此區間的 border-0x17f18 ;第二步,解碼從 border-0x20000 到 border-0x10000 的內容,因為此時此區間不含%號,所以解碼只是簡單拷貝,解碼后長度不變,解碼后寫的地址范圍為 border-0x10000+0xc 到 border+0xc ,解碼后的 JSArrayBuffer 位于此區間的 border-0x7f0c ,第三步,解碼從 border-0x10000 到 border-0x2018 (越界串的邊界)的內容,這步解碼還是簡單拷貝,解碼后寫的地址范圍為 border+0xc 到 border+0xdfe8 ,解碼后的 JSArrayBuffer 正好位于 border+0x8100 ,覆蓋了在 Large Space 中的超長字符串對象。在 JavaScript 空間引用此字符串其實是引用了一個惡意構造的 JSArrayBuffer 對象,通過這個 JSArrayBuffer 對象可以很容易實現任意地址讀寫,就不再贅述。
7. 任意地址讀寫到任意代碼執行
現在已經有了任意地址讀寫的能力,要將這種能力轉為任意代碼執行非常容易,這一步也是所有步驟中最容易的一步。Chrome 中的 JIT 代碼所在的頁具有 rwx 屬性,我們只需找到這樣的頁,覆蓋 JIT 代碼即可以執行 ShellCode 。找到 JIT 代碼也很容易,下圖是 JSFunction 對象的內存布局,其中 kCodeEnryOffset 所指的地址既是 JSFucntion 對象的 JIT 代碼的地址。

8.總結
這篇文章從一個微小的邏輯漏洞出發,詳細介紹了如何克服重重阻礙,利用這個漏洞實現穩定的任意代碼執行。文中所述的將一個越界讀漏洞轉換為越界寫漏洞的思路,應該也可以被一些其他的信息泄露漏洞所使用,希望對大家有所幫助。
對于漏洞的具體利用,此文中還有很多細節沒有提及,真正的利用流程遠比文中所述復雜,感興趣的可以去看這個漏洞的詳細利用[10]。
引用
[1]https://www.ecma-international.org/ecma-262/7.0/index.html#sec-object.assign
[2]https://github.com/secmob/cansecwest2016/blob/master/Pwn a Nexus device with a single vulnerability.pdf
[3]https://chromium.googlesource.com/v8/v8/+/chromium/2840/src/builtins/builtins-object.cc#65
[4]https://codereview.chromium.org/2499593002/diff/1/src/lookup.cc
[5]https://chromium.googlesource.com/v8/v8/+/chromium/2840/src/heap-symbols.h#160
[6]https://chromium.googlesource.com/v8/v8/+/chromium/2840/src/uri.cc#333
[7]http://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
[8]
[9]https://chromium.googlesource.com/v8/v8/+/chromium/2840//src/base/platform/platform-linux.cc#227
[10]https://github.com/secmob/pwnfest2016
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/325/