87b4375777 [JSC] Scan OSR exits' ScratchBuffers
Triage note: GC-visibility UAF in JIT OSR-exit scratch buffers.
Contents
The bug at a glance
This is a garbage-collector visibility use-after-free in the DFG and FTL OSR-exit machinery: scratch buffers that transiently become the sole retainers of live on-stack JSValues were not published to the conservative GC via activeLength, so a collection during the exit could free objects that are still referenced. GC-visibility bugs in the JIT are high severity because they let ordinary JavaScript induce a dangling pointer to a real, type-correct heap object, which is a premium exploitation primitive. The fix is small but load-bearing — writing activeLength around the stack-shuffle window — and the added test drives the exact condition (a large allocation and a timer forcing GC after an exit), confirming reachability.
OSR exit is reached whenever optimized (DFG/FTL) code hits a speculation that no longer holds and must bail back to a lower tier. Any web page can shape code that speculates and then violates the speculation — here a string concatenation that overflows the maximum rope/string length and throws, forcing an exit. The scratch buffer is used to shuffle registers/stack during that exit, so the vulnerable window is entered by nothing more exotic than optimized JavaScript that throws or deoptimizes while live values are in flight.
Root cause
During an OSR exit the compiler-generated stub must reconstruct the lower tier’s stack frame. To do this it first spills all live values from GPRs (and later restores/reshuffles them) through a VM ScratchBuffer obtained via vm.scratchBufferForSize(...). A ScratchBuffer is a chunk of memory the GC scans conservatively, but only up to its activeLength: the collector treats the first activeLength bytes as a range of potential pointers and marks anything they point at. If activeLength is left at zero (or stale), the GC ignores the buffer’s contents entirely even though it holds live EncodedJSValue pointers.
The root cause is that the OSR exit stubs wrote live JSValues into the scratch buffer but never set its activeLength for the window in which the buffer is the authoritative copy. The exit overwrites the machine stack while reshuffling — in DFGOSRExit.cpp the comment pinpoints emitSaveCalleeSavesFor as the point where the original on-stack copies of those values can be clobbered. Between saving into the scratch buffer and re-establishing the values in the reconstructed frame, the scratch buffer can be the ONLY place a given object pointer lives. If a GC is triggered in that window (e.g. because the exit path allocates, or a concurrent/incremental collector runs), the collector scans the stack and registers but not the scratch buffer, fails to mark the object, and frees it. When the exit finishes and reads the pointer back out, it is dangling — a use-after-free of a fully type-correct object.
The fix makes the buffer a visible conservative root for exactly its live window. In both DFGOSRExit::compileExit and FTL compileStub, the code now hoists the buffer size into scratchBufferSize and, right before the stack-clobbering shuffle, emits machine code to store that size into the buffer’s activeLength: jit.move(TrustedImmPtr(scratchBuffer->addressOfActiveLength()), regT0); jit.storePtr(TrustedImm32(scratchBufferSize), Address(regT0));. This tells the GC to scan the whole buffer, so every saved pointer is marked and kept alive across any collection during the exit. After the stack has been reconstructed and the values are safely live in their normal locations again, the code stores 0 back into activeLength (storePtr(TrustedImm32(0), ...)), retracting the buffer as a root so its stale contents are not scanned on subsequent collections. The FTL path is structurally identical, with the buffer sized to cover m_descriptor->m_values, materialization pointers and arguments, the required scratch memory, and the callee-save area; the same set-to-size-then-set-to-zero bracketing is added around its shuffle.
In short: the buffer always held the live values, but it was invisible to the collector because activeLength described an empty region. The patch publishes the buffer to the collector for the precise interval during which it is the sole retainer, then unpublishes it, closing the UAF without changing what the exit computes.
Key code
DFGOSRExit::compileExit publishes the scratch buffer to the GC around the stack shuffle (verbatim from diff)
// 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));
}
// ... stack is reconstructed ...
if (scratchBuffer) {
jit.move(CCallHelpers::TrustedImmPtr(scratchBuffer->addressOfActiveLength()), GPRInfo::regT0);
jit.storePtr(CCallHelpers::TrustedImm32(0), CCallHelpers::Address(GPRInfo::regT0));
}
Patch walkthrough
Source/JavaScriptCore/dfg/DFGOSRExit.cpp— In compileExit, hoists the buffer size into scratchBufferSize. After the GPRs are saved and immediately before emitSaveCalleeSavesFor overwrites the stack, emits a store of scratchBufferSize into scratchBuffer->addressOfActiveLength() so the GC conservatively scans the saved values. After the stack is reconstructed, emits a store of 0 into activeLength to retract the buffer as a root.Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp— Applies the identical pattern in compileStub: computes scratchBufferSize covering values, materialization pointers/arguments, required scratch memory and the callee-save area; sets activeLength to that size before the reshuffle and back to 0 afterward, making the FTL exit’s scratch buffer a visible conservative root only for its live window.JSTests/stress/osr-exit-scratch-buffer-gc.js— Regression test run with zombie mode and a low slowPathAllocsBetweenGCs so freed cells are poisoned and GCs happen often. opt() speculates on s+s and throws when the concatenation exceeds the max string length, forcing repeated OSR exits with live values; a huge 0x40000000-char string and a deferred a.toString() in a timer force a collection and then a read of the values that were only retained by the scratch buffer.
Background
OSR exit — On-Stack Replacement exit is how JSC abandons optimized (DFG/FTL) execution mid-function when a speculation fails, transferring control and reconstructing the equivalent baseline/LLInt stack frame. The exit stub must move values between registers, the scratch buffer and the rebuilt stack, and during that shuffle the canonical location of a value changes moment to moment, which is why GC visibility of every transient copy matters.
ScratchBuffer.activeLength — A ScratchBuffer is VM-owned scratch memory with an activeLength field the GC reads to decide how many bytes of the buffer to scan conservatively. Setting activeLength to N tells the collector to treat the first N bytes as possible pointers and mark their referents; leaving it 0 means the buffer is effectively invisible. Code that stashes live pointers in a scratch buffer must set activeLength for as long as the buffer is a retainer and clear it afterward.
Conservative GC scanning — JSC’s collector scans the machine stack, registers, and registered scratch buffers conservatively — treating any bit pattern that looks like a heap pointer as a live reference. This keeps values alive without precise stack maps, but it only covers regions the collector knows about. A live pointer that exists solely in a region with activeLength 0 is not scanned, so the object it references can be reclaimed.
emitSaveCalleeSavesFor and stack clobbering — Part of reconstructing the target frame is restoring callee-save registers and rewriting stack slots. This step overwrites the very stack locations that previously held the live values, so after it runs the only surviving copy of some pointers is in the scratch buffer. That is precisely the interval the patch protects by having activeLength cover the buffer.
Zombie mode / slowPathAllocsBetweenGCs — JSC test knobs: zombie mode overwrites freed cells with a poison value so a subsequent use of a dangling pointer is detected deterministically, and slowPathAllocsBetweenGCs forces a GC every few slow-path allocations. Together they turn an otherwise race-dependent UAF window into a reliably reproducible crash, which is why the regression test enables both.
Vulnerability window
- Speculate — Optimized code runs opt(s) which speculates that s + s succeeds; live objects (including the operands and the local o) are held in registers/stack.
- Exit entry — The concatenation exceeds the maximum string length and throws, forcing an OSR exit; the stub saves all live GPR values into the VM scratch buffer.
- Stack shuffle — emitSaveCalleeSavesFor and frame reconstruction overwrite the original on-stack copies, leaving some live pointers referenced only from the scratch buffer, whose activeLength is still 0.
- GC window — A collection triggered during the exit (frequent under slowPathAllocsBetweenGCs, or via the deferred timer and huge allocation) scans stack and registers but not the unpublished scratch buffer, so a still-referenced object is not marked.
- Free — The collector reclaims the object; in zombie mode its cell is poisoned, otherwise the memory is recycled.
- Use — The exit finishes and reads the pointer back from the scratch buffer (or later code, e.g. a.toString(), touches the value), dereferencing freed memory — a UAF. Post-patch the buffer’s activeLength covered it during the window, so it was marked and never freed.
Proof of concept
Verbatim added regression test. opt() is warmed so it tiers up and speculates on s + s; calling it with the 0x40000000-char string makes the concatenation exceed the max string length and throw, forcing an OSR exit whose live value (the catch-path object o / the operands) is transiently retained only by the scratch buffer. slowPathAllocsBetweenGCs=16 forces frequent GCs during the exits, and useZombieMode=1 poisons freed cells so the dangling read is caught. The deferred a.toString() touches the values after a collection has had a chance to run.
//@ 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();
Exploitation
- Enter the exit window — Craft optimized code that speculates and then deoptimizes with live object pointers in registers (a throwing s+s is one convenient trigger), repeatedly, so an OSR exit occurs with the scratch buffer holding the sole live copy.
- Force a collection in-window — Drive GC pressure (large allocations, allocation-heavy exit paths) so a collection runs while activeLength is 0, causing a still-referenced, type-correct object to be freed.
- Reclaim and confuse — Allocate attacker-controlled objects to occupy the freed cell, so the dangling pointer the exit reads back now names attacker-shaped memory of a different type — the standard UAF-to-type-confusion pivot in JSC.
- Toward R/W — From a type-confused/dangling cell an attacker builds addrof/fakeobj and arbitrary read/write. The public artifact only demonstrates the free-of-live-object crash under zombie mode; weaponization requires precise GC timing and heap grooming beyond what the patch shows, so as delivered it is a confirmed UAF crash primitive.
Detection & hunting
For defenders and SOC / detection engineers:
- UAF crash at OSR exit — ASAN heap-use-after-free or zombie-value dereference crashes whose stack is inside DFG OSRExit::compileExit-generated stubs or FTL exit stubs, especially right after frame reconstruction, match this bug. Reads of a poisoned/zombie cell immediately following a deoptimization are the clean signature.
- Deopt-then-GC correlation — Telemetry linking a garbage collection occurring during an OSR exit with a subsequent invalid access indicates the unpublished-scratch-buffer window; workloads that throw or deoptimize under heavy allocation pressure are the trigger profile.
- Version gating — Fixed at commits.webkit.org/313732@main and on safari-7624.2.5.110. On older builds OSR-exit scratch buffers are not GC-visible during the shuffle, so deopt-heavy JavaScript is a UAF vector.
Audit directions
- scratchBufferForSize without activeLength — grep for scratchBufferForSize and dataBuffer() usages and verify each sets addressOfActiveLength() to the live size while the buffer holds pointers and back to 0 afterward; any that store live JSValues without bracketing activeLength are the same GC-visibility class.
- Stack-clobbering during exits/thunks — Audit code around emitSaveCalleeSavesFor and frame reconstruction in DFG/FTL/baseline thunks for intervals where a value’s only copy migrates into a scratch region; the architectural class is ’transient sole-retainer not exposed to the conservative collector.'
- addressOfActiveLength callers — Search for addressOfActiveLength to enumerate every place that publishes a scratch buffer to the GC and confirm the set/reset are correctly paired and cover the whole clobber window, since an early reset re-opens the UAF.