This is the fifth part of a series on the Chrome browser and its javascript engine V8. After having learned about all the major parts of the V8 engine in the previous parts, we can now finally start talking about actual exploitation. We will start by talking about various mitigations used by Chrome to make exploits more difficult. Next, we will discuss what type of vulnerabilities we can expect in the browser and the basic building blocks that you can expect to see in most exploits.
As mentioned earlier, we will start by covering some of the mitigations employed by Chrome. This has evolved a lot throughout the past years with Chrome becoming more and more secure every year. Their main goal in designing the browser is defense in depth. This defense philosophy is based upon layering multiple defenses on top of each other to avoid single points of failure. Chrome's sandbox is the best example of this being used effectively. While there are many more mitigations and security features aimed at securing various different parts of the browser, let's quickly cover some of the most relevant ones.
Standard mitigations
Chrome uses many of the industry-standard mitigations such as ASLR, DEP, JIT Hardening, and SafeSEH (Windows only), however, due to the massive attack surface presented by modern browsers, bypasses for these mitigations are oftentimes possible which leads to many of the mitigations mentioned below.
Partition Alloc
Chrome's memory allocator, replaces malloc()/new(). It supports multiple independent partitions, which helps combat some memory corruption vulnerabilities. Buffer overflows in strings for example are mitigated by placing strings in a different memory partition of the heap than other objects. This mitigation is not perfect since each partition still has many objects that have the potential to influence each other given bugs, but it can help mitigate certain bug classes. Partitionalloc also makes use of guard pages to protect certain especially vulnerable objects. Some of these such as free-lists are also encoded & shadowed to make exploitation of standard heap vulnerabilities impractical. Additionally, this custom heap implementation allows for a mitigation called StarScan, which consistently scans the heap and quarantines unused chunks so they cannot be used for exploits relying on a use-after-free vulnerability.
Stack Guard Pages
Chrome also uses guard pages for its stack, which makes traditional exploits such as buffer overflows unexploitable unless they manage to write beyond the guard page without actually hitting the guard page itself.
W^X JIT
Turbofan requires executable pages of memory to place its generated code in. The W^X mitigation sets these pages as non-writable as soon as the machine code is placed in them so an attacker can no longer use them for their exploit. This however is only true for standard JS code. WASM (web-assembly) also uses a JIT compiler, however, its JIT pages are not protected by default so they can still be used for an attacker to place shellcode in.
Site Isolation
Site Isolation places every individual iframe into their very own renderer process with their own individual sandbox that limits what the process can do. This makes it much more difficult for a malicious website to steal data from other sites, even if it can break some of the rules in its own process. This mitigation has been very effective in preventing exploits such as Same Origin Policy bypasses using iframes.
Sandbox
The sandbox is probably Chrome's most effective mitigation. It leverages OS-provided security so that code execution within a chrome process does not grant the attacker control over any other chrome processes or the underlying OS. The sandbox implements inter-process-communication (IPC) between the individual processes to enable the processes to communicate. This also grants the sandbox the ability to completely filter these and take care of any potential security holes from an exploited renderer process. This sandbox means that even once an attacker successfully exploits the Chrome Renderer process, they still need an additional vulnerability in the sandbox to get actual code execution. The main attack surface of this sandbox generally lies in the IPC channels between the sandboxed process and the privileged browser process.
Rust
Since 2020 the chrome dev team has been experimenting with Rust and its interoperability with c++ in the chrome browser, however, this is more of a background investigation and has not been widely implemented in chrome yet. If it would actually become widely adopted at some point, it would make the browser a lot more secure as a whole since it has the potential to eliminate many memory corruption vulnerabilities prevalent in the current c++ code-base.
These are far from all of the mitigations enabled in the browser, but they are probably the most relevant for the type of memory corruption exploits covered in this series.
Let's talk a little about where we might actually be able to find vulnerabilities. Jumping into the source code and looking for buffer overflows or heap vulnerabilities such as double free's or use after free's will not result in much success. Such classic vulnerabilities have been almost entirely mitigated at this point.
As you may have noticed by now, the V8 javascript engine is basically a giant complex interpreter + compiler. This means that if we can manipulate some of the information the V8 engine uses to execute JS code, we can make V8 display unintended behavior. V8's JIT compiler, Turbofan, presents a very large attack surface, especially when it comes to its speculative optimizations.
Let's say Turbofan decides to just-in-time compile a function using type information from the interpreter and optimizes it for one specific type. What happens when the function suddenly gets called with different function arguments? Well optimally we would like to recognize that and trigger the deoptimization routine accordingly, but what if this routine is not triggered due to some bug in V8 and instead code just keeps executing with V8 expecting the wrong type.
Let's look at the example below. In this simple example the V8 binary was patched to remove all deoptimization routines. This is obviously a very unrealistic example, but it will do just fine to demonstrate a simple example.
function poc(a) {
for (let i = 0; i < 1; i++) {
i += 2;
}
return a[0];
}
let o = { x: 1 };
let a = [1.1, 2.2, 3.3];
let b = [ o, o ];
for (let i = 0; i < 1000000; ++i) {
poc(a);
}
console.log(poc(b));
The above code calls the poc function using an array of float values 1 million times in a row. This triggers Turbofan which produces machine code that is optimized for an array of float values. You can ignore the small seemingly useless loop in the poc function. It exists only to make the function a little more complex to prevent inlining which would make this example impractical.
So what happens when the function attempts to return a[0]? Well, for a float array it will first dereference the elements-pointer at object-base + 8, and then proceed to return the value of the element located at array[0]. Due to the format of the elements memory region, this value is located at elements+12. The overall retrieval process would be something like this: return *(*(object-base+8)+12). You can review the 3rd part on V8 memory management if you need a reminder about how objects are laid out in memory.
We can verify this using Turbolizer. (I used a non-vulnerable V8 version to demonstrate where the CheckMaps would generally be located to verify that we are in fact dealing with an array of floats.)
As you can see above, the parameter is first verified using CheckMaps before being passed to a set of LoadField to retrieve the values. Finally, LoadElement is used with the type set to Float64 to return the direct value. This seems pretty efficient compared to what you might find if you instead traced interpreter execution (You could do this using the --print-bytecode flag). However what if the call to CheckMaps was removed and we suddenly called the function using an array of objects? Suddenly the field in memory at *(*(object-base+8)+12) would be a pointer to an object instead of the actual float value. V8 however would still treat it as a Float64 value and return this value directly to the user. Suddenly we have an address leak that can be used to bypass ASLR!
This address-leak is printed out as a floating-point number, however, rest assured that this is in fact a valid address. This is because V8 is still expecting a float value for the call to console.log. We will later cover some helper functions that we can use to convert the float values to Number arrays so we can properly work with the values so don't worry about it yet. If you wish to replicate this example, I used picoctf's horsepower challenge which includes a patch to add this exact vulnerability.
We just managed to leak out an address thus giving us the potential to bypass ASLR. The above sample vulnerability is a lot simpler than any bug you would actually find in V8, so let's talk about a few more realistic scenarios of how such a bug might occur before covering how to actually exploit them.
As you know, Turbofan uses various complex optimization passes. One such pass is redundancy elimination. This optimization removes extra checks if it manages to determine that no side effects can occur between the last check on an object and its next usage. An example code snippet in which redundancy optimization might occur is: let a = obj.x + obj.y;. In this example Turbofan might recognize that the object cannot change between the retrieval of obj.x and obj.y and thus only validate the map once. However, what if Turbofan missed a side effect. For the above example, if obj was global, a race condition might be able to manipulate the object's map between the retrieval of obj.x and obj.y.
The above is a very simple example for which it is relatively easy to determine if any side effects are possible. Redundancy elimination also acts on much more complicated code samples though, oftentimes having to determine if entire functions might have side effects on an object. If a side effect is missed, this can lead to an exploitable bug.
Operator::kNoWrite is a flag associated with operations to indicate that they are not supposed to have side effects. When looking for vulnerabilities in V8, a common approach might be to look for operations with this flag and then attempt to find a case where it might actually have some side effects after all.
Most optimization phases in Turbofan can lead to some type of vulnerability if buggy code is found. The lowering phase might for example create integer over/underflows during machine code generation. Incorrect bounds-checks during the range analysis of the typer phase could lead overflow bugs. If the range analysis for example determines that an array's maximum possible size could be 10, an array of size 10+ might be allocated. At this point, since the range analysis determined that the array will not have a larger size, there may not be any size checks, leading to a possible out-of-bounds read/write. Very similar bugs can just as easily occur in other optimization phases.
At this point a good way to learn more about various vulnerability patterns is to just start looking through various Chrome CVE's and attempt to understand what exactly caused them to occur.
Shellcode
Since we intend to make use of shellcode, we first need a page in memory that is both writeable and executable. As discussed earlier, almost everything in the V8 address-space has W^X enabled. Pages created using web-assembly however are both writeable and executable. When creating a wasm function as demonstrated below, an RWX page is created in memory. This address is then stored at wasm_instance + offset and can be leaked out using some of the exploit primitives we discuss below. After leaking out the address of this page, we use our arbitrary write primitive (also discussed below) to write shellcode to this area in memory and thus execute our own code.
let wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,...]);
let wasm_module = new WebAssembly.Module(wasm_code);
let wasm_instance = new WebAssembly.Instance(wasm_module);
let pwn = wasm_instance.exports.main;
ftoi & itof
These are 2 very useful helper functions that we will use to convert values from floats to integers and integers to floats. The first helper function, ftoi, takes a value of type float and converts it to a BigInt (large 64-bit integer) value, this will be helpful to convert the retrieved float values into numbers that we can actually work with. The second helper function, itof, accepts a BigInt value as its argument and converts it to a float. This function will be important when trying to write values into memory.
An example of how these 2 helper functions might look like is shown below.
var f64_buf = new Float64Array(buf);
var u32_buf = new Uint32Array(buf);
function ftoi(val) {
f64_buf[0] = val;
return BigInt(u32_buf[0]) + (BigInt(u32_buf[1]) << 32n); // Watch for little endianness
}
function itof(val) {
u32_buf[0] = Number(val & 0xffffffffn);
u32_buf[1] = Number(val >> 32n);
return f64_buf[0];
}
addrof & fakeobj
We actually already briefly talked about the addrof primitive when we talked about the address leak earlier. The goal of the addrof function is to take in an arbitrary object and return its address. Such a primitive is necessary to bypass the ASLR mitigation that randomizes the V8 address-spaces. This is usually accomplished by a type-confusion. Unlike the above 2 helper functions, these primitives require an actual bug, and thus their exact implementation depends on the actual bug you're attempting to exploit. Pseudo-code of how this might look like however is shown below.
function addrof(obj) {
// Insert the object which's address we want to leak into an object array
obj_arr[0] = obj;
// Change the obj array's map to the float array's map
obj_arr_map = float_arr_map;
// Get the address by accessing an index of the object array that is now interpreted as a float array due to the map change
let addr = obj_arr[0];
// Return the address as a BigInt
return ftoi(addr);
}
In the above example, we overwrite the map of a float array with the map of an object array using some V8 bug. When attempting to access this object, the float map will treat the elements as float values and thus return the value instead of an address pointer. Carefully crafted, this primitive can be generalized, thus leading to the addrof primitive.
Moving on to the fakeobj primitive, this is in fact very similar to the above example. We overwrite the map of an object array with the map of a float array, thus giving us the ability to directly manipulate pointers in V8's memory space. This primitive can then later be used to place fake objects anywhere in memory, thus giving us the ability to arbitrarily read/write to memory.
Once we have these 2 primitives working, the combination of the addrof primitive to leak addresses, and fakeobj primitive to create fake objects can be used to craft our next, more powerful primitives.
arbread & arbwrite
The purpose of these two primitives is to gain arbitrary read and write access throughout the processes address space. This will enable us to execute our own code by writing shellcode into memory.
Let's start with the arbitrary read. To perform this we could for example create a float array. By setting the 0th index of this array to a float array's map, we can place a fake object on top of this array. The float array's second index would then be treated as the fake object's elements pointer. It could then be used to perform reads at arbitrary addresses by editing the element pointer using the backing float array.
The arbitrary write works very similarly to the above arbitrary read. Just instead of reading an arbitrary address using the underlying float array, we can write to an address.
There is another technique that can be used to craft these 2 primitives using ArrayBuffer objects. If you are curious as to how these work, I have a detailed explanation in the browser exploitation ctf-writeup I linked below at the end of this post.
Browsers are incredibly complex targets, and there is much more to learn when it comes to effectively hacking them, however, I hope that this series gave you a strong introduction into how Browsers function and a little insight into exploiting bugs in the Chrome browser. If you want to see how a full exploit looks or how the ArrayBuffer arb read/writes work, take a look at one of my ctf-writeups, exploiting a relatively simple V8 bug: Download Horsepower.
Going from here, you should be able to attempt some browser-based ctf challenges of your own, and replicate some CVE's to further develop your exploit development skills in the realm of browsers. If you have any questions, feel free to reach out to me over twitter or discord and I'll be happy to help.