[JSC] Fix incorrect handling of unwritten slots in operationPopulateObjectInOSR for PhantomNewArrayWithButterfly
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
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
Patch Details
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.
Background
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.
Analysis
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:
- Two hundred calls to
opt()walk it up to FTL. arr.map(f)is inlined; the result array's allocation is sunk to aPhantomNewArrayWithButterfly, and the builtin's per-iteration@putByValDirect(result, i, newValue)stores becomePutHints on it.- On iteration zero,
fexecutes(0)[0]— aGetByValwhose base is anInt32where the FTL speculated a cell. - 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.
- Materialization allocates the seven-element result for real and walks slots 0..6, feeding the unwritten ones to
putDirectIndex. ret[0] = 1then 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.
Insight
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).
Audit directions
-
Deferred/partial state records replayed into a structure whose store API assumes every entry is present. The invariant: a materializer must distinguish "recorded as X" from "never recorded", and must have a distinct emission path for the latter. Narrow — audit every phantom node kind handled by
operationMaterializeObjectInOSR/operationPopulateObjectInOSRinSource/JavaScriptCore/ftl/FTLOperations.cppand the DFG equivalent; for each indexed or named slot loop, check whether the absent case has its own branch before the store call. Match tell: a loop over slot indices that computes a value and calls a store helper unconditionally, with noif (!value)/isEmpty()guard in front. Wider — the same shape appears anywhere JSC replays recorded state into a real object:DFGOSRExitvalue recoveries,Materialize*handling inDFGObjectAllocationSinkingPhase.cpp, arguments-object materialization (PhantomDirectArguments,PhantomClonedArguments), and structure-transition replay; the tell is any consumer treating a default-constructed recovery entry as a legitimate value. Widest — the principle is a partial record replayed into a total structure needs an explicit absent-value encoding, and it holds outside WebKit in any deoptimizing runtime (V8's Turbofan escape-analysis deopt materialization, SpiderMonkey's recover instructions and bailout snapshots) and in ordinary serialization code, where protobuf/JSON round-trips collapse "field unset" into "field is zero". The question to carry into those codebases: does the replay path have a third case besides "value" and "default", and is the caller's precondition checked against it? -
Internal sentinel values escaping into a script-visible container. The invariant: a sentinel JavaScript cannot name must never be reachable through a normal indexed or named read. Narrow — grep
Source/JavaScriptCore/ftl/FTLOperations.cpp,Source/JavaScriptCore/dfg/DFGOperations.cpp, andSource/JavaScriptCore/runtime/JSObject.cppfor calls toputDirectIndex,putDirectMayBeIndex, andinitializeIndexwhose value operand originates from an exit scratch buffer or a recovery record; the tell is aJSValueflowing from an OSR structure into a store helper with no interveningif (value)orASSERT(value). Wider — the class covers every WebKit path where hole representation is chosen per indexing type: confirm contiguous (empty), double (poisoned NaN), and ArrayStorage backings are each handled wherever a slot is skipped, since a fix covering only contiguous storage would still be wrong forArrayWithDouble; navigate via callers ofconvertContiguousToArrayStorageand theensure*family. Widest — in-band sentinels leak when producer and consumer disagree about the encoding, which applies to any runtime with tagged values and reserved bit patterns: V8'sthe_holein holey arrays, CPython'sNULL-vs-Py_Nonein list internals, Rust'sMaybeUninitslices. Whenever a data structure reserves a "no value here" encoding, every write path into it needs auditing for whether that encoding can arrive as ordinary data. -
Control-dependent writes inside an inlined builtin loop whose target allocation has been sunk. The invariant: the number of hints materialized must match the number of stores actually executed on the taken path, not the number the loop would eventually perform. Narrow — audit the builtins in
Source/JavaScriptCore/builtins/ArrayPrototype.jsthat allocate a result array and fill it index-by-index under a user callback (map,filter,flatMap,splice-adjacent helpers), asking what each materializes when the callback exits at iteration k of n; this commit covers themapcase, so the siblings are the immediate variant set. Match tell: a builtin calling@arraySpeciesCreate/@newArrayWithSizeand then writing with@putByValDirectinside a loop whose body re-enters user JS. Wider — the class covers any inlined region where a user callback can force an exit between allocation and full initialization: iterator helpers,Promisecombinator builtins, typed-arrayfrom/ofpaths; in code search, look for an allocation node dominated by a loop whose body can re-enter user JS. Widest — this is the general partially initialized object made observable by an early transfer of control class, which reaches constructors that publishthisbefore finishing, scalar replacement in any JIT's escape analysis, and exception paths in C++ constructors. If control can leave a region between allocation and completed initialization, some component must define what the half-built object looks like from outside. -
The same reconstruction logic implemented twice across tiers, with a fix landing in only one. Narrow — diff the slot-population loops in
Source/JavaScriptCore/ftl/FTLOperations.cppagainst the corresponding recovery handling inSource/JavaScriptCore/dfg/DFGOSRExit.cppandDFGOSRExitCompilerCommon.cppfor each phantom node type, and confirm both branch on the absent-slot case; the tell is a phantom node kind enumerated in one file's switch and handled differently — or not at all — in the other. Wider — the class generalizes to every piece of semantics JSC implements twice for DFG and FTL: abstract interpretation rules, clobberize effects, safe-to-execute rules, where a soundness fix in one tier leaves the other reachable through a different tiering path. Widest — semantics duplicated across two implementations of the same abstraction diverge silently unless a shared oracle exercises both, the same audit question that applies to V8's Turbofan-versus-Maglev lowering, to LLVM's per-target instruction selection duplicating a generic combine, and to any dual-path validator. Establishing divergence here is not a grep exercise; it likely means running the new regression test under DFG-only and FTL-only tier configurations and comparing.