Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentWASM
Bug ClassUAF
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar — revised by independent review by AI
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.
Independent review by AIConfirmed (tier revised from patch-now)
Reviewer notes: UAF is read-only: freed external resource is copied into the result string, i.e. a leak, not demonstrated corruption. Web attacker cannot call externalizeString; relies on Blink-externalized strings and GC timing (repro uses d8 flags/natives).
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker535000102
Fix commit27bdcbf67f3f (v8/v8) +60/-3
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedJihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Reported2026-07-15 · bounty $500 (per vendor advisory)
Fix landed2026-08-12 (public git)
Disclosed2026-08-11
Patch gapNot computed: mapped commit cites a different bug, not this CVE's bug.

Background

External string
a V8 string whose character data lives in memory owned by an embedder-provided ExternalStringResource rather 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 substring directly 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.

Key insight
The single mistake is that materializing a substring from a raw 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

  1. Instantiate a Wasm module using `js-string` builtins The attacker builds and instantiates a module that imports wasm:js-string substring and tiers it up so the optimized Torque builtin runs.
  2. 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.
  3. Force GC during allocation Using %SimulateNewspaceFull (or ordinary allocation pressure), the attacker makes the AllocateNonEmptySeqOneByteString / StringFromTwoByteSlice call inside substring trigger a garbage collection.
  4. 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.
  5. Read freed memory The copy loop continues reading through the stale ConstSlice into the freed external buffer, producing a use-after-free that can be shaped into an information leak or memory corruption.

Impact Assessment

An attacker who can run WebAssembly (reachable from any web page in the renderer process) gains a use-after-free read against a freed external string resource, which can leak sensitive heap contents or be developed into full renderer memory corruption. Exploitation requires the 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

FunctionChangeNotes
for
test/mjsunit/regress/wasm/regress-543901836.js
modified

Files Changed

  • src/compiler/code-assembler.h
  • src/compiler/code-assembler.cc
  • test/mjsunit/regress/wasm/regress-543901836.js
  • test/mjsunit/mjsunit.status
  • src/builtins/wasm.tq

Audit Directions

  • Slice-then-allocate patterns
    Review every Torque/CSA builtin that creates a ConstSlice or raw backing-store pointer and then performs an allocation, ensuring the source object is Retained across the allocation.
  • External string consumers
    Audit 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 builtins
    Systematically check the remaining wasm:js-string operations (concat, encode, slice variants) for the same missing keep-alive of their string inputs across GC-triggering calls.
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();
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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();
+}
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.