← All issues

[1] JSC OSR exit ScratchBuffer not scanned by GC

Severity: High | Component: JSC DFG/FTL OSR exit | 87b4375

Rated High because the diff fixes an attacker-reachable UAF on JIT-resumed JSCells whose only live reference was held in a scratch buffer the GC could not see; the PoC drives the trigger reliably and the freed type is selectable by web content, yielding the standard JSC stepping stone toward type confusion and arbitrary R/W in WebContent.

DFG and FTL OSR exits use ScratchBuffers when shuffling the stack during the exit itself. If the stack is overwritten, it is 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 were not setting. This patch fixes that by setting the activeLength around the stack-clobbering region in both DFG::OSRExit::compileExit and FTL::compileStub.

Source/JavaScriptCore/dfg/DFGOSRExit.cpp

- ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(sizeof(EncodedJSValue) * operands.size());
+ const size_t scratchBufferSize = sizeof(EncodedJSValue) * operands.size();
+ ScratchBuffer* scratchBuffer = vm.scratchBufferForSize(scratchBufferSize);
...
+ // 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 (scratchBuffer) {
+ jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
+ jit.storePtr(CCallHelpers::TrustedImm32(0), CCallHelpers::Address(GPRInfo::regT0));
+ }

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

+// @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);
+}

Each affected exit compiler now (1) hoists the scratchBufferSize computation into a named local before requesting the buffer; (2) emits JIT code that stores scratchBufferSize into scratchBuffer->addressOfActiveLength() immediately before the calls that overwrite the stack (emitSaveCalleeSavesFor in DFG and the equivalent stack reshuffle in FTL); and (3) emits JIT code that stores 0 back into addressOfActiveLength() after stack recovery completes. A regression test runs an OOMing s + s inside an OSR-compiled function with --slowPathAllocsBetweenGCs=16 and --useZombieMode=1 to force a GC inside the exit window.

Missing GC root registration for a temporary spill buffer that becomes the sole retainer across a stack-clobbering window.

OSR exit is the runtime transition from a higher JIT tier (DFG/FTL) back to baseline when a speculative type check or other invariant fails; the exit compiler emits code that reconstructs the baseline stack frame from the optimised one. A ScratchBuffer is a VM-owned, fixed-region scratch area handed out by vm.scratchBufferForSize(); its activeLength field tells the GC how many leading bytes to treat as conservative roots. Conservative root scanning means the GC walks the words in a buffer and treats anything that looks like a valid cell pointer as a live root.

emitSaveCalleeSavesFor (DFG) and the equivalent FTL routine rewrite the JS stack frame in place during the exit. Two debug options enable the test trigger: --useZombieMode=1 retains freed cells in a poisoned state so dereferences of stale pointers fault reliably, and --slowPathAllocsBetweenGCs=16 triggers a GC every N slow-path allocations.

Both DFG::OSRExit::compileExit and FTL::compileStub allocated a ScratchBuffer and spilled live on-stack EncodedJSValues into it, but never wrote scratchBufferSize into the buffer's activeLength. The GC therefore saw zero scannable bytes in the buffer. The exit then called emitSaveCalleeSavesFor (DFG) or the analogous FTL reshuffle, which overwrites the original on-stack slots. For the window between the stack being clobbered and the values being restored, the scratch buffer was the sole live retainer of those object references — yet invisible to the marker.

🔒

A detailed walkthrough of how the OSR exit window between stack-spill and stack-restore becomes a UAF, including the conditions an attacker needs to align to weaponise it

Subscribe to read more

🔒

Four reusable audit patterns covering JIT-spill / GC-visibility omissions, with concrete grep targets and analogous subsystems to inspect

Subscribe to read more