← All reports

[JSC] Fix incorrect handling of unwritten slots in operationPopulateObjectInOSR for PhantomNewArrayWithButterfly

MediumJSC FTL JIT — OSR-exit object materializationMemoryCorruption

CVE: CVE-2026-28905 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected process crash Apple's description: The issue was addressed with improved memory handling. Credit: Yuhao Hu, Yuanming Lai, Chenggang Wu, and Zhe Wang

392f508 | Bugzilla 308545

Medium. Apple's crash classification is likely the honest ceiling, and the escalation depends entirely on which of two landings the unwritten slot takes. What keeps this off the Low pile is the second one: an empty JSValue surviving into a butterfly whose published length covers it is value confusion, not a signal.

Optimizing JIT compilers earn much of their speed by refusing to allocate objects that never escape the compiled region — and thereby owe the runtime a way to build those objects back the moment a speculation fails. In JSC that debt comes due at OSR exit, where a runtime helper walks a compile-time record of hints and replays them into a freshly allocated heap object. For PhantomNewArrayWithButterfly — the phantom form of an array that owns indexed storage — that record is partial by construction, because a store to index k is only recorded if the path the program actually took got as far as k.

The angle: A page whose map callback forces the JIT to bail out mid-loop gets back an array whose never-written entries were published as values rather than holes.

JSTests/stress/ftl-osr-exit-phantom-array-unwritten-slot.js

+function opt() {
+ const arr = [0,0,0,0,0,0,0];
+ function f() {
+ arr[0];
+ (0)[0]; // property access on a number - forces a BadType OSR exit
+ return arr;
+ }
+ const ret = arr.map(f) // map's result array is a sunk allocation (PhantomNewArrayWithButterfly)
+ ret[0] = 1 // store into the array materialized by OSR exit
+}
+for (let i = 0; i < 200; i++) {
+ opt() // 200 iterations -> FTL tier-up
+}

The production change lands in operationPopulateObjectInOSR, the FTL helper that fills in a sunk allocation at the moment the top tier bails back down. Per the commit message, when a PhantomNewArrayWithButterfly is materialized during OSR exit, indexed slots that were conditionally not written before the exit point are now materialized as holes instead of being handed to putDirectIndex. In other words the population loop gains a second emission path: a slot with no recorded value no longer flows into a store helper whose contract is "write this real value here", it is simply left absent.

What ships alongside it is the regression test above, and the test is worth reading as a spec for the bug. arr is a seven-element array literal. arr.map(f) inlines JSC's Array.prototype.map builtin, so the result array is allocated inside the inlined body and becomes a sinking candidate. The callback f performs (0)[0] — a property access on a number — which the FTL has speculated to be a cell, so the speculation fails on the very first callback invocation. The trailing ret[0] = 1 is the payload of the test: it exercises the materialized array with a subsequent store, so a badly populated result is not merely constructed but touched. The 200-iteration driver loop exists purely to walk the function up through baseline and DFG into the FTL, where sinking and this materialization helper are in play.

Tiering and OSR exit. JSC runs a function in progressively more aggressive tiers — interpreter, baseline, DFG, FTL — promoting it after a few hundred invocations. The optimizing tiers compile speculatively: they assume values will keep the shapes profiling observed, and emit code valid only under those assumptions. When an assumption is violated, the engine performs an OSR exit: it abandons the optimized frame mid-execution and transfers to a lower tier, rebuilding the interpreter-visible state from a recovery record the compiler prepared at compile time.

Object allocation sinking. If the compiler can prove an allocated object never escapes the compiled region, it deletes the allocation outright and replaces the allocation node with a Phantom… node that performs no runtime work. The object's contents are then tracked symbolically: each store that would have happened becomes a PutHint, an IR node recording "at this program point, this field or index of the sunk object would hold this value."

Materialization. Sinking is only valid as long as nobody can observe the object. At any point where a sunk object becomes observable — an OSR exit being the canonical case — the runtime must allocate it for real and populate it from the recorded hints. operationPopulateObjectInOSR is the FTL-side helper that performs the population step.

PhantomNewArrayWithButterfly. The phantom form of an array allocation that owns a butterfly — the out-of-line storage block holding an object's indexed elements and named properties. Because it owns indexed storage, its recorded state includes indexed slots, not just named properties, and its population loop walks index positions.

