← All reports

[JSC] Fix B3 ReduceStrength Select specialization when a Check appears between the Select and triggering Check

MediumJSC B3UAF

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

1d5c10e | Bugzilla 316347

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

// Now handle all values between the source and the check.
for (unsigned i = startIndex + 1; i < predecessor->size(); ++i) {
Value* value = predecessor->at(i);
+ ValueKey key = value->key(); // Compute before cloneValue mutates the Value
value->owner = nullptr;
 
cloneValue(value);
 
if (value->type() != Void)
m_insertionSet.insertValue(m_index, value);
- else
+ else {
+ m_pureCSE.remove(key, value);
m_proc.deleteValue(value);
+ }
}

Source/JavaScriptCore/b3/B3PureCSE.cpp

+void PureCSE::remove(const ValueKey& key, Value* value)
+{
+ if (!key)
+ return;
+
+ auto iter = m_map.find(key);
+ if (iter == m_map.end())
+ return;
+
+ Matches& matches = iter->value;
+ matches.removeAll(value);
+}

Source/JavaScriptCore/b3/B3PureCSE.h

void clear();
 
+ void remove(const ValueKey&, Value*);
+
Value* NODELETE findMatch(const ValueKey&, BasicBlock*, Dominators&);

Source/JavaScriptCore/b3/testb3_6.cpp

+void testCheckSelectAndDeadCheckCSE()
+{
+ ...
+ Value* condition = arguments[1];
+ auto* constant = root->appendNew<ConstPtrValue>(proc, Origin(), 42);
+ // (1) Select with a constant arm
+ auto* selectValue = root->appendNew<Value>(proc, Select, Origin(), arguments[0],
+ root->appendNew<ConstPtrValue>(proc, Origin(), -42),
+ root->appendNew<ConstPtrValue>(proc, Origin(), 35));
+ // (2) intermediate Check -- Void, deleted by specializeSelect
+ appendCheck(condition, 1);
+ // (3) keeps the Select within selectSpecializationBound (== 3)
+ auto* addValue = root->appendNew<Value>(proc, Add, Origin(), selectValue, constant);
+ // (4) triggering Check
+ appendCheck(addValue, 2);
+ // (5) later Check on the SAME condition -> pure CSE lookup hits the stale entry
+ appendCheck(condition, 3);
+ root->appendNewControlValue(proc, Return, Origin(), addValue);
+ auto code = compileProc(proc);
+ CHECK_EQ(invoke<intptr_t>(*code, 1, 0), 0);
+}

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.

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.

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:

  1. A Select with a constant arm — the specialization candidate.
  2. A Check on a plain argument, sitting between the Select and the Check that will trigger specialization. Void, therefore deleted rather than re-inserted.
  3. An Add consuming the Select, keeping the Select within selectSpecializationBound of step 4.
  4. A Check on the Add — the trigger, reaching the Select within the bound.
  5. A second Check on the same condition as step 2, so PureCSE::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.

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.