CVE-2026-9973
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
predecessor_memory_snapshots_src/compiler/turboshaft/wasm-load-elimination-reducer.h |
modified | |
phi_replacements_backups_src/compiler/turboshaft/wasm-load-elimination-reducer.h |
modified | |
ifsrc/compiler/turboshaft/wasm-load-elimination-reducer.h |
modified | |
fortest/mjsunit/regress/regress-509268941-2.js |
modified |
Files Changed
src/compiler/turboshaft/sidetable.hsrc/compiler/turboshaft/wasm-load-elimination-reducer.htest/mjsunit/regress/regress-509268941-2.jstest/mjsunit/regress/wasm/regress-509268941-1.js
Patch
From 55cc1df03832e742cba83a2491fc5a13031be0f3 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
Date: Mon, 11 May 2026 16:46:45 +0200
Subject: [PATCH] [wasm][turboshaft] Fix Phi handling in Load Elimination some more
Multiple Phis can depend on each other, so we have to clear all their
replacements up front.
Fixed: 509268941
Change-Id: I7899b28e89ed7b470bd869fb52f7443589305171
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7822799
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Auto-Submit: Jakob Kummerow <jkummerow@chromium.org>
Commit-Queue: Jakob Kummerow <jkummerow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#107278}
---
diff --git a/src/compiler/turboshaft/sidetable.h b/src/compiler/turboshaft/sidetable.h
index 54b22dff..77ce898 100644
--- a/src/compiler/turboshaft/sidetable.h
+++ b/src/compiler/turboshaft/sidetable.h
@@ -255,6 +255,8 @@
if (it != data_.end()) data_.erase(it);
}
+ void clear() { data_.clear(); }
+
auto begin() { return data_.begin(); }
auto end() { return data_.end(); }
diff --git a/src/compiler/turboshaft/wasm-load-elimination-reducer.h b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
index f9e8d29..27d6bb6 100644
--- a/src/compiler/turboshaft/wasm-load-elimination-reducer.h
+++ b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
@@ -415,7 +415,8 @@
memory_(data, phase_zone, non_aliasing_objects_, replacements_, graph_),
block_to_snapshot_mapping_(graph.block_count(), phase_zone),
predecessor_alias_snapshots_(phase_zone),
- predecessor_memory_snapshots_(phase_zone) {}
+ predecessor_memory_snapshots_(phase_zone),
+ phi_replacements_backups_(phase_zone, &graph_) {}
void Run() {
LoopFinder loop_finder(phase_zone_, &graph_, LoopFinder::Config{});
@@ -483,6 +484,7 @@
void ProcessAllocate(OpIndex op_idx, const AllocateOp& op);
void ProcessCall(OpIndex op_idx, const CallOp& op);
void ProcessPhi(OpIndex op_idx, const PhiOp& op);
+ OpIndex MaybeReplacePhi(const PhiOp& phi);
#if V8_ENABLE_WEBASSEMBLY
void ProcessAtomicRMW(OpIndex op_idx, const StructAtomicRMWOp& op);
@@ -529,6 +531,10 @@
// to process a block. We store them as members to avoid reallocation.
ZoneVector<AliasSnapshot> predecessor_alias_snapshots_;
ZoneVector<MemorySnapshot> predecessor_memory_snapshots_;
+
+ // When figuring out whether a Phi change should cause a loop revisit, we
+ // need to temporarily store all loop Phis' previous replacements.
+ SparseOpIndexSideTable<OpIndex> phi_replacements_backups_;
};
template <class Next>
@@ -974,12 +980,7 @@
non_aliasing_objects_.Set(op_idx, true);
}
-void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx, const PhiOp& phi) {
- InvalidateAllNonAliasingInputs(phi);
-
- // For robustness, unset the replacement by default.
- replacements_[op_idx] = OpIndex::Invalid();
-
+OpIndex WasmLoadEliminationAnalyzer::MaybeReplacePhi(const PhiOp& 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.
@@ -998,8 +999,14 @@
}
}
if (same_inputs) {
- replacements_[op_idx] = first;
+ return first;
}
+ return OpIndex::Invalid();
+}
+
+void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx, const PhiOp& phi) {
+ InvalidateAllNonAliasingInputs(phi);
+ replacements_[op_idx] = MaybeReplacePhi(phi);
}
void WasmLoadEliminationAnalyzer::FinishBlock(const Block* block) {
@@ -1106,6 +1113,29 @@
memory_.StartNewSnapshot(base::VectorOf(predecessor_memory_snapshots_),
merge_memory);
+ if constexpr (for_loop_revisit) {
+ phi_replacements_backups_.clear();
+ // Back up and clear all existing loop Phi replacements. Clearing is
+ // necessary because loop Phis can depend on each other, and are
+ // conceptually processed in parallel.
+ for (OpIndex op_idx : graph_.OperationIndices(*block)) {
+ if (graph_.Get(op_idx).Is<PhiOp>() && replacements_[op_idx].valid()) {
+ phi_replacements_backups_[op_idx] = replacements_[op_idx];
+ replacements_[op_idx] = OpIndex::Invalid();
+ }
+ }
+ // Check if any loop Phi replacement would change.
+ for (OpIndex op_idx : graph_.OperationIndices(*block)) {
+ if (const PhiOp* phi = graph_.Get(op_idx).TryCast<PhiOp>()) {
+ OpIndex new_replacement = MaybeReplacePhi(*phi);
+ if (new_replacement != phi_replacements_backups_[op_idx]) {
+ loop_needs_revisit = true;
+ }
+ replacements_[op_idx] = new_replacement;
+ }
+ }
+ }
+
if (block->IsLoop()) return loop_needs_revisit;
return false;
}
diff --git a/test/mjsunit/regress/regress-509268941-2.js b/test/mjsunit/regress/regress-509268941-2.js
new file mode 100644
index 0000000..87e0580
--- /dev/null
+++ b/test/mjsunit/regress/regress-509268941-2.js
@@ -0,0 +1,50 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax
+
+// As of this writing, this test does not flush out any issues. However, it
+// is the JS version of a repro for a bug we had in Wasm Load Elimination,
+// so the coverage it provides might prove useful if we ever implement similar
+// optimizations for JS.
+
+function foo(c, o, other) {
+
+ let base = o.x;
+ let initial_y = base.y;
+ let ret = initial_y;
+
+ let loop_phi = base;
+
+ for (let i = 0; i < 42; i++) {
+ // First iteration: no replacement for {loop_phi}.
+ // Second iteration: {loop_phi} has {base} has replacement
+
+ // First iteration: {inner_phi} will have {base} as replacement.
+ // Second iteration: no replacement (because {o.x} gets invalidated later).
+ let inner_phi = c ? o.x : base;
+
+ // First iteration: not load eliminated because {loop_phi} has no
+ // replacement.
+ // Second iteration: replaced by {initial_y} since {loop_phi} is replaced
+ // by {base}.
+ ret = loop_phi.y;
+
+ // Invalidate {o.x}, this will trigger initial revisit of the loop.
+ o.x = other;
+
+ loop_phi = inner_phi;
+ }
+
+ return ret;
+}
+
+let o1 = { x : { y : 17 } };
+let o2 = { y : 29 };
+
+%PrepareFunctionForOptimization(foo);
+assertEquals(29, foo(true, o1, o2));
+
+%OptimizeFunctionOnNextCall(foo);
+assertEquals(29, foo(true, o1, o2));
diff --git a/test/mjsunit/regress/wasm/regress-509268941-1.js b/test/mjsunit/regress/wasm/regress-509268941-1.js
new file mode 100644
index 0000000..2bf642d
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-509268941-1.js
@@ -0,0 +1,117 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --nowasm-loop-unrolling --nowasm-loop-peeling
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+const builder = new WasmModuleBuilder();
+
+// struct Inner { y: i32 }
+const innerStruct = builder.addStruct([makeField(kWasmI32, true)]);
+
+// struct Outer { x: ref Inner }
+const outerStruct = builder.addStruct([makeField(wasmRefType(innerStruct), true)]);
Regression Test / PoC
diff --git a/test/mjsunit/regress/regress-509268941-2.js b/test/mjsunit/regress/regress-509268941-2.js
new file mode 100644
index 0000000..87e0580
--- /dev/null
+++ b/test/mjsunit/regress/regress-509268941-2.js
@@ -0,0 +1,50 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax
+
+// As of this writing, this test does not flush out any issues. However, it
+// is the JS version of a repro for a bug we had in Wasm Load Elimination,
+// so the coverage it provides might prove useful if we ever implement similar
+// optimizations for JS.
+
+function foo(c, o, other) {
+
+ let base = o.x;
+ let initial_y = base.y;
+ let ret = initial_y;
+
+ let loop_phi = base;
+
+ for (let i = 0; i < 42; i++) {
+ // First iteration: no replacement for {loop_phi}.
+ // Second iteration: {loop_phi} has {base} has replacement
+
+ // First iteration: {inner_phi} will have {base} as replacement.
+ // Second iteration: no replacement (because {o.x} gets invalidated later).
+ let inner_phi = c ? o.x : base;
+
+ // First iteration: not load eliminated because {loop_phi} has no
+ // replacement.
+ // Second iteration: replaced by {initial_y} since {loop_phi} is replaced
+ // by {base}.
+ ret = loop_phi.y;
+
+ // Invalidate {o.x}, this will trigger initial revisit of the loop.
+ o.x = other;
+
+ loop_phi = inner_phi;
+ }
+
+ return ret;
+}
+
+let o1 = { x : { y : 17 } };
+let o2 = { y : 29 };
+
+%PrepareFunctionForOptimization(foo);
+assertEquals(29, foo(true, o1, o2));
+
+%OptimizeFunctionOnNextCall(foo);
+assertEquals(29, foo(true, o1, o2));
diff --git a/test/mjsunit/regress/wasm/regress-509268941-1.js b/test/mjsunit/regress/wasm/regress-509268941-1.js
new file mode 100644
index 0000000..2bf642d
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-509268941-1.js
@@ -0,0 +1,117 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --nowasm-loop-unrolling --nowasm-loop-peeling
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+const builder = new WasmModuleBuilder();
+
+// struct Inner { y: i32 }
+const innerStruct = builder.addStruct([makeField(kWasmI32, true)]);
+
+// struct Outer { x: ref Inner }
+const outerStruct = builder.addStruct([makeField(wasmRefType(innerStruct), true)]);
+
+// function foo(c: i32, o: ref Outer, other: ref Inner) -> i32
+builder.addFunction(
+ 'foo',
+ makeSig([kWasmI32, wasmRefType(outerStruct), wasmRefType(innerStruct)],
+ [kWasmI32]))
+ .addLocals(wasmRefType(innerStruct), 1) // 3: base
+ .addLocals(kWasmI32, 1) // 4: initial_y
+ .addLocals(kWasmI32, 1) // 5: ret
+ .addLocals(wasmRefType(innerStruct), 1) // 6: loop_phi
+ .addLocals(kWasmI32, 1) // 7: i
+ .addLocals(wasmRefType(innerStruct), 1) // 8: inner_phi
+ .addBody([
+ // let base = o.x;
+ kExprLocalGet, 1, // o
+ kGCPrefix, kExprStructGet, outerStruct, 0,
+ kExprLocalSet, 3, // base
+
+ // let initial_y = base.y;
+ kExprLocalGet, 3, // base
+ kGCPrefix, kExprStructGet, innerStruct, 0,
+ kExprLocalSet, 4, // initial_y
+
+ // let ret = initial_y;
+ kExprLocalGet, 4, // initial_y
+ kExprLocalSet, 5, // ret
+
+ // let loop_phi = base;
+ kExprLocalGet, 3, // base
+ kExprLocalSet, 6, // loop_phi
+
+ // i = 0
+ kExprI32Const, 0,
+ kExprLocalSet, 7, // i
+
+ // loop
+ kExprLoop, kWasmVoid,
+ // let inner_phi = c ? o.x : base;
+ kExprLocalGet, 0, // c
+ kExprIf, kWasmRef, innerStruct,
+ kExprLocalGet, 1, // o
+ kGCPrefix, kExprStructGet, outerStruct, 0,
+ kExprElse,
+ kExprLocalGet, 3, // base
+ kExprEnd,
+ kExprLocalSet, 8, // inner_phi
+
+ // ret = loop_phi.y;
+ kExprLocalGet, 6, // loop_phi
+ kGCPrefix, kExprStructGet, innerStruct, 0,
+ kExprLocalSet, 5, // ret
+
+ // o.x = other;
+ kExprLocalGet, 1, // o
+ kExprLocalGet, 2, // other
+ kGCPrefix, kExprStructSet, outerStruct, 0,
+
+ // loop_phi = inner_phi;
+ kExprLocalGet, 8, // inner_phi
+ kExprLocalSet, 6, // loop_phi
+
+ // i++
+ kExprLocalGet, 7, // i
+ kExprI32Const, 1,
+ kExprI32Add,
+ kExprLocalTee, 7, // i
+ kExprI32Const, 42,
+ kExprI32LtS,
+ kExprBrIf, 0,
+ kExprEnd,
+
+ // return ret;
+ kExprLocalGet, 5, // ret
+ ])
+ .exportFunc();
+
+builder.addFunction(
+ 'create_inner', makeSig([kWasmI32], [wasmRefType(innerStruct)]))
+ .addBody([
+ kExprLocalGet, 0,
+ kGCPrefix, kExprStructNew, innerStruct,
+ ])
+ .exportFunc();
+
+builder.addFunction(
+ 'create_outer',
+ makeSig([wasmRefType(innerStruct)], [wasmRefType(outerStruct)]))
+ .addBody([
+ kExprLocalGet, 0,
+ kGCPrefix, kExprStructNew, outerStruct,
+ ])
+ .exportFunc();
+
+const instance = builder.instantiate({});
+const wasm = instance.exports;
+
+let o1_inner = wasm.create_inner(17);
+let o1 = wasm.create_outer(o1_inner);
+let o2 = wasm.create_inner(29);
+
+let res = wasm.foo(1, o1, o2);
+assertEquals(29, res);
diff --git a/test/mjsunit/regress/wasm/regress-509268941.js b/test/mjsunit/regress/wasm/regress-509268941.js
new file mode 100644
index 0000000..f3b6856
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-509268941.js
@@ -0,0 +1,75 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --nowasm-loop-unrolling --nowasm-loop-peeling --allow-natives-syntax
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+const builder = new WasmModuleBuilder();
+const array = builder.addArray(kWasmI32, { mutability: true });
+const struct = builder.addStruct([makeField(wasmRefType(array), true)]);
+
+builder.addFunction('exploit', makeSig([], []))
+ .addLocals(wasmRefType(array), 2) // big, small
+ .addLocals(wasmRefType(struct), 1) // holder
+ .addLocals(wasmRefType(array), 1) // cur
+ .addLocals(kWasmI32, 1) // i
+ .addBody([
+ // Allocate arrays. 'big' (len 10), 'small' (len 1).
+ kExprI32Const, 10,
+ kGCPrefix, kExprArrayNewDefault, array,
+ kExprLocalSet, 0,
+
+ kExprI32Const, 1,
+ kGCPrefix, kExprArrayNewDefault, array,
+ kExprLocalSet, 1,
+
+ // Allocate 'holder' struct initialized with 'big'.
+ kExprLocalGet, 0,
+ kGCPrefix, kExprStructNew, struct,
+ kExprLocalSet, 2,
+
+ // cur = big
+ kExprLocalGet, 0,
+ kExprLocalSet, 3,
+
+ kExprI32Const, 5,
+ kExprLocalSet, 4, // i = 5
+
+ kExprLoop, kWasmVoid,
+ // Optimized code incorrectly elides this bounds check in later iterations.
+ // cur[3] = 0x1337
+ kExprLocalGet, 3,
+ kExprI32Const, 3,
+ ...wasmI32Const(0x1337),
+ kGCPrefix, kExprArraySet, array,
+
+ // cur = holder.field (loads 'small' in 2nd iteration)
+ kExprLocalGet, 2,
+ kGCPrefix, kExprStructGet, struct, 0,
+ kExprLocalSet, 3,
+
+ // holder.field = small
+ kExprLocalGet, 2,
+ kExprLocalGet, 1,
+ kGCPrefix, kExprStructSet, struct, 0,
+
+ // if (--i > 0) br loop
+ kExprLocalGet, 4,
+ kExprI32Const, 1,
+ kExprI32Sub,
+ kExprLocalTee, 4,
+ kExprI32Const, 0,
+ kExprI32GtS,
+ kExprBrIf, 0,
+ kExprEnd,
+ ])
+ .exportFunc();
+
+const instance = builder.instantiate({});
+const wasm = instance.exports;
+
+assertTraps(kTrapArrayOutOfBounds, () => wasm.exploit());
+%WasmTierUpFunction(wasm.exploit);
+assertTraps(kTrapArrayOutOfBounds, () => wasm.exploit());
Original Bug Report
V8: Wasm GC Load Elimination Stale Loop-Phi Replacement Enables V8 Heap-Sandbox Read/Write
Hello,
We’re contacting you from OpenAI Security Research to notify you of a potential security issue identified during our internal research process.
The issue appears to affect your product. We’ve validated the behavior internally and believe it may pose a risk.
Please see the below report for details.
We are reaching out privately and cooperatively. We are happy to collaborate on patch validation or coordinate timelines. We are not planning to make any public disclosure unless there’s an agreement to do so or we assess that the risk requires it.
Please let us know how you’d prefer to proceed and if you need additional details or support.
Best regards, OpenAI Security Research Team outbounddisclosures@openai.com
DISCLOSURE REPORT FOLLOWS:
Security Report: Wasm GC Load Elimination Stale Loop-Phi Replacement Enables V8 Heap-Sandbox Read/Write
Reporter: OpenAI Codex Security
Organization: OpenAI
Component: V8 JavaScript Engine (Turboshaft / WebAssembly GC optimizer)
Affected Area: WasmLoadEliminationReducer, loop snapshot/revisit handling, Wasm GC array bounds checks
Bug Class: Unsnapshotted compiler replacement state -> stale loop-phi base -> eliminated array-length check -> out-of-bounds Wasm GC array write
Discovery Method: Source review, optimizer-state analysis
Summary
Turboshaft’s Wasm GC optimization phase runs WasmLoadEliminationReducer before Wasm GC typed optimization. The load-elimination analyzer stores operation replacements in a global side table, replacements_, but loop snapshots only preserve alias and memory-table state. When a loop is revisited, the analyzer restores alias and memory snapshots while stale replacement facts from an earlier loop view remain globally visible.
The dangerous consumer is ProcessPhi(). It attempts to simplify phis whose inputs resolve to the same base by calling memory_.ResolveBase() on every phi input. ResolveBase() follows replacements_. If a backedge input is a struct.get whose stale replacement still points at the loop’s original large array, the loop-carried array phi is replaced by the large array even though the runtime value later becomes a small array.
That stale phi replacement feeds Wasm GC array-length elimination. The optimized bounds check for array.set uses the original large array’s length. At runtime, the actual receiver is a length-1 array, so small[3] = value becomes an out-of-bounds write.
This out-of-bounds write can be used to corrupt adjacent objects in the V8 heap, creating persistent out-of-bounds read/write primitives leading to arbitrary read/write within the V8 heap sandbox. No non-default flags or configuration are required to achieve this. The vulnerability affects stable releases as we have confirmed it on V8 version 14.8.178.14.
Impact
Malicious JavaScript running in the default-configuration V8 engine can achieve arbitrary read/write within the V8 heap sandbox through this load-elimination vulnerability.
This affects the latest stable release and potentially previous stable releases as well.
$ ./out/x64.release/d8 --version
Warning: unknown flag --version.
Try --help for options
V8 version 14.8.178.14
$ ./out/x64.release/d8 ./wasm-gc-wle-arb-rw-stable-14.8.178.14.js
ARB_RW_OK
victimBase=0x12f3354
targetAddr=0x12f34a5 targetElements=0x12f34bd
dblAddr=0x12f35ed originalElements=0x12f3605
read64=0x5566778811223344
write64=0x4142434445464748
The proof-of-concept corrupts the length of a Wasm GC array<i32>, uses the corrupted array as an out-of-bounds i32 window over adjacent V8 heap objects, and retargets a packed-double JavaScript array’s elements pointer. Normal JavaScript double-array access then provides the final read64/write64 operations.
Root Cause (High Level)
Wasm load elimination tracks three kinds of state:
1. non-aliasing object facts
2. memory/load-content facts
3. operation replacement facts
Only the first two are included in loop snapshots. The replacement table remains global while loops are revisited.
That is unsafe because replacement facts are not pure structural identities. A replacement can be valid for an early view of a loop and invalid after the loop backedge has been merged. In this vulnerability, an earlier pass records that a struct.get returns the original large array. Later, after loop state has changed, ProcessPhi() still follows that stale struct.get -> large array replacement through ResolveBase().
The optimized graph therefore treats a loop-carried array reference as the large array even when the runtime value is the small array. The resulting bounds check is performed with the large array’s length, but the store executes against the small array.
Affected Versions
The vulnerability is present when the following source properties are present:
WasmLoadEliminationReducer runs before Wasm GC typed optimization.
WasmLoadEliminationAnalyzer has a global replacements_ table.
Loop snapshots include alias and memory state but not replacements_.
ProcessPhi() compares phi inputs through memory_.ResolveBase().
ResolveBase() follows replacements_.
ArrayLength replacements are emitted into the optimized graph.
We confirmed exploitability on V8 14.8.178.14 on x64.
The bug is related to the previous fix for Chromium 505481948, but that fix is incomplete for this exploit class. It clears the phi operation’s own replacement slot. It does not snapshot or invalidate stale replacement facts belonging to other operations that ResolveBase() can follow while simplifying the phi.
The exploit-shape introducing commit is:
a3853a3901706d0079265335cfb3f1f6ec3cbfd6
[turboshaft][wasm] Load-eliminate AssertNotNull
The earlier prerequisite state model was introduced by:
6af530958779f086ffea75093d9bd67064622ad5
[turboshaft][wasm] Implement WasmLoadElimination
The incomplete mainline fix is:
bb38f8914db99bd3bed6758132b104a9af00ca04
[wasm][turboshaft] Fix Phi handling in Wasm Load Elimination
The M148 cherry-pick of that incomplete fix is:
c3031cebc008e7178ca9fb10b2275922d6bb041c
[M148] [wasm][turboshaft] Fix Phi handling in Wasm Load Elimination
Detailed Analysis
Wasm GC Load Elimination State
The Wasm GC optimization phase runs WasmLoadEliminationReducer before typed GC optimizations:
CopyingPhase<WasmLoadEliminationReducer, WasmGCTypedOptimizationReducer>::Run(
data, temp_zone);
The analyzer owns a global replacement side table:
WasmLoadEliminationAnalyzer(PipelineData* data, Graph& graph,
Zone* phase_zone)
: graph_(graph),
phase_zone_(phase_zone),
replacements_(graph.op_id_count(), phase_zone, &graph),
non_aliasing_objects_(phase_zone),
memory_(data, phase_zone, non_aliasing_objects_, replacements_, graph_),
block_to_snapshot_mapping_(graph.block_count(), phase_zone),
predecessor_alias_snapshots_(phase_zone),
predecessor_memory_snapshots_(phase_zone) {}
The loop snapshot does not contain replacements_:
FixedOpIndexSidetable<OpIndex> replacements_;
AliasTable non_aliasing_objects_;
wle::WasmMemoryContentTable memory_;
struct Snapshot {
AliasSnapshot alias_snapshot;
MemorySnapshot memory_snapshot;
};
When loop state is restored or merged, only alias and memory snapshots are restarted:
non_aliasing_objects_.StartNewSnapshot(
base::VectorOf(predecessor_alias_snapshots_), merge_aliases);
...
memory_.StartNewSnapshot(base::VectorOf(predecessor_memory_snapshots_),
merge_memory);
This means stale entries in replacements_ can survive loop revisits even when the corresponding memory facts have been invalidated or merged away.
Stale Replacement Use in ProcessPhi
The vulnerable base resolver follows replacements_ before looking through wrapper operations:
OpIndex ResolveBase(OpIndex base) {
while (true) {
if (replacements_[base] != OpIndex::Invalid()) {
base = replacements_[base];
continue;
}
Operation& op = graph_.Get(base);
if (AssertNotNullOp* check = op.TryCast<AssertNotNullOp>()) {
base = check->object();
continue;
}
if (WasmTypeCastOp* cast = op.TryCast<WasmTypeCastOp>()) {
base = cast->object();
continue;
}
break;
}
return base;
}
ProcessPhi() then uses this resolver to decide whether all phi inputs are the same:
void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx, const PhiOp& phi) {
InvalidateAllNonAliasingInputs(phi);
// For robustness, unset the replacement by default.
replacements_[op_idx] = OpIndex::Invalid();
base::Vector<const OpIndex> inputs = phi.inputs();
...
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;
}
}
Clearing replacements_[op_idx] is not sufficient. The stale fact used here belongs to another operation: the struct.get on the loop backedge. ProcessPhi() follows that stale input replacement and incorrectly concludes that the forward edge and backedge are the same large array.
Array-Length Elimination Turns This Into an OOB Write
The stale phi replacement is security-relevant because Wasm array length is also load-eliminated:
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);
}
Those analyzer replacements are then emitted into the optimized graph:
#define EMIT_OP(Name) \
OpIndex REDUCE_INPUT_GRAPH(Name)(OpIndex ig_index, const Name##Op& op) { \
if (v8_flags.turboshaft_wasm_load_elimination) { \
OpIndex ig_replacement_index = analyzer_.Replacement(ig_index); \
if (ig_replacement_index.valid()) { \
OpIndex replacement = Asm().MapToNewGraph(ig_replacement_index); \
return replacement; \
} \
} \
return Next::ReduceInputGraph##Name(ig_index, op); \
}
EMIT_OP(ArrayLength)
The exploit uses the following loop shape:
cur = big;
holder.field = big;
loop:
cur[3] = value;
cur = holder.field;
holder.field = small;
The runtime values are:
iteration 1: cur == big
iteration 2: cur == big
iteration 3: cur == small
The optimized graph still treats cur as big, so the array.set bounds check uses the large array length. On the third iteration, the actual receiver is the length-1 small array and the store at index 3 is out of bounds.
Exploitation Strategy
The proof-of-concept places three Wasm GC arrays consecutively:
big array
small array
victim array
The stale optimized store writes through small[3]. With the chosen allocation layout, this lands on the following victim array’s length field and changes it to 0x400000.
The enlarged victim array is an array<i32>, so it becomes a stable 32-bit read/write window over adjacent V8 heap objects. The exploit then:
- Allocates marker JavaScript objects after the victim array.
- Scans the OOB i32 window for marker fields and repeated compressed object references.
- Computes the victim array element base.
- Locates a packed-double JavaScript array and its
FixedDoubleArrayelements backing store. - Overwrites another packed-double array’s
elementspointer through the OOB window. - Uses normal JavaScript double element reads/writes as
read64andwrite64.
The final primitive does not require a V8 native syntax flag or an experimental Wasm flag.
Proof of Concept
The reporting package contains two JavaScript artifacts:
wasm-gc-wle-arb-rw-stable-14.8.178.14.js
wasm-gc-wle-arb-rw-main.js
The stable-release proof was confirmed on V8 14.8.178.14 on x64:
$ ./out/x64.release/d8 --version
V8 version 14.8.178.14
$ ./out/x64.release/d8 ./wasm-gc-wle-arb-rw-stable-14.8.178.14.js
ARB_RW_OK
victimBase=0x12f3354
targetAddr=0x12f34a5 targetElements=0x12f34bd
dblAddr=0x12f35ed originalElements=0x12f3605
read64=0x5566778811223344
write64=0x4142434445464748
The main-branch POC was confirmed on commit 77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca.
It uses the same exploit structure. The only adjustment needed for the stable 14.8.178.14 proof was in the sanity check used while selecting FixedDoubleArray element stores. The original proof assumed the FixedDoubleArray length field was stored as a raw integer and expected 7 and 9. On the stable release, those fields were Smi-tagged, so the scan observed 14 and 18.
The stable proof accepts either encoding:
function fixedArrayLengthMatches(lengthField, expected) {
if (lengthField === undefined) return false;
lengthField >>>= 0;
return lengthField === (expected >>> 0) ||
lengthField === ((expected << 1) >>> 0);
}
The stable proof uses ordinary JavaScript and WebAssembly APIs. The exploit logic did not change: it still corrupts the Wasm array length, builds the OOB i32 window, retargets a double array’s elements pointer, and proves read64/write64 through normal JavaScript array access.
Suggested Fix
The core issue is that replacements_ participates in semantic reasoning across loop revisits but is not part of the loop snapshot state.
The safest fix is to make replacement facts obey the same lifetime rules as the alias and memory tables. In practice, one of the following should be done:
- Add
replacements_to the loop snapshot state and merge it conservatively across predecessors. - Clear or invalidate replacement facts when loop snapshots are restored or revisited.
- Stop
ProcessPhi()from usingResolveBase()results that follow unsnapshotted replacement facts. A safer phi simplification can compare raw inputs, or only strip transparent wrappers such asAssertNotNull/WasmTypeCast, without following the global replacement side table.
Option 3 is the smallest targeted mitigation for this specific exploit path. Option 1 is the more complete design fix if replacement facts are intended to remain available for other cross-block reasoning.
A regression test should cover the loop shape:
cur = big;
holder.field = big;
loop:
cur[3] = value;
cur = holder.field;
holder.field = small;
The test should assert that the optimized Wasm code does not use the original large array length for the later small-array store.
This information is being shared by OpenAI solely for the purpose of improving security and reducing potential harm. This information is presented as-is. We make no representations or warranties, express or implied, as to the completeness, accuracy, or fitness for any particular purpose of the information. This includes, without limitation, any suggestions or ideas presented on how to remedy or mitigate an identified vulnerability, including whether such suggestions or ideas would be effective and/or could have other negative impacts.
OpenAI disclaims any liability for direct or indirect damages arising from the reliance on, or use, misuse, or interpretation of this information. Any references to third-party systems, services, or entities are included solely for identification purposes and do not imply endorsement, responsibility, or attribution.
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-gc-optimize-phase.cc#L18-L19
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-gc-optimize-phase.cc\#L18-L19
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h#L1086-L1107
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h#L327-L345
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h#L409-L418
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h#L516-L524
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h#L547-L559
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h#L816-L831
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h#L977-L1002
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h\#L1086-L1107
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h\#L327-L345
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h\#L409-L418
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h\#L516-L524
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h\#L547-L559
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h\#L816-L831
- https://github.com/v8/v8/blob/77663b3a0434fe0f8f1b73dbc3fcfe480e2490ca/src/compiler/turboshaft/wasm-load-elimination-reducer.h\#L977-L1002