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

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

May 4, 2026

Authored by stratan

In Part 1, we went over the background details that are needed to follow along, as well as the trigger specifics of the bug. In Part 2, we’ll go through the relevant code paths, analyze the patch, and develop an in-cage read/write primitive.

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.

Recap

The trigger needs one optimized JavaScript access site, two Wasm-backed getters for the same property, and a lazy deopt after the Wasm call returns:

  • foo(o) { return o.x; } gives TurboFan one property access site.
  • ProtoForI64.prototype.x points to a Wasm getter returning i64.
  • ProtoForRef.prototype.x points to a Wasm getter returning externref.
  • both getters can be inlined into the same optimized graph with their own JS-to-Wasm continuation frame states.
  • the callback mutates the prototype during the final externref call, so V8 has to deopt after the Wasm call has produced its return value.

On the vulnerable build, those two continuation frame states can compare equal because FrameStateFunctionInfo::operator== ignores JSToWasmFrameStateFunctionInfo
::signature_
. Value numbering can keep one frame state and reuse it for both Wasm calls. The surviving signature is then lowered into the recorded Wasm return kind used by the deoptimizer.

That creates the following primitive:

  • externref -> i64: a JavaScript object returned as externref can be decoded as an i64 BigInt.
  • i64 -> externref: an i64 value whose bits encode a valid V8 tagged value can be decoded as an externref.

The trigger needs one optimized JavaScript access site, two Wasm-backed getters for the same property, and a lazy deopt after the Wasm call returns:

  • foo(o) { return o.x; } gives TurboFan one property access site.
  • ProtoForI64.prototype.x points to a Wasm getter returning i64.
  • ProtoForRef.prototype.x points to a Wasm getter returning externref.
  • both getters can be inlined into the same optimized graph with their own JS-to-Wasm continuation frame states.
  • the callback mutates the prototype during the final externref call, so V8 has to deopt after the Wasm call has produced its return value.

On the vulnerable build, those two continuation frame states can compare equal because FrameStateFunctionInfo::operator==
ignores JSToWasmFrameStateFunctionInfo::
signature_
. Value numbering can keep one frame state and reuse it for both Wasm calls. The surviving signature is then lowered into the recorded Wasm return kind used by the deoptimizer.

That creates the following primitive:

  • externref -> i64: a JavaScript object returned as externref can be decoded as an i64 BigInt.
  • i64 -> externref: an i64 value whose bits encode a valid V8 tagged value can be decoded as an externref.

Root Cause

The root cause is a metadata equality bug in the JS-to-Wasm deopt pipeline. The Wasm call can return the right machine value, while the recovery metadata loses which signature should describe that value after lazy deopt.

  1. JSCallReducer::CanInlineJSToWasmCall allows JS-to-Wasm calls with the return kinds involved in this bug.

The root cause is a metadata equality bug in the JS-to-Wasm deopt pipeline. The Wasm call can return the right machine value, while the recovery metadata loses which signature should describe that value after lazy deopt.

  1. JSCallReducer::CanInlineJSTo
    WasmCall
     allows JS-to-Wasm calls with the return kinds involved in this bug.
Copy to Clipboard
  1. JSInliner::ReduceJSWasmCall creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
  1. JSInliner::ReduceJSWasm
    Call
     creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
Copy to Clipboard
  1. CreateBuiltinContinuationFrameStateCommon stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
  1. CreateBuiltinContinuationFrame
    StateCommon
     stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
Copy to Clipboard
  1. CommonOperatorBuilder::FrameState places the resulting FrameStateInfo into the operator parameter.
  1. CommonOperatorBuilder::FrameState
    places the resulting FrameStateInfo into the operator parameter.
Copy to Clipboard
  1. For FrameState nodes, equality reaches FrameStateInfo::operator==, then FrameStateFunctionInfo::operator==. In the vulnerable version, the base comparison never checks the JS-to-Wasm signature_.
  1. For FrameState nodes, equality reaches FrameStateInfo::operator==, then FrameStateFunctionInfo::
    operator==
    . In the vulnerable version, the base comparison never checks the JS-to-Wasm signature_.
