← All reports

[1] [JSC] Fix GC safety for sunk contiguous array materialization in FTL

HighJSC FTL JITUninitializedMemory

B3 was right that the value was dead — the collector agreed, and freed it.

e638840

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:

compileMaterializeNewArrayWithButterfly writes 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 when allocateJSArray'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 ensureStillAliveHere after allocateJSArray. 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

ObjectMaterializationData& data = m_node->objectMaterializationData();
 
+ // Contiguous element values may be GC cell pointers; keep them live across allocateJSArray
+ // since the butterfly is unowned at allocateCell and the GC won't trace its contents.
+ Vector<LValue> contiguousElementValues;
for (unsigned i = 0; i < data.m_properties.size(); ++i) {
...
- case ALL_INT32_INDEXING_TYPES:
+ case ALL_INT32_INDEXING_TYPES: {
+ LValue value = lowJSValue(edge, ManualOperandSpeculation);
+ m_out.store64(value, butterfly, m_heaps.forIndexingType(indexingType)->at(index));
+ break;
+ }
case ALL_CONTIGUOUS_INDEXING_TYPES: {
LValue value = lowJSValue(edge, ManualOperandSpeculation);
m_out.store64(value, butterfly, m_heaps.forIndexingType(indexingType)->at(index));
+ contiguousElementValues.append(value);
break;
}
...
LValue array = allocateJSArray(indexingType, publicLength, butterfly);
+ // Keep contiguous element values live across the GC point in allocateJSArray's slow path.
+ ensureStillAliveHere(contiguousElementValues);
setJSValue(array);
mutatorFence();
 
- void ensureStillAliveHere(LValue value)
+ void ensureStillAliveHereImpl(const Vector<LValue>& values)
{
+ if (values.isEmpty())
+ return;
PatchpointValue* patchpoint = m_out.patchpoint(Void);
...
- patchpoint->append(value, ValueRep::ColdAny);
+ for (LValue value : values)
+ patchpoint->append(value, ValueRep::ColdAny);
patchpoint->setGenerator([=] (CCallHelpers&, const StackmapGenerationParams&) { });
}
+ void ensureStillAliveHere(const Vector<LValue>& values) { ensureStillAliveHereImpl(values); }
+ void ensureStillAliveHere(LValue value) { ensureStillAliveHereImpl({ value }); }

JSTests/stress/ftl-materialize-new-array-with-butterfly.js

+//@ runDefault("--useConcurrentJIT=false", "--jitPolicyScale=0.1", "--slowPathAllocsBetweenGCs=3")
+function opt(escape) {
+ const arr = new Array(2);
+ arr[0] = {};
+ arr[1] = {};
+ if (escape)
+ return arr;
+ return 0;
+}
+function main() {
+ noInline(opt);
+ for (let i = 0; i < 1000; i++)
+ opt(!(i % 10));
+ for (let i = 0; i < 100; i++) {
+ const arr = opt(true);
+ for (let j = 0; j < 5; j++) {
+ const object = {};
+ if (arr[0] === object)
+ throw new Error("bad");
+ }
+ }
+}
+main();

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.

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.

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:

  1. opt(escape) allocates new Array(2), stores two {} object references, and returns the array only when escape is true — so the array does not escape on most paths, which is what makes allocation sinking fire.
  2. 1000 warm-up calls with escape true one time in ten tier the function up to FTL and supply the profile the compiler needs to sink the allocation.
  3. On the escaping path the FTL emits MaterializeNewArrayWithButterfly, storing the two cell pointers into the butterfly with store64, then calling allocateJSArray.
  4. The test's allocation pressure drives that header allocation into its slow path, and --slowPathAllocsBetweenGCs=3 guarantees 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.
  5. The second loop allocates fresh {} objects and checks arr[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.

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.