Holes, and the empty JSValue. A hole is an index within an array's length that carries no value; a read of a hole does not stop at the array, it falls through to the prototype chain. How a hole is represented depends on the array's indexing type — an empty JSValue for contiguous storage, a poisoned NaN for double storage. The empty JSValue is the default-constructed JSValue() sentinel the engine uses internally to mean "nothing here"; it is not a value JavaScript source can name or produce.

putDirectIndex. JSObject's direct indexed-store entry point, used by builtins through @putByValDirect. It is the mechanism for storing a real value at an index, and is not the mechanism by which holes are created.

Array.prototype.map. In JSC this is a JS builtin, which means the optimizing tiers can inline its body into the caller. Its result array is allocated inside that inlined body and filled index by index in a loop whose body calls back into user JavaScript.

The root cause is a logic error in exit-state reconstruction: the population loop had one emission path where the hint record has two possible shapes.

  Before:                              After:
  populate(array, hints)               populate(array, hints)
    └─ for i in 0..len-1                 └─ for i in 0..len-1
         v = hints[i]                         v = hints[i]
         └─► putDirectIndex(i, v)             ├─ no hint ─► leave slot as hole
                ▲                             └─ hint ───► putDirectIndex(i, v)
                └─ i never written on the
                   taken path: v is the
                   "no value" sentinel

The left column is the shape before the fix. operationPopulateObjectInOSR walked every indexed slot of the materialized array and treated each one as if it carried a real value, feeding it straight into putDirectIndex. That is sound only when the hint set is total. It never is — partiality is the entire point of sinking. A PutHint for index k is control-dependent: it records a store that happens on one path and not another, or a store that would have happened on a later loop iteration the program never reached. When the exit is taken before that store executes, the recovery record for index k carries no value, and what flowed into the store helper was the internal "no value" sentinel rather than a JSValue the type system considers legal in that position.

The test's execution is the minimal way to reach that state:

  1. Two hundred calls to opt() walk it up to FTL.
  2. arr.map(f) is inlined; the result array's allocation is sunk to a PhantomNewArrayWithButterfly, and the builtin's per-iteration @putByValDirect(result, i, newValue) stores become PutHints on it.
  3. On iteration zero, f executes (0)[0] — a GetByVal whose base is an Int32 where the FTL speculated a cell.
  4. The speculation check fails and a BadType OSR exit fires. Zero of the seven result slots have been written; at most a prefix could have been.
  5. Materialization allocates the seven-element result for real and walks slots 0..6, feeding the unwritten ones to putDirectIndex.
  6. ret[0] = 1 then stores into the array that materialization just published.

Which of two landings step 5 produces depends on how an absent slot is encoded in the recovery record. An empty JSValue reaching putDirectIndex violates that function's precondition, so it would either trip a release assertion — an attacker-triggered renderer crash, which is what Apple's "unexpected process crash" wording describes — or write an empty value into a butterfly whose publicLength covers it. The second landing is the classic "empty JSValue observable from script" hazard: a subsequent read of ret[0] would return a bit pattern the type system believes cannot exist at that position, which is the starting point for value confusion rather than a mere signal. The commit's own framing — incorrect handling of unwritten slots — plus Apple's classification is consistent with the assertion path being the one that manifested in practice.

The fix restores the invariant the materializer is supposed to uphold: every indexed slot published into an array's butterfly is a fully initialized, script-representable value, and any index the executed path never wrote appears as a hole. Because the correct hole encoding is indexing-type dependent, "treat it as a hole" is not one instruction but a per-backing decision — contiguous storage wants an empty JSValue, double storage wants a poisoned NaN.

An index the executed path never wrote was replayed into the materialized array as if it held a value, handing an internal "no value" sentinel to a store path that requires a real one.

Sunk-allocation materialization is a recurring soft spot in JSC because it is the one place where the compiler's model of an object — a set of hints, some of them control-dependent — must be converted back into a real heap object that satisfies every runtime invariant simultaneously. The hint set is inherently partial; the store paths it feeds (putDirectIndex and friends) are written for the complete case. Each time a new phantom node type is introduced that owns indexed storage, as PhantomNewArrayWithButterfly does, the question what does "absent" mean for this storage kind has to be answered again from scratch — and the right answer for indexed storage (a hole) is not the right answer for named properties (usually undefined).