CVE-2026-119262
Overview
Background
- Turboshaft
- V8’s modern optimizing-compiler backend (successor to the older TurboFan backend pipeline).
src/compiler/turboshaft/pipelines.ccdrives the phase sequence that lowers and register-allocates optimized JS code. - `AssembleConstructFrame` / prologue
- The code emitted at the entry of an optimized JS function that reserves the function’s stack frame by decrementing the stack pointer once by the full frame size, before the function body runs.
- `kJSFunctionEntry` stack check
- The explicit stack-overflow check emitted after the frame is constructed. It compares the stack pointer against the stack limit and throws
RangeErroron overflow — but it only runs once the SP has already been moved. - OS guard page
- A single unmapped page placed just past the end of the thread stack by the operating system. Touching it faults, which is how ordinary one-page-at-a-time stack growth is detected. A single SP decrement larger than a page can jump over this guard page into unrelated mapped memory without ever touching it.
- `GetTotalFrameSlotCount()`
- The register allocator’s final count of stack slots the frame needs;
kSystemPointerSize-sized each.kMaxStackSlots = 4 * KB / kSystemPointerSizeis the slot count corresponding to the smallest OS page (4 KB).
Root Cause Analysis
The violated invariant is “an optimized function’s frame must never be grown by more than one OS page in a single unchecked stack-pointer decrement.” In CodeGenerator::AssembleConstructFrame, the Turboshaft prologue reserves the whole frame with one SP subtraction and only afterwards runs the kJSFunctionEntry stack check. If the computed frame exceeds 4 KB, that single decrement can move the stack pointer past the OS guard page and land in adjacent mapped memory, so the guard page is never touched and the overflow goes undetected until the function starts writing spill slots / outgoing arguments into memory that does not belong to the stack — classic stack-pointer-jump memory corruption. Turboshaft had no bound on frame size; Maglev had already been fixed the same way (crrev.com/c/8137738), and this patch ports that bound to Turboshaft.
The fix, in Turboshaft’s pipelines.cc register-allocation driver (after AssignSpillSlotsPhase, before CommitAssignmentPhase), adds: constexpr int kMaxStackSlots = 4 * KB / kSystemPointerSize; if (data_->info()->IsOptimizing() && data_->frame()->GetTotalFrameSlotCount() > kMaxStackSlots) { data_->info()->AbortOptimization(BailoutReason::kFunctionTooBig); data()->ClearRegisterComponent(); return false; }. Rather than emitting an arbitrarily large unchecked SP jump, the compiler aborts optimization for the function (it still runs correctly via the interpreter/baseline tier). The regression test test/mjsunit/turboshaft/regress-574660146.js forces the condition by passing 1100 distinct live expressions to a non-inlined call (cb(${args})), which requires >1024 stack slots (>4 KB) while keeping the bytecode small enough to still be selected for optimization, and exercises both the normal entry and the OSR (%OptimizeOsr) entry. Note the class discrepancy: the advisory labels this Improper input validation in V8, but the diff substantiates a stack guard-page bypass / stack-based memory corruption, not an input-validation parsing bug — so this RCA classifies by the diff.
Attack Path
- Script control
Attacker runs JavaScript in the renderer and gets a function JIT-compiled by Turboshaft (achievable with ordinary hot code; the test uses
%OptimizeFunctionOnNextCall/OSR but that is only to force it deterministically). - Inflate the frame Author a function whose optimized frame needs more than 1024 stack slots (>4 KB) — e.g. keep ~1100 distinct values simultaneously live across a non-inlined call, as the regression test does — while keeping the bytecode small enough that the function is still chosen for optimization.
- Trigger prologue
Invoke the optimized function (or OSR into it).
AssembleConstructFrameemits one SP decrement of the full oversized frame. - Jump the guard page
That single decrement moves SP past the OS guard page into adjacent memory without faulting; the
kJSFunctionEntrycheck that would have caught overflow runs too late. - Corrupt memory Subsequent spill-slot / argument writes land in memory beyond the stack’s guard region. Primitive: controlled stack-pointer displacement leading to out-of-stack memory corruption, a strong exploitation primitive from the renderer.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/mjsunit/turboshaft/regress-574660146.js |
modified |
Files Changed
src/compiler/turboshaft/pipelines.cctest/mjsunit/turboshaft/regress-574660146.jssrc/maglev/maglev-compiler.cc
Audit Directions
- Other prologue/frame-growth sitesReview every path that emits a single SP adjustment before a stack check (other tiers, builtins, trampolines, wasm frame construction) for the same “grow-then-check” ordering; a one-page bound must hold wherever the SP jumps by a dynamic/large amount.
- Tier parityMaglev and Turboshaft now both bound frames; audit any other code generator (TurboFan legacy backend, wasm Liftoff/TurboFan) to ensure none still emits an unbounded unchecked frame reservation.
- `kMaxStackSlots` assumptionsThe bound assumes 4 KB is the smallest OS page; audit platforms/configs with larger guard regions or probe-based stack growth to confirm 4 KB is conservative, and confirm the count is total frame bytes (tagged + untagged + fixed) rather than just spill slots.
- Bailout completenessVerify
ClearRegisterComponent()+return falsecleanly deopts to a lower tier in all callers and cannot itself leave the pipeline in a partially-allocated state.
Patch
From ea2f03dfff2dbcf5a6ff4860790326d5d2b66356 Mon Sep 17 00:00:00 2001 From: Jakob Kummerow <[email protected]> Date: Mon, 26 Oct 2026 13:49:46 +0000 Subject: [PATCH] [turboshaft] Bound JS stack frame size to 4KB CodeGenerator::AssembleConstructFrame grows the stack frame for optimized JS functions (including OSR) with a single stack pointer decrement before the kJSFunctionEntry stack check runs. A frame larger than 4KB can therefore step over an OS stack guard page. Following Maglev (https://crrev.com/c/8137738), abort optimization in Pipeline::AllocateRegisters with BailoutReason::kFunctionTooBig when the total frame slot count exceeds 4KB. TAG=agy CONV=51ba317e-94b6-4660-b9fd-ed1d0735b6f1 Fixed: 574660146 Change-Id: Ie739ebec0b702efed0056dbd3ca87f172fc8dc7e Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8631314 Auto-Submit: Jakob Kummerow <[email protected]> Reviewed-by: Darius Mercadier <[email protected]> Commit-Queue: Jakob Kummerow <[email protected]> Cr-Commit-Position: refs/heads/main@{#111161} --- diff --git a/src/compiler/turboshaft/pipelines.cc b/src/compiler/turboshaft/pipelines.cc index 18b6291..dfefd53 100644 --- a/src/compiler/turboshaft/pipelines.cc +++ b/src/compiler/turboshaft/pipelines.cc @@ -85,6 +85,21 @@ RUN_MAYBE_ABORT(DecideSpillingModePhase); RUN_MAYBE_ABORT(AssignSpillSlotsPhase); + + // The prologue grows the frame with a single stack pointer decrement, before + // the function entry stack check runs. A frame no larger than the smallest OS + // page cannot step over a guard page, so bail out rather than emit an + // unchecked stack pointer jump of arbitrary size. + // When replacing this bailout with a different strategy, consider doing the + // same in Maglev as well. + constexpr int kMaxStackSlots = 4 * KB / kSystemPointerSize; + if (data_->info()->IsOptimizing() && + data_->frame()->GetTotalFrameSlotCount() > kMaxStackSlots) { + data_->info()->AbortOptimization(BailoutReason::kFunctionTooBig); + data()->ClearRegisterComponent(); + return false; + } + RUN_MAYBE_ABORT(CommitAssignmentPhase); // TODO(chromium:725559): remove this check once diff --git a/src/maglev/maglev-compiler.cc b/src/maglev/maglev-compiler.cc index c52bd14..ba59c97 100644 --- a/src/maglev/maglev-compiler.cc +++ b/src/maglev/maglev-compiler.cc @@ -331,6 +331,8 @@ // FunctionEntryStackCheck runs. A frame no larger than the smallest OS page // cannot step over a guard page, so bail out rather than emit an unchecked // stack pointer jump of arbitrary size. + // When replacing this bailout with a different strategy, consider doing the + // same in Turboshaft as well. if (graph->tagged_stack_slots() + graph->untagged_stack_slots() > kMaxStackSlots) { return false; diff --git a/test/mjsunit/turboshaft/regress-574660146.js b/test/mjsunit/turboshaft/regress-574660146.js new file mode 100644 index 0000000..bc82a26 --- /dev/null +++ b/test/mjsunit/turboshaft/regress-574660146.js @@ -0,0 +1,41 @@ +// 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 --turbofan --no-maglev + +function cb() { + return arguments.length; +} +%NeverOptimizeFunction(cb); + +// Passing `kArgCount` distinct expressions to a non-inlined call keeps all of +// them live simultaneously at the call site, requiring > 1024 stack slots +// (> 4KB on both 32-bit and 64-bit targets) while keeping the bytecode small +// enough to be compiled. +const kArgCount = 1100; +const args = Array.from({length: kArgCount}, (_, k) => `p ^ ${k + 1}`); + +function buildWideFrame(osr) { + return Function('x', 'cb', 'p', ` + let result = 0; + for (let i = 0; i < 10; ++i) { + ${osr ? 'if (i === 5) %OptimizeOsr();' : ''} + if (x < 0) return cb(${args}); + result = (x + i) | 0; + } + return result; + `); +} + +const wide = buildWideFrame(false); +%PrepareFunctionForOptimization(wide); +assertEquals(kArgCount, wide(-1, cb, 1)); +assertEquals(10, wide(1, cb, 1)); +%OptimizeFunctionOnNextCall(wide); +assertEquals(10, wide(1, cb, 1)); + +const wideOsr = buildWideFrame(true); +%PrepareFunctionForOptimization(wideOsr); +assertEquals(kArgCount, wideOsr(-1, cb, 1)); +assertEquals(10, wideOsr(1, cb, 1));
Regression Test / PoC
diff --git a/test/mjsunit/turboshaft/regress-574660146.js b/test/mjsunit/turboshaft/regress-574660146.js
new file mode 100644
index 0000000..bc82a26
--- /dev/null
+++ b/test/mjsunit/turboshaft/regress-574660146.js
@@ -0,0 +1,41 @@
+// 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 --turbofan --no-maglev
+
+function cb() {
+ return arguments.length;
+}
+%NeverOptimizeFunction(cb);
+
+// Passing `kArgCount` distinct expressions to a non-inlined call keeps all of
+// them live simultaneously at the call site, requiring > 1024 stack slots
+// (> 4KB on both 32-bit and 64-bit targets) while keeping the bytecode small
+// enough to be compiled.
+const kArgCount = 1100;
+const args = Array.from({length: kArgCount}, (_, k) => `p ^ ${k + 1}`);
+
+function buildWideFrame(osr) {
+ return Function('x', 'cb', 'p', `
+ let result = 0;
+ for (let i = 0; i < 10; ++i) {
+ ${osr ? 'if (i === 5) %OptimizeOsr();' : ''}
+ if (x < 0) return cb(${args});
+ result = (x + i) | 0;
+ }
+ return result;
+ `);
+}
+
+const wide = buildWideFrame(false);
+%PrepareFunctionForOptimization(wide);
+assertEquals(kArgCount, wide(-1, cb, 1));
+assertEquals(10, wide(1, cb, 1));
+%OptimizeFunctionOnNextCall(wide);
+assertEquals(10, wide(1, cb, 1));
+
+const wideOsr = buildWideFrame(true);
+%PrepareFunctionForOptimization(wideOsr);
+assertEquals(kArgCount, wideOsr(-1, cb, 1));
+assertEquals(10, wideOsr(1, cb, 1));