CVE-2026-65338
Overview
Background
- Arguments elimination
- A DFG optimization that avoids allocating a real arguments object, keeping instead the values (‘recoveries’) needed to rematerialize it if an OSR exit demands it.
- OSR exit liveness
- Values that are dead in the DFG CFG can still be live for OSR exit (including exceptional exits to catch handlers); the compiler must keep them recoverable.
- liveAtTail vs terminal liveness
- liveAtTail unions the CFG successors’ liveAtHead and misses values kept alive only by a non-CFG catch edge; terminal bytecode liveness captures those.
- Catch entrypoint (non-CFG successor)
- An exceptional exit target the DFG models outside the normal CFG edges, so CFG-based liveness can overlook values live only through it.
Root Cause Analysis
This fixes an OSR-exit liveness/availability bug in JavaScriptCore’s DFG arguments-elimination phase that could clobber the recovery data needed to rematerialize a phantom arguments object on an exceptional (catch) OSR exit. When the arguments-elimination phase sinks an arguments object, it must keep the values that would rematerialize it (‘recoveries’) live until the object’s last use; to find where a clobber of the source stack slots is safe it consults combinedLiveness. In the clobberStack path it iterated combinedLiveness.liveAtTail[block] and called removeViaKill for each live node. But liveAtTail is computed only as the union of the CFG successors’ liveAtHead. A candidate can be kept alive SOLELY by an exceptional exit to a catch entrypoint, which the DFG models as a NON-CFG successor; such a candidate is OSR-live at the block terminal yet absent from liveAtTail. Consequently a clobber of that candidate’s source slots inside the block went unnoticed, so the promoted recovery values could be overwritten while still needed. On an OSR exit into the catch handler, the phantom arguments would then be rematerialized from stale/clobbered stack slots, yielding wrong values (and a memory-safety/validity violation of OSR-exit liveness).
The fix adds bytecodeLivenessAtTerminal(graph, block) (the OSR/bytecode liveness at the terminal, derived from ssa->availabilityAtTail) and, in the clobberStack path, unions it with liveAtTail into possiblyLiveOut before running removeViaKill, so nodes live only via the exceptional catch edge are also protected. It also hardens DFGOSRAvailabilityAnalysisPhase: for LoadVarargs/ForwardVarargs it now calls killHeaps(data->count) and killHeaps(data->start + i) before reassigning availability for those locals, so stale heap availability for the varargs-written slots is dropped and a promoted recovery can no longer alias an overwritten slot. Supporting refactors factor bytecodeLivenessAtTerminal out of the CombinedLiveness constructor (declared in DFGCombinedLiveness.h) and delete the now-unused forAllKillsInBlock helper.
The restored invariant is that arguments-elimination treats values kept live only by an exceptional/catch OSR edge as live, so their recoveries are never clobbered. The regression tests build arguments via arg.apply, force a throw ((3881)(b)) whose catch handler is the only consumer of the arguments object, and verify the recovered arguments keep their expected values under FTL OSR-exit-liveness validation.
Attack Path
- Tier into the FTL/DFG Run JS that creates arguments objects via Function.prototype.apply and is hot enough to reach DFG/FTL arguments elimination.
- Keep arguments live only via catch Use the arguments object solely inside a catch handler reached by an exceptional exit, so it is OSR-live via a non-CFG (catch) successor but not in liveAtTail.
- Clobber the recovery slots A LoadVarargs in the same block overwrites the source stack slots; the phase fails to see the catch-only-live candidate and clobbers its recoveries.
- Exit into the catch Trigger the throw so OSR exits into the catch handler and rematerializes the phantom arguments from clobbered slots.
- Consume wrong values The recovered arguments hold stale/garbage values, a type-confusion-flavored miscompile in the WebContent process (advisory: crash).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ArgumentsEliminationPhase (clobberStack path)Source/JavaScriptCore/dfg/DFGArgumentsEliminationPhase.cpp |
modified | Builds possiblyLiveOut = bytecodeLivenessAtTerminal(block) unioned with combinedLiveness.liveAtTail[block] and runs removeViaKill over it, covering candidates kept live only by an exceptional catch (non-CFG) exit. |
bytecodeLivenessAtTerminalSource/JavaScriptCore/dfg/DFGCombinedLiveness.cpp |
added | New helper returning the bytecode/OSR liveness at a block's terminal from ssa->availabilityAtTail; also reused by the CombinedLiveness constructor for successorless blocks. |
LocalOSRAvailabilityCalculator::executeNode (LoadVarargs/ForwardVarargs)Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp |
modified | Calls killHeaps(data->count) and killHeaps(data->start + i) before overwriting local availability, dropping stale heap availability for the varargs-written slots. |
bytecodeLivenessAtTerminal (declaration)Source/JavaScriptCore/dfg/DFGCombinedLiveness.h |
modified | Declares the new terminal-liveness helper. |
forAllKillsInBlockSource/JavaScriptCore/dfg/DFGForAllKills.h |
deleted | Removes the now-unused per-block kill helper (and its verbose flag) superseded by the terminal-liveness handling. |
Files Changed
JSTests/stress/arguments-elimination-inlined-load-varargs-preserves-recoveries.jsJSTests/stress/arguments-elimination-load-varargs-kills-promoted-recoveries.jsSource/JavaScriptCore/dfg/DFGArgumentsEliminationPhase.cppSource/JavaScriptCore/dfg/DFGCombinedLiveness.cppSource/JavaScriptCore/dfg/DFGCombinedLiveness.hSource/JavaScriptCore/dfg/DFGForAllKills.hSource/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp
Audit Directions
- Same phase: other liveAtTail usesIn DFGArgumentsEliminationPhase.cpp and related sinking phases, audit every combinedLiveness.liveAtTail[block] use for missing coverage of catch-entrypoint (non-CFG successor) liveness.
- Availability kills on local writesGrep DFGOSRAvailabilityAnalysisPhase for nodes that overwrite local availability (LoadVarargs/ForwardVarargs, PutStack) without a preceding killHeaps for the affected slots.
- Non-CFG successor livenessReview liveness/availability consumers across DFG SSA for assumptions that liveAtTail captures all OSR-live values, especially around try/catch and exceptional edges.
Patch
diff --git a/JSTests/stress/arguments-elimination-inlined-load-varargs-preserves-recoveries.js b/JSTests/stress/arguments-elimination-inlined-load-varargs-preserves-recoveries.js
new file mode 100644
index 000000000000..6a9e21ffb290
--- /dev/null
+++ b/JSTests/stress/arguments-elimination-inlined-load-varargs-preserves-recoveries.js
@@ -0,0 +1,84 @@
+//@ runDefault("--thresholdForJITAfterWarmUp=10", "--thresholdForFTLOptimizeAfterWarmUp=1000", "--useConcurrentJIT=false", "--validateFTLOSRExitLiveness=true")
+
+"use strict";
+
+function shouldBe(actual, expected)
+{
+ if (actual !== expected)
+ throw new Error("bad value: " + actual + ", expected: " + expected);
+}
+
+function five(values1, values2)
+{
+ let result = null;
+ for (let i = 0; i < 5; ++i) {
+ function arg() { "use strict"; return arguments; }
+ const a = arg.apply(undefined, values1);
+ const b = arg.apply(undefined, values2);
+ try {
+ (3881)(b);
+ } catch (error) {
+ a.toString();
+ result = a;
+ }
+ }
+ return result;
+}
+
+function eight(values1, values2)
+{
+ let result = null;
+ for (let i = 0; i < 5; ++i) {
+ function arg() { "use strict"; return arguments; }
+ const a = arg.apply(undefined, values1);
+ const b = arg.apply(undefined, values2);
+ try {
+ (3881)(b);
+ } catch (error) {
+ a.toString();
+ result = a;
+ }
+ }
+ return result;
+}
+
+function filled(length, value)
+{
+ const result = [];
+ for (let i = 0; i < length; ++i)
+ result.push(value);
+ return result;
+}
+
+const fiveMarker = { marker: "five" };
+const eightMarker = { marker: "eight" };
+const seedArray = [{ marker: "seed" }, 1, 2, 3, 4, 5];
+
+const firstFive = filled(5, fiveMarker);
+const overwriteFive = filled(30, fiveMarker);
+overwriteFive[22] = 9;
+
+const firstEight = filled(8, eightMarker);
+const overwriteEight = filled(30, eightMarker);
+overwriteEight[20] = 9;
+
+for (let i = 0; i < testLoopCount; ++i) {
+ five(firstFive, overwriteFive);
+ eight(firstEight, overwriteEight);
+}
+
+const seedValues = filled(30, seedArray);
+seedValues[20] = 9;
+for (let i = 0; i < testLoopCount; ++i)
+ eight(firstEight, seedValues);
+
+const recoveredEight = eight(firstEight, seedValues);
+shouldBe(recoveredEight.length, firstEight.length);
+for (let i = 0; i < firstEight.length; ++i)
+ shouldBe(recoveredEight[i], eightMarker);
+
+const recoveredFive = five(firstFive, overwriteFive);
+shouldBe(recoveredFive.length, firstFive.length);
+for (let i = 0; i < firstFive.length; ++i)
+ shouldBe(recoveredFive[i], fiveMarker);
+shouldBe(recoveredFive[5], undefined);
diff --git a/JSTests/stress/arguments-elimination-load-varargs-kills-promoted-recoveries.js b/JSTests/stress/arguments-elimination-load-varargs-kills-promoted-recoveries.js
new file mode 100644
index 000000000000..aa818cb6d313
--- /dev/null
+++ b/JSTests/stress/arguments-elimination-load-varargs-kills-promoted-recoveries.js
@@ -0,0 +1,88 @@
+//@ runDefault("--thresholdForJITAfterWarmUp=10", "--thresholdForFTLOptimizeAfterWarmUp=1000", "--useConcurrentJIT=false")
+
+"use strict";
+
+function shouldBe(actual, expected)
+{
+ if (actual !== expected)
+ throw new Error("bad value: " + actual + ", expected: " + expected);
+}
+noInline(shouldBe);
+
+function five(values1, values2)
+{
+ let result = null;
+ for (let i = 0; i < 5; ++i) {
+ function arg() { "use strict"; return arguments; }
+ const a = arg.apply(undefined, values1);
+ const b = arg.apply(undefined, values2);
+ try {
+ (3881)(b);
+ } catch (error) {
+ a.toString();
+ result = a;
+ }
+ }
+ return result;
+}
+noInline(five);
+
+function eight(values1, values2)
+{
+ let result = null;
+ for (let i = 0; i < 5; ++i) {
+ function arg() { "use strict"; return arguments; }
+ const a = arg.apply(undefined, values1);
+ const b = arg.apply(undefined, values2);
+ try {
+ (3881)(b);
+ } catch (error) {
+ a.toString();
+ result = a;
+ }
+ }
+ return result;
+}
+noInline(eight);
+
+function filled(length, value)
+{
+ const result = [];
+ for (let i = 0; i < length; ++i)
+ result.push(value);
+ return result;
+}
+noInline(filled);
+
+const fiveMarker = { marker: "five" };
+const eightMarker = { marker: "eight" };
+const seedArray = [{ marker: "seed" }, 1, 2, 3, 4, 5];
+
+const firstFive = filled(5, fiveMarker);
+const overwriteFive = filled(30, fiveMarker);
+overwriteFive[22] = 9;
+
+const firstEight = filled(8, eightMarker);
+const overwriteEight = filled(30, eightMarker);
+overwriteEight[20] = 9;
+
+for (let i = 0; i < testLoopCount; ++i) {
+ five(firstFive, overwriteFive);
+ eight(firstEight, overwriteEight);
+}
+
+const seedValues = filled(30, seedArray);
+seedValues[20] = 9;
+for (let i = 0; i < testLoopCount; ++i)
+ eight(firstEight, seedValues);
+
+const recoveredEight = eight(firstEight, seedValues);
+shouldBe(recoveredEight.length, firstEight.length);
+for (let i = 0; i < firstEight.length; ++i)
+ shouldBe(recoveredEight[i], eightMarker);
+
+const recoveredFive = five(firstFive, overwriteFive);
+shouldBe(recoveredFive.length, firstFive.length);
+for (let i = 0; i < firstFive.length; ++i)
+ shouldBe(recoveredFive[i], fiveMarker);
+shouldBe(recoveredFive[5], undefined);
diff --git a/Source/JavaScriptCore/dfg/DFGArgumentsEliminationPhase.cpp b/Source/JavaScriptCore/dfg/DFGArgumentsEliminationPhase.cpp
index b90a0b9649cf..52567cea948e 100644
--- a/Source/JavaScriptCore/dfg/DFGArgumentsEliminationPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGArgumentsEliminationPhase.cpp
@@ -33,6 +33,7 @@
#include "DFGArgumentsUtilities.h"
#include <wtf/IndexMap.h>
#include "DFGClobberize.h"
+#include "DFGCombinedLiveness.h"
#include "DFGForAllKills.h"
#include "DFGGraph.h"
#include "DFGInsertionSet.h"
@@ -722,7 +723,16 @@ class ArgumentsEliminationPhase : public Phase {
}
if (clobberStack) {