Released July 29, 2025

CVE-2025-31278

CVSS 8.8 severity high CWE-119 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to memory corruption

Researchers: Yuhao Hu, Yan Kang, Chenggang Wu, and Xiaojie Wei

Key insight. OSR-exit availability tracked that a sunken object’s field was flushed to a stack slot but failed to invalidate that flush when a later PutStack/KillStack reused the same slot; the fix is to kill stale heap flushes on slot clobbering so materialization falls back to the true SSA value.

Patch Details

diff --git a/JSTests/stress/osr-availability-heap-materialization-clobbered.js b/JSTests/stress/osr-availability-heap-materialization-clobbered.js
new file mode 100644
index 000000000000..8a508252cd4b
--- /dev/null
+++ b/JSTests/stress/osr-availability-heap-materialization-clobbered.js
@@ -0,0 +1,23 @@
+//@ requireOptions("--validateFTLOSRExitLiveness=1")
+
+// testLoopCount wasn't on our software update branch when this test was added.
+globalThis.testLoopCount ??= 10000;
+
+function opt() {
+    for (let i = 0; i < 5; i++) {
+        function arg() {
+            return arguments;
+        }
+        const arg1 = arg();
+        const arg2 = arg.apply();
+        try {
+            (3881)(arg2);
+        } catch(e) {
+            arg1.toString()
+        }
+    }
+}
+for (let i = 0; i < testLoopCount; i++) {
+    opt();
+}
+gc();
diff --git a/Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp b/Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp
index 434172190359..7fbaf5a18792 100644
--- a/Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp
+++ b/Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2014, 2015 Apple Inc. All rights reserved.
+ * Copyright (C) 2014-2025 Apple Inc. All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions
@@ -35,16 +35,25 @@
 
 namespace JSC { namespace DFG {
 
-void AvailabilityMap::pruneHeap()
+AvailabilityMap AvailabilityMap::filterByLiveness(Graph& graph, CodeOrigin where)
 {
+    AvailabilityMap filtered;
+    filtered.m_locals = Operands<Availability>(OperandsLike, m_locals, Availability::unavailable());
+    graph.forAllLiveInBytecode(
+        where,
+        [&] (Operand reg) {
+            filtered.m_locals.operand(reg) = m_locals.operand(reg);
+        });
+
+    // filter the heap.
     if (m_heap.isEmpty())
-        return;
-    
+        return filtered;
+
     NodeSet possibleNodes;
-    
-    for (unsigned i = m_locals.size(); i--;) {
-        if (m_locals[i].hasNode())
-            possibleNodes.addVoid(m_locals[i].node());
+
+    for (unsigned i = filtered.m_locals.size(); i--;) {
+        if (filtered.m_locals[i].hasNode())
+            possibleNodes.addVoid(filtered.m_locals[i].node());
     }
 
     closeOverNodes(
@@ -54,25 +63,18 @@ void AvailabilityMap::pruneHeap()
         [&] (Node* node) -> bool {
             return possibleNodes.add(node).isNewEntry;
         });
-    
-    UncheckedKeyHashMap<PromotedHeapLocation, Availability> newHeap;
+
     for (auto pair : m_heap) {
         if (possibleNodes.contains(pair.key.base()))
-            newHeap.add(pair.key, pair.value);
+            filtered.m_heap.add(pair.key, pair.value);
     }
-    m_heap = WTFMove(newHeap);
+
+    return filtered;
 }
 
 void AvailabilityMap::pruneByLiveness(Graph& graph, CodeOrigin where)
 {
-    Operands<Availability> localsCopy(OperandsLike, m_locals, Availability::unavailable());
-    graph.forAllLiveInBytecode(
-        where,
-        [&] (Operand reg) {
-            localsCopy.operand(reg) = m_locals.operand(reg);
-        });
-    m_locals = WTFMove(localsCopy);
-    pruneHeap();
+    *this = filterByLiveness(graph, where);
 }
 
 void AvailabilityMap::clear()
@@ -97,6 +99,38 @@ void AvailabilityMap::merge(const AvailabilityMap& other)
     }
 }
 
+void AvailabilityMap::validateAvailability(Graph& graph, Node* where) const
+{
+    if (where->origin.exitOK) {
+        for (auto& heapPair : m_heap) {
+            if (heapPair.value.isFlushUseful()) {
+                FlushedAt heapFlushLocation = heapPair.value.flushedAt();
+                // We should always have an SSA value to use for materializations in the heap.
+                DFG_ASSERT(graph, where, heapPair.value.hasNode());
+
+                if (!heapFlushLocation.virtualRegister().isValid()) {
+                    // If we don't have a location on the stack yet then there's not much to validate.
+                    continue;
+                }
+
+                for (unsigned i = 0; i < m_locals.size(); ++i) {
+                    // Look for our flush in the locals it should be there and match our heap location.
+                    Availability localAvailability = m_locals[i];
+
+                    if (localAvailability.flushedAt().virtualRegister() == heapFlushLocation.virtualRegister()) {
+                        if (localAvailability.hasNode() && heapPair.value.node() != localAvailability.node())
+                            DFG_CRASH(graph, where, toCString("Materialization flushed availability ", heapPair.value, " and local flushed availability ", localAvailability, " disagree on which DFG node the same stack slot holds in ", *this).data());
+
+                        if (heapFlushLocation.format() != localAvailability.flushedAt().format())
+                            DFG_CRASH(graph, where, toCString("Materialization should be flushed (", heapPair.value, ") but corresponding local doesn't exist or match (", localAvailability, ") in ", *this).data());
+                        break;
+                    }
+                }
+            }
+        }
+    }
+}
+
 } } // namespace JSC::DFG
 
 #endif // ENABLE(DFG_JIT)
