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.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.
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.
JSCallReducer::CanInlineJSTo allows JS-to-Wasm calls with the return kinds involved in this bug.
WasmCall
JSInliner::ReduceJSWasmCall creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
JSInliner::ReduceJSWasm creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
Call
CreateBuiltinContinuationFrameStateCommon stores the Wasm signature inÂJSToWasmFrameStateFunctionInfo.
CreateBuiltinContinuationFrame stores the Wasm signature inÂ
StateCommonJSToWasmFrameStateFunctionInfo.
CommonOperatorBuilder::FrameState places the resultingÂFrameStateInfo into the operator parameter.
CommonOperatorBuilder::FrameState
places the resultingÂFrameStateInfo into the operator parameter.
- ForÂ
FrameState nodes, equality reachesÂFrameStateInfo::operator==, thenÂFrameStateFunctionInfo::operator==. In the vulnerable version, the base comparison never checks the JS-to-WasmÂsignature_.
- ForÂ
FrameState nodes, equality reachesÂFrameStateInfo::operator==, thenÂFrameStateFunctionInfo::. 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Â#97Â uses continuationÂ#59; callÂ#135Â uses continuationÂ#106.

ValueNumberingReducer::Reduce can replace a node whenÂNodeProperties::Equals finds an equivalent existing node.
The vulnerable trace records that exact replacement:
- After early optimization (
V8.TFEarlyOptimization),Â#106Â is gone and both inlined Wasm calls useÂ#59.

- Later, instruction selection reads the survivingÂ
function_info->signature()Â into aÂJSToWasmFrameStateDescriptor.
JSToWasmFrameStateDescriptor reduces the signature to a return kind, and code generation records that kind in the deopt translation.
- On lazy deopt,Â
Deoptimizer::TranslatedValueForWasmReturnKind uses that return kind to decide how to read the return register.
- On lazy deopt,Â
Deoptimizer::TranslatedValue uses 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.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.
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.
JSCallReducer::CanInlineJSTo allows JS-to-Wasm calls with the return kinds involved in this bug.
WasmCall
JSInliner::ReduceJSWasmCall creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
JSInliner::ReduceJSWasm creates a JS-to-Wasm continuation frame state and passes it into the inlined wrapper.
Call
CreateBuiltinContinuationFrameStateCommon stores the Wasm signature inÂJSToWasmFrameStateFunctionInfo.
CreateBuiltinContinuationFrame stores the Wasm signature inÂ
StateCommonJSToWasmFrameStateFunctionInfo.
CommonOperatorBuilder::FrameState places the resultingÂFrameStateInfo into the operator parameter.
CommonOperatorBuilder::FrameState
places the resultingÂFrameStateInfo into the operator parameter.
- ForÂ
FrameState nodes, equality reachesÂFrameStateInfo::operator==, thenÂFrameStateFunctionInfo::operator==. In the vulnerable version, the base comparison never checks the JS-to-WasmÂsignature_.
- ForÂ
FrameState nodes, equality reachesÂFrameStateInfo::operator==, thenÂFrameStateFunctionInfo::. 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Â#97Â uses continuationÂ#59; callÂ#135Â uses continuationÂ#106.

ValueNumberingReducer::Reduce can replace a node whenÂNodeProperties::Equals finds an equivalent existing node.
The vulnerable trace records that exact replacement:
- After early optimization (
V8.TFEarlyOptimization),Â#106Â is gone and both inlined Wasm calls useÂ#59.

- Later, instruction selection reads the survivingÂ
function_info->signature()Â into aÂJSToWasmFrameStateDescriptor.
JSToWasmFrameStateDescriptor reduces the signature to a return kind, and code generation records that kind in the deopt translation.
- On lazy deopt,Â
Deoptimizer::TranslatedValueForWasmReturnKind uses that return kind to decide how to read the return register.
- On lazy deopt,Â
Deoptimizer::TranslatedValue uses 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_:
