Released August 17, 2026

CVE-2026-64715

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

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected process crash

Researchers: Hossein Lotfi (@hosselot) of TrendAI Zero Day Initiative

Key insight. Two optimizer passes disagreed about a Value’s lifetime: strength reduction deleted a Void value while PureCSE still held a raw pointer to it. The fix adds PureCSE::remove() and calls it before deleteValue — and crucially computes the ValueKey before cloneValue mutates the Value, so the correct entry is purged.

Patch Details

diff --git a/Source/JavaScriptCore/b3/B3PureCSE.cpp b/Source/JavaScriptCore/b3/B3PureCSE.cpp
index 959cd8df8fd8..62005df921e7 100644
--- a/Source/JavaScriptCore/b3/B3PureCSE.cpp
+++ b/Source/JavaScriptCore/b3/B3PureCSE.cpp
@@ -44,6 +44,19 @@ void PureCSE::clear()
     m_map.clear();
 }
 
+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);
+}
+
 Value* PureCSE::findMatch(const ValueKey& key, BasicBlock* block, Dominators& dominators)
 {
     if (!key)
diff --git a/Source/JavaScriptCore/b3/B3PureCSE.h b/Source/JavaScriptCore/b3/B3PureCSE.h
index 8ce819ab5af3..320da2feb776 100644
--- a/Source/JavaScriptCore/b3/B3PureCSE.h
+++ b/Source/JavaScriptCore/b3/B3PureCSE.h
@@ -48,6 +48,8 @@ public:
 
     void clear();
 
+    void remove(const ValueKey&, Value*);
+
     Value* NODELETE findMatch(const ValueKey&, BasicBlock*, Dominators&);
 
     bool process(Value*, Dominators&);
diff --git a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
index b587e0ddf97a..452b0bc23d60 100644
--- a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
+++ b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
@@ -4398,14 +4398,17 @@ private:
         // 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);
+            }
         }
 
         // Finally, deal with the check.
diff --git a/Source/JavaScriptCore/b3/testb3.h b/Source/JavaScriptCore/b3/testb3.h
index cce642296502..d816357d9d97 100644
--- a/Source/JavaScriptCore/b3/testb3.h
+++ b/Source/JavaScriptCore/b3/testb3.h
@@ -663,6 +663,7 @@ void testSelectInvert();
 void testCheckSelect();
 void testCheckSelectCheckSelect();
 void testCheckSelectAndCSE();
+void testCheckSelectAndDeadCheckCSE();
 void testPowDoubleByIntegerLoop(double xOperand, int32_t yOperand);
 double b3Pow(double x, int y);
 void testTruncOrHigh();
diff --git a/Source/JavaScriptCore/b3/testb3_1.cpp b/Source/JavaScriptCore/b3/testb3_1.cpp
index bc038f769ecf..453796ceca4c 100644
--- a/Source/JavaScriptCore/b3/testb3_1.cpp
+++ b/Source/JavaScriptCore/b3/testb3_1.cpp
@@ -987,6 +987,7 @@ void run(const TestConfig* config)
     RUN(testCheckSelect());
     RUN(testCheckSelectCheckSelect());
     RUN(testCheckSelectAndCSE());
+    RUN(testCheckSelectAndDeadCheckCSE());
     RUN_BINARY(testPowDoubleByIntegerLoop, floatingPointOperands<double>(), int64Operands());
 
     RUN(testTruncOrHigh());
diff --git a/Source/JavaScriptCore/b3/testb3_6.cpp b/Source/JavaScriptCore/b3/testb3_6.cpp
index 64f52a112b1f..26312a3ea634 100644
--- a/Source/JavaScriptCore/b3/testb3_6.cpp
+++ b/Source/JavaScriptCore/b3/testb3_6.cpp
@@ -960,6 +960,81 @@ void testCheckSelectAndCSE()
     CHECK_EQ(invoke<int>(*code, false), 666);
 }
 
