CVE-2026-78910
Overview
Files Changed
src/builtins/wasm.tqsrc/compiler/backend/arm/instruction-selector-arm.ccsrc/compiler/backend/arm64/instruction-selector-arm64.ccsrc/compiler/backend/code-generator.ccsrc/compiler/backend/ia32/instruction-selector-ia32.ccsrc/compiler/backend/loong64/instruction-selector-loong64.ccsrc/compiler/backend/mips64/instruction-selector-mips64.ccsrc/compiler/backend/ppc/instruction-selector-ppc.cc
Patch
From 35882d958a9c2b38d5034b439328dd1f1a12fdf5 Mon Sep 17 00:00:00 2001
From: Thibaud Michaud <thibaudm@chromium.org>
Date: Wed, 22 Jul 2026 11:24:57 +0200
Subject: [PATCH] [wasm] Fix stack checks for Liftoff deopts
This CL updates Wasm stack check handling to correctly reserve stack
space for potential Liftoff deoptimization output frames:
1. Seed max_unoptimized_frame_height during Turboshaft code generator
initialization (InitializeCodeGenerator) by scanning FrameStateOp
nodes for kLiftoffFunction frame states.
2. Update ShouldApplyOffsetToStackCheck in CodeGenerator to support
StackCheckKind::kWasm.
3. Update backend instruction selectors across architectures (ia32,
arm, arm64, loong64, mips64, ppc, riscv, s390) to allocate temporary
registers for StackCheckKind::kWasm stack check offsets.
4. Guard leaf function stack check elimination in
StackCheckLoweringReducer to ensure functions with
kLiftoffFunction frame states retain entry stack checks.
5. Pass calculated stack check gap to Builtin::kWasmStackGuard and
Builtin::kWasmGrowableStackGuard in Turboshaft reducers.
6. Update Torque WasmStackGuard and WasmGrowableStackGuard builtin
signatures to accept gap and pass it to runtime/overflow handlers.
7. Explicitly pass a 0 gap constant for out-of-line calls in Liftoff
(liftoff-compiler.cc).
8. Add safety CHECK_GT stack limit assertions in
Deoptimizer::DoComputeOutputFramesWasmImpl.
TAG=agy
Bug: 511260796
Change-Id: I5549efac7807dbbff890c79c83c6e8d420237085
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8128082
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
Commit-Queue: Thibaud Michaud <thibaudm@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108814}
---
diff --git a/src/builtins/wasm.tq b/src/builtins/wasm.tq
index f646434..aef4354 100644
--- a/src/builtins/wasm.tq
+++ b/src/builtins/wasm.tq
@@ -474,13 +474,14 @@
// {paramSlotsSize} is the size of the incoming stack parameters of the
// currently-executing function, which have to be copied along with its
// stack frame if the stack needs to be grown.
-builtin WasmGrowableStackGuard(paramSlotsSize: intptr): JSAny {
+builtin WasmGrowableStackGuard(paramSlotsSize: intptr, gap: intptr): JSAny {
tail WasmHandleStackOverflow(
- LoadParentFramePointer() + paramSlotsSize + kFixedFrameSizeAboveFp, 0);
+ LoadParentFramePointer() + paramSlotsSize + kFixedFrameSizeAboveFp,
+ Unsigned(Convert<int32>(gap)));
}
-builtin WasmStackGuard(): JSAny {
- tail runtime::WasmStackGuard(LoadContextFromFrame(), SmiConstant(0));
+builtin WasmStackGuard(gap: intptr): JSAny {
+ tail runtime::WasmStackGuard(LoadContextFromFrame(), Convert<Smi>(gap));
}
builtin WasmStackGuardLoop(): JSAny {
diff --git a/src/compiler/backend/arm/instruction-selector-arm.cc b/src/compiler/backend/arm/instruction-selector-arm.cc
index 3e56d63..492552c 100644
--- a/src/compiler/backend/arm/instruction-selector-arm.cc
+++ b/src/compiler/backend/arm/instruction-selector-arm.cc
@@ -1229,10 +1229,11 @@
// are only applied to the first stack check. If applying an offset, we must
// ensure the input and temp registers do not alias, thus kUniqueRegister.
InstructionOperand temps[] = {g.TempRegister()};
- const int temp_count = (kind == StackCheckKind::kJSFunctionEntry) ? 1 : 0;
- const auto register_mode = (kind == StackCheckKind::kJSFunctionEntry)
- ? OperandGenerator::kUniqueRegister
- : OperandGenerator::kRegister;
+ const bool has_offset =
+ kind == StackCheckKind::kJSFunctionEntry || kind == StackCheckKind::kWasm;
+ const int temp_count = has_offset ? 1 : 0;
+ const auto register_mode = has_offset ? OperandGenerator::kUniqueRegister
+ : OperandGenerator::kRegister;
InstructionOperand inputs[] = {g.UseRegisterWithMode(value, register_mode)};
static constexpr int input_count = arraysize(inputs);
diff --git a/src/compiler/backend/arm64/instruction-selector-arm64.cc b/src/compiler/backend/arm64/instruction-selector-arm64.cc
index 9cf8cbe..376450b 100644
--- a/src/compiler/backend/arm64/instruction-selector-arm64.cc
+++ b/src/compiler/backend/arm64/instruction-selector-arm64.cc
@@ -2407,10 +2407,11 @@
// are only applied to the first stack check. If applying an offset, we must
// ensure the input and temp registers do not alias, thus kUniqueRegister.
InstructionOperand temps[] = {g.TempRegister()};
- const int temp_count = (kind == StackCheckKind::kJSFunctionEntry) ? 1 : 0;
- const auto register_mode = (kind == StackCheckKind::kJSFunctionEntry)
- ? OperandGenerator::kUniqueRegister
- : OperandGenerator::kRegister;
+ const bool has_offset =
+ kind == StackCheckKind::kJSFunctionEntry || kind == StackCheckKind::kWasm;
+ const int temp_count = has_offset ? 1 : 0;
+ const auto register_mode = has_offset ? OperandGenerator::kUniqueRegister
+ : OperandGenerator::kRegister;
InstructionOperand inputs[] = {g.UseRegisterWithMode(value, register_mode)};
static constexpr int input_count = arraysize(inputs);
diff --git a/src/compiler/backend/code-generator.cc b/src/compiler/backend/code-generator.cc
index b9a861f..237cc91 100644
--- a/src/compiler/backend/code-generator.cc
+++ b/src/compiler/backend/code-generator.cc
@@ -132,7 +132,10 @@
StackCheckKind kind =
static_cast<StackCheckKind>(StackCheckField::decode(instr->opcode()));
- if (kind != StackCheckKind::kJSFunctionEntry) return false;
+ if (kind != StackCheckKind::kJSFunctionEntry &&
+ kind != StackCheckKind::kWasm) {
+ return false;
+ }
uint32_t stack_check_offset = *offset = GetStackCheckOffset();
return stack_check_offset > kStackLimitSlackForDeoptimizationInBytes;
@@ -156,7 +159,7 @@
static_cast<int32_t>(max_unoptimized_frame_height_);
// The offset is either the delta between the optimized frames and the
- // interpreted frame, or the maximal number of bytes pushed to the stack
+ // unoptimized frame, or the maximal number of bytes pushed to the stack
// while preparing for function calls, whichever is bigger.
uint32_t frame_height_delta = static_cast<uint32_t>(std::max(
signed_max_unoptimized_frame_height - optimized_frame_height, 0));
diff --git a/src/compiler/backend/ia32/instruction-selector-ia32.cc b/src/compiler/backend/ia32/instruction-selector-ia32.cc
index 8187c3c..4d376de 100644
--- a/src/compiler/backend/ia32/instruction-selector-ia32.cc
+++ b/src/compiler/backend/ia32/instruction-selector-ia32.cc
@@ -1278,11 +1278,11 @@
// are only applied to the first stack check. If applying an offset, we must
// ensure the input and temp registers do not alias, thus kUniqueRegister.
InstructionOperand temps[] = {g.TempRegister()};
- const int temp_count =
- (op.kind == StackCheckKind::kJSFunctionEntry) ? 1 : 0;
- const auto register_mode = (op.kind == StackCheckKind::kJSFunctionEntry)
- ? OperandGenerator::kUniqueRegister
- : OperandGenerator::kRegister;
+ const bool has_offset = op.kind == StackCheckKind::kJSFunctionEntry ||
+ op.kind == StackCheckKind::kWasm;
+ const int temp_count = has_offset ? 1 : 0;
+ const auto register_mode = has_offset ? OperandGenerator::kUniqueRegister
+ : OperandGenerator::kRegister;
OpIndex value = op.stack_limit();
if (g.CanBeMemoryOperand(kIA32Cmp, node, value, effect_level)) {
diff --git a/src/compiler/backend/loong64/instruction-selector-loong64.cc b/src/compiler/backend/loong64/instruction-selector-loong64.cc
index 2a15a8f..b9b0b7b 100644
--- a/src/compiler/backend/loong64/instruction-selector-loong64.cc
+++ b/src/compiler/backend/loong64/instruction-selector-loong64.cc
@@ -2687,10 +2687,11 @@
// are only applied to the first stack check. If applying an offset, we must
// ensure the input and temp registers do not alias, thus kUniqueRegister.
InstructionOperand temps[] = {g.TempRegister(), g.TempRegister()};
- const int temp_count = (kind == StackCheckKind::kJSFunctionEntry ? 2 : 1);
- const auto register_mode = (kind == StackCheckKind::kJSFunctionEntry)
- ? OperandGenerator::kUniqueRegister
- : OperandGenerator::kRegister;
+ const bool has_offset =
+ kind == StackCheckKind::kJSFunctionEntry || kind == StackCheckKind::kWasm;
+ const int temp_count = (has_offset ? 2 : 1);
+ const auto register_mode = has_offset ? OperandGenerator::kUniqueRegister
+ : OperandGenerator::kRegister;
InstructionOperand inputs[3];
int input_count = 0;
diff --git a/src/compiler/backend/mips64/instruction-selector-mips64.cc b/src/compiler/backend/mips64/instruction-selector-mips64.cc
index dd08197..062c5d3 100644
--- a/src/compiler/backend/mips64/instruction-selector-mips64.cc
+++ b/src/compiler/backend/mips64/instruction-selector-mips64.cc
@@ -1992,10 +1992,11 @@
// are only applied to the first stack check. If applying an offset, we must
// ensure the input and temp registers do not alias, thus kUniqueRegister.
InstructionOperand temps[] = {g.TempRegister(), g.TempRegister()};
- const int temp_count = (kind == StackCheckKind::kJSFunctionEntry ? 2 : 1);
- const auto register_mode = (kind == StackCheckKind::kJSFunctionEntry)
- ? OperandGenerator::kUniqueRegister
- : OperandGenerator::kRegister;
+ const bool has_offset =
+ kind == StackCheckKind::kJSFunctionEntry || kind == StackCheckKind::kWasm;
+ const int temp_count = (has_offset ? 2 : 1);
+ const auto register_mode = has_offset ? OperandGenerator::kUniqueRegister
+ : OperandGenerator::kRegister;
InstructionOperand inputs[] = {g.UseRegisterWithMode(value, register_mode)};
static constexpr int input_count = arraysize(inputs);
diff --git a/src/compiler/backend/ppc/instruction-selector-ppc.cc b/src/compiler/backend/ppc/instruction-selector-ppc.cc
index bfc5eea..20417bb 100644
--- a/src/compiler/backend/ppc/instruction-selector-ppc.cc
+++ b/src/compiler/backend/ppc/instruction-selector-ppc.cc
@@ -762,10 +762,11 @@
// are only applied to the first stack check. If applying an offset, we must
// ensure the input and temp registers do not alias, thus kUniqueRegister.
InstructionOperand temps[] = {g.TempRegister()};
- const int temp_count = (kind == StackCheckKind::kJSFunctionEntry) ? 1 : 0;
- const auto register_mode = (kind == StackCheckKind::kJSFunctionEntry)
- ? OperandGenerator::kUniqueRegister
- : OperandGenerator::kRegister;
+ const bool has_offset =
Regression Test / PoC
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index 14d618f..006b02c 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -2606,6 +2606,7 @@
'wasm/half-dup-shuffles': [SKIP],
'wasm/redundant-shuffle-lanes': [SKIP],
'wasm/simd-*': [SKIP],
+ 'wasm/deopt/deopt-large-frames-jspi': [SKIP],
'wasm/turboshaft/array-new-unreachable': [SKIP],
'wasm/turboshaft/reduction-shuffle': [SKIP],
'regress/wasm/regress-9447': [SKIP],
diff --git a/test/mjsunit/wasm/deopt/deopt-large-frames-jspi.js b/test/mjsunit/wasm/deopt/deopt-large-frames-jspi.js
new file mode 100644
index 0000000..7265aa5
--- /dev/null
+++ b/test/mjsunit/wasm/deopt/deopt-large-frames-jspi.js
@@ -0,0 +1,151 @@
+// 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: --wasm-deopt --allow-natives-syntax --liftoff
+// Flags: --wasm-inlining --wasm-inlining-ignore-call-counts --no-jit-fuzzing
+// Flags: --wasm-stack-switching-stack-size=32
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+// Triggers a wasm deopt close to the stack limit on a JSPI secondary stack.
+// The optimized function speculatively inlines a chain of functions that each
+// declare many v128 locals, so the materialised Liftoff output frames are much
+// larger than the optimized frame. The function-entry stack check has to
+// account for this so that the output frames still fit on the stack.
+(function TestDeoptLargeOutputFramesNearStackLimit() {
+ print(arguments.callee.name);
+ const kLocalCount = 480;
+ const kChainLength = 8;
+
+ let builder = new WasmModuleBuilder();
+ let funcRefT = builder.addType(kSig_i_v);
+ let table = builder.addTable(wasmRefNullType(funcRefT), kChainLength + 2);
+
+ // The chain f0..f7: each function declares many v128 locals (so the Liftoff
+ // frame is large) and performs a call_ref through the table to the next
+ // function in the chain. With monomorphic feedback this whole chain gets
+ // speculatively inlined into f0.
+ let chain = [];
+ for (let i = 0; i < kChainLength; ++i) {
+ chain.push(
+ builder.addFunction("f" + i, funcRefT)
+ .addLocals(kWasmS128, kLocalCount)
+ .addBody([
+ kExprI32Const, i + 1,
+ kExprTableGet, table.index,
+ kExprCallRef, funcRefT,
+ ]));
+ }
+ chain[0].exportFunc();
+ let leafA = builder.addFunction("leafA", funcRefT)
+ .addBody([kExprI32Const, 1]);
+ let leafB = builder.addFunction("leafB", funcRefT)
+ .addBody([kExprI32Const, 2]);
+
+ // Initially every table slot points at the next function in the chain and
+ // the slot after the last chain function points at {leafA}.
+ let initialTargets = [];
+ for (let i = 0; i < kChainLength; ++i) {
+ initialTargets.push([kExprRefFunc, chain[i].index]);
+ }
+ initialTargets.push([kExprRefFunc, leafA.index]);
+ initialTargets.push([kExprRefFunc, leafB.index]);
+ builder.addActiveElementSegment(table.index, wasmI32Const(0), initialTargets,
+ wasmRefNullType(funcRefT));
+
+ // The chain entry is called through a separate table so that it does not get
+ // inlined into {recur}.
+ let mainTable = builder.addTable(wasmRefNullType(funcRefT), 1);
+ builder.addActiveElementSegment(mainTable.index, wasmI32Const(0),
+ [[kExprRefFunc, chain[0].index]],
+ wasmRefNullType(funcRefT));
+
+ // Recurse {depth} times with small frames, then optionally call the chain.
+ let recur = builder.addFunction("recur", kSig_i_ii);
+ recur.addBody([
+ kExprLocalGet, 0,
+ kExprIf, kWasmI32,
+ kExprLocalGet, 0, kExprI32Const, 1, kExprI32Sub,
+ kExprLocalGet, 1,
+ kExprCallFunction, recur.index,
+ kExprElse,
+ kExprLocalGet, 1,
+ kExprIf, kWasmI32,
+ kExprI32Const, 0,
+ kExprCallIndirect, funcRefT, mainTable.index,
+ kExprElse,
+ kExprI32Const, 0,
+ kExprEnd,
+ kExprEnd,
+ ]).exportFunc();
+
+ builder.addFunction("setLeaf", kSig_v_i)
+ .addBody([
+ kExprI32Const, kChainLength - 1,
+ kExprLocalGet, 0,
+ kExprTableGet, table.index,
+ kExprTableSet, table.index,
+ ]).exportFunc();
+
+ let wasm = builder.instantiate().exports;
+
+ // Collect monomorphic feedback for every call_ref in the chain.
+ assertEquals(1, wasm.f0());
+ assertEquals(1, wasm.f0());
+
+ %WasmTierUpFunction(wasm.f0);
+ if (%IsWasmTieringPredictable()) {
+ assertTrue(%IsTurboFanFunction(wasm.f0));
+ }
+ assertEquals(1, wasm.f0());
+
+ let promisingRecur = WebAssembly.promising(wasm.recur);
+
+ // Find the largest recursion depth at which {recur} still fits on the JSPI
+ // stack without calling into the chain.
+ let lo = 0, hi = 64;
+ function probe(depth) {
+ return promisingRecur(depth, 0).then(_ => true, e => {
+ assertInstanceof(e, RangeError);
+ return false;
+ });
+ }
+ let chainPromise = probe(hi);
+ function expandHi(ok) {
+ if (!ok) return;
+ lo = hi; hi *= 2;
+ return probe(hi).then(expandHi);
+ }
+ function bisect() {
+ if (hi - lo <= 1) return;
+ let mid = (lo + hi) >> 1;
+ return probe(mid).then(ok => {
+ if (ok) lo = mid; else hi = mid;
+ return bisect();
+ });
+ }
+ chainPromise = chainPromise.then(expandHi).then(bisect).then(() => {
+ // Redirect the deepest speculatively-inlined call_ref to a different
+ // target so the next call deopts.
+ wasm.setLeaf(kChainLength + 1);
+ if (%IsWasmTieringPredictable()) {
+ assertTrue(%IsTurboFanFunction(wasm.f0));
+ }
+ // Walk from the stack limit upwards and call the optimized chain at each
+ // depth. As long as the stack does not have enough room for the deopt
+ // output frames the call must throw a RangeError; once enough room is
+ // available the deopt happens normally and the call returns.
+ let depth = lo;
+ function step() {
+ if (depth < 0) return;
+ let d = depth;
+ depth -= 16;
+ return promisingRecur(d, 1).then(
+ v => { assertEquals(2, v); },
+ e => { assertInstanceof(e, RangeError); return step(); });
+ }
+ return step();
+ });
+ assertPromiseResult(chainPromise);
+})();
Original Bug Report
OOB write via Wasm deoptimization stack limit bypass on ARM64
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The V8 Wasm deoptimization path potentially lacks necessary compile-time and runtime stack limit checks for materialized Liftoff frames. On ARM64 platforms, this allows the stack pointer to skip over a JSPI stack guard page during large frame allocations. Subsequent frame copying writes attacker-controlled data into adjacent memory before crashing.
Affected files:
v8/src/deoptimizer/deoptimizer.ccv8/src/compiler/backend/code-generator.ccv8/src/builtins/arm64/builtins-arm64.cc
Estimated timestamp from git blame: 2024-05-27
Description
There is a potential out-of-bounds (OOB) write vulnerability in V8’s WebAssembly deoptimization implementation. When a Wasm function deoptimizes, the engine materializes unoptimized (Liftoff) frames on the stack. The current logic fails to enforce stack limits during this process, allowing the stack pointer to bypass guard pages on ARM64 platforms (where page probing is not default for stack allocation).
This issue involves two missing safety checks in the V8 codebase:
- Compile-time Slack Offset Bypass:
In
v8/src/compiler/backend/code-generator.cc,CodeGenerator::ShouldApplyOffsetToStackCheckdetermines if extra slack should be added to stack checks to account for potential deoptimization frames. However, the logic explicitly returnsfalseif the kind is notkJSFunctionEntry:
if (kind != StackCheckKind::kJSFunctionEntry) return false;
Because Wasm uses StackCheckKind::kWasm, the Turbofan compiler does not reserve the necessary kStackLimitSlackForDeoptimizationInBytes for Wasm functions.
- Runtime Stack Limit Bypass:
In
v8/src/deoptimizer/deoptimizer.cc, the Wasm-specific frame computation functionDeoptimizer::DoComputeOutputFramesWasmImplcalculates the required space for output frames. Unlike the JavaScript equivalent (DoComputeOutputFrames), which strictly enforces limits withCHECK_GT(..., stack_guard->real_jslimit() - ...), the Wasm implementation completely omits this check. It blindly computes the frames assuming infinite stack space.
The ARM64 Write Primitive:
On ARM64, the deoptimization trampoline (Generate_DeoptimizationEntry in v8/src/builtins/arm64/builtins-arm64.cc) allocates space for output frames using the Claim macro. On non-Windows platforms, Claim merely performs a bulk subtraction on the stack pointer (sp).
If an attacker crafts a Wasm function with enough locals such that its Liftoff frame exceeds the OS page size (e.g., >4KB), the Claim operation moves sp directly across the stack’s guard page and into the adjacent memory page below it.
The trampoline then calls CopyDoubleWords to populate the frame with the attacker’s local variables. This performs an upward copy starting from the out-of-bounds sp. The attacker’s data is written into the adjacent page until the copy loop hits the bottom of the guard page, which finally triggers a segmentation fault.
Potential Steps to Trigger
(Note: These are suggested steps based on static analysis. Our tooling agent does not currently have the ability to run code to produce a working proof-of-concept).
- Enable JSPI: The attacker uses JavaScript Promise Integration (JSPI) (currently available via Origin Trial) to execute Wasm on a secondary stack (
StackSegment). These stacks are bounded by a guard page. - Groom Memory: The attacker grooms the renderer heap/memory to place a sensitive data structure immediately below a target JSPI stack allocation.
- Craft Wasm Payload: The attacker creates a Wasm function with a large number of local variables to ensure its unoptimized Liftoff frame is larger than the 4KB guard page.
- Force Deep Execution: The attacker executes the optimized Wasm function recursively or deep within a call chain to push the stack pointer very close to the stack limit.
- Trigger Deopt: The attacker provides input that fails a speculative check in the optimized code, triggering
wasm_deopt. - Race Condition: As the deoptimizer writes attacker-controlled locals into the page below the stack guard, a concurrent Web Worker thread quickly reads or utilizes the corrupted memory before the deoptimizing thread inevitably crashes on the guard page.
Suggested Fix
- Enforce Runtime Stack Checks: Add a
CHECK_GT(or appropriate fatal bailout) inDeoptimizer::DoComputeOutputFramesWasmImplto ensure that the newly computedcaller_frame_top_ - total_output_frame_sizedoes not exceed the valid stack limits (stack_guard->real_jslimit()). - Correct Compile-Time Slack: Update
CodeGenerator::ShouldApplyOffsetToStackCheckto properly handleStackCheckKind::kWasmwhen Wasm deoptimization is enabled, ensuring stack limits are correctly respected during normal execution. - Defense in Depth (ARM64): Consider implementing page probing for the
MacroAssembler::Claimbulk stack allocations on ARM64 Linux/Android (similar to the Windows implementation), which would safely trap on the guard page beforespreaches out-of-bounds memory.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.