Low watch CVSS 4.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentWASM
Bug ClassLogic Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker493099941
Fix commit45ec8457e14c (v8/v8) +89/-5
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedTaisic Yun (@taisic) of Theori
Reported2026-03-16 (per vendor advisory)
Fix landed2026-03-24 (public git)
Disclosed2026-05-05
Patch gap42 days — fix public in source before the advisory

Background

Turboshaft WebAssembly load elimination
an optimization pass (wasm-load-elimination-reducer.h) that caches the results of loads and reuses them for later identical loads instead of re-loading from memory.
`PrepareForGetCodeUnit` / `kStringPrepareForGetCodeunitIndex`
a “load-like” operation that resolves and caches the pointer to a string’s first character so subsequent charCodeAt-style code-unit reads can index directly.
In-place string rewriting
as the commit message states, although string contents are immutable, a SeqString can be rewritten in place into a ThinString (forwarding pointer) or ExternalString (data moved off-heap), which relocates where the character bytes live.
`mutability` flag in `FindImpl`/`Insert`
a per-value bit telling load elimination whether a cached value must be invalidated after arbitrary calls (mutable) or may be kept indefinitely (immutable).

Root Cause Analysis

FindLoadLike and InsertLoadLike hardcoded static constexpr bool mutability = false for every load-like value, including the cached first-character pointer produced by PrepareForGetCodeUnit (kStringPrepareForGetCodeunitIndex). Marking that pointer immutable told load elimination it could safely reuse the cached character pointer across intervening calls. But an arbitrary call — for example an imported function that externalizes the string or forces it to become a ThinString — rewrites the string object in place and relocates its character data, so the cached pointer becomes stale. Later code-unit reads then dereference the stale pointer, reading from freed or wrong memory (the regression test interleaves charCodeAt calls with a callIndirect to ext/thin that externalizes/thins the same string, and after tier-up the optimized code must still return the correct bytes).

The fix introduces LoadLikeMutability(offset_sentinel), which returns true for kStringPrepareForGetCodeunitIndex, so that cached pointer is treated as mutable and invalidated at calls, forcing a reload of the first-character pointer after any call that could have rewritten the string.

Key insight
The single mistake was treating the cached “first character” pointer from PrepareForGetCodeUnit as immutable across calls, ignoring that a string’s heap representation (and thus its data location) can be rewritten in place; the fix reclassifies exactly that offset as mutable via LoadLikeMutability so it is invalidated at call boundaries.

Attack Path

  1. Get a WebAssembly function to read string code units using the wasm:js-string charCodeAt builtin, emit code that calls PrepareForGetCodeUnit and caches the first-character pointer.
  2. Interleave an arbitrary call that rewrites the string between two code-unit reads, call an imported/indirect function that externalizes the string or turns it into a ThinString, relocating its character data (as ext/thin do in the PoC).
  3. Tier up so load elimination applies %WasmTierUpFunction (optimizing compilation) runs the load-elimination reducer, which reuses the pre-call cached pointer for the post-call code-unit reads because it was marked immutable.
  4. Read through the stale pointer the optimized code dereferences the now-relocated first-character pointer, producing an out-of-bounds / type-confused read of string data ((inferred): the diff shows only the mis-classification, not a concrete read/write primitive).

Impact Assessment

An attacker running crafted WebAssembly in the renderer can cause optimized code to read string character data through a stale pointer after the string’s representation was rewritten in place, an incorrect-optimization bug that yields memory disclosure or corruption within the V8 sandbox ((inferred) beyond the stale read the patch implies). The advisory classifies it as inappropriate implementation in V8 and does not state exploited-in-the-wild status. Preconditions are the ability to instantiate a module using the js-string builtins and to trigger optimization/tier-up.

Files Changed

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