Copy to Clipboard

The omitted field is the subclass-owned Wasm signature:

Copy to Clipboard

That omission lets two JS-to-Wasm frame states compare equal even when they carry different Wasm return signatures.

  1. During JS-to-Wasm inlining (V8.TFJSWasmInlining), with TurboFan tracing enabled (--trace-turbo --trace-turbo-filter=foo --trace-turbo-reduction), the graph shows the pre-merge state: two inlined Wasm calls, each with its own JS-to-Wasm continuation frame state. Call #97 uses continuation #59; call #135 uses continuation #106.
  1. ValueNumberingReducer::Reduce can replace a node when NodeProperties::Equals finds an equivalent existing node.
Copy to Clipboard

The vulnerable trace records that exact replacement:

Copy to Clipboard
  1. After early optimization (V8.TFEarlyOptimization), #106 is gone and both inlined Wasm calls use #59.
  1. Later, instruction selection reads the surviving function_info->signature() into a JSToWasmFrameStateDescriptor.
Copy to Clipboard
  1. JSToWasmFrameStateDescriptor reduces the signature to a return kind, and code generation records that kind in the deopt translation.
Copy to Clipboard
  1. On lazy deopt, Deoptimizer::TranslatedValueForWasmReturnKind uses that return kind to decide how to read the return register.
  1. On lazy deopt, Deoptimizer::TranslatedValue
    ForWasmReturnKind
     uses that return kind to decide how to read the return register.
Copy to Clipboard

If the merged frame state carries the wrong signature, the deoptimizer reads the right register with the wrong interpretation.

The PoC

We start by building two Wasm getters with the same parameter shape and different return types. Both getters call back into JavaScript before returning, then read a mutable Wasm global:

Copy to Clipboard

The callback arms lazy deopt. It runs before the selected Wasm getter returns. When armDeopt is set, the callback mutates the prototype for the side we want to deopt through:

Copy to Clipboard

Both receiver shapes expose the same JavaScript property name, x, but each getter calls a Wasm export with a different return type:

Copy to Clipboard

This gives TurboFan one optimized property access site:

Copy to Clipboard

The warmup makes that single access site see both receiver shapes. The optimizing call selects which side’s continuation frame state survives when value numbering merges the two states. The final call arms deopt and enters the side we want to decode with the surviving return kind:

Copy to Clipboard

addrof uses the externref -> i64 direction. The final Wasm call returns a JavaScript object reference, but lazy deopt decodes the return register as i64, so we get back a BigInt:

Copy to Clipboard

fakeobj uses the i64 -> externref direction. The final Wasm call returns an i64 whose bits are a valid tagged object value, but lazy deopt decodes it as externref, so we get back the object reference:

Copy to Clipboard

The next step uses those primitives to craft a fake JSArray. The fake JSArray header and the fake FixedDoubleArray header live inside victimArray‘s double-elements storage:

Copy to Clipboard

Once fakeArray exists, fakeArray[0] becomes the read/write slot. Before each access, setFakeElementsAt updates the fake array’s elements field to the target address:

Copy to Clipboard

cage_read64 sets the elements field and reads fakeArray[0]:

Copy to Clipboard

cage_write64 sets the elements field and writes fakeArray[0]:

Copy to Clipboard

Output:

Copy to Clipboard

The Patch

The fix is CL 7735261.

The main change is in FrameStateFunctionInfo::operator==:

Copy to Clipboard

The important part is the new signature() comparison. Before the patch, two JS-to-Wasm continuation frame states could match on the base FrameStateFunctionInfo fields while still carrying different Wasm return signatures. After the patch, that mismatch returns false, so value numbering cannot reuse one recovery description for the other.

The patch in src/compiler/frame-states.h prevents object slicing. Copying a JSToWasmFrameStateFunctionInfo through the base class would drop fields like signature_:

Copy to Clipboard

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

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