diff --git a/Source/JavaScriptCore/dfg/DFGAvailabilityMap.h b/Source/JavaScriptCore/dfg/DFGAvailabilityMap.h
index 5c674429b11e..8613d5889b4d 100644
--- a/Source/JavaScriptCore/dfg/DFGAvailabilityMap.h
+++ b/Source/JavaScriptCore/dfg/DFGAvailabilityMap.h
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2014, 2015 Apple Inc. All rights reserved.
+ * Copyright (C) 2014-2025 Apple Inc. All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions
@@ -33,7 +33,8 @@
 namespace JSC { namespace DFG {
 
 struct AvailabilityMap {
-    void pruneHeap();
+    AvailabilityMap() = default;
+
     void pruneByLiveness(Graph&, CodeOrigin);
     void clear();
     
@@ -78,6 +79,10 @@ struct AvailabilityMap {
         closeOverNodes(has, add);
     }
     
+    AvailabilityMap filterByLiveness(Graph&, CodeOrigin);
+
+    void validateAvailability(Graph& graph, Node* where) const;
+
     Operands<Availability> m_locals;
     UncheckedKeyHashMap<PromotedHeapLocation, Availability> m_heap;
 };
diff --git a/Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp b/Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp
index 0a76ca979260..6dc6f1ceda24 100644
--- a/Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2013-2021 Apple Inc. All rights reserved.
+ * Copyright (C) 2013-2025 Apple Inc. All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions
@@ -95,8 +95,13 @@ public:
 
                 calculator.m_availability = availabilityAtHead(block);
                 
-                for (unsigned nodeIndex = 0; nodeIndex < block->size(); ++nodeIndex)
-                    calculator.executeNode(block->at(nodeIndex));
+                for (unsigned nodeIndex = 0; nodeIndex < block->size(); ++nodeIndex) {
+                    Node* node = block->at(nodeIndex);
+                    calculator.executeNode(node);
+
+                    if (Options::validateFTLOSRExitLiveness()) [[unlikely]]
+                        calculator.m_availability.validateAvailability(m_graph, node);
+                }
                 

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2025-31278
Bugzilla 291742
Component JSC
Bug Class TypeConfusion
Severity medium
Commit 90aa8070e1b674f9…
Advisory Apple Advisory

Terminology

  • OSR exit — On-Stack Replacement deoptimization that bails out of optimized (FTL) code back to a lower tier, requiring the optimized state to be reconstructed into the values the baseline interpreter expects.
  • Object sinking / materialization — A DFG/FTL optimization that eliminates an allocation whose object doesn’t escape and instead recreates (‘materializes’) it lazily at the points (including OSR exits) where it is actually needed.
  • AvailabilityMap / Availability — Compiler bookkeeping mapping each bytecode local and each PromotedHeapLocation of a sunken object to how its value can be recovered at exit — from a DFG node and/or a FlushedAt stack location.
  • FlushedAt / flush — A record that a value has been spilled to a specific virtual register (stack slot) and its format; OSR exit may recover the value from that slot when the flush is ‘useful’.
  • PutStack / KillStack — DFG nodes that write a value to, or mark dead, a stack slot; PutStack in particular can reuse a virtual register that a still-referenced sunken-object field was previously flushed to.
  • validateFTLOSRExitLiveness — A JSC option enabling extra debug validation of OSR-exit availability/liveness; the new validateAvailability runs under it and the regression test requires it.

Root Cause Analysis

The patch fixes a stale-flush bug in DFG/FTL OSR-availability analysis that governs how sunken (materialized) objects are reconstructed on OSR exit. AvailabilityMap tracks, for each bytecode local and for each PromotedHeapLocation of a phantom/sunken object, an Availability that may carry a FlushedAt location — a virtual register (stack slot) where the value has been spilled. On OSR exit the FTL recovers a materialization’s field either from its SSA node or, if the field is ‘flush useful’, from that stack slot. The violated invariant is that a heap availability’s FlushedAt stack slot must still hold the expected DFG node’s value at the exit point. LocalOSRAvailabilityCalculator::executeNode processed GetStack/PutStack/KillStack by updating only the m_locals flush state; it never invalidated heap availabilities that pointed at a stack slot which a later PutStack or KillStack reused for a different value. Consequently a sunken object’s promoted-location Availability could keep a FlushedAt pointing at a virtual register whose contents had since been clobbered, so on OSR exit the materialization would read a wrong/attacker-influenced value out of that slot. The fix adds a killHeaps lambda invoked when executeNode sees a PutStack (for its target operand) or KillStack: it scans m_availability.m_heap and, for every heap entry whose FlushedAt virtualRegister equals the clobbered operand’s register, resets it to FlushedAt(ConflictingFlush), forcing the OSR exit to rematerialize from the SSA node instead of the stale slot. The heap/local pruning was also refactored from the mutating pruneHeap()+pruneByLiveness() into a non-mutating filterByLiveness() that first filters locals by bytecode liveness and then rebuilds the heap from nodes reachable from the surviving locals, and a debug-only validateAvailability() was added (gated on Options::validateFTLOSRExitLiveness) that cross-checks each ‘flush useful’ heap entry against the local occupying the same stack slot and DFG_CRASHes if they disagree on the node or flush format. New ASSERT(!isDead()) checks on inline-call-frame argumentCount/callee/argument availabilities, and an ASSERT(property.value().kind() != ExitValueDead) in the OSR exit stub compiler, assert the corrected invariant at materialization time. The added regression test osr-availability-heap-materialization-clobbered.js drives phantom arguments objects (arg()/arg.apply()) inside a loop with a throwing call so the sunken arguments materialization’s flushed slot is reused, reproducing the disagreement.

Attack Path

1. Trigger FTL tiering

Run a hot function repeatedly so JSC tiers it up to the FTL, enabling object sinking/allocation elimination and OSR-exit-based deoptimization.

2. Create sinkable phantom objects with flushed fields

Use constructs the DFG sinks — e.g. arguments objects from a nested function plus Function.prototype.apply, closures, and inlined call frames — so their fields become PromotedHeapLocations whose Availability is flushed to stack slots.

3. Force stack-slot reuse

Shape control flow (a loop with a throwing/invalid call such as (3881)(arg2) caught in try/catch) so a later PutStack/KillStack reuses the same virtual register that a live sunken-object field’s FlushedAt still references.

4. Provoke an OSR exit

Cause a speculation failure at that point so the FTL runs the OSR exit and materializes the sunken object, recovering the affected field from the now-clobbered stack slot instead of its true SSA value.

5. Obtain a type-confused / wrong-valued field

The materialized object receives an attacker-influenced or type-inconsistent value in one of its slots, yielding a JS-level object whose internal state violates its structure’s assumptions (inference: the concrete corruption depends on which slot and value are groomed into the reused stack slot).

6. Escalate

Standard JSC exploitation from a value/type confusion: build addrof/fakeobj, corrupt a butterfly/length or structure to gain arbitrary R/W, then achieve code execution — all within the WebContent process (background).

Impact Assessment

The primitive is a wrong-value / type-confusion at OSR-exit object materialization: a sunken object’s field is reconstructed from a clobbered stack slot, so an attacker who grooms which value occupies the reused slot can place an unexpected/type-inconsistent value into a JS object. In JSC such deopt-time confusions are routinely escalated to addrof/fakeobj and then to arbitrary read/write and code execution, so realistic escalation is toward RCE rather than a mere controlled crash. The bug is in JavaScriptCore and is confined to the WebContent process, subject to the WebKit sandbox; exploitation still requires a separate sandbox escape for full device compromise. Reachability is high (pure JS, no special API), matching the ‘maliciously crafted web content -> memory corruption’ description.

Audit Directions

Other executeNode cases that write stack slots

In DFGOSRAvailabilityAnalysisPhase.cpp audit every switch case that touches m_availability.m_locals.operand(…).setFlush or sets heap entries; confirm each slot-clobbering path now routes through killHeaps. Grep for setFlush, FlushedAt(ConflictingFlush), and KillStack/PutStack handling.

Heap availability vs local flush consistency

Run/extend validateAvailability coverage: search for places that build PromotedHeapLocation entries with flushedAt() set (ArgumentCountPLoc, ArgumentsCalleePLoc, ArgumentPLoc and any *PLoc) and verify a corresponding live local always backs the same VirtualRegister; look for isFlushUseful() call sites.

Other consumers of FlushedAt at OSR exit

In FTLLowerDFGToB3.cpp and FTLOSRExitCompiler.cpp grep for exitValueForAvailability, neededForMaterialization, and ExitValueDead to find any materialization/recovery path that trusts a flushed slot without checking the flush is still valid.

Availability liveness pruning across phases

Search the whole dfg/ftl tree for remaining callers of pruneByLiveness / former pruneHeap semantics and for AvailabilityMap copies (assignments/merges) that could reintroduce heap entries referencing dead-or-reused slots; verify they now use filterByLiveness.

Changed Functions

Function File Change Note
AvailabilityMap::filterByLiveness Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp added Non-mutating replacement for pruneByLiveness+pruneHeap: builds a fresh map, keeps only locals live in bytecode at ‘where’, then rebuilds the heap from PromotedHeapLocations whose base node is reachable from those live locals.
AvailabilityMap::pruneHeap Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp deleted Old in-place heap pruner folded into filterByLiveness.
AvailabilityMap::pruneByLiveness Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp modified Now just assigns *this = filterByLiveness(graph, where).
AvailabilityMap::validateAvailability Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp added Debug validator (used under validateFTLOSRExitLiveness): for each flush-useful heap entry with a valid flushed VirtualRegister, finds the local at the same slot and DFG_CRASHes if they disagree on the DFG node or flush format — directly encodes the violated invariant.
AvailabilityMap::AvailabilityMap (default ctor) Source/JavaScriptCore/dfg/DFGAvailabilityMap.h added Explicit default constructor needed now that filterByLiveness returns a fresh map by value.
LocalOSRAvailabilityCalculator::executeNode Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp modified Core fix: adds killHeaps(operand) which, on PutStack (of data->operand) and KillStack (of unlinkedOperand), invalidates any heap Availability whose FlushedAt VirtualRegister matches the clobbered slot by setting FlushedAt(ConflictingFlush); also adds ASSERT(!isDead()) on inline-call-frame argumentCount/callee/argument.
OSRAvailabilityAnalysisPhase::run (block loop / exit handling) Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp modified Runs validateAvailability after each executeNode under validateFTLOSRExitLiveness, and uses filterByLiveness at exit origins instead of copy-then-pruneByLiveness.
FTLLowerDFGToB3 OSR-exit lowering (compileNode / buildExitArguments) Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp modified Restructures the validateFTLOSRExitLiveness guard and iterates availabilityMap.m_heap by reference; largely validation/cleanup around the same exit-materialization path.
compileStub (FTL OSR exit) Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp modified Adds ASSERT(property.value().kind() != ExitValueDead) before recovering a materialization property value, asserting that no needed materialization field is dead.
PutStackSinkingPhase (liveness loops) Source/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cpp modified Adds verbose dataLog tracing only; no behavioral change.

Files Changed

Source Files

  • Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp
  • Source/JavaScriptCore/dfg/DFGAvailabilityMap.h
  • Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp
  • Source/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cpp
  • Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
  • Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp

Test Files

  • JSTests/stress/osr-availability-heap-materialization-clobbered.js

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/JSTests/stress/osr-availability-heap-materialization-clobbered.js b/JSTests/stress/osr-availability-heap-materialization-clobbered.js
new file mode 100644
index 000000000000..8a508252cd4b
--- /dev/null
+++ b/JSTests/stress/osr-availability-heap-materialization-clobbered.js
@@ -0,0 +1,23 @@
+//@ requireOptions("--validateFTLOSRExitLiveness=1")
+
+// testLoopCount wasn't on our software update branch when this test was added.
+globalThis.testLoopCount ??= 10000;
+
+function opt() {
+    for (let i = 0; i < 5; i++) {
+        function arg() {
+            return arguments;
+        }
+        const arg1 = arg();
+        const arg2 = arg.apply();
+        try {
+            (3881)(arg2);
+        } catch(e) {
+            arg1.toString()
+        }
+    }
+}
+for (let i = 0; i < testLoopCount; i++) {
+    opt();
+}
+gc();