CVE-2026-64715
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.cppSource/JavaScriptCore/b3/B3PureCSE.hSource/JavaScriptCore/b3/B3ReduceStrength.cpp
Test Files
Source/JavaScriptCore/b3/testb3.hSource/JavaScriptCore/b3/testb3_1.cppSource/JavaScriptCore/b3/testb3_6.cpp