May 4, 2026

Authored by stratan

In Part 1, we went over the background details that are needed to follow along, as well as the trigger specifics of the bug. In Part 2, we’ll go through the relevant code paths, analyze the patch, and develop an in-cage read/write primitive.

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.

Recap

The trigger needs one optimized JavaScript access site, two Wasm-backed getters for the same property, and a lazy deopt after the Wasm call returns:

  • foo(o) { return o.x; } gives TurboFan one property access site.
  • ProtoForI64.prototype.x points to a Wasm getter returning i64.
  • ProtoForRef.prototype.x points to a Wasm getter returning externref.
  • both getters can be inlined into the same optimized graph with their own JS-to-Wasm continuation frame states.
  • the callback mutates the prototype during the final externref call, so V8 has to deopt after the Wasm call has produced its return value.

On the vulnerable build, those two continuation frame states can compare equal because FrameStateFunctionInfo::operator== ignores JSToWasmFrameStateFunctionInfo
::signature_
. Value numbering can keep one frame state and reuse it for both Wasm calls. The surviving signature is then lowered into the recorded Wasm return kind used by the deoptimizer.

That creates the following primitive:

  • externref -> i64: a JavaScript object returned as externref can be decoded as an i64 BigInt.
  • i64 -> externref: an i64 value whose bits encode a valid V8 tagged value can be decoded as an externref.

The trigger needs one optimized JavaScript access site, two Wasm-backed getters for the same property, and a lazy deopt after the Wasm call returns:

  • foo(o) { return o.x; } gives TurboFan one property access site.
  • ProtoForI64.prototype.x points to a Wasm getter returning i64.
  • ProtoForRef.prototype.x points to a Wasm getter returning externref.
  • both getters can be inlined into the same optimized graph with their own JS-to-Wasm continuation frame states.
  • the callback mutates the prototype during the final externref call, so V8 has to deopt after the Wasm call has produced its return value.

On the vulnerable build, those two continuation frame states can compare equal because FrameStateFunctionInfo::operator==
ignores JSToWasmFrameStateFunctionInfo::
signature_
. Value numbering can keep one frame state and reuse it for both Wasm calls. The surviving signature is then lowered into the recorded Wasm return kind used by the deoptimizer.

That creates the following primitive:

  • externref -> i64: a JavaScript object returned as externref can be decoded as an i64 BigInt.
  • i64 -> externref: an i64 value whose bits encode a valid V8 tagged value can be decoded as an externref.

Root Cause

The root cause is a metadata equality bug in the JS-to-Wasm deopt pipeline. The Wasm call can return the right machine value, while the recovery metadata loses which signature should describe that value after lazy deopt.

  1. JSCallReducer::CanInlineJSToWasmCall allows JS-to-Wasm calls with the return kinds involved in this bug.

The root cause is a metadata equality bug in the JS-to-Wasm deopt pipeline. The Wasm call can return the right machine value, while the recovery metadata loses which signature should describe that value after lazy deopt.

  1. JSCallReducer::CanInlineJSTo
    WasmCall
     allows JS-to-Wasm calls with the return kinds involved in this bug.
Copy to Clipboard
  1. JSInliner::ReduceJSWasmCall creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
  1. JSInliner::ReduceJSWasm
    Call
     creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
Copy to Clipboard
  1. CreateBuiltinContinuationFrameStateCommon stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
  1. CreateBuiltinContinuationFrame
    StateCommon
     stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
Copy to Clipboard
  1. CommonOperatorBuilder::FrameState places the resulting FrameStateInfo into the operator parameter.
  1. CommonOperatorBuilder::FrameState
    places the resulting FrameStateInfo into the operator parameter.
Copy to Clipboard
  1. For FrameState nodes, equality reaches FrameStateInfo::operator==, then FrameStateFunctionInfo::operator==. In the vulnerable version, the base comparison never checks the JS-to-Wasm signature_.
  1. For FrameState nodes, equality reaches FrameStateInfo::operator==, then FrameStateFunctionInfo::
    operator==
    . In the vulnerable version, the base comparison never checks the JS-to-Wasm signature_.