+void testCheckSelectAndDeadCheckCSE()
+{
+    // Exercises B3 strength reduction's "select specialization" (specializeSelect) together with pure CSE.
+    //
+    // When a Check is reached (within selectSpecializationBound) from a Select that has a constant arm,
+    // reduceStrength specializes the Select: it splits the block at the Check, clones the values between
+    // the Select and the Check into a then/else pair of blocks -- substituting the Select's then/else
+    // value in each -- and replaces the originals with Phis at the merge. Void values in that range (such
+    // as an intermediate Check) are removed from the original block and re-emitted in both arms.
+    //
+    // The IR (single block):
+    //
+    //   @cond = arg1
+    //   @sel  = Select(arg0, -42, 35)   ; Select with a constant arm
+    //           Check(@cond)            ; intermediate Check
+    //   @add  = Add(@sel, 42)
+    //           Check(@add)             ; triggering Check (reaches @sel within the bound)
+    //           Check(@cond)            ; later Check on the same condition (CSE)
+    //           Return(@add)
+    //
+    Procedure proc;
+    if (proc.optLevel() < 1)
+        return;
+    BasicBlock* root = proc.addBlock();
+    auto arguments = cCallArgumentValues<int32_t, int32_t>(proc, root);
+
+    // Condition shared by the intermediate and later Checks, so the later Check is a CSE candidate. A
+    // plain argument: each Check is kept (the condition isn't a known constant) and doesn't lead back to
+    // the Select.
+    Value* condition = arguments[1];
+
+    auto appendCheck = [&] (Value* predicate, int32_t exitValue) {
+        CheckValue* check = root->appendNew<CheckValue>(proc, Check, Origin(), predicate);
+        check->setGenerator(
+            [exitValue] (CCallHelpers& jit, const StackmapGenerationParams&) {
+                AllowMacroScratchRegisterUsage allowScratch(jit);
+                jit.move(CCallHelpers::TrustedImm32(exitValue), GPRInfo::returnValueGPR);
+                jit.emitFunctionEpilogue();
+                jit.ret();
+            });
+    };
+
+    // Defined before the Select so the Select -> triggering-Check run stays within
+    // selectSpecializationBound (== 3): Select, intermediate Check, Add, triggering Check.
+    auto* constant = root->appendNew<ConstPtrValue>(proc, Origin(), 42);
+
+    // (1) The Select to specialize: at least one data arm is constant.
+    auto* selectValue = root->appendNew<Value>(
+        proc, Select, Origin(),
+        arguments[0],
+        root->appendNew<ConstPtrValue>(proc, Origin(), -42),
+        root->appendNew<ConstPtrValue>(proc, Origin(), 35));
+
+    // (2) An intermediate Check between the Select and the triggering Check. Specialization moves it out
+    //     of this block and clones it into both arms.
+    appendCheck(condition, 1);
+
+    // (3) Add consuming the Select, feeding (4); keeps the Select within bound 3 of the triggering Check.
+    auto* addValue = root->appendNew<Value>(proc, Add, Origin(), selectValue, constant);
+
+    // (4) The Check that triggers specialization: it reaches the Select within selectSpecializationBound.
+    appendCheck(addValue, 2);
+
+    // (5) A later Check on the SAME condition as (2), so pure CSE relates the two across the specialized
+    //     region.
+    appendCheck(condition, 3);
+
+    root->appendNewControlValue(proc, Return, Origin(), addValue);
+
+    // After specialization the merged value is unchanged: select(true, -42, 35) + 42 == 0, with no Check
+    // firing for these inputs.
+    auto code = compileProc(proc);
+    CHECK_EQ(invoke<intptr_t>(*code, 1, 0), 0);
+}
+
 double NODELETE b3Pow(double x, int y)
 {
     if (y < 0 || y > 1000)

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-64715
Bugzilla 316347
Component JSC
Bug Class UAF
Severity medium
Commit 1d5c10e2f9c8f313…
Advisory Apple Advisory

Terminology

  • B3 / FTL JIT — JavaScriptCore’s optimizing compiler tier; B3 is its low-level SSA IR on which optimizations like strength reduction and CSE run.
  • PureCSE — Common-subexpression elimination for pure values; it keys Value* pointers by ValueKey so a later identical computation can reuse an earlier one.
  • deleteValue — Removes a Value from the procedure. If a deleted Value is still referenced by the CSE map, a later lookup returns a dangling pointer.

Root Cause Analysis

This fixes a use-after-free in JavaScriptCore’s B3 optimizer between strength reduction’s select specialization and pure common-subexpression elimination (PureCSE). When reduceStrength specializes a Select that reaches a Check within selectSpecializationBound, it splits the block and processes the values between the source and the Check; Void values in that range (such as an intermediate Check) are removed from the block with m_proc.deleteValue(value). Before the fix, those deleted Values were not removed from the PureCSE map (B3PureCSE::m_map stores raw Value* keyed by ValueKey), so a later CSE lookup (findMatch) on a matching key could return a pointer to an already-deleted Value and the compiler would dereference it — a use-after-free during compilation. The fix adds PureCSE::remove(key, value) and calls it before deleteValue for the Void case; it also computes the ValueKey BEFORE cloneValue mutates the Value (the added comment notes this), so the correct key is removed. The restored invariant is that the PureCSE map never retains a pointer to a freed Value. The new B3 unit test testCheckSelectAndDeadCheckCSE reproduces the pattern (a Select with a constant arm, an intermediate Check, and a later Check on the same condition that is a CSE candidate). Established by the diff.

Attack Path

1. Run JS that reaches the FTL/B3 JIT

Hot JavaScript is compiled through B3 so reduceStrength and PureCSE run.

2. Hit the select-specialization pattern

The compiled IR contains a Select with a constant arm reaching a Check within the specialization bound, plus a later Check on the same condition (a CSE candidate).

3. Delete a Void value still in PureCSE

Specialization deletes the intermediate Void Check but, pre-patch, leaves its entry in the PureCSE map.

4. Use-after-free via CSE lookup

A later PureCSE::findMatch returns the freed Value pointer, and the compiler dereferences it, crashing (or worse) during compilation.

Impact Assessment

A use-after-free during JIT compilation of attacker-controlled JavaScript. Compile-time UAFs in JSC are high-value: the attacker shapes both the freed object and the reuse, and the corruption occurs inside the engine’s own compiler, historically a reliable route to arbitrary read/write and code execution within the WebContent process.

Audit Directions

deleteValue without CSE purge

Audit every deleteValue()/removal site in B3 passes to confirm the Value is also removed from PureCSE (and any other pointer-keyed side table) before deletion.

Keys computed after mutation

Look for ValueKey (or hash-key) computations performed after cloneValue/replaceWith mutates the underlying Value; the key must be captured before mutation.

Changed Functions

Function File Change Note
PureCSE::remove Source/JavaScriptCore/b3/B3PureCSE.cpp added New method that removes a specific Value from the matches list for a ValueKey so a deleted Value is no longer reachable via CSE.
ReduceStrength select specialization (Void-value handling) Source/JavaScriptCore/b3/B3ReduceStrength.cpp modified Computes the ValueKey before cloneValue mutates the Value and calls m_pureCSE.remove(key, value) before m_proc.deleteValue(value), preventing a dangling Value* in the PureCSE map.
PureCSE::remove (declaration) Source/JavaScriptCore/b3/B3PureCSE.h modified Declares the new remove(const ValueKey&, Value*) method.

Files Changed

Source Files

  • Source/JavaScriptCore/b3/B3PureCSE.cpp
  • Source/JavaScriptCore/b3/B3PureCSE.h
  • Source/JavaScriptCore/b3/B3ReduceStrength.cpp

Test Files

  • Source/JavaScriptCore/b3/testb3.h
  • Source/JavaScriptCore/b3/testb3_1.cpp
  • Source/JavaScriptCore/b3/testb3_6.cpp