Exploiting a patched version of the v8 engine for RCE
Environment Setup
OS: Ubuntu 20.04 VM
Tools: gef
Target: patched v8 engine
I shall assume that the reader has already downloaded and set up the relevant environment.
1 | osboxes@osboxes:~/Desktop/htb/rope2/v8$ ls |
Patch Analysis
1 | +BUILTIN(ArrayGetLastElement) |
The vulnerability occurs because we are reading and writing to one index past the end of the array.
For example if array a = [1,2,3], length of the array will be 3.
However, the last element in the array is actually a[2], thus a[3] will be an out of bounds access.
By abusing this we can cause a type confusion and gain arbitrary read and write.
Arrays in v8
This is how arrays are represented in v8:
1 | A JSArray object A FixedArray object |
This diagram can be found here https://www.elttam.com/blog/simple-bugs-with-complex-exploits/, and I think it’s a really clear representation.
The important point is that we are returned a JSArray object, but when we do array[0] it uses the element pointer to locate the FixedArray object, where the real elements are stored 8 bytes into it.
The Map Pointer determines the type of array(whether it is an array of floats or an array of objects).
For floats, the exact value is stored in the FixedArray, whereas for objects, a pointer to the actual object is stored in the FixedArray.
Let’s look at them in gdb, with the gef extension.
1 | osboxes@osboxes:~/Desktop/htb/rope2/v8/out.gn/x64.release$ rlwrap gdb -q ./d8 |
I ran gdb with rlwrap
so I can use the arrow keys.
Array of floats
1 | d8> arr = [1.1, 1.2, 1.1]; |
%DebugPrint
shows us the address of the JSArray object in memory.
Due to pointer tagging, the least significant bit of every pointer is always set, so we have to minus 1 when we inspect that address.
Ctrl-C to break in d8
, and we can view the JSArray object in memory.
1 | gef➤ x/16wx 0x3c5e08085e41-1 |
Compare this to the diagram above, the first address is the map pointer, second is properties, third is element and fourth is length.
Length is 6 because immediate small integers(SMI) in v8 are left shifted by 1.
Since our float array has 3 values, left shifted by 1 will give us 6.
The memory addresses are also only 32bit, because of pointer compression.
Pointer compression basically chops off the upper bits of the address, known as the base value and stores it in some register.
This approach makes v8 more memory efficient, because all addresses have this common base value and there’s no need to store it over and over again.
When we inspect an address in memory we have to add it manually, and minus 1.
Let’s inspect the actual FixedArray object.
1 | gef➤ x/16wx 0x3c5e08085e21-1 |
We see it also has its own map pointer and length, followed by the values. The cool thing is that this FixedArray object is directly before the JSArray object that points to it in memory.
So if we perform a GetLastElement()
on the JSArray object returned to us, we will be able to get a value with the lower 32 bits being the map pointer, and the higher 32 bits being the properties pointer of the JSArray object.
1 | d8> arr.GetLastElement() |
The value is returned as a float, which isn’t really intuitive to look at, so let’s make some helper functions to convert the datatypes.
1 | /// Helper functions to convert between float and integer primitives |
We can use these functions in d8 by running r --allow-natives-syntax --shell exploit.js
on startup, assuming you named your file exploit.js
1 | d8> ftoh(arr.GetLastElement()) |
Now we get the leaked map of a float array.
Arrays of objects
Arrays of objects are largely similar to arrays of floats in memory, the only difference is that instead of the actual objects, the FixedArray object contains pointers to the objects in the array.
Thus I’ll not be showing them in memory here, but I encourage you to play around with them in d8.
Addrof and Fakeobj primitives
These are two common primitives used in browser exploitation.
Addrof allows you to get the address of any object in memory, while Fakeobj returns you a reference to an object anywhere in memory, where you can read and write to.
The idea here is that since we are able to set the map pointer of any array with SetLastElement()
, we can cause the arrays to behave differently while retrieving data.
Before we start coding the primitives, let’s save the map pointer values for arrays of floats and arrays of objects.
1 | var fl_arr = [1.1]; |
There’s no way for us to get the map pointer for arrays of objects because only their pointer is stored, and the pointer is compressed to 32bits. Hence when we do a GetLastElement()
, we will get the properties pointer and the element pointer instead.
Fortunately, it seems like the object map pointer is always 0x50 bytes larger than the float map pointer, so we can hardcode that.
Addrof Primitive
1 | function addrof(in_obj) |
We first create an array of floats, then we change its map pointer to the map pointer for an array of objects. Now, v8 will treat it as an array of objects. When we update the first element in the array to be an arbitrary object, the pointer(a pointer to an object contains the address of the object) to that object is stored in the FixedArray.
Now we change the map pointer back to be of a float array, and we can retrieve the address of the object.
We only care about the lower 32 bits, because that’s the 32 bit compressed address of the object in memory.
Fakeobj Primitive
1 | function fakeobj(addr) |
This is also pretty straightforward. By exploiting a type confusion, we are returned a reference to an arbitrary object in memory, located at the addr
argument we specify.
The question now is how do we use these for arbitrary read/write.
Arbitrary read/write
The trick here is to create a fake JSArray object, just like the one returned to us when we define an array in javascript.
When we read or write to that fake array, we will be able to gain arbitrary read/write.
First let’s create an array, with the first element set as the float map.
Later we will convince d8 to believe that the first element in our array is the map pointer of a JSArray object.
1 | d8> arr = [fl_map, 1.1, 1.2, 1.3]; |
Let’s see how this array looks like in memory.
1 | gef➤ x/16wx 0x3cb208088609-1 |
From the above output, we see that the map pointer we supplied is 32 bytes lower than the actual map pointer(which is also the address of the JSArray object returned to us).
So let’s set our fake object to be exactly 32 bytes smaller than the address of our array.
1 | var fake_arr = [fl_map, 1.1, 1.2, 1.3]; |
We can’t do this in d8, because it will try to locate the FixedArray object of our fake object, which is now just some float value that we passed in.
Now how do we achieve arbitrary read/write?
If you remember from the diagram, the JSArray object will have an elements pointer pointing to a FixedArray object that stores the actual elements.
So if we change that pointer to an arbitrary address of our choosing, when we call fake[0] we are returned whatever is at the arbitrary address plus 8 bytes.
The elements pointer in this case will be the second element of our fake_arr
.
1 | function read(addr) |
By modifying the second element, we are also changing the length attribute, so we must take that into consideration.
I used 8 as the length because our array has 4 elements, and 4 left shifted by 1 is 8.
With this we are able to read/write freely in memory.
RCE
The problem now is, how do we turn this into arbitrary code execution.
Through reading other articles I learnt that when you create a webassembly
function in v8, v8 somehow allocates a page that has RWX
privileges.
1 | // https://wasdk.github.io/WasmFiddle/ |
Let’s make a useless wasm instance and check it out in memory.
1 | d8> %DebugPrint(wasm_instance) |
The address of the RWX
page is 0x68 bytes from the wasm instance.
Now we can just copy some shellcode and run it.
1 | var rwx_addr = ftoi(read(addrof(wasm_instance) + 0x68n)); |
Running chrome without sandbox pops xcalc.
full exploit
1 | /// Helper functions to convert between float and integer primitives |
Conclusion
This challenge sort of demystified browser exploitation for me, and I realised that “yeah the browser is also dependent on a bunch of engines and code, and it can also have weird memory corruption bugs or logic bugs that occur in binaries”. (yes i know this is just a random ctf challenge with an unrealistic bug but…. it’s still quite cool for a beginner to browser exploitation like myself)
If I have the time I’ll explore some CVEs and maybe do another writeup on it, but I probably have to mug for A Levels.
Thanks for reading. Goodbye :)