Type Confusion in TurboFan's Side-Effect Handling
CVE-2023-3420 is a type confusion vulnerability in V8's TurboFan JIT compiler, reported as bug 1452137 in June 2023. Fixed in Chrome 114.0.5735.198/199, the bug allows remote code execution within Chrome's renderer sandbox through a single visit to a malicious page. Such renderer-level RCEs typically serve as the first stage of a chain, followed by a separate sandbox escape—either another Chrome browser-process vulnerability or an OS-level bug. Attackers have exploited similar combinations in the wild; Google's Threat Analysis Group documented one such chain (CVE-2022-3723, CVE-2022-4135, and CVE-2022-38181) being used by spyware vendors.
Users should treat these findings as a reminder to keep Chrome updated with automatic updates enabled. V8 bugs are frequently weaponized quickly after patches ship, as attackers analyze the fixes to derive working exploits.
TurboFan's Optimization Model
TurboFan compiles frequently-executed JavaScript functions into optimized machine code guided by type feedback collected during interpretation. This speculative optimization assumes inputs will behave as observed historically. When those assumptions break, the function is deoptimized and reverts to bytecode execution. The complexity of this machinery—particularly around object layout and effect tracking—has made JIT engines a rich hunting ground for security researchers. Samuel Groß's Phrack article "Exploiting Logic Bugs in JavaScript JIT Engines" provides an accessible introduction to the attack surface, while Jeremy Fetiveau's "Introduction to TurboFan" covers the foundational concepts.
During optimization, each bytecode instruction is reduced into a graph of nodes—the "Sea of Nodes"—connected by control edges (control flow), value edges (dataflow), and effect edges. Effect edges enforce ordering when operations access or modify object state. In the following example, the read y = x.a must occur after the write x.a = 0x41:
x.a = 0x41;
var y = x.a;
Object field offsets are defined by the object's Map, which functions as a type descriptor. TurboFan leverages map knowledge from type feedback to generate efficient field accesses. Consider this function:
function foo(obj) {
var y = obj.x;
obj.x = 1;
return y;
}
To load obj.x safely, TurboFan emits a CheckMaps node verifying obj has the expected map. For the store obj.x = 1, since the map was already checked and no intervening code can modify obj, TurboFan omits a redundant check:
Nodes with side effects complicate this reasoning. A call to a user-defined JavaScript function can mutate any object reachable in memory:
function foo(obj) {
var y = obj.x;
callback();
obj.x = 1;
return y;
}
When such a call sits between a map check and a field store, TurboFan must insert another CheckMaps before the store because the map could have changed:
Node authors express side-effect behavior through operator properties. The kNoWrite flag signals a node performs no writes and therefore cannot modify object state:
class V8_EXPORT_PRIVATE Operator : public NON_EXPORTED_BASE(ZoneObject) {
public:
...
enum Property {
...
kNoWrite = 1 << 4, // Does not modify any Effects and thereby
// create new scheduling dependencies.
...
};
In contrast, a Call node (such as invoking an arbitrary callback) carries kNoProperties, meaning it may have any side effect—including map alterations—forcing subsequent checks.
Compilation Dependencies and Deoptimization
TurboFan's assumptions can also become invalid after optimized code is compiled. In the next example, foo has only seen objects where field x is the constant 1:
var a = {x : 1};
function foo(obj) {
var y = obj.x;
return y;
}
%PrepareFunctionForOptimization(foo);
foo(a);
%OptimizeFunctionOnNextCall(foo);
foo(a);
The compiler substitutes the literal value 1 for obj.x in the optimized code. However, if the field is later reassigned:
var a = {x : 1};
function foo(obj) {
var y = obj.x;
return y;
}
%PrepareFunctionForOptimization(foo);
foo(a);
%OptimizeFunctionOnNextCall(foo);
foo(a);
//Invalidates the optimized code
a.x = 2;
The optimized code now holds stale information. Running with --trace-deopt in the standalone d8 shell reveals the mechanism:
$./d8 --allow-natives-syntax --trace-turbo --trace-deopt foo.js
Concurrent recompilation has been disabled for tracing.
---------------------------------------------------
Begin compiling method foo using TurboFan
---------------------------------------------------
Finished compiling method foo using TurboFan
[marking dependent code 0x1a69002021a5 (0x1a690019b9e9 ) (opt id 0) for deoptimization, reason: code dependencies]
The final log line marks foo for deoptimization with reason code dependencies. This is "lazy" deoptimization: the invalidation only takes effect on the next invocation of the function, at which point unoptimized bytecode runs instead. Separate from a trap-based deopt that occurs when an optimized code path fails a runtime check, dependency-driven invalidation prevents stale optimized code from running at all after an assumption is broken.
Internally, dependencies rely on the CompilationDependency class and its subclasses. Each subclass tracks one specific assumption and arranges for code invalidation when that assumption is violated. The FieldConstnessDependency, for example, handles the case of constant object fields. The abstract base declares three virtual methods—IsValid, PrepareInstall, and Install—called at the end of compilation. IsValid rechecks that the assumption holds after the compile finishes, while Install wires up the mechanism that invalidates generated code should the assumption later change.
A subtle flaw in the interrupt path
Concurrent compilation, enabled in Chrome 95, lets TurboFan build optimized code on a background thread while JavaScript runs on the main thread. That design opens the door to two broad classes of race conditions. One is the classic invalidation issue: JavaScript on the main thread can invalidate an assumption the compiler made, after that assumption was checked. Chrome has seen this type of bug before in issues like 1369871 and 1211215.
The second class is less common but more dangerous: the compilation itself mutating a JavaScript object that the main thread is actively using. Compilation is not supposed to touch JavaScript objects, but there is a critical exception. The PrepareInstall method on the CompilationDependency class calls EnsureHasInitialMap, which operates directly on a JavaScript Function object:
void PrepareInstall(JSHeapBroker* broker) const override {
SLOW_DCHECK(IsValid(broker));
Handle function = function_.object();
if (!function->has_initial_map()) JSFunction::EnsureHasInitialMap(function);
}
...
}
Inside EnsureHasInitialMap, the prototype field of the function is passed to Map::SetPrototype. That call can trigger OptimizeAsPrototype, which converts an object's backing store from a fast "properties array" layout to a slower dictionary-based layout. Setting an object as the __proto__ of another object produces the same effect. This is an abrupt layout change for any code that previously observed the fast layout.
An initial suspicion was that this race could be exploited much like CVE-2018-17463, since PrepareInstall is invoked at the end of the compilation phase. But debugging shows PrepareInstall actually runs on the main thread, so it is not a direct race between threads. The real question is what triggers that main-thread execution while other JavaScript code is mid-flight.
When interrupts actually fire
V8 handles background-thread requests via StackGuard::HandleInterrupts. When a background compilation finishes, it queues an INSTALL_CODE task. The main thread does not pull that task at an arbitrary instruction boundary; it checks for pending interrupts only at specific, well-defined points. Function entry is one such point.
Searching for other callers of HandleInterrupts exposes a conflict. The StackCheck node in TurboFan's graph is annotated with the kNoWrite property, supposedly asserting that the node performs no writes to JavaScript objects. In the lower level, StackCheck can call Runtime::kStackGuard:
void JSGenericLowering::LowerJSStackCheck(Node* node) {
Node* effect = NodeProperties::GetEffectInput(node);
Node* control = NodeProperties::GetControlInput(node);
...
if (stack_check_kind == StackCheckKind::kJSFunctionEntry) {
node->InsertInput(zone(), 0,
graph()->NewNode(machine()->LoadStackCheckOffset()));
ReplaceWithRuntimeCall(node, Runtime::kStackGuardWithGap);
} else {
ReplaceWithRuntimeCall(node, Runtime::kStackGuard);
}
}
that path eventually reaches HandleInterrupts:
RUNTIME_FUNCTION(Runtime_StackGuard) {
...
return isolate->stack_guard()->HandleInterrupts(
StackGuard::InterruptLevel::kAnyEffect);
}
So a node labeled kNoWrite can, in fact, invoke the same interrupt machinery that installs compilation dependencies and runs EnsureHasInitialMap. That means StackCheck can silently convert a function's prototype object from a fast object into a dictionary object, directly contradicting its kNoWrite guarantee. This is precisely the primitive needed to recreate an attack similar to CVE-2018-17463.
Getting a StackCheck into the graph
TurboFan inserts StackCheck nodes naturally at the end of loop iterations. The JumpLoop opcode, emitted when the bytecode graph is built, includes an explicit call to build a body iteration stack check:
void BytecodeGraphBuilder::VisitJumpLoop() {
BuildIterationBodyStackCheck();
BuildJump();
}
That helper introduces the StackCheck node into the graph:
void BytecodeGraphBuilder::BuildIterationBodyStackCheck() {
Node* node =
NewNode(javascript()->StackCheck(StackCheckKind::kJSIterationBody));
environment()->RecordAfterState(node, Environment::kAttachFrameState);
}
This is intentional: a long-running tight loop should periodically yield so the engine can handle interrupts. The exploitation strategy is to combine two functions:
- A function
barthat, when optimized, carries aPrototypePropertyDependencyon a class constructorB. When the dependency is installed,B.prototypeis coerced into dictionary mode. - A function
foothat reads a property fromB.prototypebefore a long loop, then reads it again after the loop. The pre-loop access records aCheckMapsto validate the object's currentMap. Because theStackCheckinside the loop is markedkNoWrite, TurboFan inserts no secondCheckMapsfor the post-loop access.
The trigger sequence is:
- Optimize
fooand let concurrent compilation finish so steady-state optimized code runs. - Execute
barenough times to start its concurrent compilation. - Call
fooimmediately. While its loop runs,bar's compilation completes and queues anINSTALL_CODEtask. The loop hits the stack check, services the interrupt, andEnsureHasInitialMapflipsB.prototypeto a dictionary object. - Control returns to the loop in
foo. After the loop, property access onB.prototypeuses stale map assumptions and stale field offsets, treating the dictionary-backed object as if it were still a fast object.
At that point an out-of-bounds read or write into an adjacent JavaScript object becomes reachable, turning the incorrect kNoWrite assumption into a practical exploit primitive.
Turning the Bug into a Memory Corruption
The vulnerable bar function can be synthesized by looking for nodes that introduce a PrototypePropertyDependency, such as the JSOrdinaryHasInstance node created when using instanceof in JavaScript. A function like this:
function bar(x) {
return x instanceof B;
}
will register the dependency that mutates B.prototype into a dictionary object when bar is compiled. Meanwhile, foo can be defined to read from B.prototype with an installed CheckMaps guard by passing a constant object of the following shape:
var obj = {obj: B.prototype};
The critical trick is that after the loop consumes the interrupt from bar, the field write proto.b = 33 in foo proceeds using the fast map offsets, while proto has already transitioned to a dictionary representation.
Fast vs. Dictionary Objects
The exploitation strategy relies on the differing memory layouts of fast objects and dictionary-mode objects. A fast object lacking in-object properties stores its fields in a PropertyArray, which has three header fields—map, length, and hash—at offsets 0x0, 0x4, and 0x8 respectively. Its element slots then begin at offset 0xc, each being 4 bytes. Optimized code directly accesses a field by its precomputed element offset within that PropertyArray:
class B {}
B.prototype.a = 1;
B.prototype.b = 2;
A dictionary-mode object instead stores its fields in a NamedDictionary (a customized FixedArray). While it retains the map and length headers, it omits hash and appends elements, deleted, and capacity fields. Consequently, when optimized code accesses a dictionary object using PropertyArray offsets, it will land on those extra fields. The alignment works out so that the offset for a property b in a PropertyArray matches the position of capacity in a NamedDictionary:
Since capacity governs the bounds for dictionary lookups, overwriting it via this write gives a conditional out-of-bounds access.
A practical hurdle is that dictionary lookup hashes the key into a random index, making any OOB access address unstable across runs. The workaround, borrowed from "Patch-gapping Google Chrome," is to overwrite capacity with a power-of-two plus one. This forces any key lookup to resolve to either index 0 or the index at the crafted capacity value. A failure (i.e., hitting index 0) won’t crash—it just means a page reload and another try with a fresh random seed.
For the lookup to succeed, the target offset must contain a valid fake dictionary entry tuple, (Key, Value, Attribute). Since dictionary entries are checked for key equality before returning the value, this can be arranged by positioning a controlled object with consecutive fields directly after the corrupted dictionary:
If fields vn, vn+1, and vn+2 coincide with capacity, setting vn to the sought key k makes the lookup interpret vn+1 as the value of property k on the dictionary object, yielding an arbitrary object read.
From OOB to Type Confusion
The next phase mirrors the approach in Section 4 of "Exploiting Logic Bugs in JavaScript JIT Engines." TurboFan omits CheckMaps for a property load if the parent object’s map is fixed and a compilation dependency guarantees the property’s map doesn't change:
function tc(x) {
var obj = x.p1.px;
obj.x = 100;
}
var obj0 = {px : {x : 1}};
var obj1 = {p0 : str0, p1 : obj0, p2 : 0};
//optimizing tc
for (let i = 0; i < 20000; i++) {
tc(obj1);
}
Here, since tc only sees obj1 during optimization, it inserts a map check for x but trusts that x.p1’s map stays constant due to the compilation dependency. If the memory corruption from the dictionary bug can rewrite the p1 field in obj1 to point to a different object, the optimization remains valid because no property set on obj1 occurs. This lets tc operate on an object that violates the assumptions baked into the optimized code.
The final layout accomplishes this by aligning obj0 behind the corrupted B.prototype dictionary, such that the field aaa in B.prototype actually aliases obj1.p1:
var corrupted_arr = [1.1];
var corrupted = {a : corrupted_arr};
...
//Overwrite `obj1.p1` to `corrupted`
Object.defineProperty(B.prototype, 'aaa', {value : corrupted, writable : true});
//obj.x = 100 in `tc` now overwrites length of `corrupted_arr`
tc(obj1);
Overwriting aaa through the corrupted dictionary swaps obj1.p1 with an Array object, bypassing the map check in tc. A subsequent call to tc then returns that array as the value of p1.px, and the following obj.x = 100 write corrupts the array’s length field to 100.
Escalating to Code Execution
With a corrupted array length for the double array corrupted_arr, the standard V8 exploit chain proceeds:
- Place an object array directly after the corrupted array, using the out-of-bounds read to leak the compressed addresses of any V8 object stored there.
- Position a second double array,
writeArr, after the corrupted array. Use the OOB write incorrupted_arrto overwritewriteArr’s element backing store pointer, turning it into an arbitrary read/write within the V8 heap. - The introduction of the V8 heap sandbox prevents escaping the heap for direct renderer memory access, so the classic technique of patching
RWXWebAssembly code pages is unavailable. - Instead, JIT spraying bypasses the sandbox. By manipulating the JIT code pointer stored in a JavaScript
Functionobject with the arbitrary write primitive, control flow can be redirected into the middle of JIT-generated code—which can contain shellcode encoded in a double array floating-point values.
The complete exploit and setup notes are available on GitHub.
Root Cause Analysis
Side-effect modeling mistakes in JIT compilers have produced exploitable vulnerabilities before, as seen in CVE-2018-17463 and CVE-2020-6418. Here, the introduction of concurrent compilation skewed the side-effect assumptions placed on the StackCheck node, which no longer matched its behavior in a race condition. This case highlights how apparently unrelated subsystems (JIT optimization and garbage-collection-triggered interrupts) can quietly violate each other’s invariants, creating subtle bugs that evade conventional analysis.



