81aa535db3 Fix dominance analysis in B3CanonicalizePrePostIncrements
Triage note: Requires all address uses to be dominated before rewriting, fixing an SSA-dominance JIT miscompile reachable via Atomics loops.
Contents
The bug at a glance
A JIT dominance bug in B3CanonicalizePrePostIncrements rewrites the SSA graph even when the moved Add does not dominate all of its uses, producing a miscompiled memory-address computation reachable from ordinary JS running through Atomics loops. Miscompilations in the JSC optimizing backend are classic exploit primitives: a wrong address or wrong value materialized in optimized code can be leveraged into out-of-bounds access and type confusion. The fix is guarded behind –useB3CanonicalizePrePostIncrements and the trigger is plain typed-array/Atomics code, so it is remotely reachable script. High is appropriate; it is a correctness/type-safety defect in the compiler rather than a directly demonstrated memory write.
The optimization pass had a dominance-validation loop that computed nothing: it continued on the non-dominating case instead of aborting the rewrite, so the guard was a no-op and the Add was hoisted regardless of whether every use was dominated, violating SSA and generating an address for a memory op that is invalid on some paths.
Root cause
B3CanonicalizePrePostIncrements is a peephole-style pass in the B3 backend that recognizes the pre/post-increment addressing idiom: an Add value that computes base + offset whose result is both stored back (the increment) and used to address a memory operation. To turn this into a single addressing-mode-bearing instruction, the pass wants to sink/duplicate the Add so that a fresh address value is materialized just before the memory operation. Correctness of that move depends on SSA dominance: the new address inserted at the memory’s block must dominate every existing use of the original address, otherwise a use on a path that does not go through memory->owner would read a value that was never defined on that path.
The pass gathered all uses into addressUses and looped over uses->value calling dominators.dominates(memory->owner, use->owner). The intent was clearly: if any use is not dominated by the memory’s block, cancel the optimization for this candidate. But the body of the original loop did if (!dominates(...)) continue; — continue merely advances to the next use in the loop, it does not cancel anything. After the loop finished (having done effectively nothing) the code unconditionally proceeded to insertionSet.insert<Value>(index, Add, ...), inserting a new Add at the memory site and rewiring uses. So the dominance check was dead code: the transform fired even when uses were not dominated.
The consequence is a broken SSA graph where a value is used on a path along which its (newly relocated) definition does not execute. Downstream register allocation and lowering then emit code that computes the address on some paths but not others; on the un-dominated path the address register holds stale or garbage contents, so the memory operation reads or writes the wrong location. In the repro the idiom is created by an Atomics.add(arr, 1, 1337) inside a conditional within a loop, with a following res = arr[1] load. The conditional (if (cond)) creates a control-flow join so that the Atomics-generated address Add has a use (the later load’s addressing) that the Atomics block does not dominate — exactly the case the guard was supposed to reject.
The fix introduces an explicit bool allUsesDominated = true; flag, sets it false and breaks on the first non-dominated use, and then if (!allUsesDominated) continue; — where this continue is on the outer candidate loop, correctly skipping the whole rewrite for that memory. This restores the invariant that the pass only fires when the relocation is dominance-safe.
Key code
B3CanonicalizePrePostIncrements.cpp: the dominance guard now actually cancels the rewrite
auto uses = addressUses.find(address);
ASSERT(uses != addressUses.end() && uses->value.size());
bool allUsesDominated = true;
for (Value* use : uses->value) {
if (!dominators.dominates(memory->owner, use->owner)) {
allUsesDominated = false;
break;
}
}
if (!allUsesDominated)
continue;
unsigned index = memoryToIndex.get(memory);
Value* newAddress = insertionSet.insert<Value>(index, Add, memory->origin(), address->child(0), address->child(1));
Patch walkthrough
Source/JavaScriptCore/b3/B3CanonicalizePrePostIncrements.cpp— In canonicalizePrePostIncrements, the inner loop over the address’s uses previously didif (!dominators.dominates(memory->owner, use->owner)) continue;, which never cancelled the transform. The patch adds anallUsesDominatedflag, flips it to false and breaks on the first non-dominated use, then addsif (!allUsesDominated) continue;before theinsertionSet.insert<Value>(index, Add, ...)call so the whole rewrite is skipped whenever any use of the address is not dominated by the memory’s block.JSTests/stress/b3cppi-ssa-dominance.js— New regression test that forces the pass on (–useB3CanonicalizePrePostIncrements=1, –useConcurrentJIT=0) and builds the exact idiom: an Atomics.add insideif (cond)followed byres = arr[1]in a 2-iteration loop, run 100000 times over 256-element Int32Arrays to tier up, so the un-dominated-use case is exercised.
Background
B3CanonicalizePrePostIncrements — A B3 IR optimization pass that recognizes base+offset Add values feeding both a store-back increment and a memory operation, and rewrites them into pre/post-increment addressing forms native to the target ISA.
SSA dominance — In static single assignment form every use of a value must be dominated by its definition. Relocating/duplicating a defining instruction requires that the new definition site dominates all uses, or a path can reach a use with no live definition.
Dominators::dominates(a, b) — B3 dominator-tree query returning whether basic block a dominates block b — i.e. every path to b passes through a. Here it checks that the memory op’s block dominates each use of the address.
Atomics.add addressing idiom — Atomics operations on a typed array generate an internal base+index address computation; a following normal indexed load reusing the same base produces the multiple-use pattern the pass targets.
–useB3CanonicalizePrePostIncrements — Runtime option gating this pass. The bug only manifests when the option is enabled; the test forces it on alongside –useConcurrentJIT=0 for determinism.
Vulnerability window
- Warm-up — trigger() is called 100000 times with alternating arrays and cond values, promoting it to the FTL/B3 tier where the pass runs.
- Idiom recognition — The pass identifies the Atomics.add-generated Add as a canonicalization candidate with multiple address uses.
- Broken guard — The dominance loop encounters a use (the later arr[1] load addressing) not dominated by the Atomics block but
continuefails to cancel; the rewrite proceeds. - Insertion — A new Add is inserted at the memory site and uses are rewired, creating an SSA graph with a use not dominated by its definition.
- Lowering — Register allocation and codegen emit an address that is undefined on the un-dominated path.
- Miscompile — On that path the memory op uses stale/garbage address contents — the exploitable wrong-address primitive.
Proof of concept
The test constructs the pre/post-increment idiom (Atomics.add inside a conditional followed by a plain arr[1] read) so that the address Add has a use in a block the Atomics block does not dominate. It is a correctness regression test (no explicit assertion of a value); on a vulnerable build the pass miscompiles the address computation. requireOptions forces the pass and disables the concurrent JIT for determinism.
//@ requireOptions("--useConcurrentJIT=0", "--useB3CanonicalizePrePostIncrements=1")
const arr1 = new Int32Array(256);
arr1.fill(0x1337)
const arr2 = new Int32Array(256);
arr2.fill(0x1337)
function trigger(arr, cond) {
if (arr.length !== 256) return 0; // Eliminate bounds check side-exits
let res = 0;
for (let j = 0; j < 2; j++) {
if (cond) {
Atomics.add(arr, 1, 1337);
}
res = arr[1];
}
return res;
}
for (let i = 0; i < 100000; i++) {
let ret = trigger(i % 2 === 0 ? arr1 : arr2, i % 2 === 0);
}
Exploitation
- Prime — Craft a hot function whose Atomics-plus-load pattern makes the moved Add’s uses non-dominated, then loop to trigger FTL/B3 compilation.
- Miscompute address — Rely on the mis-hoisted Add so an indexed access computes an attacker-influenced or stale address, escaping the intended bounds.
- OOB primitive — Shape indices/strides so the miscompiled address reads or writes outside the typed array backing store, yielding an OOB relative read/write.
- Escalate — Convert OOB on the JS heap into addrof/fakeobj and a controlled read/write primitive per standard JSC exploitation.
Detection & hunting
For defenders and SOC / detection engineers:
- Pass-enabled fuzzing divergence —
- B3 validation —
- Crash telemetry —
Audit directions
- Dead-guard loops —
- Dominance-dependent motion —
- Atomics lowering —
- Option matrix —