Map mechanics and the deprecation edge case
CVE-2024-5830 is a type confusion in V8, Chrome's JavaScript engine, reported in May 2024 and fixed in version 126.0.6478.56/57. A single visit to a malicious page is sufficient to achieve remote code execution inside the renderer sandbox.
The bug lives in the interplay between object maps, map transitions, and the deprecation logic that V8 uses to keep object representations consistent. Every V8 object has a map that describes its property layout, including whether fields hold SMIs (31-bit integers) or HeapNumbers (doubles). Objects with identical layouts share maps. When new properties are added, transitions link the old map to the new one. A map also stores a back pointer to the map it originated from. When a field's type changes—for instance, when a SMI field receives a double value—the old map becomes deprecated.
Deprecation triggers a repair operation through the Update and UpdateImpl functions. These functions walk back through the back pointers to the root map, then traverse forward through transitions looking for or constructing a suitable replacement map. After a deprecation, newly created objects use the more general replacement map, and existing objects with deprecated maps are migrated on their next property access.
Normally that migration produces another fast map that the object can use. But there is a special case: a map can only hold a limited number of transitions. If a migration requires adding a new transition to a map that is already full, V8 falls back to creating a dictionary-mode map via Normalize. In debug builds, a DCHECK traps this situation because callers of Update generally expect a fast map back. The vulnerability is that one particular caller does not, and the debug assertion only fires in debug builds, not in production.
The vulnerable path
PrepareForDataProperty is the function that adjusts an object's map before writing a new data property value. Most call paths into it cannot end with a dictionary map after Update returns. However, PrepareForDataProperty is also reachable via CreateDataProperty, which in turn is called by TryFastAddDataProperty. That path has no such guarantee.
One particularly practical route into CreateDataProperty is object cloning with the spread syntax. Cloning a simple object with the spread operator copies each own property into a fresh target object, and each copy operation goes through CreateDataProperty. The problem emerges when the source object contains property accessors.
Consider cloning an object where a getter is defined after some initial data properties. During the clone, the data properties are copied first, then the object is property accessed to evaluate the getter. That getter runs user code while the clone operation is still in progress. The map of the target object at that point reflects only the properties copied so far. If the getter itself performs property additions on other objects, it can force map deprecations that involve the target object's map.
Once the getter returns, CreateDataProperty runs again to copy the accessor property into the target. If the target's map became deprecated inside the getter, Update runs to migrate it. If the getter also saturated the relevant transition array, that update resolves to a dictionary map instead of a fast map. The code following Update assumes a fast map and proceeds with fast-mode property storage assumptions. That mismatch corrupts the object representation and gives an attacker a type confusion primitive in the renderer.
Triggering the confusion
The attack constructs a source object with an accessor whose getter performs object manipulation designed to:
- deprecate the target object's map, and
- fill the transition array of the map that would need to hold the new transition during the repair.
Then, cloning the source object with the spread operator, or triggering the same internal call pattern through another CreateDataProperty path, lands the target object in a dictionary map state while the surrounding code still treats it as fast. The subsequent write operations corrupt property storage and yield exploitable memory corruption inside the V8 sandbox.
The fix in Chrome 126 addresses the handling in the data-property preparation path so that a dictionary map result from Update is handled correctly instead of being passed to fast-path code that cannot cope with it.
Turning the dictionary corruption into a read/write primitive
After PrepareForDataProperty returns the dictionary map, the engine continues with code that still assumes the old fast-property layout. The updated map’s instance_descriptors are accessed again — for a dictionary map these are the shared empty_descriptor_array in read-only space. The resulting PropertyDetails value supplies the property offset for a subsequent write via FastPropertyAtPut.
void JSObject::WriteToField(InternalIndex descriptor, PropertyDetails details,
Tagged
That write lands on an object whose real backing store is a NameDictionary, not a PropertyArray. Because the dictionary has extra internal fields before its elements, an offset computed for a fast-property array can hit one of those fields. The usual exploitation route here would be to overwrite the dictionary’s capacity, but the PropertyDetails bytes are dictated by whatever the OOB read returned, so that field wasn't controllable enough. Instead, the overwrite lands on the elements field of the NameDictionary.
elements is not consulted during normal dictionary lookups, but it is used as an iteration bound in MigrateSlowToFast, a routine that converts a slow (dictionary-mode) object back to fast mode. The relevant loop trusts NumberOfElements() from the dictionary:
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);
}
...
}
...
}
Inflating elements makes that loop walk past the end of the dictionary. Each iteration reads a property value and copies it into a fresh fast object. By arranging the heap so that the memory right after the dictionary contains a crafted pointer, the loop will treat that pointer as a property value — pointing at a fake object of our choosing. The figure below shows the layout: green is the legitimate dictionary bounds, red is whatever sits past it after the corruption.
Placing controlled data after the dictionary is straightforward since v8 allocates linearly. Clone the victim object, allocate arrays after it, and fill their entries with the fake-object pointer. To make that pointer valid, the fake object’s address must be known ahead of time. As noted previously, v8 object addresses are predictable for a given Chrome build, so computing the target address is just arithmetic:
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);
The fake object can therefore carry a valid map pointer as well as a bogus length. To trigger the migration, set the cloned dictionary-mode object y as the prototype of another object z. Any property access on z invokes MakePrototypesFast, which calls MigrateSlowToFast on y:
var z = {};
z.__proto__ = y;
z.p; //<------ Calls MigrateSlowToFast for y
After the conversion, y is a fast object with a property that points at our fake — in this case, a fake double array with an oversized length. That gives an OOB read/write on the double array’s backing store, which is enough for the usual post-exploitation dance:
- Corrupt the length so the fake array can read past its own elements, then place a real object array after it. Reading that array’s entries reveals addresses of arbitrary v8 objects.
- Create a second double array,
writeArr, after the fake one. OverwritewriteArr’s element pointer with the address of a target object. The read/write onwriteArrnow lands wherever that pointer points.
Beyond the heap sandbox
The heap sandbox ensures that v8-internal pointers cannot be forged or dereferenced outside the v8 heap. Blink API objects sit outside that boundary; in v8 they appear as wrapper objects whose embedder fields encode their location. Under the sandbox those fields are not raw pointers but indices into a protected external lookup table. A forged index is rejected, and read-back is validated against real Blink objects.
What the sandbox does not prevent is swapping one API object’s embedder field with another’s. If two wrapper objects of different Blink types are visible in the v8 heap, a write primitive can copy the descriptor of one onto the other. The result is a Blink-level type confusion using only valid table entries.
A useful pair is DOMRect and DOMTypedArray. DOMRect exposes four read/write properties (x, y, width, height) mapped directly onto its backing struct. If a DOMTypedArray is mislabeled as a DOMRect, altering those four offsets lets us manipulate the typed array’s fields. In particular, the backing_store_ pointer of the typed array can be replaced with an arbitrary address; subsequent reads and writes through the typed array hit that address, granting full-process memory access.
To resolve what to point at, the API-object confusion also helps defeat ASLR. Each Blink wrapper carries an embedder field referencing its wrapper_type_info, a global static. Reading that value through a confused DOMRect property reveals the location of TrustedCage::base_, the region housing JIT code. With a JIT function compiled, overwriting its code entry with shellcode turns the arbitrary memory access into code execution in the renderer.
The full exploit for CVE-2024-5830 is available here.
Analysis
CVE-2024-5830 is the latest in a line of issues where map deprecation and transition produce conflicting views of an object’s layout. Updating a deprecated map forces a switch to dictionary mode, and the engine then reuses that map in a path that expects fast properties. The immediate effect is a small out-of-bounds write inside a read-only descriptor array — controllable enough to avoid a crash — and from there a second-order corruption of a NameDictionary field turns into an OOB read via MigrateSlowToFast.
The heap-sandbox bypass is conceptually simple: swap embedder fields between two legitimate API objects rather than forging anything. That moves the confusion from the v8 heap, where sandbox checks apply, to the Blink layer where they do not. Combined, the two steps yield arbitrary read/write over the renderer process and eventual code execution.