Copy to Clipboard

The omitted field is the subclass-owned Wasm signature:

Copy to Clipboard

That omission lets two JS-to-Wasm frame states compare equal even when they carry different Wasm return signatures.

  1. During JS-to-Wasm inlining (V8.TFJSWasmInlining), with TurboFan tracing enabled (--trace-turbo --trace-turbo-filter=foo --trace-turbo-reduction), the graph shows the pre-merge state: two inlined Wasm calls, each with its own JS-to-Wasm continuation frame state. Call #97 uses continuation #59; call #135 uses continuation #106.
  1. ValueNumberingReducer::Reduce can replace a node when NodeProperties::Equals finds an equivalent existing node.
Copy to Clipboard

The vulnerable trace records that exact replacement:

Copy to Clipboard
  1. After early optimization (V8.TFEarlyOptimization), #106 is gone and both inlined Wasm calls use #59.
  1. Later, instruction selection reads the surviving function_info->signature() into a JSToWasmFrameStateDescriptor.
Copy to Clipboard
  1. JSToWasmFrameStateDescriptor reduces the signature to a return kind, and code generation records that kind in the deopt translation.
Copy to Clipboard
  1. On lazy deopt, Deoptimizer::TranslatedValueForWasmReturnKind uses that return kind to decide how to read the return register.
  1. On lazy deopt, Deoptimizer::TranslatedValue
    ForWasmReturnKind
     uses that return kind to decide how to read the return register.
Copy to Clipboard

If the merged frame state carries the wrong signature, the deoptimizer reads the right register with the wrong interpretation.

The PoC

We start by building two Wasm getters with the same parameter shape and different return types. Both getters call back into JavaScript before returning, then read a mutable Wasm global:

Copy to Clipboard

The callback arms lazy deopt. It runs before the selected Wasm getter returns. When armDeopt is set, the callback mutates the prototype for the side we want to deopt through:

Copy to Clipboard

Both receiver shapes expose the same JavaScript property name, x, but each getter calls a Wasm export with a different return type:

Copy to Clipboard

This gives TurboFan one optimized property access site:

Copy to Clipboard

The warmup makes that single access site see both receiver shapes. The optimizing call selects which side’s continuation frame state survives when value numbering merges the two states. The final call arms deopt and enters the side we want to decode with the surviving return kind:

Copy to Clipboard

addrof uses the externref -> i64 direction. The final Wasm call returns a JavaScript object reference, but lazy deopt decodes the return register as i64, so we get back a BigInt:

Copy to Clipboard

fakeobj uses the i64 -> externref direction. The final Wasm call returns an i64 whose bits are a valid tagged object value, but lazy deopt decodes it as externref, so we get back the object reference:

Copy to Clipboard

The next step uses those primitives to craft a fake JSArray. The fake JSArray header and the fake FixedDoubleArray header live inside victimArray‘s double-elements storage:

Copy to Clipboard

Once fakeArray exists, fakeArray[0] becomes the read/write slot. Before each access, setFakeElementsAt updates the fake array’s elements field to the target address:

Copy to Clipboard

cage_read64 sets the elements field and reads fakeArray[0]:

Copy to Clipboard

cage_write64 sets the elements field and writes fakeArray[0]:

Copy to Clipboard

Output:

Copy to Clipboard

The Patch

The fix is CL 7735261.

The main change is in FrameStateFunctionInfo::operator==:

Copy to Clipboard

The important part is the new signature() comparison. Before the patch, two JS-to-Wasm continuation frame states could match on the base FrameStateFunctionInfo fields while still carrying different Wasm return signatures. After the patch, that mismatch returns false, so value numbering cannot reuse one recovery description for the other.

The patch in src/compiler/frame-states.h prevents object slicing. Copying a JSToWasmFrameStateFunctionInfo through the base class would drop fields like signature_:

Copy to Clipboard