[1] OSR exit scratch buffers were never published to the collector
The collector was never told where the exit stub parked its pointers
High. The exit stub relocates live cell pointers off a stack it is about to overwrite, into a buffer the collector only scans on request — and the request was never made. Escalation hinges on landing a collection inside that window without the test's three GC-stress options.
Premature collection is what happens when a runtime parks the last pointer to a live object somewhere the garbage collector does not look, and in JavaScriptCore that risk concentrates in OSR exit — the machinery that rewrites an optimized stack frame back into the baseline interpreter's layout when a speculation fails or an exception unwinds. The intermediate parking spot for the values in flight is a ScratchBuffer, a VM-owned scratch region that the collector scans conservatively, but only across the byte range that a companion field named activeLength declares live. The invariant the whole design rests on is that every location capable of being the sole retainer of a JS cell is enumerable by the collector for exactly as long as it holds that role.
The angle: any page that gets a function optimized and then forces it to exit could have a live JS object collected while its only reference is mid-transit, leaving ordinary script holding a pointer into freed heap memory.
DFG and FTL OSR exits use ScratchBuffers when shuffling the stack during the exit itself. If the stack is overwritten, it's possible that the ScratchBuffer becomes the sole retainer of the previously on-stack pointers. These buffers are treated as conservative roots by the GC according to their
activeLength, which the OSR exits weren't setting. This PR fixes that by setting theactiveLength. Originally landed as305413.448@rapid/safari-7624.2.5.110-branch.
Source/JavaScriptCore/dfg/DFGOSRExit.cpp
Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp
JSTests/stress/osr-exit-scratch-buffer-gc.js
Patch Details
Both stub generators now publish the size of the scratch buffer they use. In OSRExit::compileExit, the buffer size is hoisted out of the vm.scratchBufferForSize call into a local scratchBufferSize, and two new machine-code sequences are emitted: after the value-saving/recovery section and before the stack reshuffle, jit.move(TrustedImmPtr(scratchBuffer->addressOfActiveLength()), regT0) followed by jit.storePtr(TrustedImm32(scratchBufferSize), Address(regT0)); then, after spooler.finalizeGPR() has restored everything onto the reconstructed stack and before emitRestoreArguments, the mirror sequence storing 0. FTL's compileStub receives identical treatment — its multi-term size expression (values plus materializations plus max materialization arguments plus requiredScratchMemorySizeInBytes() plus the callee-save area) is hoisted into scratchBufferSize, the publish store lands before the register-preservation stack fixup, and the zeroing store lands after the final spooler.finalizeGPR(), just ahead of handleExitCounts / reifyInlinedCallFrames / adjustAndJumpToTarget. Both stores sit behind if (scratchBuffer), mirroring the pre-existing null-tolerant ternary. The regression test runs under --useConcurrentJIT=0 --useZombieMode=1 --slowPathAllocsBetweenGCs=16.
Values relocated into an opt-in-scanned side buffer during a stack rewrite, without publishing that buffer's live extent to the garbage collector.
Background
Where this lives. JSC executes JavaScript across tiers — LLInt, baseline, DFG, FTL — with the upper tiers compiling speculatively on profile data. When a speculation turns out to be wrong, or an exception unwinds through an optimized frame, execution has to return to a lower tier.
OSR exit.
That return is not a simple jump: the physical stack frame is in the optimizing tier's layout and has to be rewritten into the baseline/LLInt layout. The rewrite is performed by generated machine code — OSRExit::compileExit for DFG, compileStub for FTL. The recipes it replays come from ValueRecovery/Operands, the compiler's record of where each bytecode-visible variable lives in the optimized frame (register, stack slot, constant, boxed or unboxed format).
ScratchBuffer and activeLength.
A ScratchBuffer is a VM-level scratch region obtained via vm.scratchBufferForSize(n) and used by JIT-generated code as a spill area; the call site's scratchBuffer ? ... : nullptr ternary shows the returned pointer may be null. Each buffer carries an activeLength word, addressable from JIT code via addressOfActiveLength(), recording how many bytes at the start of the buffer currently hold live data.
Conservative roots.
JSC's collector treats machine registers, the machine stack, and the active portion of each scratch buffer as untyped word arrays, retaining anything that looks like a heap pointer — this is how on-stack JS values stay alive with no explicit handles. ActiveScratchBufferScope is the C++-side RAII helper that sets and clears activeLength around code that parks values in a buffer.
Spoolers and callee saves.
emitSaveCalleeSavesFor writes callee-saved register contents into the frame being constructed; AssemblyHelpersSpoolers' finalizeGPR() flushes the batched stores that put recovered values back onto the reconstructed frame.
Test options and ropes.
--useZombieMode=1 makes freed cells recognizable after reclamation and --slowPathAllocsBetweenGCs=N forces a collection every N slow-path allocations. In JSC, s + s normally builds a lazy JSRopeString; a concatenation whose combined length exceeds the maximum string length raises an error from the slow path.
Analysis
The bug is a missing GC root, and the resulting shape is a use-after-free on an object that ordinary script still holds.
OSR exit stub Collector's view of cell `o`
───────────── ────────────────────────────
save operands -> scratch buffer stack slot: reachable
emitSaveCalleeSavesFor / reshuffle stack slot: OVERWRITTEN
[ window ] scratch buffer: activeLength == 0
-> looks unreachable -> swept
spooler.finalizeGPR() writes back baseline slot: dangling pointer
(post-fix: activeLength = size before (post-fix: buffer scanned across
the window, 0 after) the whole window)
In the window marked above, the conservative stack scan finds nothing because the slots have been clobbered, and the scratch-buffer scan contributes nothing because activeLength is zero. A still-live cell is therefore treated as garbage and swept, after which the exit stub faithfully writes the now-dangling pointer into the reconstructed baseline frame and execution resumes with a freed cell sitting in a normal JS variable. The commit's own added comment states the case directly: the buffer "can become the sole retainer of saved on-stack values if the stack is overwritten by emitSaveCalleeSavesFor below."
The placement of the clearing store is deliberate. In the DFG stub it goes before emitRestoreArguments, which calls allocating helpers such as operationCreateDirectArgumentsDuringExit — by that point the reconstructed stack is a valid conservative root again, so leaving activeLength set would only over-retain. Which specific allocating call site could actually run a collection inside the newly-covered region is not established by the supplied context (both files are truncated); the presence of a GC trigger there follows the fix's comment. FTL is the more promising place to look, since its scratch buffer is explicitly sized to include numMaterializations and maxMaterializationNumArguments slots for object materialization.
The regression test is a compact trigger. opt() is warmed 100 times so it compiles, const o = {} is allocated in the same frame so its only reference is that frame's operand slot, and opt is then called with a ~1GB string so s + s exceeds the maximum string length and throws, driving an exception-kind exit. The exit stub saves the register-resident operands (the cell pointer for o among them) into the scratch buffer, the callee-save and reshuffle section overwrites the original slots, and until spooler.finalizeGPR() completes the buffer would be the only holder of that pointer. The catch block hands o back into the array a, and the deferred a.toString() under zombie mode is what turns a reclaimed cell into a visible assertion point.
Reachability is unrestricted — any web-content JavaScript that gets a function optimized and then triggers an exit reaches this code. Realizing the window in the wild without --slowPathAllocsBetweenGCs would require sustained allocation pressure timed against repeated exits. Escalation past the dangling reference would depend on two further conditions: the freed cell's size class being reclaimed by an attacker-chosen allocation, which conventional JSC heap grooming with sized arrays or typed objects could plausibly satisfy; and the reclaimed cell's new type differing from the one script still believes it holds. If both held, the stale reference could give a type-confusion primitive over a fully script-controlled object; without them the observable effect stays a crash on a reclaimed or poisoned cell.
This vulnerability weakens memory safety inside the WebContent process by breaking the GC's foundational invariant that a reachable object is never reclaimed. An attacker who arranges the collection to land inside the window could obtain a dangling reference to an object of a type and size they chose — the standard starting point for a type-confusion or arbitrary-read/write primitive in the renderer.
The instructive part is the asymmetry within the same file: operationCompileOSRExit opens with ActiveScratchBufferScope activeScratchBufferScope(ScratchBuffer::fromData(bufferToPreserve), ...), so the C++ half of the exit path has always published its buffer's live extent, while the JIT-emitted half of the same exit never did. Whenever a subsystem has both a C++ RAII helper and a hand-written machine-code equivalent for the same invariant, the machine-code path is the one that silently drifts — no destructor runs and the compiler cannot enforce the pairing.
Audit directions
- JIT-generated code parking GC-managed pointers in opt-in-scanned memory. The invariant is that any region which can be the sole retainer of a cell must be enumerable by the collector for exactly the window it holds that role, and it is easy to violate because the spilling code and the scanning code live in different files with no compile-time link. Narrow: grep
Source/JavaScriptCoreforscratchBufferForSize(and check each result for a correspondingaddressOfActiveLength()store orActiveScratchBufferScope— start with the thunk generators indfg/DFGThunks.cppand the callOperation spill paths injit/. Wider: the same class covers any JIT spill area that is not the machine stack —ProbeContext, the entry-frame callee-saves buffer written bycopyCalleeSavesToEntryFrameCalleeSavesBuffer, and Wasm's temporary value buffers; the shape to notice in code search is emitted stores of boxedEncodedJSValues into aTrustedImmPtr-addressed absolute buffer with no adjacent bookkeeping store. Widest: this is the general "off-stack root region with opt-in scanning" class, applicable to V8's handle-scope and stack-scanning boundaries, HotSpot's oop-map coverage, and Go's stack maps for spill slots. Carry this tell across codebases: find every place the runtime copies references out of a region the collector definitely scans, and ask which code marks the destination as a root and when it unmarks it. - Set/clear pairs of a GC-visibility marker emitted as straight-line machine code. Because the clear is a store rather than a destructor, any early branch out of the emitted region leaves
activeLengthstale — stale-large over-retains and conservatively pins whatever words remain in the buffer, stale-zero re-opens this bug. Narrow: trace every exit edge out ofOSRExit::compileExitand FTLcompileStubbetween the two new stores — theGenericUnwindpath,handleExitCounts' jettison branches, andadjustAndJumpToTarget— and confirm each passes through the zeroing store. Wider: the same shape appears anywhere JIT code emits a paired enter/leave state mutation across branching emitted code, such asvalidateDFGDoesGCexpectation stores, VM entry/exit frame pushes, and exception-handler state stores. In code review, astoreof a constant flag with no dominator/post-dominator relationship to its companion store is the visual tell. Widest: this is the general "manually-paired state transition with no scope guard" class — every path leaving the region, including exceptional ones, must restore the marker. - Audit newly-inserted JIT bookkeeping code for scratch-register clobbering. Both new hunks unconditionally
jit.move(..., GPRInfo::regT0), which is safe only ifregT0is dead at those two insertion points. Narrow: verify by inspection that no value survives inregT0across the inserted sequences in either file — the tell is aregT0def in the emitted code that dominates the insertion point with a use after it. Wider: whenever a fix adds machine-code emission into an existing stub, ask the same question of everyGPRInfo::regT*/nonArgGPR*use inside register-restore sequences and spooler regions; in review, a newjit.move/jit.storePtrinserted between a spooler's batching and itsfinalize*()call is the shape to stop on. This one is bound to hand-written MacroAssembler stub code, where register allocation is manual and no verifier checks liveness across inserted code. - Check whether other deoptimization or unwinding paths have the same coverage gap. Does the baseline-to-LLInt unwinding path,
genericUnwind, or the checkpoint OSR machinery (CheckpointOSRExitSideState, included by both changed files) ever hold the last reference to a cell outside the stack? Start by enumerating the structures that persist values across a frame rewrite —CheckpointOSRExitSideState's stored temporaries andvm.callFrameForCatch-adjacent state — and for each ask which GC visitor visits it. Match tell: a container ofJSValue/EncodedJSValuewritten from JIT code that is not aWriteBarrier, not on the machine stack, and not named in the heap's root-gathering routines.