This is going to document my journey into V8 exploitation, and hopefully provide some tools to help you learn too.
To start with, we're going to go through *CTF's OOB-V8 challenge, mostly following Faith's brilliantly in-depth writeup. From there, well, we'll see.
Saelo's classic V8 paper is also a goldmine.
A lesson in floating-point form
You will need an account for picoCTF to play this. The accounts are free, and there are hundreds of challenges for all categories - highly recommend it!
We are given d8
, source.tar.gz
and server.py
. Let's look at server.py
first:
It's very simple - you input the size of the file, and then you input the file itself. The file contents get written to a javascript file, then run under ./d8
with the output returned. Let's check the source code.
The patch
is as follows:
This just just generally quite strange. The only particularly relevant part is the new AssembleEngine()
function:
This is a pretty strange function to have, but the process is simple. FIrst there are a couple of checks, and if they are not passed, they fail:
Check if the number of arguments is 1
Assign 4096 bytes of memory with RWX permissions
Then, if the first argument is an array, we cast it to one and store it in arr
. We then loop through arr
, and for every index i
, we store the result in the local variable element
. If it's a number, it gets written to func
at a set offset. Essentially, it copies the entirety of arr
to func
! With some added checks to make sure the types are correct.
There is then a memory dump of func
, just to simplify things.
And then finally execution is continued from func
, like a classic shellcoding challenge!
This isn't really much of a V8-specific challenge - the data we are input is run as shellcode, and the output is returned to us.
HOWEVER
val->Value()
actually returns a floating-point value (a double
), not an integer. Maybe you could get this from the source code, but you could also get it from the mmap()
line:
You can see it's all double
values. This means we have to inject shellcode, but in their floating-point form rather than as integers.
If you've read the oob-v8 writeup, you know there are common functions for converting the integers you want to be written to memory to the floating-point form that would write them (and if you haven't, check it out).
So now we just need to get valid shellcode, convert it into 64-bit integers and find the float equivalent. Once we make the array, we simply call AssembleEngine()
on it and it executes it for us. Easy peasy!
We can't actually interact with the process, only get stdout
and stderr
, so we'll have to go to a direct read of flag.txt
. We can use pwntools to generate the shellcode for this:
We want to convert shellcode
to bytes, then to 64-bit integers so we can transform them to floats. Additionally, the 64-bit integers have to have the bytes in reverse order for endiannes! We'll let python do all of that for us:
We can dump this (after minor cleanup) into exploit.js
and convert the entire list to floats before calling AssembleEngine()
. Make sure you put the n
after every 64-bit value, to signify to the javascript that it's a BigInt
type!
And finally we can deliver it with a python script using pwntools
, and parse the input to get the important bit:
And we get the flag:
Setting Up
Most of what is written from here is courtesy of and their . Please go check them out!
Ok so first off, we're gonna need an old VM. Why? It's an old challenge with an old version of v8. Back then, the v8 version compilation steps required the python
command to point at python2
instead of python3
like on my ParrotOS VM, and there is the odd number of other steps. Long story short, there is a very real possibility for needing to jerry-rig a bunch of stuff, and I don't want to break a VM I actually use. Whoops.
So, we're gonna use a . You can get the ISO file directly from (amd64 version), and then set up a VM in VMware Workstation or your preferred virtualisation program.
Now we want to set up the system we're actually attacking. Instead of building v8 itself, we're going to build d8, the REPL (read–eval–print loop) for v8. It's essentially the command-line of v8, meaning we can compile less.
First off, install useful stuff.
Now let's grab the depot_tools
, which is needed for building v8, then add it to our PATH
:
Restart terminal for PATH
to update. Then in folder of choice (I am in ~/Desktop/oob-v8
), we fetch v8 and install all the dependencies needed to build it:
The next step is to checkout
the commit that the challenge is based on, then sync the local files to that:
Now we want to apply the diff
file we get given. The challenge archive can be found , and we'll extract it. The oob.diff
file defines the changes made to the source code since the commit we checked out, which includes the vulnerability.
Now let's apply it then prepare and build the release version:
But there is small problem when it gets run:
Now we have Python 3.8 installed in /usr/bin/python3.8
, we can try and overwrite the symlink /usr/bin/python3
to point here instead of the default 3.6.9 version that came with the ISO.
Now we hope and pray that rerunning the ninja
command breaks nothing:
Then run it again:
And it starts working! The output release
version is found in v8/out.gn/x64.release/d8
. Now let's build debug.
And it's done. Epic!
I'm going to revert default Python to version 3.6 to minimise the possibility of something breaking.
Now we can move on to the challenge itself.
According to in NVIDIA, this is because in python 3.8+ lru_cache
has gotten a user_function
argument. We can try and update to python3.8, but the fear is that it will break something. Oh well! Let's try anyway.
Ok, no ninja
. Let's follow and install it:
I'm also going to install , the GDB extension. gef
is actively maintained, and also actually supports Ubuntu 18.04 (which pwndbg
, although that's due to requiring Python 3.8+ which we have technically set up in a roundabout way - use at your own risk!).
Another OOB, but with pointer compression
server.py
is the same as in Kit Engine - send it a JS file, it gets run.
Let's check the patch
again:
The only really relevant code is here:
We can essentially set the length
of an array by using .setHorsepower()
. By setting it to a larger value, we can get an OOB read and write, from which point it would be very similar to the oob-v8 writeup.
Let's first try and check the OOB works as we expected. We're gonna create an exploit.js
with the classic ftoi()
and itof()
functions:
Then load up d8 under GDB. This version is a lot newer than the one from OOB-V8, so let's work out what is what.
So, right of the bat there are some differences. For example, look at the first value 0x0804222d082439f1
. What on earth is that? Well, if you have eagle eyes or are familiar with a new V8 feature called pointer compression, you may notice that it lines up with the properties
and the map
:
Notice that the last 4 bytes are being stored in that value 0x0804222d082439f1
- the first 4 bytes here at the last 4 bytes of the properties
location, and the last 4 bytes are the last 4 of the map
pointer.
This is a new feature added to V8 in 2020 called pointer compression, where the first 4 bytes of pointers are not stored as they are constant for all pointers - instead, a single reference is saved, and only the lower 4 bytes are stored. The higher 4 bytes, known as the isolate root, are stored in the R13 register. More information can be found in this blog post, but it's made a huge difference to performance. As well as pointers, smis have also changed representation - instead of being 32-bit values left-shifted by 32 bits to differentiate them from pointers, they are now simply doubled (left-shifted by one bit) and therefore also stored in 32-bit space.
A double is stored as its 64-bit binary representation
An smi is a 32-bit number, but it's stored as itself left-shifted by 1 so the bottom bit is null
e.g. 0x12345678
is stored as 0x2468acf0
A pointer to an address addr
is stored as addr | 1
, that is the least significant bit is set to 1
.
e.g. 0x12345678
is stored as 0x12345679
This helps differentiate it from an smi, but not from a double!
We can see the example of an smi in the second value from the x/10gx
command above: 0x0000000408085161
. The upper 4 bytes are 4
, which is double 2
, so this is the length of the list. The lower 4 bytes correspond to the pointer to the elements
array, which stores the values themselves. Let's double-check that:
The first value 0x0000000408042a99
is the length
smi (a value of 2
, doubled as it's an smi) followed by what I assume is a pointer to the map. That's not important - what's important is the next two values are the floating-point representations of 1.5
and 2.5
(I recognise them from oob-v8!), while the value directly after is 0x0804222d082439f1
, the properties
and map
pointer. This means our OOB can work as planned! We just have to ensure we preserve the top 32 bits of this value so we don't ruin the properties
pointer.
Note that we don't know the upper 4 bytes, but that's not important!
Let's test that the OOB works as we expected by calling setHorsepower()
on an array, and reading past the end.
Fantastic!
This is a bit more complicated than in oob-v8, because of one simple fact: last time, we gained an addrof
primitive using this:
In our current scenario, you could argue that we can reuse this (with minor modifications) and get this:
However, this does not work. Why? It's the difference between these two lines:
In oob-v8, we noted that the function .oob()
not only reads an index past the end, but it also returns it as a double. And that's the key difference - in this challenge, we can read past the end of the array, but this time it's treated as an object. obj_arr[1]
will, therefore, return an object - and a pretty invalid one, at that!
You might be thinking that we don't need the object map to get an addrof
primitive at all, we just can't set the map back, but we can create a one-use array. I spent an age working out why it didn't work, instead returning a NaN
, but of course it was this line:
Setting the map to that of a float array would never work, as it would treat the first index like an object again!
So, this time we can't copy the object map so easily. But not all is lost! Instead of having a single OOB read/write, we can set the array to have a huge length
. This way, we can use an OOB on the float array to read the map of the object array - if we set it correctly, that is.
Let's create two arrays, one of floats and one of objects. We'll also grab the float map (which will also contain the properties
pointer!) while we're at it.
My initial thought was to create an array like this:
And then I could slowly increment the index of float_arr
, reading along in memory until we came across two 3.5
values in a row. I would then know that the location directly after was our desired object, making a reliable leak. Unfortunately, while debugging, it seems like mixed arrays are not quite that simple (unsurprisingly, perhaps). Instead, I'm gonna hope and pray that the offset is constant (and if it's not, we'll come back and play with the mixed array further).
Let's determine the offset. I'm gonna %DebugPrint
float_arr
, obj_arr
and initial_obj
:
Let's check the obj_arr
first:
In line with what we get from %DebugPrint()
, we get the lower 4 bytes of 0808594d
. If we print from elements
onwards for the float_arr
:
We can see the value 0x08243a410808594d
at 0x30e008085980
. If the value 1.5
at 0x22f908085370
is index 0
, we can count and get an index of 12
. Let's try that:
And from the output, it looks very promising!
The lower 4 bytes match up perfectly. We're gonna return just the last 4 bytes:
And bam, we have an addrof()
primitive. Time to get a fakeobj()
.
If we follow the same principle for fakeobj()
:
However, remember that pointer compression is a thing! We have to make sure the upper 4 bytes are consistent. This isn't too bad, as we can read it once and remember it for all future sets:
And then fakeobj()
becomes
We can test this with the following code:
If I run this, it does in fact print 1
:
I was as impressed as anybody that this actually worked, I can't lie.
Once again, we're gonna try and gain an arbitrary read by creating a fake array object that we can control the elements
pointer for. The offsets are gonna be slightly different due to pointer compression. As we saw earlier, the first 8 bytes are the compressed pointer for properties
and map
, while the second 8 bytes are the smi for length
and then the compressed pointer for elements
. Let's create an initial arb_rw_array
like before, and print out the layout:
The leak works perfectly. Once again, elements
is ahead of the JSArray
itself.
If we want to try and fake an array with compression pointers then we have the following format:
32-bit pointer to properties
32-bit pointer to map
smi for length
32-bit pointer to elements
The first ones we have already solved with float_map
. We can fix the latter like this:
We can test the arbitrary read, and I'm going to do this by grabbing the float_map
location and reading the data there:
A little bit of inspection at the location of float_map
shows us we're 8 bytes off:
This is because the first 8 bytes in the elements
array are for the length
smi and then for a compressed map pointer, so we just subtract if 8
and get a valid arb_read()
:
We can continue with the initial_arb_write()
from oob-v8, with a couple of minor changes:
We can test this super easily too, with the same principle:
Observing the map location in GDB tells us the write worked:
Last time we improved our technique by usingArrayBuffer
backing pointers. This is a bit harder this time because for this approach you need to know the full 64-bit pointers, not just the compressed version. This is genuinely very difficult because the isolate root is stored in the r13 register, not anywhere in memory. As a result, we're going to be using initial_arb_write()
as if it's arb_write()
, and hoping it works.
If anybody knows of a way to leak the isolate root, please let me know!
The final step is to shellcode our way through, using the same technique as last time. The offsets are slightly different, but I'm sure that by this point you can find them yourself!
First I'll use any WASM code to create the RWX page, like I did for oob-v8:
Again, this generates an RWX page:
Using the same technique of printing out the wasm_instance
address and comparing it to the output of search-pattern
from before:
I get an offset of 0x67
. In reality it is 0x68
(pointer tagging!), but who cares.
Now we can use the ArrayBuffer
technique, because we know all the bits of the address! We can just yoink it directly from the oob-v8 writeup (slightly changing 0x20
to 0x14
, as that is the new offset with compression):
I am going to grab the shellcode for cat flag.txt
from this writeup, because I suck ass at working out endianness and it's a lot of effort for a fail :)))
Running this:
Ok, epic! Let's deliver it remote using the same script as Kit Engine:
And we get the flag!
The actual challenge
Let's first read the patch itself:
In essence, there is a new function ArrayOob
that is implemented. We can see it's added to the array object as a .oob()
method:
There's the odd bit of other stuff thrown around for getting it working, but the actual source of the challenge is (unsurprisingly) ArrayOob
itself (with a name like that, who would have thought?). Cleaned up a little, it looks like this:
Familiarity with the V8 codebase is unlikely, and even if you are familiar with it, it's unlikely you can read it like a native language.
It looks at the number of arguments the function takes, then stores it in len
If len
is greater than 2
, it throws an error (note that the first argument is always this
, so in reality it's just one).
It then gets the array in question, stored in array
array
is cast to a FixedDoubleArray
, an array of fixed size that stores doubles, called elements
The length of the array is stored in length
If there is no argument (len == 1
, i.e. only this
is passed) then elements[length]
is returned as a number
This is a clear Out-Of-Bounds (OOB) Read, as arrays in javascript are zero-indexed like most other programming languages
If an argument is given, elements[length]
is set to the value
that is the argument cast to a Number with Object::ToNumber
This is a clear Out-Of-Bounds (OOB) Write, for the same reason as above
So we have a very clear OOB vulnerability, allowing both a read and a write to one index further than the maximum length of the array. This begs an important question: what exists past the end of an array?
First, let's talk about data types in V8 and how they are represented.
V8 uses pointers, doubles and smis (standing for immediate small integers). Since it has to distinguish between these values, they are all stored in memory with slight differences.
A double is stored as its 64-bit binary representation (easy)
An smi is a 32-bit number, but it's stored as itself left-shifted by 32
so the bottom 32 bits are null
e.g. 0x12345678
is stored as 0x1234567800000000
A pointer to an address addr
is stored as addr | 1
, that is the least significant bit is set to 1
.
e.g. 0x12345678
is stored as 0x12345679
This helps differentiate it from an smi, but not from a double!
Saelo's paper refers to pointers as HeapObjects as well.
Any output you get will always be in floating-point form; this is because V8 actually doesn't have a way to express 64-bit integers normally. We need a way to convert floating-point outputs to hexadecimal addresses (and vice versa!). To do this, we'll use the standard approach, which is as follows:
You'll see these functions in most V8 exploits. They essentially just convert between interpreting data as floating-point form or as integers.
We're going to throw this into a javascript file exploit.js
. If we want to use these functions, we can simply pass them to d8 in the command line:
The Map is an incredibly important V8 data structure, storing key information such as
The dynamic type of the object (e.g. String, Uint8Array, etc)
The size of the object in bytes
The properties of the object and where they are stored
The type of the array elements (e.g. unboxed doubles, tagged pointers, etc)
Each javascript object is linked to a map. While the property names are usually stored in the map, the values are stored with the object itself. This allows objects with the same sort of structure to share maps, increasing efficiency.
There are three different regions that property values can be stored
Inside the object itself (inline properties)
In a separate dynamically-sized heap buffer (out-of-line properties)
If the property name is an integer index, then as array elements in a dynamically-sized heap array
to be honest, not entirely sure that this means, but I'll get it eventually
In the first two cases, the Map stores each property of the object with a linked slot number. Each object then contains all of the property values, matching with the slot number of the relevant property. The object does not store the name of the property, only the slot number.
I promise this makes sense - for example, let's take two array objects:
Once this is run, memory will contain two JSObject
instances and one Map
:
We can see that the Map
stores the properties a
and b
, giving them the slot values 0
and 1
respectively. The two objects object1
and object2
, because of their identical structure, both use Map1
as a map. The objects do not themselves know the name of the properties, only the slot values, which they assign a value to.
However, if we add another property - say c
, with value 60
- to object1
, they stop sharing the map:
If we then added a property c
to object2
, they would then share Map1
again! This works assigning each map something called a transition table, which is just a note of which map to transition to if a property of a certain name (and possibly type) are added to it. In the example above, Map2
would make a note that if a property c
is added to object2
then it should transition to use Map1
.
Let's see how this works out in memory for arrays using the debug
version of d8, along with the incredibly helpful %DebugPrint()
feature that comes along with it. We'll run it under gdb
so we can analyse memory as well, and make connections between all the parts.
Instead of creating our own objects, let's focus specifically on how it works for arrays, as that is what we are dealing with here.
That is a lot of information. Let's sift through the relevant parts.
Firstly, we notice that a
is a type JSArray
, stored in memory at 0x30b708b4dd70
. The array's map is stored at 0x09bccc0c2ed8
, with the properties (in this case length
) stored at 0x3659bdb00c70
. The elements
themselves are in a FixedDoubleArray
stored at 0x30b708b4dd50
.
Remember pointer tagging! All the addresses are represented as addr | 1
, so we have to subtract off 1
for every pointer to get the real location!
Let's view memory itself. Hit Ctrl-C
and you'll go to the gef
prompt. Let's view the memory at the location of the JSArray
object itself, 0x30b708b4dd70
.
So the JSArray
first has its pointer to its own map, then a pointer to its properties, then a pointer to its elements and then its length (note that length
will be an smi, so a length of 2
is actually represented in memory as 2<<32
!).
One thing that is very curious is that the the elements
array is actually located 0x20
bytes ahead of memory from the JSArray
object itself. Interesting! Let's view it:
Note that elements
itself is a FixedDoubleArray
, so the first value will be a pointer to its map at 0x00003659bdb014f8
; this map doesn't concern us right now. The next value is the length of the FixedDoubleArray
, the smi of 0x2
again. After this, it gets interesting.
As expected, the next two entries are the doubles representing 1.5
and 2.5
, the entries in the array:
But immediately after in memory is the original JSArray
. So? Well, if we have an OOB read/write to an extra index past the array, the value we are accessing is the pointer in the JSArray
that points to the map. We can write to and read the map of the array.
Just to confirm this is correct, we're going to run the release version of d8 and check the output of .oob()
. The reason we have to use release is that the debug version has a lot more safety and OOB checks (I assume for fuzzing purposes) so will just break if we try to use a.oob()
. We need to run it with --shell exploit.js
, and you'll see why in a second.
Now we need to use our ftoi()
function to convert it to a hexadecimal integer:
Note that ftoi()
only exists because of the --shell
, which is why we needed it.
If our reasoning is correct, this is a pointer to the map, which is located at 0x2a0a9af82ed9
. Let's compare with GDB tells us:
The first value at the location of the JSArray
is, as we saw earlier, the pointer to the map. Not only that, but we successfully read it! Look - it's 0x2a0a9af82ed9
again!
Now we know we can read and write to the map that the array uses. How do we go from here?
The important thing to note is that sometimes a program will store values (pass by value), and sometimes it will store a pointer to a value (pass by reference). We can abuse this functionality, because an array of doubles will store the double values themselves while an array of objects will store pointers to the objects.
This means there is an extra link in the chain - if we do array[2]
on an array of doubles, V8 will go to the address in memory, read the value there, and return it. If we do array[2]
on an array of objects, V8 will go to the address in memory, read the value there, go to that address in memory, and return the object placed there.
We can see this behaviour by defining two arrays, one of doubles and one of custom objects:
Break out to gef
and see the elements
of both arrays.
float_arr
:
Again, 1.5
and 2.5
in floating-point form.
obj_arr
:
Note that the elements
array in the second case has values 0x3a38af8904f1
and 0x3a38af8906b1
. If our suspicions are correct, they would be pointers to the objects obj1
and obj2
. Do c
to continue the d8 instance, and print out the debug for the objects:
And look - so beautifully aligned!
What happens if we overwrite the map of an object array with the map of a float array? Logic dictates that it would treat it as a double rather than a pointer, resulting in a leak of the location of obj1
! Let's try it.
We leak 0x3a38af8904f1
- which is indeed the location of obj1
! We therefore can leak the location of objects. We call this an addrof
primitive, and we can add another function to our exploit.js
to simplify it:
Really importantly, the reason we can set map_obj
and get the map is because obj_arr.oob()
will return the value as a double, which we noted before! If it returned that object itself, the program would crash. You can see this in my Download Horsepower writeup.
We can load it up in d8 ourselves and compare the results:
Perfect, it corresponds exactly!
The opposite of the addrof
primitive is called a fakeobj
primitive, and it works in the exact opposite way - we place a memory address at an index in the float array, and then change the map to that of the object array.
From here, an arbitrary read is relatively simple. It's important to remember that whatever fakeobj()
returns is an object, not a read! So if the data there does not form a valid object, it's useless.
The trick here is to create a float array, and then make the first index a pointer to a map for the float array. We are essentially faking an array object inside the actual array. Once we call fakeobj()
here, we have a valid, faked array.
But why does this help? Remember that the third memory address in a JSArray
object is an elements
pointer, which is a pointer to the list of values actually being stored. We can modify the elements
pointer by accessing index 2
of the real array, faking the elements
pointer to point to a location of our choice. Accessing index 0
of the fake array will then read from the fake pointer!
[TODO image, but not sure what exactly would help]
Because we need an index 2
, we're going to make the array of size 4, as 16-byte alignment is typically nice and reduces the probability of things randomly breaking.
Now we want to start an arb_read()
function. We can begin by tagging the pointer, and then placing a fakeobj
at the address of the arb_rw_arr
:
HOWEVER - this is not quite right! We want fake
to point at the first element of the FixedDoubleArray
elements
, so we need an offset of 0x20 bytes back (doubles are 8 bytes of space each, and we know from before that elements
is just ahead of the JSArray
itself in memory), so it looks like this:
Now we want to access arb_rw_arr[2]
to overwrite the fake elements
pointer in the fake array. We want to set this to the desired RW address addr
, but again we need an offset! This time it's 0x10 bytes, because the first index is 0x10 bytes from the start of the object as the first 8 bytes are a map and the second 8 are the length
smi:
And finally we return the leak. Putting it all together:
Logic would dictate that we could equally get an arbitrary write using the same principle, by simply setting the value instead of returning it. Unfortunately, not quite - if we look at Faith's original writeup, the initial_arb_write()
function fails:
Note that we're not explicitly accounting for pointer tagging here. This is not because it's not important, but because the way we've set up addrof
and fakeobj
preserves the tagging, and since we're working with static offsets of multiples of 0x10
the tag is preserved. If we tried to explicitly write to a location, we would have to tag it. If we wanted to be very thorough, we would put pointer tagging explicitly in all functions.
In the blog post they tell us they're not sure why, and goes on to explain the intended method with ArrayBuffer
backing pointers. In a short twitter conversation we had they tell us that
The arbitrary write doesn't work with certain addresses due to the use of floats. The overwrite had precision loss with certain addresses, but this wasn't the case with ArrayBuffer backing pointers. The code handles that differently compared to the elements ptr.
I can confirm that running the initial_arb_write()
does, in fact, crash with a SIGSEGV. If anybody finds a fix, I'm sure they would be very interested (and I would too).
An ArrayBuffer
is simply used to represent a generic raw binary data buffer. We combine this with the DataView
object to provide a low-level interface for reading and writing multiple number types. These number types includes the ever-useful setInt64()
, which is where our reliability for handling the integers probably comes from.
The backing store of an ArrayBuffer
is much like the elements
of a JSArray
, in that it points to the address of the object that actually stores the information. It's placed 0x20 bytes ahead of the ArrayBuffer
in memory (which you can check with GDB).
We will have to use the initial_arb_write()
to perform this singular write, and hope that the address precision is good enough (if not, we just run it again).
From here, it's similar to userland exploitation.
The simplest approach, as any call to console.log()
will inevitably be freed immediately after. To do this, we'll need a libc leak.
In order for it to be reliable, it'll have to be through a section of memory allocated by V8 itself. We can use GDB to comb throught the memory of the area that stored the maps. I'm going to get exploit.js
to print out a bunch of the addresses we have. I'll then try and retrieve every single notable address I can.
Running it multiple times, the last 4 digits are consistent, implying that they're a fixed offset:
That bodes well. Running vmmap
, we can find the region they are in:
So the offsets appear to be 0x2ed9
and 0x2f79
. Let's throw that into exploit.js
and see if that's right by running it again and again. It appears to be, but randomly there is an issue and the address is not even in assigned memory - I assume it's at least in part due to the floating-point issues.
Now we have that, let's try combing through the map region and see if there are any other interesting values at fixed offsets.
We can see that, very close to the start of the region, there appear to be two heap addresses (and more later). This makes sense, as many maps will point to areas of the heap as the heap stores dynamically-sized data.
That seems more useful than what we have right now, so let's grab that and see if the offset is constant. Right now, the offsets are 0xaef60
and 0x212e0
. They appear to be constant. Let's throw those leaks in too.
It all seems to be pretty good, but a heap leak itself is not the most helpful. Let's keep digging, but looking at the heap this time, as that is probably more likely to store libc or binary addresses.
Ok, pretty useless. What about if we actually use the heap addresses we have, and see if there's anything useful there? The first one has nothing useful, but the second:
The vmmap
output for this specific run shows a binary base of 0x555555554000
and a heap base of 0x5555562f9000
. This makes the first address a binary address! Let's make sure it's a consistent offset from the base, and we're also gonna swap out our exploit to use the second heap address we spotted in the map region. And it is!
Now we just have to work out the GOT offset and read the entry to find libc base!
So the GOT entry is an offset of 0xd9a4c0
from base. Easy leak:
Then we just need to get system and free_hook offsets, and we are good to go. Pretty easy from inside GDB:
With base 0x7ffff7005000
, the offsets are easy to calculate:
And we can overwrite free hook and pop a calculator:
It does, in fact, work!
Unfortunately, as Faith recognised in their article, when running the exploit on the Chrome binary itself (the actual browser provided with the challenge!) the __free_hook
route does not work. It's likely due to a different memory layout as a result of different processes running, so the leaks are not the same and the offsets are broken. Debugging would be nice, but it's very hard with the given binary. Instead we can use another classic approach and abuse WebAssembly to create a RWX page for our shellcode.
This approach is even better because it will (theoretically) work on any operating system, not be reliant on the presence of libc and __free_hook
as it allows us to run our own shellcode. I'm gonna save this in exploit2.js
.
If we create a function in WebAssembly, it will create a RWX page that we can leak. The WASM code itself is not important, we only care about the RWX page. To that effect I'll use the WASM used by Faith, because the website wasmfiddle
has been closed down and I cannot for the life of me find an alternative. Let me know if you do.
We can see that this creates an RWX page:
If we leak the addresses of wasm_mod
, wasm_instance
and f
, none of them are actually located in the RWX page, so we can't simply addrof()
and apply a constant offest. Instead, we're gonna comb memory for all references to the RWX page. The WASM objects likely need a reference to it of sorts, so it's possible a pointer is stored near in memory.
The last four are in the heap, so unlikely, but the first instance is near to the wasm_instance
and f
. The offset between wasm_instance
and that offset appears to be 0x87
. In reality it is 0x88
(remember pointer tagging!), but that works for us.
It spits out the right base, which is great. Now we just want to get shellcode for popping calculator as well as a method for copying the shellcode there. I'm gonna just (once again) shamelessly nab Faith's implementations for that, which are fairly self-explanatory.
And then we just copy it over and pop a calculator:
Running this under GDB causes it to crash for me, but running it in bash works fine:
With a calculator popped!
Create an index.html
with the following code:
Make sure exploit2.js
is in the same folder. Then load the index.html
with the version of Chrome bundled in the challenge:
And it pops calculator! You can also place it in another folder and use python's SimpleHTTPServer to serve it and connect that way - it works either way.
Well, we are hackers, we like the idea of a reverse shell, no? Plus it makes you feel way cooler to be able to do that.
Grabbing the reverse shell code from here and modifying it slightly to change it to loopback to 127.0.0.1
:
Listening with nc -nvlp 4444
, we get the prompt for a password, which is 12345678
. Input that, and bingo! It even works on the Chrome instance!
First off, give Faith a follow, they deserve it.
Secondly, WASM makes no sense to me, but oh well. Sounds like a security nightmare.