CVE-2026-6307 (Part 1): Turbofan JS-to-Wasm Deopt Type Confusion

CVE-2026-6307 (Part 1): Turbofan JS-to-Wasm Deopt Type Confusion

April 27, 2026

Authored by stratan

Summary
CVE-2026-6307 is a V8 compiler bug in the metadata used to recover from optimized JS-to-Wasm calls. The upstream regression describes the bug as a missing signature comparison in 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:

TL;DR:

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::
kJSToWasmBuiltinContinuation
, compare the JSToWasmFrameStateFunctionInfo::
signature()
values before treating the frame states as equal.

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:

Copy to Clipboard

Output:

Copy to Clipboard

Running the same regression with deopt tracing:

Copy to Clipboard

Output:

Copy to Clipboard

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:

Copy to Clipboard

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.

Copy to Clipboard

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:

Copy to Clipboard
  • 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.

Copy to Clipboard

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:

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:

Copy to Clipboard

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:

Copy to Clipboard

For JS-to-Wasm lazy deopt, the frame state includes a JSToWasmFrameStateFunctionInfo, a specialized form of FrameStateFunctionInfo with one extra field:

Copy to Clipboard

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:

Copy to Clipboard

If two nodes compare equal, value numbering can replace one with the existing equivalent node.

Copy to Clipboard

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:

Copy to Clipboard

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:

Copy to Clipboard

foo has one property access site. But foo is warmed up with two different receiver objects:

Copy to Clipboard

Those objects have different prototypes, and each prototype installs a different Wasm export as the getter for property x:

Copy to Clipboard

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:

Copy to Clipboard

After optimization, the final call arms deopt and then enters the externref path:

Copy to Clipboard

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:

Copy to Clipboard

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

April 27, 2026

Authored by stratan

Summary
CVE-2026-6307 is a V8 compiler bug in the metadata used to recover from optimized JS-to-Wasm calls. The upstream regression describes the bug as a missing signature comparison in 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:

TL;DR:

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::
kJSToWasmBuiltinContinuation
, compare the JSToWasmFrameStateFunctionInfo::
signature()
values before treating the frame states as equal.

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:

Copy to Clipboard

Output:

Copy to Clipboard

Running the same regression with deopt tracing:

Copy to Clipboard

Output:

Copy to Clipboard

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:

Copy to Clipboard

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.

Copy to Clipboard

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:

Copy to Clipboard
  • 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.

Copy to Clipboard

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:

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:

Copy to Clipboard

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:

Copy to Clipboard

For JS-to-Wasm lazy deopt, the frame state includes a JSToWasmFrameStateFunctionInfo, a specialized form of FrameStateFunctionInfo with one extra field:

Copy to Clipboard

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:

Copy to Clipboard

If two nodes compare equal, value numbering can replace one with the existing equivalent node.

Copy to Clipboard

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:

Copy to Clipboard

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:

Copy to Clipboard

foo has one property access site. But foo is warmed up with two different receiver objects:

Copy to Clipboard

Those objects have different prototypes, and each prototype installs a different Wasm export as the getter for property x:

Copy to Clipboard

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:

Copy to Clipboard

After optimization, the final call arms deopt and then enters the externref path:

Copy to Clipboard

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:

Copy to Clipboard

In Part 2, we will dig deeper into the root cause, the patch, as well as develop an in-cage read/write primitive.