[JSC] Fix B3 ReduceStrength Select specialization when a Check appears between the Select and triggering Check
CVE: CVE-2026-64715 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected process crash Apple's description: A use-after-free issue was addressed with improved memory management. Credit: Hossein Lotfi (@hosselot) of TrendAI Zero Day Initiative
Medium. A compiler-internal cache kept a raw pointer to an IR node the optimizer had just destroyed, and the cache's own "is this entry still valid?" test is a load from that freed node. Realistic outcome is a compiler-thread crash; steering a later CSE decision would need the slot reclaimed by another IR node first.
JavaScriptCore's top optimizing tier lowers JavaScript into B3, an SSA intermediate representation whose nodes are heap-allocated objects that optimization phases freely rewrite, clone, and destroy. Phases speed themselves up by memoizing on node identity — PureCSE maps a structural hash of each pure computation to a small vector of the raw Value* pointers that produce it, so a later occurrence can be folded into the earlier one. That memoization carries an obligation the table itself cannot enforce: every pointer parked in it must outlive the table, or be pulled out before the node dies.
The angle: Web content that compiles a function with the right IR shape can make the JIT compiler thread read an IR node it already freed, crashing the renderer.
Source/JavaScriptCore/b3/B3ReduceStrength.cpp
Source/JavaScriptCore/b3/B3PureCSE.cpp
Source/JavaScriptCore/b3/B3PureCSE.h
Source/JavaScriptCore/b3/testb3_6.cpp
Patch Details
Two production changes and a regression test, all inside Source/JavaScriptCore/b3/.
B3PureCSE.{h,cpp} gains a new method, PureCSE::remove(const ValueKey&, Value*). It early-returns on a null key, looks the key up in m_map, early-returns if the key is absent, and otherwise calls matches.removeAll(value) on the Matches bucket — a Vector<Value*, 1>. It deliberately does not erase a bucket that has become empty; that costs nothing, because both findMatch() and process() simply iterate the vector and an empty one yields no candidates.
B3ReduceStrength.cpp wires that method into specializeSelect(). Inside the loop over values between the Select's block-split point and the triggering Check — for (unsigned i = startIndex + 1; i < predecessor->size(); ++i) — the patch hoists ValueKey key = value->key(); to the top of the body, with a comment stating the reason: cloneValue() mutates the value, so the key has to be captured first. The Void arm of the type test is then converted from a bare else into a block that calls m_pureCSE.remove(key, value) immediately before m_proc.deleteValue(value). The value->owner = nullptr; line and the non-Void m_insertionSet.insertValue(...) path are untouched.
Test collateral is a new B3 IR unit test, testCheckSelectAndDeadCheckCSE() in testb3_6.cpp, declared in testb3.h and registered via RUN(...) in testb3_1.cpp's run(). It sits directly beside the pre-existing testCheckSelectAndCSE() and adds the one ingredient that test lacks: a Void Check in the specialized range whose predicate is reused afterwards.
Background
B3. JavaScriptCore's low-level SSA intermediate representation and backend. It is the compilation target for the FTL — the top-tier JavaScript JIT — and for the WebAssembly OMG tier. A Procedure owns a set of BasicBlocks, each of which holds an ordered list of Values. Each Value carries an owner pointer back to its containing block, and Procedure::deleteValue(Value*) removes a value from the procedure's value collection.
reduceStrength. A B3 phase performing peephole rewrites, constant folding, reassociation and canonicalization. It sweeps values in block order and runs to fixpoint, so the same block can be revisited across iterations. The phase object holds state that survives the entire sweep.
Dominators. dominators.dominates(a, b) is true when every path reaching block b passes through block a. It is the condition under which a value already computed at a can be reused at b instead of recomputed.
ValueKey and PureCSE. A ValueKey is a structural hash key derived from a value's opcode, type and children — two values sharing a key compute the same thing. PureCSE is a reusable common-subexpression-elimination table mapping ValueKey to Matches, a Vector<Value*, 1>. process(value, dominators) records the value under its key and, when an already-recorded value with the same key dominates the current one, rewrites the current value via replaceWithIdentity(match). findMatch(key, block, dominators) performs the same dominator-filtered lookup without recording. Both filter candidates by consulting match->owner and match->owner->isInserted().
Check and Select. A Check tests a predicate and, when it is nonzero, jumps to an out-of-line stackmap generator — in the FTL, this is how OSR exits and speculation failures are expressed. A Check has type Void: it produces no data value. Even so, PureCSE::process() records Checks, because a later Branch on the same predicate can then be resolved against a dominating Check. A Select is B3's branchless ternary: Select(cond, a, b) yields a or b.
specializeSelect(). A ReduceStrength transform. When a Check's predicate derives from a Select with at least one constant arm, and the two sit within selectSpecializationBound values of each other (3 in the current source), the transform splits the block at the Check and clones the intervening values into a then/else pair of arms, so each arm sees a constant where the Select used to be, with Phis at the merge. Non-Void originals are re-inserted at the merge; Void originals are removed from the original block and re-emitted in both arms.
Analysis
The bug is a use-after-free of a compiler IR node, caused by a destroy operation and a memoization table that were never taught about each other.
reduceStrength sweep over the block
─────────────────────────────────────────────────────────────
i=1 Select(arg0, -42, 35) ── process() ─► m_pureCSE[key(Select…)] += @sel
i=2 Check(@cond) ── process() ─► m_pureCSE[key(Check,@cond)] += @chk
i=3 Add(@sel, 42)
i=4 Check(@add) ── triggers specializeSelect()
├─ clone @chk into then/else arms
├─ @chk->owner = nullptr (tombstone)
└─ deleteValue(@chk) (tombstone freed)
i=5 Check(@cond) ── process() ─► bucket still holds @chk
└─► load @chk->owner ✗ UAF
Sweep order is what loads the gun. The intermediate Check at step 2 precedes the triggering Check, so PureCSE::process() has already filed it under ValueKey(Check, Void, condition) by the time specialization fires at step 4. Specialization then clones that Check into both arms and destroys the original — correctly, from its own point of view; the node no longer belongs anywhere in the block. What it does not do, before the fix, is tell m_pureCSE. The stale Value* stays in the bucket for the rest of the sweep, and ReduceStrength keeps its PureCSE instance alive across the whole procedure.
The trap is that PureCSE does have an invalidation protocol, and specializeSelect() does honour it — which is precisely why the code reads as correct. The tombstone is owner == nullptr, and both consumers check it:
for (Value* match : iter->value) {
// Value is invalidated.
if (!match->owner)
continue;
if (!match->owner->isInserted())
continue;
if (dominators.dominates(match->owner, block))
return match;
}
The tombstone lives inside the object whose liveness is in question. That works fine for a value that has merely been unhooked from its block and left allocated; it collapses the moment deleteValue() runs, because if (!match->owner) — the statement that implements the liveness check — is itself a load from memory that no longer backs a live Value. specializeSelect() sets value->owner = nullptr on the line above m_proc.deleteValue(value): the polite gesture, immediately followed by the fatal one.
The trigger is exactly what the new regression test assembles. Five ingredients in one block:
- A
Selectwith a constant arm — the specialization candidate. - A
Checkon a plain argument, sitting between the Select and the Check that will trigger specialization. Void, therefore deleted rather than re-inserted. - An
Addconsuming the Select, keeping the Select withinselectSpecializationBoundof step 4. - A
Checkon theAdd— the trigger, reaching the Select within the bound. - A second
Checkon the same condition as step 2, soPureCSE::process()looks up the key whose bucket still holds the freed pointer.
The fix closes the gap on both sides. PureCSE::remove() gives the table a deregistration entry point, and specializeSelect() calls it on the Void path immediately before deleteValue(), so the bucket no longer names a node that is about to stop existing. The ordering detail in the patch is load-bearing in its own right: ValueKey is derived from opcode, type and children, and cloneValue() mutates the value — computing the key after the clone would produce a key that misses in m_map.find(key), and the removal would quietly do nothing. Hence ValueKey key = value->key(); hoisted above cloneValue(value), with the comment spelling out why.
What an attacker gets out of this is bounded. Reaching it needs web content that tiers up a function — or Wasm through OMG — into an IR shape matching the five-step pattern above, and the payoff is a compile-time read of a destroyed Value on the concurrent compiler thread: the match->owner load and the chained match->owner->isInserted(). No attacker-supplied data lands in the freed slot; the only plausible reclaimer is another Value subclass allocated by the same phase. That caps the realistic outcome at a controlled crash of the WebContent process, which is what Apple's advisory reports. Escalation would require the freed slot to be reclaimed such that a later CSE or branch-folding decision is steered by the stale entry — miscompilation of attacker-supplied code rather than heap-data control. No process or sandbox boundary is crossed either way; FTL compilation runs in-process, so a fault there takes down the renderer the content already occupies.
A CSE table kept a raw pointer to an IR node the optimizer deleted, and the table's liveness check — a load of that node's owner field — was itself the use-after-free.
Insight
The fix's own fragility is the more transferable lesson. PureCSE::remove() is keyed on value->key(), and ValueKey derives from opcode and children — mutable state, which is why the patch carries the comment "Compute before cloneValue mutates the Value". A hash-keyed removal whose key is recomputed from mutable object state fails silently: m_map.find(key) simply misses, remove() returns, and nothing complains. A future refactor that moves the key() computation one line down reintroduces this exact CVE with no signal other than the single regression test added here. Removal-by-derived-key deserves the same scrutiny that insertion-by-derived-key already gets.
Audit directions
-
Raw IR back-pointers held across a destroying operation. Narrow: grep
Source/JavaScriptCore/b3/fordeleteValue(and ask whether the enclosing phase holds a livePureCSE, aHashMap<..., Value*>, or anIndexSet/IndexMapkeyed on value identity that isn't cleared or updated at that point —B3ReduceStrength.cpp(transforms other thanspecializeSelect),B3EliminateCommonSubexpressions.cpp,B3ReduceLoopStrength.cpp,B3LowerMacros.cppare the starting set; the tell is adeleteValue/replaceWithIdentitytextually inside the same function scope as a container of rawValue*. Wider: the same class recurs wherever a rewrite engine memoizes on node identity — B3 Air's per-CodeTmp/Instside tables, and any phase cachingBasicBlock*acrossBlockInsertionSetmutations; the search tell is a container declared as a phase member (so it survives the whole sweep) whose element type is a bare pointer into a collection the same phase mutates. Widest: the invariant — a memo table keyed on node identity must be invalidated by the same operation that destroys nodes, or must store a self-invalidating handle — is why LLVM shipsCallbackVH/WeakVH, why V8's Turbofan usesNodeMarkerand zone-scoped GVN caches, and why Cranelift's egraph uses arena indices. The portable tell is a deletion API with neither an invalidation callback nor a generation counter. -
Liveness flags stored inside the memory whose validity is in question. Narrow: audit
PureCSE::findMatch()andPureCSE::process()— both filter withif (!match->owner)andmatch->owner->isInserted()— then look for B3/Air iterators of the same shape: a loop over cached pointers whose first statement dereferences the candidate in order to decide whether the candidate is usable. Tell: a predicate of the formif (!p->someField) continue;on a pointer sourced from a long-lived container. Wider: the class appears anywhere WebKit storesT*alongside a convention like "skip ift->m_dead" instead ofWeakPtr/CanMakeWeakPtr— WebCore and JSC containers holding bare pointers with an in-object dead flag rather than zeroing weak references; the tell is a comment like "Value is invalidated" sitting directly above a dereference. Widest: a liveness check must not require loading from the object being checked. That transfers to LLVMWeakVHversus rawValue*, to Rust slotmap and ECS generational indices, and to any handle table with an external generation counter — carry the question "where does the validity bit live relative to the allocation?" into any codebase you review. -
Hash removal keyed on mutable derived state. Narrow: verify that every present and future caller of the new
PureCSE::remove()computesvalue->key()before any mutation of the value; the patch's own comment inspecializeSelect()marks the hazard, and the tell ismap.find(x->derivedKey())ormap.remove(x->derivedKey())appearing downstream of a call that rewritesx's opcode, type, or children. Wider: the shape recurs in any WebKit cache keyed on derived state that the code also mutates — B3ValueKey-keyed maps, DFG maps keyed on structure/opcode tuples, WebCore style and computed-value caches keyed on element attributes; the tell in search results is a mutation of a field participating in a hash key while the object is still resident in a hash container. Widest: if a container is keyed on derived state, mutation of that state must be bracketed by remove-then-reinsert — the same defect class is Java's mutable-hashCodeset corruption and RustHashMapkeys mutated through interior mutability. Note this one resists grep, because the failure mode is a silent miss rather than a crash; the practical check is to assert or log when a removal finds no entry. -
IR surgery performed mid-traversal while the phase carries state about the moved values. Narrow: start with
specializeSelect()'s block splitting andm_insertionSetuse, and for each such transform ask whether every phase-level structure indexing values —m_pureCSE, insertion sets, worklists, index-keyed maps — is still consistent afterwards; the tell is a transform that reassignsvalue->owner, re-parents values into newly created blocks, or deletes values, with no corresponding update to a phase member declared above it. Wider: the class is "IR surgery performed while an iteration over that IR is in flight", which also covers Air phases inserting or removingInsts during a walk. The invariant to carry elsewhere: a rewrite performed inside a traversal must publish its structural changes to every index that traversal depends on before the traversal continues.