Audit Directions

  • Cached pointers into relocatable objects
    audit any load-elimination/CSE cache of a raw data pointer (string chars, typed-array backing store, off-heap buffers) marked immutable; confirm it is invalidated whenever the underlying object can be relocated or rewritten.
  • In-place object shape transitions
    review optimizations that assume “immutable content ⇒ immutable representation”; ThinString, ExternalString, and similar in-place rewrites break that assumption at call sites.
  • Per-offset mutability tables
    check other offset_sentinel/load-like classifications for entries that should be mutable but default to false, especially those reached across arbitrary calls.
From 45ec8457e14cbdc9a23b118f0ad48f7ac6d9bcff Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <[email protected]>
Date: Mon, 23 Mar 2026 13:47:22 +0100
Subject: [PATCH] [wasm][strings] Fix PrepareForGetCodeUnit elimination

While strings themselves are immutable, they could be rewritten
in-place into ThinStrings or ExternalStrings, so we have to reload
the cached pointer to the first character after arbitrary calls.

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

diff --git a/src/compiler/turboshaft/wasm-load-elimination-reducer.h b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
index c3d67a4..95f6833 100644
--- a/src/compiler/turboshaft/wasm-load-elimination-reducer.h
+++ b/src/compiler/turboshaft/wasm-load-elimination-reducer.h
@@ -234,10 +234,17 @@
     return all_keys_.find(mem) != all_keys_.end();
   }
 
