← All reports

[1] OSR exit scratch buffers were never published to the collector

HighJSC DFG and FTL JITUAF

The collector was never told where the exit stub parked its pointers

87b4375

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 the activeLength. Originally landed as 305413.448@rapid/safari-7624.2.5.110-branch.

Source/JavaScriptCore/dfg/DFGOSRExit.cpp

// Save all state from GPRs into the scratch buffer.
 
- ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(sizeof(EncodedJSValue) * operands.size());
+ const size_t scratchBufferSize = sizeof(EncodedJSValue) * operands.size();
+ ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(scratchBufferSize);
EncodedJSValue* scratch = scratchBuffer ? static_cast<EncodedJSValue*>(scratchBuffer->dataBuffer()) : nullptr;
...
+ // The scratch buffer can become the sole retainer of saved on-stack values if the
+ // stack is overwritten by emitSaveCalleeSavesFor below, so set the active length
+ // for the GC.
+ if (scratchBuffer) {
+ jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
+ jit.storePtr(CCallHelpers::TrustedImm32(scratchBufferSize), CCallHelpers::Address(GPRInfo::regT0));
+ }
+
if constexpr (validateDFGDoesGC) {
...
spooler.finalizeGPR();
#endif
 
+ if (scratchBuffer) {
+ jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
+ jit.storePtr(CCallHelpers::TrustedImm32(0), CCallHelpers::Address(GPRInfo::regT0));
+ }
+
// Now that things on the stack are recovered, do the arguments recovery. ...

Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp

- ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(
- sizeof(EncodedJSValue) * (
- exit.m_descriptor->m_values.size() + numMaterializations + maxMaterializationNumArguments) +
+ const size_t scratchBufferSize =
+ sizeof(EncodedJSValue) * (exit.m_descriptor->m_values.size() + numMaterializations + maxMaterializationNumArguments) +
requiredScratchMemorySizeInBytes() +
- codeBlock->jitCode()->calleeSaveRegisters()->sizeOfAreaInBytes());
+ codeBlock->jitCode()->calleeSaveRegisters()->sizeOfAreaInBytes();
+ ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(scratchBufferSize);
...
+ // The scratch buffer can become the sole retainer of saved on-stack values, so set the
+ // active length for the GC.
+ if (scratchBuffer) {
+ jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
+ jit.storePtr(CCallHelpers::TrustedImm32(scratchBufferSize), CCallHelpers::Address(GPRInfo::regT0));
+ }
...
+ if (scratchBuffer) {
+ jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
+ jit.storePtr(CCallHelpers::TrustedImm32(0), CCallHelpers::Address(GPRInfo::regT0));
+ }
+
handleExitCounts(vm, jit, exit);
reifyInlinedCallFrames(jit, exit);
adjustAndJumpToTarget(vm, jit, exit);

JSTests/stress/osr-exit-scratch-buffer-gc.js

+//@ skip if $architecture == "arm"
+// @requireOptions("--useConcurrentJIT=0", "--useZombieMode=1", "--slowPathAllocsBetweenGCs=16")
+
+function opt(s) {
+ const o = {};
+
+ try {
+ return s + s;
+ } catch {
+ return o;
+ }
+}
+
+function main() {
+ noDFG(main);
+ noFTL(main);
+
+ for (let i = 0; i < 100; i++) {
+ opt("hello");
+ }
+
+ const s = 's'.repeat(0x40000000);
+ const a = [opt(s), opt(s), opt(s), opt(s), opt(s), opt(s), opt(s), opt(s)];
+
+ setTimeout(() => {
+ a.toString();
+ }, 100);
+}
+
+main();

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.

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.

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.