e638840dd2 [JSC] Fix GC safety for sunk contiguous array materialization in FTL
Triage note: Element cell pointers stored into an unowned butterfly could be freed at the GC point in allocateJSArray's slow path; ensureStillAliveHere fixes the UAF.
Contents
The bug at a glance
A GC-safety defect in FTL’s compileMaterializeNewArrayWithButterfly leaves contiguous-array element cell pointers off the stack across the GC point inside allocateJSArray’s slow path, so a collection can free live objects that the reconstructed array still references — a genuine use-after-free reachable from ordinary JS that allocates and escapes small arrays of objects under memory pressure. UAF in the JIT-materialized heap is a premier exploitation primitive (freed object reuse -> type confusion). High is correct; it is a real memory-safety bug demonstrated by a regression test.
When FTL sinks/rematerializes a NewArray with a contiguous butterfly, it stores each element (a GC cell pointer) into a raw, still-unowned butterfly; the store64 is the value’s last B3 use, so backward liveness marks it dead and it is absent from the stack when allocateJSArray’s slow path triggers GC — the GC neither sees the stack copy nor traces the unowned butterfly, so the live cell is collected.
Root cause
In FTLLowerDFGToB3, compileMaterializeNewArrayWithButterfly reconstructs an array object that was sunk by object allocation sinking. It first writes the element values into a freshly-computed butterfly (raw memory) and then calls allocateJSArray(indexingType, publicLength, butterfly) to allocate the JSArray header cell that will own that butterfly. For contiguous indexing types the element values are full JSValues that may be GC cell pointers (e.g. the {} objects in the repro). The write is m_out.store64(value, butterfly, m_heaps.forIndexingType(indexingType)->at(index));.
The problem is a lifetime gap created by B3’s backward liveness analysis. Once a value’s last use is the store64 into the butterfly, B3 considers the value dead immediately after that instruction. So by the time control reaches allocateJSArray, those element LValues are no longer live and the register allocator is free to not keep them anywhere the GC can find. allocateJSArray has a slow path (when the inline allocator is exhausted — the test forces this with –slowPathAllocsBetweenGCs=3) that calls into the runtime and can trigger a garbage collection. At that GC point: (1) the butterfly is not yet owned by any cell — the JSArray header has not been allocated/linked — so the GC’s precise marker does not trace the butterfly’s contents; and (2) the element cell pointers are dead per liveness, so they are not on the stack for the conservative scanner to find. The live objects are therefore unreachable to the collector and get freed, even though the about-to-be-created array still holds their pointers in the butterfly. After allocation the array is handed back containing dangling pointers — a use-after-free.
The fix collects the contiguous element LValues into Vector<LValue> contiguousElementValues;, appends each as it is stored, and after allocateJSArray calls ensureStillAliveHere(contiguousElementValues);. ensureStillAliveHere emits a zero-instruction PatchpointValue with Effects::none() plus writesLocalState/reads=top that formally uses each value with ValueRep::ColdAny. This extends each value’s backward liveness across the allocation (including the slow-path GC point) and forces the register allocator to keep the pointers materialized on the stack, where the conservative scanner finds and marks them — keeping the objects alive until the array owns the butterfly. INT32 and DOUBLE indexing types are excluded because their elements are not cell pointers; the patch also splits ALL_INT32_INDEXING_TYPES into its own case (storing but not tracking) so only ALL_CONTIGUOUS_INDEXING_TYPES values are appended. ensureStillAliveHere is refactored into an ensureStillAliveHereImpl taking a Vector with single-value and vector overloads.
Key code
FTLLowerDFGToB3.cpp: keep contiguous element cells live across allocateJSArray
// 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) {
// Add two to account for `size` and `butterfly`
Edge edge = m_graph.varArgChild(m_node, i + 2);
...
case ALL_CONTIGUOUS_INDEXING_TYPES: {
LValue value = lowJSValue(edge, ManualOperandSpeculation); // We already speculated it so it is fine.
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();
Patch walkthrough
Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp— In compileMaterializeNewArrayWithButterfly a Vector<LValue> contiguousElementValues is added; each ALL_CONTIGUOUS_INDEXING_TYPES element value stored via store64 is appended to it (ALL_INT32_INDEXING_TYPES is split into its own case that stores but does not append, since int32s aren’t cell pointers). After allocateJSArray, ensureStillAliveHere(contiguousElementValues) is called to keep the cell pointers live across the slow-path GC point. ensureStillAliveHere is refactored: ensureStillAliveHereImpl(const Vector<LValue>&) builds one patchpoint appending every value with ColdAny, with thin single-value and vector overloads.JSTests/stress/ftl-materialize-new-array-with-butterfly.js— Regression test: opt() creates new Array(2), fills it with two object literals, and conditionally returns it so allocation sinking + rematerialization applies. Run with –slowPathAllocsBetweenGCs=3 to force allocateJSArray’s slow-path GC. After warm-up it repeatedly reads arr[0] and checks it is never accidentally equal to a fresh object — detecting the freed/reused cell that the pre-fix UAF would produce.
Background
compileMaterializeNewArrayWithButterfly — FTL lowering that rematerializes an array (and its butterfly) that DFG object-allocation-sinking deferred, by writing elements into a raw butterfly then allocating the JSArray header that owns it.
B3 backward liveness — B3 determines a value is dead immediately after its last use. If a cell pointer’s last use is the store into the butterfly, it becomes eligible to be dropped from registers/stack right after — even though logically still needed for GC.
Unowned butterfly / conservative scan — Before the JSArray header exists, no cell owns the butterfly, so precise marking cannot trace it. JSC’s GC relies on a conservative stack scan for such transient values — but only if the pointer is actually on the stack.
ensureStillAliveHere — Emits a zero-instruction patchpoint that formally consumes given LValues (ColdAny), extending their liveness so the register allocator keeps them stack-resident across a following operation — a standard JSC GC-safety idiom.
allocateJSArray slow path — When inline allocation fails it calls the runtime, which may trigger a GC. –slowPathAllocsBetweenGCs=3 forces this frequently to expose the window.
Vulnerability window
- Warm-up — opt() runs 1000 times so FTL compiles it with allocation sinking of the new Array(2).
- Store elements — Rematerialization stores the two object-literal cell pointers into the raw butterfly; store64 is their last B3 use, so they go dead.
- Allocate — allocateJSArray hits its slow path (forced by slowPathAllocsBetweenGCs=3) and triggers a GC while the butterfly is still unowned.
- Collect — GC finds neither the unowned butterfly’s contents nor the (dead) cell pointers on the stack, and frees the live objects.
- UAF — The finished array holds dangling pointers; arr[0] now references freed memory, reusable as a fresh object — detected by the test’s equality check.
- Fixed — ensureStillAliveHere keeps the pointers stack-resident across the GC point, so the conservative scanner marks them and they survive.
Proof of concept
opt() allocates a 2-element array of object literals and conditionally escapes it, so FTL sinks then rematerializes it. –slowPathAllocsBetweenGCs=3 forces allocateJSArray’s slow-path GC during rematerialization. On a vulnerable build the object cells are collected mid-materialization and their memory reused; the loop allocating fresh objects and comparing to arr[0] catches the reused/freed cell (throws “bad”).
//@ 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();
Exploitation
- Force sinking — Write a hot function that allocates a small contiguous array of objects and conditionally escapes it so FTL applies allocation sinking + rematerialization.
- Induce GC — Apply allocation pressure so allocateJSArray takes its slow path and collects during materialization, freeing the element cells.
- Reclaim — Spray replacement objects to reoccupy the freed cell’s memory with attacker-controlled contents.
- Type confusion — Access arr[0] (now the reused allocation) as the original type to obtain addrof/fakeobj and escalate to arbitrary read/write.
Detection & hunting
For defenders and SOC / detection engineers:
- GC-stress crashes in FTL arrays —
- Zombie/poisoned cell reads —
- Liveness audits —
Audit directions
- Unowned-butterfly stores —
- ensureStillAliveHere coverage —
- Indexing-type classification —
- Object allocation sinking —