CVE-2026-91745
Overview
Background
- TurboFan
- V8’s optimizing JIT compiler, which builds and mutates a sea-of-nodes IR graph where each node’s
UseCount()tracks how many other nodes reference it. - `FrameState` / `StateValues`
- IR nodes that capture the interpreter/deopt state;
StateValuesholds the live value inputs, and when a node exceeds 8 inputs it is split into a tree of nestedStateValueschildren. - `JSInliningHeuristic::TryReuseDispatch`
- the pass that specializes and duplicates frame states across the branches of an inlined polymorphic (megamorphic) call site, so each callee branch has its own renamed state.
- `DuplicateStateValuesAndRename`
- the recursive helper that walks a
StateValuestree, either mutating in place (kChangeInPlace) or copy-on-writing viaCloneNode, while renaming a calleePhireference to the branch-specific value.
Root Cause Analysis
DuplicateStateValuesAndRename bailed out early with if (state_values->UseCount() > 1) return state_values;, treating any shared node as immutable and returning it unrenamed. For a large StateValues node (>8 inputs) that has been split into a tree, renaming an earlier child subtree causes the parent to be cloned via CloneNode, and cloning bumps the UseCount() of every sibling child subtree from 1 to 2 because the clone now also references them. When the recursion then descended into a sibling subtree, it observed UseCount() > 1 and returned immediately without renaming the callee Phi inside it. The cloned FrameState therefore retained a reference to the original callee Phi in those siblings instead of the branch-specific replacement. When that callee Phi was subsequently killed/replaced during inlining, the cloned FrameState was left pointing at a dead IR node — a dangling reference.
The fix drops the early bailout and instead computes Node* copy = (mode == kChangeInPlace && state_values->UseCount() <= 1) ? state_values : nullptr;, so a shared node is still traversed and renamed via copy-on-write rather than skipped.
UseCount() > 1 bailout conflated “shared, so don’t mutate in place” with “already handled, so skip entirely” — but a sibling’s UseCount became >1 as an artifact of cloning its own parent, meaning nodes that still needed renaming were silently skipped.Attack Path
- Reach the heuristic
Craft JavaScript with a polymorphic call site whose callee is inlined so that
JSInliningHeuristic::TryReuseDispatchduplicates and specializes the frame states across branches. - Force a StateValues tree
Arrange enough live values (>8 inputs) at the call site that the
StateValuesnode splits into a tree of nested children. - Trigger the sibling skip
Ensure an earlier child subtree is renamed first, so
CloneNodeon the parent inflates siblingUseCount()to 2, causingDuplicateStateValuesAndRenameto bail on the sibling and leave its calleePhiunrenamed. - Kill the retained node
Let later inlining/reduction kill or replace that callee
Phi, leaving the clonedFrameStateholding a dangling reference to the freed IR node. - Exploit downstream Use the corrupted deopt state / dangling node to drive memory corruption during optimization or at deopt.
Impact Assessment
FrameState retains a reference to an IR node that is later killed, which surfaces at minimum as a graph verifier failure and, in a release build, as a dangling reference consumed during compilation or deoptimization. Because deopt frame state governs how the runtime reconstructs interpreter state, a corrupted or freed node reachable through it is a strong primitive for controlled memory corruption. Exploitation is reachable from untrusted JavaScript, making it a high-severity renderer-side V8 vulnerability.Files Changed
test/mjsunit/compiler/regress-558734727.jssrc/compiler/js-inlining-heuristic.cc
Audit Directions
- UseCount-based bailouts during graph mutationAudit any pass that skips a node when
UseCount() > 1; if the same pass clones parents viaCloneNode, sibling use counts change mid-traversal and “shared” can no longer be treated as “already handled.” - Recursive StateValues/FrameState rewritingReview other recursive
StateValueswalkers (renaming, duplication, specialization) for the >8-input tree-split edge case where nested children are shared and must be copy-on-written rather than short-circuited. - Dangling references from cloned deopt stateLook for spots where cloned
FrameStatenodes may retain references to nodes that are later killed or replaced, especiallyPhinodes rewritten during inlining, as these produce dangling-node UAFs that only manifest under verifier or at deopt.
Patch
From 1926a0d990ffa5db0c27cfa1130eba651e1a9ffb Mon Sep 17 00:00:00 2001 From: Leszek Swirski <[email protected]> Date: Wed, 09 Sep 2026 16:58:35 +0200 Subject: [PATCH] [turbofan] Fix UseCount bailout in DuplicateStateValuesAndRename In JSInliningHeuristic::TryReuseDispatch, when duplicating and specializing frame states for inlined polymorphic call branches, DuplicateStateValuesAndRename recursively traversed StateValues trees. However, it bailed out early if state_values->UseCount() > 1. When a StateValues node has more than 8 inputs, it is split into a tree of nested StateValues nodes. When renaming in an earlier child subtree, the parent StateValues is cloned via CloneNode, which immediately increments the UseCount of all sibling child subtrees from 1 to 2. Subsequent recursive calls encountered UseCount() > 1 and bailed out without renaming the callee Phi in the sibling subtrees. When the callee Phi was later killed, the cloned FrameState retained dangling references to the dead node, causing verifier check failures. This CL removes the premature UseCount() > 1 early return from DuplicateStateValuesAndRename and ensures that kChangeInPlace only mutates when UseCount() <= 1, falling back to cloning (copy-on-write) otherwise. Note that removing this early return does not cause unnecessary copying for StateValues subtrees that already had UseCount() > 1 initially (e.g. shared with other frame states): if such a subtree contained the callee, CollectStateValuesOwnedUses would have skipped it, causing subsequent validation in TryReuseDispatch to abort before cloning began. Therefore, any subtree with UseCount() > 1 initially is guaranteed not to contain the callee, so DuplicateStateValuesAndRename visits its inputs, finds no matches, and returns the original node without copying. Fixed: 558734727 TAG=agy CONV=cc6d3524-1bd6-4a59-892f-3bae5a2cc132 Change-Id: I09116bd7eed39def19a5d15f9919db2f302c13c4 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8378907 Auto-Submit: Leszek Swirski <[email protected]> Commit-Queue: Leszek Swirski <[email protected]> Reviewed-by: Patrick Thier <[email protected]> Commit-Queue: Patrick Thier <[email protected]> Cr-Commit-Position: refs/heads/main@{#109756} --- diff --git a/src/compiler/js-inlining-heuristic.cc b/src/compiler/js-inlining-heuristic.cc index fd43d7a..b6d9a3f 100644 --- a/src/compiler/js-inlining-heuristic.cc +++ b/src/compiler/js-inlining-heuristic.cc @@ -423,8 +423,9 @@ StateCloneMode mode) { // Only rename in states that are not shared with other users. This needs to // be in sync with the condition in {CollectStateValuesOwnedUses}. - if (state_values->UseCount() > 1) return state_values; - Node* copy = mode == kChangeInPlace ? state_values : nullptr; + Node* copy = (mode == kChangeInPlace && state_values->UseCount() <= 1) + ? state_values + : nullptr; for (int i = 0; i < state_values->InputCount(); i++) { Node* input = state_values->InputAt(i); Node* processed; diff --git a/test/mjsunit/compiler/regress-558734727.js b/test/mjsunit/compiler/regress-558734727.js new file mode 100644 index 0000000..4642ee5 --- /dev/null +++ b/test/mjsunit/compiler/regress-558734727.js @@ -0,0 +1,30 @@ +// 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: --turbofan --allow-natives-syntax + +function f1(a, b) { return a + b + 1; } +function f2(a, b) { return a + b + 2; } + +function test(cond) { + var f = cond ? f1 : f2; + var v0 = f; + var v1 = 1, v2 = 2, v3 = 3, v4 = 4, v5 = 5, v6 = 6, v7 = 7; + var v8 = (v1 ? f : f); + var v9 = 9, v10 = 10, v11 = 11, v12 = 12, v13 = 13, v14 = 14, v15 = 15; + var res = f(v1, v15); + var temp0 = v0; + temp0 = 0; + var temp8 = v8; + return res + v1 + v2 + v3 + v4 + v5 + v6 + v7 + v9 + v10 + v11 + v12 + v13 + v14 + v15; +} + +%PrepareFunctionForOptimization(f1); +%PrepareFunctionForOptimization(f2); +%PrepareFunctionForOptimization(test); +assertEquals(129, test(true)); +assertEquals(130, test(false)); +%OptimizeFunctionOnNextCall(test); +assertEquals(129, test(true)); +assertEquals(130, test(false));
Regression Test / PoC
diff --git a/test/mjsunit/compiler/regress-558734727.js b/test/mjsunit/compiler/regress-558734727.js
new file mode 100644
index 0000000..4642ee5
--- /dev/null
+++ b/test/mjsunit/compiler/regress-558734727.js
@@ -0,0 +1,30 @@
+// 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: --turbofan --allow-natives-syntax
+
+function f1(a, b) { return a + b + 1; }
+function f2(a, b) { return a + b + 2; }
+
+function test(cond) {
+ var f = cond ? f1 : f2;
+ var v0 = f;
+ var v1 = 1, v2 = 2, v3 = 3, v4 = 4, v5 = 5, v6 = 6, v7 = 7;
+ var v8 = (v1 ? f : f);
+ var v9 = 9, v10 = 10, v11 = 11, v12 = 12, v13 = 13, v14 = 14, v15 = 15;
+ var res = f(v1, v15);
+ var temp0 = v0;
+ temp0 = 0;
+ var temp8 = v8;
+ return res + v1 + v2 + v3 + v4 + v5 + v6 + v7 + v9 + v10 + v11 + v12 + v13 + v14 + v15;
+}
+
+%PrepareFunctionForOptimization(f1);
+%PrepareFunctionForOptimization(f2);
+%PrepareFunctionForOptimization(test);
+assertEquals(129, test(true));
+assertEquals(130, test(false));
+%OptimizeFunctionOnNextCall(test);
+assertEquals(129, test(true));
+assertEquals(130, test(false));