CVE-2026-102302
Overview
Background
- Ignition vs. Sparkplug (baseline)
- Ignition is V8’s bytecode interpreter; Sparkplug is the fast baseline JIT, and functions can transition (“OSR”, on-stack replacement) from the interpreter into baseline code while executing a loop.
- `frame_size_` vs. `max_frame_size()`
BytecodeArray::frame_size_is the interpreter frame’s local slots, whilemax_frame_size()isframe_size_ + max_arguments_, the extra room baseline code needs to push outgoing call arguments.- Entry stack checks
InterpreterEntryTrampolinechecks onlyframe_size_(Ignition’s call helpers stack-check before pushing args), whereasBaselineOutOfLineProloguepre-checksmax_frame_size()so baseline call sites can push arguments without a per-call stack check.- `real_jslimit` / `real_climit`
- The true JS/C++ stack limits (
kRealJsLimit) below which pushing further data overruns the stack.
Root Cause Analysis
When a function is entered in Ignition near the stack limit, InterpreterEntryTrampoline only reserved frame_size_, not max_frame_size(), because Ignition’s call helpers stack-check before pushing outgoing arguments. If that function then OSRs into Sparkplug from inside a loop, InterpreterOnStackReplacement_ToBaseline transfers into baseline code while bypassing BaselineOutOfLinePrologue, so the max_arguments_ headroom that baseline code assumes was never validated. Baseline call sites push outgoing arguments without their own stack check, so a subsequent call with many arguments can write past real_jslimit/real_climit, a stack buffer overflow.
The fix, in InterpreterAssembler::OnStackReplacement at the osr_to_sparkplug label, loads max_arguments_ from the BytecodeArray, converts it to bytes, and uses StackPointerGreaterThan(IntPtrAdd(stack_limit, max_arguments_bytes)) to verify enough remaining space before tail-calling the OSR builtin; if the space is insufficient it branches to skip_osr_to_sparkplug and executes JumpBackward(relative_jump), continuing the loop in Ignition instead of OSRing. Staying in the interpreter (rather than throwing StackOverflow at JumpLoop) is deliberate, since interpreter exception lookup is keyed by bytecode offset and an inner try-catch could otherwise mis-catch it.
max_frame_size() (frame_size_ + max_arguments_) stack check, leaving baseline call sites free to push arguments past the real stack limit; the fix adds an explicit max_arguments_ headroom check before the OSR tail-call and falls back to continuing the loop in the interpreter when the space is not available.Attack Path
- Enter near the stack limit
Cause a function to be entered in Ignition when the stack is close to the limit, so only
frame_size_(notmax_frame_size()) has been reserved; the PoC uses--stack-size=100and anew Functionbody. - OSR into Sparkplug from a loop
Run a loop so a backedge interrupt triggers
InterpreterOnStackReplacement_ToBaseline, moving execution into baseline code without theBaselineOutOfLineProloguemax_arguments_check (for (let i = 0; i < 50; i++)). - Issue a large-argument call
From the now-baseline loop body, perform a call that pushes many outgoing arguments (
String.fromCharCode(${many_char_codes})with ~11000 args), which baseline pushes without a stack check. - Overflow the stack
The pushed arguments write past
real_jslimit/real_climit, overrunning the stack (buffer overflow in V8). (inferred as to any further primitive)
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/mjsunit/regress/regress-563716534.js |
modified |
Files Changed
test/mjsunit/regress/regress-563716534.jssrc/interpreter/interpreter-assembler.cc
Audit Directions
- Asymmetric prologue/entry invariants across tiersAudit every transition (OSR, tail-call, deopt) between execution tiers to confirm the destination tier’s stack/frame assumptions (
max_frame_size(), argument headroom) are established when the normal prologue is bypassed. - Argument pushes without a stack checkLook for call-emitting code that relies on a one-time entry/prologue check for outgoing-argument space, and verify no path reaches those pushes without that check having run.
- Near-limit fallback behaviorReview stack-limit handling that must avoid throwing at points where exception dispatch is offset-keyed (e.g.
JumpLoop), ensuring fallbacks re-check on later interrupts rather than corrupting the stack.
Patch
From 4615af981a0b4775e01481d9fc3bfed02c5f8a68 Mon Sep 17 00:00:00 2001 From: Leszek Swirski <[email protected]> Date: Fri, 25 Sep 2026 11:37:13 +0200 Subject: [PATCH] [interpreter] Check max_arguments stack space before Sparkplug OSR InterpreterEntryTrampoline only checks BytecodeArray::frame_size_ on function entry because Ignition call helpers check stack space before pushing outgoing call arguments. Sparkplug, by contrast, relies on BaselineOutOfLinePrologue pre-checking BytecodeArray::max_frame_size() (frame_size_ + max_arguments_) at function entry and pushes outgoing arguments at call sites without a stack check. When a function entered in Ignition near the stack limit OSRs into Sparkplug inside a loop, InterpreterOnStackReplacement_ToBaseline bypasses the baseline prologue, allowing subsequent calls with many arguments to push past real_jslimit and real_climit. In InterpreterAssembler::OnStackReplacement, verify that there is enough remaining stack space for max_arguments_ before tail-calling InterpreterOnStackReplacement_ToBaseline, continuing the loop in Ignition if not. TAG=agy CONV=8ac6e096-bff0-46cd-8db3-1f8046c78ca3 Fixed: 563716534 Change-Id: I4fca55df3627b1e36a7e57cc2c4a9655b2abbfcd Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8460873 Commit-Queue: Leszek Swirski <[email protected]> Reviewed-by: Igor Sheludko <[email protected]> Auto-Submit: Leszek Swirski <[email protected]> Cr-Commit-Position: refs/heads/main@{#110091} --- diff --git a/src/interpreter/interpreter-assembler.cc b/src/interpreter/interpreter-assembler.cc index 074f827..b8fbf96 100644 --- a/src/interpreter/interpreter-assembler.cc +++ b/src/interpreter/interpreter-assembler.cc @@ -1599,12 +1599,40 @@ BIND(&osr_to_sparkplug); { + Label skip_osr_to_sparkplug(this); + // InterpreterEntryTrampoline only checks BytecodeArray::frame_size_, + // whereas baseline code requires BytecodeArray::max_frame_size() + // (frame_size_ + max_arguments_) so it can push outgoing call arguments + // without a stack check. Since frame_size_ is already allocated in the + // current interpreter frame, verify that there is enough remaining stack + // space for max_arguments_ before OSRing into baseline code. + // + // If there isn't enough space, we skip OSR and stay in the interpreter + // rather than throwing a StackOverflow here: exception handler lookup in + // the interpreter is keyed by bytecode offset, so throwing at JumpLoop + // could be caught by an inner try-catch wrapping the loop (and the call + // requiring max_arguments_ might not even be reached). Skipping means we + // will re-check on subsequent backedge interrupts while near the stack + // limit, which is an acceptable cost for this edge case. + TNode<Uint16T> max_arguments = LoadObjectField<Uint16T>( + BytecodeArrayTaggedPointer(), offsetof(BytecodeArray, max_arguments_)); + TNode<UintPtrT> max_arguments_bytes = + TimesSystemPointerSize(ChangeUint32ToWord(max_arguments)); + TNode<UintPtrT> stack_limit = + Load<UintPtrT>(IsolateField(IsolateFieldId::kRealJsLimit)); + GotoIfNot( + StackPointerGreaterThan(IntPtrAdd(stack_limit, max_arguments_bytes)), + &skip_osr_to_sparkplug); + // We already compiled the baseline code, so we don't need to handle failed // compilation as in the Ignition -> Turbofan case. Therefore we can just // tailcall to the OSR builtin. SaveBytecodeOffset(); TailCallBuiltin(Builtin::kInterpreterOnStackReplacement_ToBaseline, context); + + BIND(&skip_osr_to_sparkplug); + JumpBackward(relative_jump); } } diff --git a/test/mjsunit/regress/regress-563716534.js b/test/mjsunit/regress/regress-563716534.js new file mode 100644 index 0000000..45c80b2 --- /dev/null +++ b/test/mjsunit/regress/regress-563716534.js @@ -0,0 +1,17 @@ +// 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: --no-concurrent-sparkplug --stack-size=100 + +const many_char_codes = new Array(11000).fill(48).join(","); +const loop_then_large_call = new Function(` + for (let i = 0; i < 50; i++) { + if (i === 49) return String.fromCharCode(${many_char_codes}); + } +`); + +try { + loop_then_large_call(); +} catch (e) { +}
Regression Test / PoC
diff --git a/test/mjsunit/regress/regress-563716534.js b/test/mjsunit/regress/regress-563716534.js
new file mode 100644
index 0000000..45c80b2
--- /dev/null
+++ b/test/mjsunit/regress/regress-563716534.js
@@ -0,0 +1,17 @@
+// 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: --no-concurrent-sparkplug --stack-size=100
+
+const many_char_codes = new Array(11000).fill(48).join(",");
+const loop_then_large_call = new Function(`
+ for (let i = 0; i < 50; i++) {
+ if (i === 49) return String.fromCharCode(${many_char_codes});
+ }
+`);
+
+try {
+ loop_then_large_call();
+} catch (e) {
+}