← All issues

[1] JSC OSR exit ScratchBuffer not scanned by GC

JSC's IC and ValueProfile disagreed about a slot's type — and the DFG resolved the conflict by casting a function pointer to a double.

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.

The PoC walkthrough is reliable: opt is OSR-compiled to DFG/FTL after the warm-up loop of 100 small-string calls. Each call with the huge 's'.repeat(0x40000000) string triggers s + s, which overflows JSString's length limit and throws, entering the catch clause that returns the local o. The thrown-and-caught path forces an OSR exit out of optimised opt; live JS values from the optimised frame (including o) are spilled into the scratch buffer. Inside the exit, the stack is rewritten, so the scratch buffer is now the sole holder of pointers to those cells. A slow-path allocation during the exit's materialisation work — argument-object materialisation in emitRestoreArguments is the plausible site, inferred from the surrounding code — fires a GC. The GC scans the buffer with activeLength == 0 and does not see o; o is collected. The exit completes and pushes the dangling pointer back onto the baseline frame; a ends up holding eight dangling references and a.toString() walks them. With ZombieMode the dereference faults; in a real attack the freed slot would have been reclaimed via heap spray with attacker-controlled JSCell layouts.

If the attacker reclaims the freed cell with a chosen Structure/cell type before the resumed baseline frame uses it, this would give a type-confusion primitive over a JSCell — the typical JSC stepping stone to arbitrary read and arbitrary write inside WebContent. All effects are contained within the WebContent process sandbox; converting this primitive into RCE on the host would require a separate escape.

This vulnerability weakened memory safety inside WebContent by violating the invariant that any cell whose only live pointer is in an OSR exit's ScratchBuffer remains visible to the GC for the duration of the exit. ActiveScratchBufferScope is the standard idiom in JSC for bracketing scratch usage, and the surrounding C++ wrappers (operationCompileOSRExit, IC slow paths) use it; the DFG/FTL OSR exit emitters were the outlier — they hand-rolled spill code into a ScratchBuffer but forgot the activeLength bookkeeping that the surrounding C++ wrappers normally provide.