← All reports

[2] Stale OSR recovery rebuilds `arguments` from a second varargs call's stack slots

HighJSC DFG/FTL OSR machineryTypeConfusion

DFG liveness is a union over CFG successors. A catch handler isn't one.

26aa84f

High. Two independent gaps — a missing heap-availability kill and a liveness set that never sees catch handlers — combine so that a materialized arguments object is filled from stack slots a later call has since overwritten. Escalation past value/length confusion into an uninitialized-memory read is a projected direction, not one this change itself establishes.

Deoptimization metadata is the JIT's standing promise that it can rebuild, at any exit point, the interpreter state the optimized code elided — and like any record of where values live, it goes wrong when a slot is reused without the record being invalidated. DFGOSRAvailabilityAnalysisPhase is the forward flow analysis that maintains it, tracking per-stack-operand availability in m_locals alongside m_heap, a parallel map of promoted heap locations describing the fields of objects the compiler deleted and must rebuild on exit. DFGArgumentsEliminationPhase deletes arguments allocations outright and leaves a phantom marker behind, so an exit is the only thing that ever materializes such an object and those recovery entries are the only description of its contents.

The angle: a script nesting two inlined varargs calls and forcing an exit from a catch handler gets a live arguments object whose length and elements were taken from the other call's stack slots.

Two coupled changes. In LocalOSRAvailabilityCalculator, the LoadVarargs / ForwardVarargs case now calls killHeaps() for the operands it is about to redefine — data->count and each data->start + i slot — before installing their new m_locals availability. killHeaps()'s body lies outside the supplied context; its role is fixed by its name and by where the patch places it, namely as the discharge of the obligation that redefining a virtual register invalidates any m_heap entry recovering through that register.

Second, ArgumentsEliminationPhase::eliminateCandidatesThatInterfere() gains handling for values that are bytecode-live only inside a catch handler, along with an explicit comment recording why they were being missed: the DFG does not model exceptional control flow as CFG edges.

Promoted heap-location recoveries left naming stack slots that a later node redefines, with liveness that does not model exceptional edges.

Where this lives. OSR exit is the mechanism by which optimized DFG/FTL code bails back to the baseline tier when a speculation fails. Everything the optimized code omitted has to be reconstructible at that moment.

Availability, locals and heap. AvailabilityMap carries two coupled maps. m_locals says, per stack operand, where that operand's value can be recovered from. m_heap does the same per PromotedHeapLocation — the individual fields of an object the compiler sank or eliminated. When a promoted location's recovery is "flushed at virtual register R", the analysis owes an obligation for the lifetime of that entry.

Arguments elimination. The phase replaces an arguments allocation with a PhantomClonedArguments node and describes its contents through ArgumentCountPLoc and ArgumentPLoc promoted locations. It also calls collectAvailability() to snapshot calculator.m_availability into a phase-local m_candidates map; that snapshot feeds the phase's own decisions, not the exit recoveries themselves.

Combined liveness. DFGCombinedLiveness computes liveAtTail[block] purely as the union of the CFG successors' liveAtHead, seeding bytecode liveness only for successor-less blocks. A catch handler in the DFG is a separate catch entrypoint, not a CFG successor.

ExitValueInJSStack. The exit-value form that says "load this from the recorded virtual register at exit time".

This is stale deoptimization metadata — a state/value confusion at materialization time rather than a lifetime bug. Nothing is freed; the wrong stack storage is read.

  inlined varargs call #1          inlined varargs call #2
  -----------------------          -----------------------
  LoadVarargs
    m_locals[count]   = R_c
    m_locals[start+i] = R_i
    m_heap[ArgumentCountPLoc] -> R_c
    m_heap[ArgumentPLoc(i)]   -> R_i
  PhantomClonedArguments
    (allocation removed)
                                   LoadVarargs (overlaps R_c, R_i)
                                     m_locals rewritten
                                     m_heap NOT killed  <-- gap 1
  clobber of source-frame slots
    liveAtTail misses catch-only
    values -> removeViaKill()
    never runs                                          <-- gap 2
  OSR exit inside catch handler
    ExitValueInJSStack(R_c), (R_i)
      -> call #1's arguments object
         materialized from call #2's
         count and element values

Each gap on its own is survivable. Gap 1 leaves promoted-heap recoveries pointing at operands a second LoadVarargs has taken over. The supplied context does not include the stack-slot allocation rule that makes the second LoadVarargs land on the same operands, so the overlap is a projected condition rather than an established one — but it is exactly the case the fix targets, since killHeaps() is inserted on data->count and each data->start + i, the operands the first call's recoveries name. Gap 2 is why the interference analysis does not catch the situation: removeViaKill() is called for every node in combinedLiveness.liveAtTail[block] at a stack-clobbering node, and a value that is bytecode-live only inside a catch handler appears in no CFG successor's liveAtHead, hence in no block's liveAtTail, hence is never passed to removeViaKill(). The candidate stays eligible for elimination while it is still OSR-live across the clobber.

The concrete trigger shape is a strict-mode arguments allocation created inside an inlined varargs call, turned into a PhantomClonedArguments, with a second inlined varargs call whose LoadVarargs overlaps the first's argument slots, and an exit taken from a catch handler where the object is bytecode-live. At exit, each ArgumentPLoc recovery compiles to an ExitValueInJSStack load from the recorded virtual register, so the reconstructed object's length and elements come from the second call.

The exploitability question turns on what the second call's frame contains at exit time. A count taken from a different call is a length confusion the script controls directly by choosing the two calls' arities. The projected uninitialized-memory-read component — element slots read from stack storage the second call never wrote — follows from the same recoveries but requires arranging a count larger than what the overlapping call actually installed; that arrangement is a projected direction rather than something this change establishes.

This vulnerability weakens the correctness guarantee that makes deoptimization invisible: the promise that an object materialized at exit is indistinguishable from the object the baseline tier would have allocated.