Medium CVSS 4.3 webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected Safari crash
ComponentJSC DFG
Bug ClassType Confusion
Tracker318348
Fix commit26aa84fcd527 (WebKit/WebKit) +173/-0
CWECWE-119, CWE-416 (Buffer bounds error, Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
CISA KEVNot listed
CreditedOpenAI Codex Security - Amy Burnett
Disclosed2026-08-17

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.

Key insight
Arguments-elimination used liveAtTail, which omits values kept alive only by an exceptional exit to a catch entrypoint (a non-CFG successor), so it could clobber the recoveries of an arguments object still live via catch; unioning in terminal bytecode/OSR liveness closes the gap.

Attack Path

  1. 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.
  2. 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.
  3. 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.
  4. Exit into the catch Trigger the throw so OSR exits into the catch handler and rematerializes the phantom arguments from clobbered slots.
  5. Consume wrong values The recovered arguments hold stale/garbage values, a type-confusion-flavored miscompile in the WebContent process (advisory: crash).

Impact Assessment

An FTL/DFG miscompilation in the WebContent process where a phantom arguments object can be rematerialized from clobbered stack slots on an exceptional OSR exit, producing wrong/garbage JSValues. The advisory rates it a crash, but recovering attacker-influenced garbage as arguments values is a type-confusion-class condition that JIT OSR bugs have historically escalated into stronger read/write primitives.

Changed Functions

FunctionChangeNotes
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.
bytecodeLivenessAtTerminal
Source/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.
forAllKillsInBlock
Source/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.js
  • JSTests/stress/arguments-elimination-load-varargs-kills-promoted-recoveries.js
  • Source/JavaScriptCore/dfg/DFGArgumentsEliminationPhase.cpp
  • Source/JavaScriptCore/dfg/DFGCombinedLiveness.cpp
  • Source/JavaScriptCore/dfg/DFGCombinedLiveness.h
  • Source/JavaScriptCore/dfg/DFGForAllKills.h
  • Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp

Audit Directions

  • Same phase: other liveAtTail uses
    In 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 writes
    Grep DFGOSRAvailabilityAnalysisPhase for nodes that overwrite local availability (LoadVarargs/ForwardVarargs, PutStack) without a preceding killHeaps for the affected slots.
  • Non-CFG successor liveness
    Review liveness/availability consumers across DFG SSA for assumptions that liveAtTail captures all OSR-live values, especially around try/catch and exceptional edges.
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) {
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.