Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Bug ClassLogic Error
Tracker493099941
Fix commit45ec8457e14c (v8/v8) +89/-5
CISA KEVNot listed
CreditedTaisic Yun (@taisic) of Theori
Disclosed2026-05-05

Files Changed

  • src/compiler/turboshaft/wasm-load-elimination-reducer.h
  • test/mjsunit/regress/wasm/regress-493099941.js
From 45ec8457e14cbdc9a23b118f0ad48f7ac6d9bcff Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
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 <jkummerow@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Darius Mercadier <dmercadier@chromium.org>
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 yt...@gmail.com

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