Medium CVSS 8.8 webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to memory corruption
ComponentJSC
Sub-componentJSC DFG
Bug ClassType Confusion
Tracker291742
Fix commit90aa8070e1b6 (WebKit/WebKit) +124/-34
CWECWE-119 (Buffer bounds error)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedYuhao Hu, Yan Kang, Chenggang Wu, and Xiaojie Wei
Disclosed2025-07-29

Background

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.

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.

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.

Changed Functions

FunctionChangeNotes
for
Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp
modified
if
Source/JavaScriptCore/dfg/DFGAvailabilityMap.cpp
modified
for
Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp
modified
if
Source/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cpp
modified
for
Source/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cpp
modified
for
Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
modified
for
JSTests/stress/osr-availability-heap-materialization-clobbered.js
modified

Files Changed

  • JSTests/stress/osr-availability-heap-materialization-clobbered.js
  • 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

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.
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);
+                }
                 
                 if (calculator.m_availability == availabilityAtTail(block))
                     continue;
@@ -142,9 +147,7 @@ public:
                         // to be dead.
 
                         CodeOrigin exitOrigin = node->origin.forExit;
-                        // FIXME: availabilityMap seems like it should be able to be a reference to the calculator's map. https://bugs.webkit.org/show_bug.cgi?id=215675
-                        AvailabilityMap availabilityMap = calculator.m_availability;
-                        availabilityMap.pruneByLiveness(m_graph, exitOrigin);
+                        AvailabilityMap availabilityMap = calculator.m_availability.filterByLiveness(m_graph, exitOrigin);
 
                         for (auto heapPair : availabilityMap.m_heap) {
                             switch (heapPair.key.kind()) {
@@ -240,6 +243,19 @@ void LocalOSRAvailabilityCalculator::endBlock(BasicBlock* block)
 
 void LocalOSRAvailabilityCalculator::executeNode(Node* node)
 {
+    auto killHeaps = [&] (Operand killedOperand) {
+        Availability localAvailability = m_availability.m_locals.operand(killedOperand);
+        // Our heap's FlushedAt should already be pruned if the flush isn't useful. We'll assert this after executeNode returns.
+        if (!localAvailability.isFlushUseful() || localAvailability.flushedAt().virtualRegister() == VirtualRegister())
+            return;
+
+        // In theory this is O(n) and we could have a seperate HashMap tracking Operand -> AbstractHeap's with relevant flushes. In practice, the availability heap is small (there's not usually a lot of phantom objects) so the O(n) search isn't bad.
+        for (auto& heapPair : m_availability.m_heap) {
+            if (heapPair.value.flushedAt().virtualRegister() == localAvailability.flushedAt().virtualRegister())
+                heapPair.value.setFlush(FlushedAt(ConflictingFlush));
+        }
+    };
+
     switch (node->op()) {
 
     // It's somewhat subtle why we cannot use the node for the GetStack itself in the Availability's node field. The reason is that if we did we would need to make any phase that converts nodes to GetStack availability aware. For instance a place where this could come up is if you had a graph like:
@@ -261,11 +277,14 @@ void LocalOSRAvailabilityCalculator::executeNode(Node* node)
     case GetStack:
     case PutStack: {
         StackAccessData* data = node->stackAccessData();
+        if (node->op() == PutStack)
+            killHeaps(data->operand);
         m_availability.m_locals.operand(data->operand).setFlush(data->flushedAt());
         break;
     }
         
     case KillStack: {
+        killHeaps(node->unlinkedOperand());
         m_availability.m_locals.operand(node->unlinkedOperand()).setFlush(FlushedAt(ConflictingFlush));
         break;
     }
@@ -320,12 +339,15 @@ void LocalOSRAvailabilityCalculator::executeNode(Node* node)
             Availability argumentCount =
                 m_availability.m_locals.operand(VirtualRegister(inlineCallFrame->stackOffset + CallFrameSlot::argumentCountIncludingThis));
             
+            ASSERT(!argumentCount.isDead());
             m_availability.m_heap.set(PromotedHeapLocation(ArgumentCountPLoc, node), argumentCount);
         }
         
         if (inlineCallFrame->isClosureCall) {
             Availability callee = m_availability.m_locals.operand(
                 VirtualRegister(inlineCallFrame->stackOffset + CallFrameSlot::callee));
+
+            ASSERT(!callee.isDead());
             m_availability.m_heap.set(PromotedHeapLocation(ArgumentsCalleePLoc, node), callee);
         }
         
@@ -333,6 +355,7 @@ void LocalOSRAvailabilityCalculator::executeNode(Node* node)
             Availability argument = m_availability.m_locals.operand(
                 VirtualRegister(inlineCallFrame->stackOffset + CallFrame::argumentOffset(i)));
             
+            ASSERT(!argument.isDead());
             m_availability.m_heap.set(PromotedHeapLocation(ArgumentPLoc, node, i), argument);
         }
         break;
diff --git a/Source/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cpp b/Source/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cpp
index b99a920a3040..175685793018 100644
--- a/Source/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cpp
@@ -94,6 +94,7 @@ public:
                 if (!block)
                     continue;
                 
+                dataLogLnIf(verbose, "Starting liveness for block: ", *block);
                 Operands<bool> live = liveAtTail[block];
                 for (unsigned nodeIndex = block->size(); nodeIndex--;) {
                     Node* node = block->at(nodeIndex);
@@ -222,6 +223,7 @@ public:
             for (BasicBlock* block : m_graph.blocksInNaturalOrder()) {
                 Operands<FlushFormat> deferred = deferredAtHead[block];
                 
+                dataLogLnIf(verbose, "Looking for deferred PutStacks in block: ", *block);
                 for (Node* node : *block) {
                     dataLogLnIf(verbose, "Deferred at ", node, ":", deferred);
                     
diff --git a/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp b/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
index 7bcca48ce063..150c4ac61915 100644
--- a/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
+++ b/Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
@@ -1913,6 +1913,7 @@ private:
         case InitializeEntrypointArguments:
             codeGenerationResult = CodeGenerationResult::NotGenerated;
             break;
+
         default:
             DFG_CRASH(m_graph, m_node, "Unrecognized node in FTL backend");
             break;
@@ -24516,11 +24517,11 @@ IGNORE_CLANG_WARNINGS_END
             Availability availability = availabilityMap.m_locals[i];
 
             // FIXME: It seems like we should be able to do at least some validation when OSR entering. https://bugs.webkit.org/show_bug.cgi?id=215511
-            if (Options::validateFTLOSRExitLiveness()
-                && m_graph.m_plan.mode() != JITCompilationMode::FTLForOSREntry) {
-
-                if (availability.isDead() && m_graph.isLiveInBytecode(operand, exitOrigin))
-                    DFG_CRASH(m_graph, m_node, toCString("Live bytecode local not available: operand = ", operand, ", availability = ", availability, ", origin = ", exitOrigin).data());
+            if (Options::validateFTLOSRExitLiveness()) [[unlikely]] {
+                if (m_graph.m_plan.mode() != JITCompilationMode::FTLForOSREntry) {
+                    if (availability.isDead() && m_graph.isLiveInBytecode(operand, exitOrigin))
+                        DFG_CRASH(m_graph, m_node, toCString("Live bytecode local not available: operand = ", operand, ", availability = ", availability, ", origin = ", exitOrigin).data());
+                }
             }
             ExitValue exitValue = exitValueForAvailability(arguments, map, availability);
             if (exitValue.hasIndexInStackmapLocations())
@@ -24528,11 +24529,12 @@ IGNORE_CLANG_WARNINGS_END
             exitDescriptor->m_values[i] = exitValue;
         }
 
-        for (auto heapPair : availabilityMap.m_heap) {
+        for (auto& heapPair : availabilityMap.m_heap) {
             Node* node = heapPair.key.base();
             ExitTimeObjectMaterialization* materialization = map.get(node);
             if (!materialization)
                 DFG_CRASH(m_graph, m_node, toCString("Could not find materialization for ", node, " in ", availabilityMap).data());
+
             ExitValue exitValue = exitValueForAvailability(arguments, map, heapPair.value);
             if (exitValue.hasIndexInStackmapLocations())
                 exitValue.adjustStackmapLocationsIndexByOffset(offsetOfExitArgumentsInStackmapLocations);
diff --git a/Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp b/Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp
index 14280792e7e7..56241c894338 100644
--- a/Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp
+++ b/Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp
@@ -332,6 +332,7 @@ static void compileStub(VM& vm, unsigned exitID, JITCode* jitCode, OSRExit& exit
                 if (!property.location().neededForMaterialization())
                     continue;
 
+                ASSERT(property.value().kind() != ExitValueDead);
                 recoverValue(property.value());
                 jit.storePtr(GPRInfo::regT0, materializationArguments + propertyIndex);
             }
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();
Loading diff…

Regression Test / PoC

shipped with the fix
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();
Loading diff…

Original Bug Report

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