High chrome Memory Corruption 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in V8
DescriptionImproper input validation in V8
ComponentV8
Sub-componentTurboshaft
Bug ClassMemory Corruption
Tracker574660146
Fix commitea2f03dfff2d (v8/v8) +58/-0
CISA KEVNot listed
CreditedHyeonjun Ahn (@_deayzl)
Disclosed2026-10-29

Background

Turboshaft
V8’s modern optimizing-compiler backend (successor to the older TurboFan backend pipeline). src/compiler/turboshaft/pipelines.cc drives 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 RangeError on 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 / kSystemPointerSize is 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.

Key insight
The optimized-function prologue reserved the entire frame with a single stack-pointer decrement before the entry stack check ran, so a frame larger than one OS page could step over the guard page undetected; the real fix is to refuse to compile such a frame at all.

Attack Path

  1. 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).
  2. 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.
  3. Trigger prologue Invoke the optimized function (or OSR into it). AssembleConstructFrame emits one SP decrement of the full oversized frame.
  4. Jump the guard page That single decrement moves SP past the OS guard page into adjacent memory without faulting; the kJSFunctionEntry check that would have caught overflow runs too late.
  5. 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

The bug is in the V8 JIT running in the renderer process. Privilege required is only the ability to execute JavaScript on a page, so it is fully web-reachable with no special permissions. The achieved primitive is a stack-pointer jump that skips the OS guard page, giving out-of-bounds writes into memory adjacent to the thread stack during frame setup — a stack-corruption primitive that is a realistic foundation for renderer RCE (the exact exploit chain beyond corruption is not in the diff and is therefore inferred). Gating: the attacker must construct a function whose optimized frame exceeds 4 KB of stack slots yet is still eligible for optimization, and get it compiled by Turboshaft specifically (the sibling Maglev path was already fixed). This is memory corruption, not a crash/DoS-only issue.

Changed Functions

FunctionChangeNotes
for
test/mjsunit/turboshaft/regress-574660146.js
modified

Files Changed

  • src/compiler/turboshaft/pipelines.cc
  • test/mjsunit/turboshaft/regress-574660146.js
  • src/maglev/maglev-compiler.cc

Audit Directions

  • Other prologue/frame-growth sites
    Review 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 parity
    Maglev 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` assumptions
    The 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 completeness
    Verify ClearRegisterComponent() + return false cleanly deopts to a lower tier in all callers and cannot itself leave the pipeline in a partially-allocated state.
From ea2f03dfff2dbcf5a6ff4860790326d5d2b66356 Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
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 <jkummerow@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Jakob Kummerow <jkummerow@chromium.org>
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));
Loading diff…

Regression Test / PoC

shipped with the fix
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));
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.