CVE-2026-6307 (Part 1): Turbofan JS-to-Wasm Deopt Type Confusion
CVE-2026-6307 (Part 1): Turbofan JS-to-Wasm Deopt Type Confusion
Authored by stratan
Summary
FrameStateFunctionInfo::operator==.A type confusion in the deoptimizer caused by missing signature comparison in
FrameStateFunctionInfo::operator==.
A type confusion in the deoptimizer caused by missing signature comparison in
FrameStateFunctionInfo.
::operator==
Disclaimer
This article has been prepared for educational and informational purposes in the field of cybersecurity.
The vulnerability analyzed (CVE-2026-6307) was publicly identified, is known to the vendor, and has been fully patched in current versions of the affected software (fixed in revision 1b0cbb19f825, released on Tue Mar 31 11:05:12 2026).
The content is intended for information security professionals and does not constitute a functional exploitation guide nor does it provide tools to compromise third-party systems.
Tashita Software Security condemns any use of this information for unlawful purposes.
Unauthorized access to computer systems constitutes a criminal offense under the Spanish Penal Code.
TL;DR:
- TurboFan creates
FrameStatenodes so optimized code can leave optimized execution and reconstruct an unoptimized frame. - JS-to-Wasm lazy-deopt frame states are represented by
FrameStateType::kJSToWasmBuiltinContinuation; their specialized metadata class,JSToWasmFrameStateFunctionInfo, stores the Wasmsignature_. - In the vulnerable build,
FrameStateFunctionInfo::operator==compared the base frame-state fields, but it did not compare the JS-to-Wasm signature. - Because value numbering replaces a node when
NodeProperties::Equalsfinds an equivalent entry, two JS-to-Wasm frame states with different Wasm return types could collapse into one. - When deoptimization happened, V8 used
TranslatedValueForWasmReturnKindto materialize the Wasm return register (decode the return register into the JavaScript value used by the deoptimized frame) using the selected return kind: for example, treating anexternreftagged value as ani64BigInt.
TL;DR:
- TurboFan creates
FrameStatenodes so optimized code can leave optimized execution and reconstruct an unoptimized frame. - JS-to-Wasm lazy-deopt frame states are represented by
FrameStateType::; their specialized metadata class,
kJSToWasmBuiltinContinuationJSToWasmFrameStateFunctionInfo, stores the Wasmsignature_. - In the vulnerable build,
FrameStateFunctionInfo::compared the base frame-state fields, but it did not compare the JS-to-Wasm signature.
operator== - Because value numbering replaces a node when
NodeProperties::Equalsfinds an equivalent entry, two JS-to-Wasm frame states with different Wasm return types could collapse into one. - When deoptimization happened, V8 used
TranslatedValueForWasmReturnKindto materialize the Wasm return register (decode the return register into the JavaScript value used by the deoptimized frame) using the selected return kind: for example, treating anexternreftagged value as ani64BigInt.
The regression sets up externref and i64 Wasm getters at the same optimized JavaScript call site, then triggers lazy deopt on the externref path. On a vulnerable build, that return can be materialized through the deoptimizer’s kI64 path, returning a BigInt instead of the original object. The reverse is also true: an i64 payload containing a valid tagged object value can be materialized back as an externref.
The fix: when both frame states are FrameStateType::kJSToWasmBuiltinContinuation, compare the JSToWasmFrameStateFunctionInfo::signature() values before treating the frame states as equal.
The fix: when both frame states are FrameStateType::, compare the
kJSToWasmBuiltinContinuationJSToWasmFrameStateFunctionInfo:: values before treating the frame states as equal.
signature()
Setup
We reproduced the bug on macOS 26.4.1 with V8 14.7.173.17 at 09c9603016d8, and confirmed the fix at fixed revision 1b0cbb19f825.
Running the regression on the vulnerable build:
Output:
Running the same regression with deopt tracing:
Output:
Background
JavaScript can call exported WebAssembly functions. From JavaScript’s point of view, a Wasm export is just a callable function. Internally, V8 still has to bridge two different worlds:
- JavaScript values are V8 tagged values.
- Wasm values are typed machine-level values such as
i32,i64,f32,f64, or reference values.
When JavaScript calls a Wasm export, V8 goes through a JS-to-Wasm wrapper. That wrapper converts JavaScript tagged values into Wasm values on the way in, and converts the Wasm result back into a JavaScript value on the way out. The Wasm signature is therefore not just type metadata: it tells V8 how to materialize the return value.
The wrapper builds Wasm arguments from JavaScript parameters, then calls the Wasm function and returns a JavaScript value:
For performance, TurboFan can inline supported JS-to-Wasm calls into optimized JavaScript. Once a Wasm call is inlined, V8 still has to preserve enough recovery metadata to leave optimized code if an assumption is invalidated. In V8, that exit path is deoptimization. V8 creates a nested continuation frame state so lazy deopt can reconstruct the JavaScript continuation after the Wasm call returns.
The bug lives in that recovery description. The optimized machine code can still call the right Wasm function and receive the right value. The failure happens later, when lazy deopt decodes that value using the wrong return kind.
Wasm Return Kinds
The relevant JS-to-Wasm return kinds are the ones accepted by CanInlineJSToWasmCall:
i32: 32-bit integer.i64: 64-bit integer, surfaced to JavaScript as a BigInt.f32: 32-bit float, surfaced as a Number.f64: 64-bit float, surfaced as a Number.externref: a reference to a JavaScript value, surfaced as that value.
The return kind decides whether the value is treated as an integer, float, or tagged reference; V8 maps Wasm return types to machine types in MachineTypeForWasmReturnType.
The same machine register can hold different kinds of result. For example, an i64 result and an externref result both come back through the general return register. The deoptimizer must know which kind it is looking at.
Tagged Values
V8 represents ordinary JavaScript values as Tagged<T> values: pointer-sized values whose low bits distinguish small integers from heap object references.
Two representation details matter for understanding what gets leaked and how it can later be used as a reference:
- A Smi is a small integer stored directly in the tagged value.
- With pointer compression, heap object references carry a cage base plus compressed offset.
An externref return is already a JavaScript reference value. An i64 return is not; when it becomes visible to JavaScript, V8 exposes it as a BigInt. That distinction matters because lazy deopt later uses recorded Wasm return metadata to decide how to decode the returned machine value.
Deoptimization
Optimized JavaScript code depends on assumptions: object shapes, prototype chains, feedback, and other facts learned while the function warmed up. When one of those assumptions becomes false, V8 deoptimizes.
In this path, lazy deoptimization happens after the optimized call has already returned a value. V8’s builtin-continuation code documents this clearly: in lazy mode, BuiltinContinuationFrame adds a final missing parameter during deoptimization, and that parameter carries the value returned from the callee of the call site that triggered lazy deopt.
For JS-to-Wasm, V8 reads a JS-to-Wasm builtin continuation frame from the deopt translation, including the recorded return_kind. It then adds the Wasm return value from the input frame, using that recorded return kind to decide whether the value should become a BigInt, Number, or JavaScript reference.
The return decoding happens in TranslatedValueForWasmReturnKind:
The Wasm call itself can return the correct machine value. The bug appears when lazy deopt later decodes that value using return-kind metadata from the wrong frame state.
FrameState
A FrameState node is compiler metadata that describes how to reconstruct program state during deoptimization. V8 depends on it when it rebuilds the stack after leaving optimized code. If the metadata is wrong, V8 can resume with the wrong values or types.
A JS-to-Wasm continuation frame state is the specific recovery description for this situation: optimized JavaScript has called a Wasm export, the Wasm call has returned, and V8 may need to rebuild the frame that continues JavaScript execution after that call. Because the return value is still represented according to the Wasm signature, this recovery description must also say how to turn the return register back into a JavaScript value.
V8 classifies this recovery state as kJSToWasmBuiltinContinuation:
For JS-to-Wasm lazy deopt, the frame state includes a JSToWasmFrameStateFunctionInfo, a specialized form of FrameStateFunctionInfo with one extra field:
That signature is later reduced to a return_kind_, which tells the deoptimizer whether the return register should become a BigInt, Number, or tagged JavaScript value.
Equality and Merging
Compiler nodes can often be merged when they are equivalent. In V8, a FrameState operator stores a FrameStateInfo parameter, and FrameStateInfo::operator== eventually compares the underlying FrameStateFunctionInfo.
In the vulnerable build, FrameStateFunctionInfo::operator== compares the base fields, but no JS-to-Wasm signature field appears in that comparison:
If two nodes compare equal, value numbering can replace one with the existing equivalent node.
The Trigger
The trigger sends two Wasm getters through the same optimized JavaScript property access. One getter returns i64; the other returns externref. The final externref call invalidates optimized code during the Wasm call, forcing lazy deopt to materialize the return value.
The snippets below are from the upstream regress-497404188.js.
First, the Wasm module creates two exports with the same parameter shape and different return types:
Both exports call the imported callback before reading their return global. That callback gives the test a way to invalidate optimized JavaScript while the Wasm getter call is in progress.
The JavaScript side then installs those exports as getters for the same property name, x, on two different prototypes:
foo has one property access site. But foo is warmed up with two different receiver objects:
Those objects have different prototypes, and each prototype installs a different Wasm export as the getter for property x:
So the same optimized JavaScript access site, o.x inside foo, has seen both getter targets:
- one getter returns
i64; - one getter returns
externref.
That matters because TurboFan optimizes foo as one function with one property access site, but that site can carry inlined JS-to-Wasm call/recovery metadata for both Wasm getters. The bug needs those two JS-to-Wasm continuation frame states to coexist in the same optimized graph so value numbering can incorrectly decide they are equivalent.
The callback is where the test invalidates optimized code:
After optimization, the final call arms deopt and then enters the externref path:
During foo(obj_ref), the externref getter calls back into JavaScript before returning. The callback mutates ProtoForRef.prototype, invalidating optimized code. Then the getter returns sentinel through the externref path.

