CVE-2026-6307 (Part 2): Turbofan JS-to-Wasm Deopt Type Confusion
CVE-2026-6307 (Part 2): Turbofan JS-to-Wasm Deopt Type Confusion
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.xpoints to a Wasm getter returningi64.ProtoForRef.prototype.xpoints to a Wasm getter returningexternref.- 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
externrefcall, 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 asexternrefcan be decoded as ani64BigInt.i64 -> externref: ani64value whose bits encode a valid V8 tagged value can be decoded as anexternref.
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.xpoints to a Wasm getter returningi64.ProtoForRef.prototype.xpoints to a Wasm getter returningexternref.- 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
externrefcall, 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 asexternrefcan be decoded as ani64BigInt.i64 -> externref: ani64value whose bits encode a valid V8 tagged value can be decoded as anexternref.
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.
JSCallReducer::CanInlineJSToWasmCallallows 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.
JSCallReducer::CanInlineJSToallows JS-to-Wasm calls with the return kinds involved in this bug.
WasmCall
JSInliner::ReduceJSWasmCallcreates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
JSInliner::ReduceJSWasmcreates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
Call
CreateBuiltinContinuationFrameStateCommonstores the Wasm signature inJSToWasmFrameStateFunctionInfo.
CreateBuiltinContinuationFramestores the Wasm signature in
StateCommonJSToWasmFrameStateFunctionInfo.
CommonOperatorBuilder::FrameStateplaces the resultingFrameStateInfointo the operator parameter.
CommonOperatorBuilder::FrameState
places the resultingFrameStateInfointo the operator parameter.
- For
FrameStatenodes, equality reachesFrameStateInfo::operator==, thenFrameStateFunctionInfo::operator==. In the vulnerable version, the base comparison never checks the JS-to-Wasmsignature_.
- For
FrameStatenodes, equality reachesFrameStateInfo::operator==, thenFrameStateFunctionInfo::. In the vulnerable version, the base comparison never checks the JS-to-Wasm
operator==signature_.
The omitted field is the subclass-owned Wasm signature:
That omission lets two JS-to-Wasm frame states compare equal even when they carry different Wasm return signatures.
- 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#97uses continuation#59; call#135uses continuation#106.

ValueNumberingReducer::Reducecan replace a node whenNodeProperties::Equalsfinds an equivalent existing node.
The vulnerable trace records that exact replacement:
- After early optimization (
V8.TFEarlyOptimization),#106is gone and both inlined Wasm calls use#59.

- Later, instruction selection reads the surviving
function_info->signature()into aJSToWasmFrameStateDescriptor.
JSToWasmFrameStateDescriptorreduces the signature to a return kind, and code generation records that kind in the deopt translation.
- On lazy deopt,
Deoptimizer::TranslatedValueForWasmReturnKinduses that return kind to decide how to read the return register.
- On lazy deopt,
Deoptimizer::TranslatedValueuses that return kind to decide how to read the return register.
ForWasmReturnKind
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:
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:
Both receiver shapes expose the same JavaScript property name, x, but each getter calls a Wasm export with a different return type:
This gives TurboFan one optimized property access site:
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:
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:
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:
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:
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:
cage_read64 sets the elements field and reads fakeArray[0]:
cage_write64 sets the elements field and writes fakeArray[0]:
Output:
The Patch
The fix is CL 7735261.
The main change is in FrameStateFunctionInfo::operator==:
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_:
CVE-2026-6307 (Part 2): Turbofan JS-to-Wasm Deopt Type Confusion
CVE-2026-6307 (Part 2): Turbofan JS-to-Wasm Deopt Type Confusion
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.xpoints to a Wasm getter returningi64.ProtoForRef.prototype.xpoints to a Wasm getter returningexternref.- 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
externrefcall, 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 asexternrefcan be decoded as ani64BigInt.i64 -> externref: ani64value whose bits encode a valid V8 tagged value can be decoded as anexternref.
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.xpoints to a Wasm getter returningi64.ProtoForRef.prototype.xpoints to a Wasm getter returningexternref.- 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
externrefcall, 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 asexternrefcan be decoded as ani64BigInt.i64 -> externref: ani64value whose bits encode a valid V8 tagged value can be decoded as anexternref.
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.
JSCallReducer::CanInlineJSToWasmCallallows 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.
JSCallReducer::CanInlineJSToallows JS-to-Wasm calls with the return kinds involved in this bug.
WasmCall
JSInliner::ReduceJSWasmCallcreates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
JSInliner::ReduceJSWasmcreates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
Call
CreateBuiltinContinuationFrameStateCommonstores the Wasm signature inJSToWasmFrameStateFunctionInfo.
CreateBuiltinContinuationFramestores the Wasm signature in
StateCommonJSToWasmFrameStateFunctionInfo.
CommonOperatorBuilder::FrameStateplaces the resultingFrameStateInfointo the operator parameter.
CommonOperatorBuilder::FrameState
places the resultingFrameStateInfointo the operator parameter.
- For
FrameStatenodes, equality reachesFrameStateInfo::operator==, thenFrameStateFunctionInfo::operator==. In the vulnerable version, the base comparison never checks the JS-to-Wasmsignature_.
- For
FrameStatenodes, equality reachesFrameStateInfo::operator==, thenFrameStateFunctionInfo::. In the vulnerable version, the base comparison never checks the JS-to-Wasm
operator==signature_.
The omitted field is the subclass-owned Wasm signature:
That omission lets two JS-to-Wasm frame states compare equal even when they carry different Wasm return signatures.
- 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#97uses continuation#59; call#135uses continuation#106.

ValueNumberingReducer::Reducecan replace a node whenNodeProperties::Equalsfinds an equivalent existing node.
The vulnerable trace records that exact replacement:
- After early optimization (
V8.TFEarlyOptimization),#106is gone and both inlined Wasm calls use#59.

- Later, instruction selection reads the surviving
function_info->signature()into aJSToWasmFrameStateDescriptor.
JSToWasmFrameStateDescriptorreduces the signature to a return kind, and code generation records that kind in the deopt translation.
- On lazy deopt,
Deoptimizer::TranslatedValueForWasmReturnKinduses that return kind to decide how to read the return register.
- On lazy deopt,
Deoptimizer::TranslatedValueuses that return kind to decide how to read the return register.
ForWasmReturnKind
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:
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:
Both receiver shapes expose the same JavaScript property name, x, but each getter calls a Wasm export with a different return type:
This gives TurboFan one optimized property access site:
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:
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:
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:
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:
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:
cage_read64 sets the elements field and reads fakeArray[0]:
cage_write64 sets the elements field and writes fakeArray[0]:
Output:
The Patch
The fix is CL 7735261.
The main change is in FrameStateFunctionInfo::operator==:
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_:
