CVE-2025-31278
Overview
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.
Attack Path
- 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.
- 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.
- 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.
- 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.
- 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).
- 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
AvailabilityMap::filterByLivenessSource/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::pruneHeapSource/JavaScriptCore/dfg/DFGAvailabilityMap.cpp |
deleted | Old in-place heap pruner folded into filterByLiveness. |
AvailabilityMap::pruneByLivenessSource/JavaScriptCore/dfg/DFGAvailabilityMap.cpp |
modified | Now just assigns *this = filterByLiveness(graph, where). |
AvailabilityMap::validateAvailabilitySource/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::executeNodeSource/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
JSTests/stress/osr-availability-heap-materialization-clobbered.jsSource/JavaScriptCore/dfg/DFGAvailabilityMap.cppSource/JavaScriptCore/dfg/DFGAvailabilityMap.hSource/JavaScriptCore/dfg/DFGOSRAvailabilityAnalysisPhase.cppSource/JavaScriptCore/dfg/DFGPutStackSinkingPhase.cppSource/JavaScriptCore/ftl/FTLLowerDFGToB3.cppSource/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp
Audit Directions
- Other executeNode cases that write stack slotsIn 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 consistencyRun/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 exitIn 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 phasesSearch 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.