+  bool LoadLikeMutability(int offset_sentinel) {
+    // While strings themselves are immutable, their heap object representation
+    // could get rewritten into a ThinString or ExternalString, so we need
+    // to consider them mutable (and invalidate such values at calls).
+    if (offset_sentinel == kStringPrepareForGetCodeunitIndex) return true;
+    return false;
+  }
+
   OpIndex FindLoadLike(OpIndex op_idx, int offset_sentinel) {
-    static constexpr bool mutability = false;
     return FindImpl(ResolveBase(op_idx), offset_sentinel, kLoadLikeType,
-                    kLoadLikeSize, mutability);
+                    kLoadLikeSize, LoadLikeMutability(offset_sentinel));
   }
 
   OpIndex FindImpl(OpIndex object, int offset, wasm::ModuleTypeIndex type_index,
@@ -273,9 +280,8 @@
   void InsertLoadLike(OpIndex base_idx, int offset_sentinel,
                       OpIndex value_idx) {
     OpIndex base = ResolveBase(base_idx);
-    static constexpr bool mutability = false;
-    Insert(base, offset_sentinel, kLoadLikeType, kLoadLikeSize, mutability,
-           value_idx);
+    Insert(base, offset_sentinel, kLoadLikeType, kLoadLikeSize,
+           LoadLikeMutability(offset_sentinel), value_idx);
   }
 
 #ifdef DEBUG
diff --git a/test/mjsunit/regress/wasm/regress-493099941.js b/test/mjsunit/regress/wasm/regress-493099941.js
new file mode 100644
index 0000000..88860e4
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-493099941.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: --allow-natives-syntax --expose-externalize-string
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+const builder = new WasmModuleBuilder();
+
+const sig_i_ri = makeSig([kWasmExternRef, kWasmI32], [kWasmI32]);
+const $sig_v_r = builder.addType(kSig_v_r);
+
+const $ext = builder.addImport("m", "ext", $sig_v_r);
+const $thin = builder.addImport("m", "thin", $sig_v_r);
+assertEquals(1, $thin);  // Reused as index in table below.
+
+const $charCodeAt = builder.addImport("wasm:js-string", "charCodeAt", sig_i_ri);
+const $table0 = builder.addTable(wasmRefNullType($sig_v_r), 2, 2);
+builder.addActiveElementSegment(
+    $table0.index, wasmI32Const(0),
+    [[kExprRefFunc, $ext], [kExprRefFunc, $thin]],
+    wasmRefType($sig_v_r));
+
+builder.addFunction("test", sig_i_ri).exportFunc().addBody([
+    kExprLocalGet, 0,
+    kExprI32Const, 0,
+    kExprCallFunction, $charCodeAt,
+    kExprDrop,
+
+    kExprLocalGet, 0,  // string
+    kExprLocalGet, 1,  // function index
+    kExprCallIndirect, $sig_v_r, $table0.index,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 0,
+    kExprCallFunction, $charCodeAt,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 1,
+    kExprCallFunction, $charCodeAt,
+    kExprI32Const, 8,
+    kExprI32Shl,
+    kExprI32Ior,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 2,
+    kExprCallFunction, $charCodeAt,
+    kExprI32Const, 16,
+    kExprI32Shl,
+    kExprI32Ior,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 3,
+    kExprCallFunction, $charCodeAt,
+    kExprI32Const, 24,
+    kExprI32Shl,
+    kExprI32Ior,
+  ]);
+
+function thin(s) { const o = {}; o[s]; }
+function ext(s) { externalizeString(s); }
+
+let instance =
+    builder.instantiate({m: { ext, thin }}, {builtins: ["js-string"]});
+
+function f(s, func) {
+  let clone = String.fromCharCode(...s.split("").map(c => c.charCodeAt(0)));
+  clone = createExternalizableString(clone);
+  return instance.exports.test(clone, func).toString(16);
+}
+const string = "abcdefghijklmnopqrstuvwxyz";
+const expected = "64636261";  // Hex ASCII for "abcd", little endian order.
+assertEquals(expected, f(string, $thin));
+assertEquals(expected, f(string, $ext));
+%WasmTierUpFunction(instance.exports.test);
+assertEquals(expected, f(string, $thin));
+assertEquals(expected, f(string, $ext));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/regress/wasm/regress-493099941.js b/test/mjsunit/regress/wasm/regress-493099941.js
new file mode 100644
index 0000000..88860e4
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-493099941.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: --allow-natives-syntax --expose-externalize-string
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+const builder = new WasmModuleBuilder();
+
+const sig_i_ri = makeSig([kWasmExternRef, kWasmI32], [kWasmI32]);
+const $sig_v_r = builder.addType(kSig_v_r);
+
+const $ext = builder.addImport("m", "ext", $sig_v_r);
+const $thin = builder.addImport("m", "thin", $sig_v_r);
+assertEquals(1, $thin);  // Reused as index in table below.
+
+const $charCodeAt = builder.addImport("wasm:js-string", "charCodeAt", sig_i_ri);
+const $table0 = builder.addTable(wasmRefNullType($sig_v_r), 2, 2);
+builder.addActiveElementSegment(
+    $table0.index, wasmI32Const(0),
+    [[kExprRefFunc, $ext], [kExprRefFunc, $thin]],
+    wasmRefType($sig_v_r));
+
+builder.addFunction("test", sig_i_ri).exportFunc().addBody([
+    kExprLocalGet, 0,
+    kExprI32Const, 0,
+    kExprCallFunction, $charCodeAt,
+    kExprDrop,
+
+    kExprLocalGet, 0,  // string
+    kExprLocalGet, 1,  // function index
+    kExprCallIndirect, $sig_v_r, $table0.index,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 0,
+    kExprCallFunction, $charCodeAt,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 1,
+    kExprCallFunction, $charCodeAt,
+    kExprI32Const, 8,
+    kExprI32Shl,
+    kExprI32Ior,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 2,
+    kExprCallFunction, $charCodeAt,
+    kExprI32Const, 16,
+    kExprI32Shl,
+    kExprI32Ior,
+
+    kExprLocalGet, 0,
+    kExprI32Const, 3,
+    kExprCallFunction, $charCodeAt,
+    kExprI32Const, 24,
+    kExprI32Shl,
+    kExprI32Ior,
+  ]);
+
+function thin(s) { const o = {}; o[s]; }
+function ext(s) { externalizeString(s); }
+
+let instance =
+    builder.instantiate({m: { ext, thin }}, {builtins: ["js-string"]});
+
+function f(s, func) {
+  let clone = String.fromCharCode(...s.split("").map(c => c.charCodeAt(0)));
+  clone = createExternalizableString(clone);
+  return instance.exports.test(clone, func).toString(16);
+}
+const string = "abcdefghijklmnopqrstuvwxyz";
+const expected = "64636261";  // Hex ASCII for "abcd", little endian order.
+assertEquals(expected, f(string, $thin));
+assertEquals(expected, f(string, $ext));
+%WasmTierUpFunction(instance.exports.test);
+assertEquals(expected, f(string, $thin));
+assertEquals(expected, f(string, $ext));
Loading diff…

Original Bug Report

reported by [email protected]

V8 Wasm Turboshaft stale `StringPrepareForGetCodeUnit` reuse across JS import leading to memory disclosure

VULNERABILITY DETAILS

wasm:js-string.charCodeAt lowers through StringAsWtf16 to GetCodeUnitImpl, which calls StringPrepareForGetCodeUnit and then performs a raw immutable load using the prepared (base, base_offset, charwidth_shift) tuple.

Turboshaft Wasm load elimination caches StringPrepareForGetCodeUnit as a load-like entry keyed by the string input. If a JS import executes code that uses the string as a property key, that JS can internalize the string and rewrite it from a sequential string to a thin string. However, the cached preparation survives the call because InvalidateMaybeAliasing() skips load-like entries whose mutability == false:

template <EntriesWithOffsets offsets = EntriesWithOffsets::kInvalidate>
void InvalidateMaybeAliasing() {
  for (auto& base_keys : base_keys_) {
    OpIndex base = base_keys.first;
    if (non_aliasing_objects_.Get(base)) continue;
    if constexpr (offsets == EntriesWithOffsets::kInvalidate) {
      for (auto it = base_keys.second.with_offsets.begin();
           it != base_keys.second.with_offsets.end();) {
        Key key = *it;
        if (key.data().mem.mutability == false) {
          ++it;
          continue;
        }
        it = base_keys.second.with_offsets.RemoveAt(it);
        Set(key, OpIndex::Invalid());
      }
    }
  }
}

The next charCodeAt therefore reuses a stale sequential-string layout after a call whose JS body can change string representation, and on the tested build it returns non-character data instead of the original string bytes.

On a current V8 development build, this is a stable non-crashing V8-side memory disclosure reachable from Wasm. The attached addrof PoC uses the same stale StringPrepareForGetCodeUnit reuse bug to leak a pointer-derived low32 value from the internalized key string path. The PoC then places nearby heap objects in an attacker-controlled allocation schedule (key, sym, then tmp, with warm-up and GC to stabilize placement), so the leaked key address can be combined with a stable relative delta in this exact schedule to derive the low 32 bits of the tmp object’s address inside the V8 cage. In the reproduced output below, addrof_tmp_tagged=0x10bc2c9 matches the low32 reported by %DebugPrint(tmp).

VERSION

Version tested: current V8 development d8 build from revision 8274e932001c26f5282ec0e26e4dfe1717d32e08 (V8 version 14.8.0 (candidate)).

REPRODUCTION CASE

Attached files:

  • poc.js

Run d8 poc.js --allow-natives-syntax --expose-gc.

Result:

leaked_key_tagged=0x1093755
leaked_key=0x1093754
tmp_key_delta=0x28b74
addrof_tmp=0x10bc2c8
addrof_tmp_tagged=0x10bc2c9
tmp_debugprint:
DebugPrint: 0x...010bc2c9: [JS_OBJECT_TYPE] in OldSpace

addrof_tmp_tagged matches the low 32 bits of the tagged address printed by %DebugPrint(tmp).

The key side effect that makes the stale preparation invalid is the property-key operation inside this JS import:

ext(s) {
  const o = {};
  o[s];
}

The import call itself is not the important part; the important part is that the JS body uses s as a property key. In this setup, that property-key processing internalizes the string and rewrites the original sequential string as a thin string. After tier-up, the Wasm code reuses the stale StringPrepareForGetCodeUnit result created for the pre-internalization sequential string, so the later charCodeAt reads from the wrong layout.

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: N/A (non-crashing memory disclosure) Crash State: N/A Client ID (if relevant): N/A

CREDIT INFORMATION

Reporter credit: Taisic Yun (@taisic) of Theori

View on issue tracker