On a correct build, the final externref call returns the original sentinel object. On the vulnerable build, the merged frame state can carry a stale i64 return kind, so V8 reads the tagged object return register as an integer and creates a BigInt.
Output:
In Part 2, we will dig deeper into the root cause, the patch, as well as develop an in-cage read/write primitive.
CVE-2026-6307 (Part 1): Turbofan JS-to-Wasm Deopt Type Confusion
CVE-2026-6307 (Part 1): Turbofan JS-to-Wasm Deopt Type Confusion
Authored by stratan
Summary
FrameStateFunctionInfo::operator==.A type confusion in the deoptimizer caused by missing signature comparison in
FrameStateFunctionInfo::operator==.
A type confusion in the deoptimizer caused by missing signature comparison in
FrameStateFunctionInfo.
::operator==
Disclaimer
This article has been prepared for educational and informational purposes in the field of cybersecurity.
The vulnerability analyzed (CVE-2026-6307) was publicly identified, is known to the vendor, and has been fully patched in current versions of the affected software (fixed in revision 1b0cbb19f825, released on Tue Mar 31 11:05:12 2026).
The content is intended for information security professionals and does not constitute a functional exploitation guide nor does it provide tools to compromise third-party systems.
Tashita Software Security condemns any use of this information for unlawful purposes.
Unauthorized access to computer systems constitutes a criminal offense under the Spanish Penal Code.
TL;DR:
- TurboFan creates
FrameStatenodes so optimized code can leave optimized execution and reconstruct an unoptimized frame. - JS-to-Wasm lazy-deopt frame states are represented by
FrameStateType::kJSToWasmBuiltinContinuation; their specialized metadata class,JSToWasmFrameStateFunctionInfo, stores the Wasmsignature_. - In the vulnerable build,
FrameStateFunctionInfo::operator==compared the base frame-state fields, but it did not compare the JS-to-Wasm signature. - Because value numbering replaces a node when
NodeProperties::Equalsfinds an equivalent entry, two JS-to-Wasm frame states with different Wasm return types could collapse into one. - When deoptimization happened, V8 used
TranslatedValueForWasmReturnKindto materialize the Wasm return register (decode the return register into the JavaScript value used by the deoptimized frame) using the selected return kind: for example, treating anexternreftagged value as ani64BigInt.
TL;DR:
- TurboFan creates
FrameStatenodes so optimized code can leave optimized execution and reconstruct an unoptimized frame. - JS-to-Wasm lazy-deopt frame states are represented by
FrameStateType::; their specialized metadata class,
kJSToWasmBuiltinContinuationJSToWasmFrameStateFunctionInfo, stores the Wasmsignature_. - In the vulnerable build,
FrameStateFunctionInfo::compared the base frame-state fields, but it did not compare the JS-to-Wasm signature.
operator== - Because value numbering replaces a node when
NodeProperties::Equalsfinds an equivalent entry, two JS-to-Wasm frame states with different Wasm return types could collapse into one. - When deoptimization happened, V8 used
TranslatedValueForWasmReturnKindto materialize the Wasm return register (decode the return register into the JavaScript value used by the deoptimized frame) using the selected return kind: for example, treating anexternreftagged value as ani64BigInt.
The regression sets up externref and i64 Wasm getters at the same optimized JavaScript call site, then triggers lazy deopt on the externref path. On a vulnerable build, that return can be materialized through the deoptimizer’s kI64 path, returning a BigInt instead of the original object. The reverse is also true: an i64 payload containing a valid tagged object value can be materialized back as an externref.
The fix: when both frame states are FrameStateType::kJSToWasmBuiltinContinuation, compare the JSToWasmFrameStateFunctionInfo::signature() values before treating the frame states as equal.
The fix: when both frame states are FrameStateType::, compare the
kJSToWasmBuiltinContinuationJSToWasmFrameStateFunctionInfo:: values before treating the frame states as equal.
signature()
Setup
We reproduced the bug on macOS 26.4.1 with V8 14.7.173.17 at 09c9603016d8, and confirmed the fix at fixed revision 1b0cbb19f825.
Running the regression on the vulnerable build:
Output:
Running the same regression with deopt tracing:
Output:
Background
JavaScript can call exported WebAssembly functions. From JavaScript’s point of view, a Wasm export is just a callable function. Internally, V8 still has to bridge two different worlds:
- JavaScript values are V8 tagged values.
- Wasm values are typed machine-level values such as
i32,i64,f32,f64, or reference values.
When JavaScript calls a Wasm export, V8 goes through a JS-to-Wasm wrapper. That wrapper converts JavaScript tagged values into Wasm values on the way in, and converts the Wasm result back into a JavaScript value on the way out. The Wasm signature is therefore not just type metadata: it tells V8 how to materialize the return value.
The wrapper builds Wasm arguments from JavaScript parameters, then calls the Wasm function and returns a JavaScript value:
For performance, TurboFan can inline supported JS-to-Wasm calls into optimized JavaScript. Once a Wasm call is inlined, V8 still has to preserve enough recovery metadata to leave optimized code if an assumption is invalidated. In V8, that exit path is deoptimization. V8 creates a nested continuation frame state so lazy deopt can reconstruct the JavaScript continuation after the Wasm call returns.
The bug lives in that recovery description. The optimized machine code can still call the right Wasm function and receive the right value. The failure happens later, when lazy deopt decodes that value using the wrong return kind.
Wasm Return Kinds
The relevant JS-to-Wasm return kinds are the ones accepted by CanInlineJSToWasmCall:
i32: 32-bit integer.i64: 64-bit integer, surfaced to JavaScript as a BigInt.f32: 32-bit float, surfaced as a Number.f64: 64-bit float, surfaced as a Number.externref: a reference to a JavaScript value, surfaced as that value.
The return kind decides whether the value is treated as an integer, float, or tagged reference; V8 maps Wasm return types to machine types in MachineTypeForWasmReturnType.
The same machine register can hold different kinds of result. For example, an i64 result and an externref result both come back through the general return register. The deoptimizer must know which kind it is looking at.
Tagged Values
V8 represents ordinary JavaScript values as Tagged<T> values: pointer-sized values whose low bits distinguish small integers from heap object references.
Two representation details matter for understanding what gets leaked and how it can later be used as a reference:
- A Smi is a small integer stored directly in the tagged value.
- With pointer compression, heap object references carry a cage base plus compressed offset.
An externref return is already a JavaScript reference value. An i64 return is not; when it becomes visible to JavaScript, V8 exposes it as a BigInt. That distinction matters because lazy deopt later uses recorded Wasm return metadata to decide how to decode the returned machine value.
Deoptimization
Optimized JavaScript code depends on assumptions: object shapes, prototype chains, feedback, and other facts learned while the function warmed up. When one of those assumptions becomes false, V8 deoptimizes.
In this path, lazy deoptimization happens after the optimized call has already returned a value. V8’s builtin-continuation code documents this clearly: in lazy mode, BuiltinContinuationFrame adds a final missing parameter during deoptimization, and that parameter carries the value returned from the callee of the call site that triggered lazy deopt.
For JS-to-Wasm, V8 reads a JS-to-Wasm builtin continuation frame from the deopt translation, including the recorded return_kind. It then adds the Wasm return value from the input frame, using that recorded return kind to decide whether the value should become a BigInt, Number, or JavaScript reference.
The return decoding happens in TranslatedValueForWasmReturnKind:
The Wasm call itself can return the correct machine value. The bug appears when lazy deopt later decodes that value using return-kind metadata from the wrong frame state.
FrameState
A FrameState node is compiler metadata that describes how to reconstruct program state during deoptimization. V8 depends on it when it rebuilds the stack after leaving optimized code. If the metadata is wrong, V8 can resume with the wrong values or types.
A JS-to-Wasm continuation frame state is the specific recovery description for this situation: optimized JavaScript has called a Wasm export, the Wasm call has returned, and V8 may need to rebuild the frame that continues JavaScript execution after that call. Because the return value is still represented according to the Wasm signature, this recovery description must also say how to turn the return register back into a JavaScript value.
V8 classifies this recovery state as kJSToWasmBuiltinContinuation:
For JS-to-Wasm lazy deopt, the frame state includes a JSToWasmFrameStateFunctionInfo, a specialized form of FrameStateFunctionInfo with one extra field:
That signature is later reduced to a return_kind_, which tells the deoptimizer whether the return register should become a BigInt, Number, or tagged JavaScript value.
Equality and Merging
Compiler nodes can often be merged when they are equivalent. In V8, a FrameState operator stores a FrameStateInfo parameter, and FrameStateInfo::operator== eventually compares the underlying FrameStateFunctionInfo.
In the vulnerable build, FrameStateFunctionInfo::operator== compares the base fields, but no JS-to-Wasm signature field appears in that comparison:
If two nodes compare equal, value numbering can replace one with the existing equivalent node.
The Trigger
The trigger sends two Wasm getters through the same optimized JavaScript property access. One getter returns i64; the other returns externref. The final externref call invalidates optimized code during the Wasm call, forcing lazy deopt to materialize the return value.
The snippets below are from the upstream regress-497404188.js.
First, the Wasm module creates two exports with the same parameter shape and different return types:
Both exports call the imported callback before reading their return global. That callback gives the test a way to invalidate optimized JavaScript while the Wasm getter call is in progress.
The JavaScript side then installs those exports as getters for the same property name, x, on two different prototypes:
foo has one property access site. But foo is warmed up with two different receiver objects:
Those objects have different prototypes, and each prototype installs a different Wasm export as the getter for property x:
So the same optimized JavaScript access site, o.x inside foo, has seen both getter targets:
- one getter returns
i64; - one getter returns
externref.
That matters because TurboFan optimizes foo as one function with one property access site, but that site can carry inlined JS-to-Wasm call/recovery metadata for both Wasm getters. The bug needs those two JS-to-Wasm continuation frame states to coexist in the same optimized graph so value numbering can incorrectly decide they are equivalent.
The callback is where the test invalidates optimized code:
After optimization, the final call arms deopt and then enters the externref path:
During foo(obj_ref), the externref getter calls back into JavaScript before returning. The callback mutates ProtoForRef.prototype, invalidating optimized code. Then the getter returns sentinel through the externref path.

On a correct build, the final externref call returns the original sentinel object. On the vulnerable build, the merged frame state can carry a stale i64 return kind, so V8 reads the tagged object return register as an integer and creates a BigInt.
Output:
In Part 2, we will dig deeper into the root cause, the patch, as well as develop an in-cage read/write primitive.
