[1] [JSC] Fix GC safety for sunk contiguous array materialization in FTL
B3 was right that the value was dead — the collector agreed, and freed it.
High. The collector can free objects that JavaScript still holds a live reference to, and both the freed type and the reclaiming type are chosen by script. Escalation is gated on landing a collection inside one allocation slow path — a heap-grooming problem, not an architectural one.
Garbage collectors need to see every live pointer at every point where a collection can run. JSC's collector finds most pointers by tracing from object to object, and finds the rest by conservatively scanning the machine stack and callee-saved registers for words that look like cell pointers. The FTL is JSC's top-tier optimizing JIT, and its final stage lowers the DFG's IR into B3 — a backend that computes value liveness by backward dataflow, so a value's register or stack slot becomes reusable immediately after its last use in the IR. A Butterfly — the separately allocated block holding an object's indexed elements — is traced only through the cell that owns it, so any cell pointer written into a butterfly is invisible to the collector until the owning array header exists and points at it.
The angle: any script can hot-loop a function whose array is rebuilt by the JIT, land a collection in the array-header allocation, and end up holding a live array whose elements point at freed objects it can then have reclaimed by objects of its own choosing.
The commit message states the mechanism directly:
compileMaterializeNewArrayWithButterflywrites element values into a raw butterfly before allocating the JSArray header. For contiguous arrays the element values are GC cell pointers, but their last B3 use is the store64 into the butterfly. B3 backward liveness therefore marks them dead at that point, so they are absent from the stack whenallocateJSArray's slow path triggers a collection — the butterfly is unowned and the GC does not trace its contents.Fix by collecting contiguous element values and calling
ensureStillAliveHereafterallocateJSArray. This inserts a zero-instruction patchpoint that is a formal B3 use of each value, extending their liveness backward through the allocation slow path and forcing them onto the stack where the GC conservative scanner can find them.INT32 and DOUBLE elements are not cell pointers and need no treatment.
Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
JSTests/stress/ftl-materialize-new-array-with-butterfly.js
Patch Details
Two changes land in LowerDFGToB3::compileMaterializeNewArrayWithButterfly, plus a helper generalization. The element-store loop previously handled ALL_INT32_INDEXING_TYPES and ALL_CONTIGUOUS_INDEXING_TYPES in one shared switch arm; the patch splits INT32 into its own byte-identical block so that only contiguous element values are accumulated into a new Vector<LValue> contiguousElementValues as they are stored with m_out.store64(value, butterfly, ...). Immediately after LValue array = allocateJSArray(indexingType, publicLength, butterfly); — and before setJSValue(array) and mutatorFence() — the patch calls ensureStillAliveHere(contiguousElementValues).
ensureStillAliveHere(LValue) is refactored into ensureStillAliveHereImpl(const Vector<LValue>&): an early return when the vector is empty, then a single zero-instruction PatchpointValue (Effects::none() plus writesLocalState, reads = HeapRange::top()) that appends every value with ValueRep::ColdAny and installs an empty generator. Two thin overloads cover the vector and single-value call shapes. The new stress test JSTests/stress/ftl-materialize-new-array-with-butterfly.js runs under --useConcurrentJIT=false --jitPolicyScale=0.1 --slowPathAllocsBetweenGCs=3 and detects reclamation by comparing arr[0] against freshly allocated objects.
Compiler liveness analysis treating a store into not-yet-traced memory as the last use of a heap pointer, dropping it from the collector's root set across a later allocation safepoint.
Background
Where this lives. The FTL is JSC's highest optimization tier. FTLLowerDFGToB3 translates the DFG's IR into B3, the low-level SSA IR that the FTL backend register-allocates and emits machine code from.
Object allocation sinking and materialization. Object allocation sinking is a DFG optimization that removes an allocation entirely when the allocated object provably does not escape on some paths. On the paths where it does escape, the compiler inserts a materialization node that recreates the object from the recorded field and element values. MaterializeNewArrayWithButterfly is the node form that recreates an array: an out-of-line Butterfly holding the elements, plus the JSArray cell header that points at it.
Butterflies and indexing types. A butterfly is the separately allocated memory block holding an object's indexed elements and out-of-line properties. The GC traces a butterfly only through the cell that owns it. The indexing type determines what the elements are: Int32 and Double butterflies hold unboxed or boxed non-pointer values, while Contiguous butterflies hold arbitrary JSValues, which may be pointers to GC cells.
B3 liveness. B3's backend computes value liveness by backward dataflow: a value is live from its definition to its last use, and after that last use its register or stack slot is free for reuse. The IR has no notion of "this value must remain findable by the garbage collector" — liveness is purely about IR uses.
Conservative stack scanning. JSC's collector scans the machine stack and callee-saved registers word by word and treats anything resembling a cell pointer as a root. This is why a value merely sitting in a stack slot is enough to keep an object alive, and why a value the register allocator has dropped is not.
ensureStillAliveHere. An FTL helper that emits a PatchpointValue with an empty generator — zero machine instructions — which formally uses the given values. Because it is a use, B3 liveness keeps those values alive up to that program point, and the register allocator must keep them somewhere the conservative scanner can observe. The idiom exists because the IR cannot express the GC dependency any other way: there is no B3 "root" concept, so a fake use is the only lever available.
Allocation slow paths and mutatorFence. Inline JIT allocation pulls from thread-local free lists; when a free list is empty it falls back to a runtime call, and that runtime path is a point at which a collection may run. mutatorFence() is the store-store fence emitted after object initialization so that a concurrent collector never observes a partially initialized cell.
--slowPathAllocsBetweenGCs=N. A JSC debug option that makes the runtime perform a collection every N slow-path allocations, used by GC stress tests to make collection timing deterministic once a slow path is actually reached.
Analysis
This is a use-after-free via premature garbage collection — a GC-safety hole in JIT lowering that degenerates into type confusion once the freed cells are reclaimed.
Pre-fix emitted sequence GC view of element cells
──────────────────────── ────────────────────────
butterfly = allocate(...) (untraced raw memory)
store64 value0 -> butterfly[0] value0 LAST B3 USE -> dead
store64 value1 -> butterfly[1] value1 LAST B3 USE -> dead
array = allocateJSArray(...)
└─► slow path -> runtime
└─► COLLECTION RUNS ──► butterfly unowned: not traced
value0/value1 not on stack: not roots
=> both objects freed
setJSValue(array) array published with dangling elements
mutatorFence()
In the diagram above, the two store64s are the final IR uses of the element values, so B3's backward liveness marks them dead the instant the store is emitted and the register allocator may reuse their slots. The collection that runs inside allocateJSArray's slow path then has neither route to the objects: the marking phase cannot reach them because the JSArray header that would own the butterfly does not exist yet, and the conservative scanner cannot reach them because nothing guarantees they still occupy a stack slot or callee-saved register. Execution continues, setJSValue(array) publishes a JSArray whose butterfly still holds the now-dangling pointers, and subsequent reads of arr[0]/arr[1] hand JavaScript references to freed and potentially reallocated cells. INT32 and DOUBLE elements are not cell pointers, which is exactly why the patch splits the INT32 case out of the shared switch arm and accumulates only the contiguous ones.
The patch's own comment states the invariant it restores: "the butterfly is unowned at allocateCell and the GC won't trace its contents."
The regression test is a hand-written UAF oracle rather than a crash test. Walking it:
opt(escape)allocatesnew Array(2), stores two{}object references, and returns the array only whenescapeis true — so the array does not escape on most paths, which is what makes allocation sinking fire.- 1000 warm-up calls with
escapetrue one time in ten tier the function up to FTL and supply the profile the compiler needs to sink the allocation. - On the escaping path the FTL emits
MaterializeNewArrayWithButterfly, storing the two cell pointers into the butterfly withstore64, then callingallocateJSArray. - The test's allocation pressure drives that header allocation into its slow path, and
--slowPathAllocsBetweenGCs=3guarantees that a collection runs there. The flag forces a GC every third slow-path allocation; it does not itself force the slow path to be taken. - The second loop allocates fresh
{}objects and checksarr[0] === object. Pre-fix, the two element objects had been reclaimed, so a newly allocated object could reuse a freed cell and the identity comparison would hold — a direct oracle for the reclamation.
Without the debug flag, an attacker would have to steer the collection themselves: groom the heap so that the array-header allocation for the relevant size class hits an empty free list, and sustain enough allocation pressure that the slow path elects to collect. If that timing is achieved, the freed element cells would be reclaimed by attacker-controlled allocations of the same size class, and the surviving array would then alias attacker-chosen objects through arr[0]; reading that element as one type while the reclaiming allocation is another type would give the classic addrof/fakeobj pair, which would in turn provide arbitrary read/write in the renderer address space. What makes this materially more useful than most UAFs is that the element values are ordinary JS values the attacker writes (arr[0] = <any object>), so both the freed type and the reclaiming type are under attacker control.
Exploitation is confined to the WebContent process — this is JSC heap corruption in the renderer, and a separate sandbox escape would still be required for system compromise. FTL must be enabled, so JIT-disabled configurations such as Lockdown Mode are not affected by this path.
The likely discovery route is pattern auditing over JSC's GC-safety convention rather than blind fuzzing. The fix is expressed entirely in terms of the ensureStillAliveHere idiom, and the natural way to find it is to enumerate places where cell pointers are stored into unowned memory ahead of an allocation and check which ones lack a keepalive. The test's shape supports that reading: a minimal hand-written identity oracle, not reduced fuzzer output, plus a debug flag chosen to make a collection the auditor already predicted happen deterministically.
This vulnerability weakens memory safety inside the WebContent process's JavaScript engine. The security-model assumption at stake is JSC's core GC invariant: every reachable cell must be visible to the collector's root set at every safepoint, either through a traced owner object or through a conservatively scanned stack or register slot. Before the fix, JIT-generated code could hold the only reference to live objects inside a butterfly that no cell owned yet, so the collector could free objects that JavaScript still holds a reference to.
Insight
This bug class is created by an optimization doing its job correctly: B3's backward liveness is right that the value has no further IR use, and the register allocator is right to reuse the slot. The unsound assumption lives in the lowering, which relies on the value remaining discoverable by the collector — a property the IR does not model at all. JSC's only expression of that dependency is the manually placed ensureStillAliveHere keepalive, so any lowering that writes cell pointers into memory before the owning cell exists is correct only by convention, and the window is exactly [first store into unowned memory, publication of the owner]. Allocation sinking widens this class specifically because materialization deliberately reconstructs objects out of loose values the graph no longer holds anywhere else. Note that the fix does not change when the butterfly becomes traceable — it only extends the keepalive across the allocation. The structural alternatives (allocate the header first, or make the butterfly independently traceable) are not taken here, so the convention remains load-bearing.
Audit directions
-
Cell pointers stored into memory with no GC-visible owner yet. The invariant is from the moment a cell pointer is written into untraced memory until the owner is published, the pointer must also remain in a root the collector can see. Narrow: grep
FTLLowerDFGToB3.cppforstore64(into a freshly allocatedbutterfly/storageLValuethat is followed by anyallocate*/vmCall/lazy-slow-path beforemutatorFence()— start with the sibling materializers (compileMaterializeNewObject,compileNewArrayBuffer,compileNewArrayWithSpread,compileNewArrayWithSize) and every other caller ofallocateJSArray. Match tell: anLValueproduced bylowJSValuewhose textually last appearance is a store, with noensureStillAliveHerebetween that store and the next allocation. Wider: the same class appears wherever an optimizing backend's liveness is the only thing keeping a root alive — audit the DFGSpeculativeJITmaterialization paths,DFGOperationsslow paths that hold rawButterfly*/void*across a second allocation, and Wasm GC struct/array initialization lowering, where the tell is a raw pointer local surviving across a call that can collect. Widest: any managed runtime pairing an optimizing compiler with conservative or partially precise root scanning carries this invariant — V8/Turbofan's handle andRootVisitordiscipline, SpiderMonkey'sRooted/AutoSuppressGCregions, Go'sruntime.KeepAlive, JNI critical sections. The portable question: between the last IR use of a managed pointer and the next safepoint, is there any path where the pointer is only reachable from memory the collector does not yet trace? -
Partially initialized objects observed across a safepoint. Audit every FTL/DFG allocation sequence for a second allocation, or any runtime call, interleaved between the first allocation call and the closing
mutatorFence()— a collection landing there sees an object that is neither fully formed nor fully rooted. Start withFTLLowerDFGToB3.cppsites that allocate two coupled objects (cell + butterfly, cell + storage, iterator + internal fields) and check the ordering: allocating the owner first and the payload second removes the untraced window entirely, and asking why a given site chose the opposite order usually surfaces either a good reason or this bug. Match tell: two allocation calls with stores between them and a single trailing fence. Widest: any runtime with a two-step construct-then-publish protocol (Java's escape-analysis reconstruction, .NET tiered-JIT object init, C++ placement-new into a pool followed by another allocating call) — the invariant is nothing that can reclaim memory may run between the first write into an object and the moment the object becomes reachable to the reclaimer. -
Keepalive intrinsics as the sole correctness mechanism, so their absence is silent. Trace every existing call site of
ensureStillAliveHereinFTLLowerDFGToB3.cpp, reconstruct the rule each one encodes, then look for structurally identical code that lacks the call — a keepalive appearing in one of N similar lowerings is evidence the other N-1 were never audited. Verification here is not purely syntactic: confirming a candidate requires running the stress-test shape used in this commit (--slowPathAllocsBetweenGCs=<n>,--useConcurrentJIT=false, low--jitPolicyScale) plus an identity-comparison oracle against freshly allocated objects, as inftl-materialize-new-array-with-butterfly.js. Widest: the same audit applies to any codebase with an explicit keepalive primitive (GC.KeepAlive,runtime.KeepAlive,std::black_box-style barriers) — the invariant is if correctness depends on an intrinsic that generates no code, every peer call site must be enumerated, because omission is invisible to both the compiler and the reader. -
Type-directed switch arms sharing a fallthrough between GC-relevant and GC-irrelevant cases. The pre-fix code handled
ALL_INT32_INDEXING_TYPESandALL_CONTIGUOUS_INDEXING_TYPESin one arm precisely because the store instruction is identical — but the GC obligation differs. Review other indexing-type and value-format switches in the FTL and DFG backends (m_heaps.forIndexingType, theALL_*_INDEXING_TYPESmacros, boxed/unboxed format dispatch) for arms that merge a cell-carrying representation with a non-cell one. Match tell: a case-label list where at least one member can carry a cell and at least one cannot, and the shared body treats the value purely as 64 bits. Widest: any dispatch that unifies tagged and untagged representations because the machine operation coincides — the question is does every arm of this merged case share the same lifetime/ownership obligation, or only the same instruction encoding?