CVE-2026-64715
Overview
Background
- B3 `ReduceStrength`
- A B3 JIT optimization phase (
Source/JavaScriptCore/b3/B3ReduceStrength.cpp) performing strength reduction and other peephole transforms over B3 IRValues. - Select specialization (`specializeSelect`)
- When a
Checkis reached from aSelectwith a constant arm withinselectSpecializationBound, the phase splits the block at theCheck, clones the values between theSelectand theCheckinto then/else arms (substituting the Select’s arms), and replaces the originals withPhis.Void-typed values in that range (e.g. an intermediateCheck) are removed from the original block viam_proc.deleteValue(value)and re-emitted in both arms. - B3 `PureCSE`
- Pure common-subexpression elimination (
Source/JavaScriptCore/b3/B3PureCSE.{cpp,h}). It keeps a map from aValueKeyto the matchingValues (Matches) so a later equivalent computation can reuse an earlier one viafindMatch. - `Value::key()` / lifetime
- A
Value’sValueKeyis derived from its opcode/operands;deleteValuedestroys theValue. B3Values are owned by theProcedure(not refcounted), so a rawValue*left in another structure afterdeleteValuedangles.
Root Cause Analysis
During select specialization, ReduceStrength walked the values between the source Select and the triggering Check, cloning each and — for Void-typed values — deleting the original with m_proc.deleteValue(value). The problem: those deleted Values (such as an intermediate Check) could already be registered in the PureCSE map as the canonical match for their ValueKey. deleteValue freed the Value but left the dangling pointer in PureCSE’s m_map. A later equivalent computation (e.g. a second Check on the same condition — a CSE candidate across the specialized region) could then have findMatch return the freed Value*, and the phase would consult/wire up a destroyed value: a use-after-free of a B3 Value.
The patch adds PureCSE::remove(const ValueKey&, Value*), which looks up the key and removes the given value from its Matches list. In ReduceStrength, before cloning (which mutates the Value), it snapshots ValueKey key = value->key();, and in the Void branch it now calls m_pureCSE.remove(key, value) before m_proc.deleteValue(value), purging the stale entry so CSE can never hand back the freed value. The key must be computed before cloneValue because cloning can mutate the value such that its key would no longer match its CSE-map entry. A new test, testCheckSelectAndDeadCheckCSE (testb3_6.cpp), constructs exactly this shape — a Select with a constant arm, an intermediate Check, an Add, a triggering Check, and a later Check on the same condition — to exercise deletion of a CSE’d Void value followed by a CSE match.
Void values (e.g. a Check) with deleteValue but left their pointers in the PureCSE map, so a later CSE lookup could return and use a freed B3 Value — a use-after-free, fixed by removing the value from the CSE map before deleting it.Attack Path
- Delivery Attacker runs JavaScript/WebAssembly on a victim page whose hot code compiles through B3 (web-reachable).
- Shape the IR
Arrange code that lowers to a
Selectwith a constant arm followed, withinselectSpecializationBound, by an intermediateCheckand a triggeringCheck, plus a laterCheckon the same condition so it is a CSE candidate. - Trigger specialization
JIT tier-up runs
ReduceStrength, which specializes theSelect, deletes the intermediateVoidCheck, but leaves its pointer inPureCSE’s map. - Dangling match
The later equivalent
ChecktriggersfindMatch, which returns the freedValue*from the stale CSE entry. - Use-after-free
The phase consults/relinks the destroyed
Value, operating on reclaimed memory. - Primitive
Use-after-free of a B3
Valueduring JIT compilation; observed as a crash. Controlled reuse of the freed allocation to influence generated code is inferred, not shown by the diff.
Impact Assessment
Value deleted during select specialization remains referenced by the PureCSE map and can be returned by a later CSE lookup. Confirmed effect is a UAF crash/process termination during compilation; whether the freed Value can be reclaimed with attacker-controlled contents to steer codegen into a stronger primitive is inferred, not demonstrated by the diff. Gating: requires the specific Select/Check/CSE IR shape and JIT compilation of it.Files Changed
Source/JavaScriptCore/b3/B3PureCSE.cppSource/JavaScriptCore/b3/B3PureCSE.hSource/JavaScriptCore/b3/B3ReduceStrength.cppSource/JavaScriptCore/b3/testb3.hSource/JavaScriptCore/b3/testb3_1.cppSource/JavaScriptCore/b3/testb3_6.cpp
Audit Directions
- Stale pointers after `deleteValue`Audit every
m_proc.deleteValue(...)in B3 phases for side tables (PureCSE, memoization maps, worklists) that may still hold the freedValue*. - Key-before-mutationReview other CSE/dedup interactions where
cloneValueor in-place mutation happens before the structure keyed on the old value is updated. - Specialization/cloning transformsExamine other transforms that split blocks and clone value ranges (loop/select/check specialization) for the same delete-without-evict pattern.
Patch
diff --git a/Source/JavaScriptCore/b3/B3PureCSE.cpp b/Source/JavaScriptCore/b3/B3PureCSE.cpp
index 9ac2ec09b3fc..d73e3b18634a 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 0491e71f3024..889c3ec7fc86 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* findMatch(const ValueKey&, BasicBlock*, Dominators&);
bool process(Value*, Dominators&);
diff --git a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
index b5199c2e5aba..e38598b51ff1 100644
--- a/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
+++ b/Source/JavaScriptCore/b3/B3ReduceStrength.cpp
@@ -3567,14 +3567,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 f768bb03606e..c334bb570c67 100644
--- a/Source/JavaScriptCore/b3/testb3.h
+++ b/Source/JavaScriptCore/b3/testb3.h
@@ -665,6 +665,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 01650d17aca5..1654704b6618 100644
--- a/Source/JavaScriptCore/b3/testb3_1.cpp
+++ b/Source/JavaScriptCore/b3/testb3_1.cpp
@@ -711,6 +711,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 06338c074c2b..46ccfbfe616f 100644
--- a/Source/JavaScriptCore/b3/testb3_6.cpp
+++ b/Source/JavaScriptCore/b3/testb3_6.cpp
@@ -861,6 +861,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 b3Pow(double x, int y)
{
if (y < 0 || y > 1000)
Regression Test / PoC
diff --git a/Source/JavaScriptCore/b3/testb3.h b/Source/JavaScriptCore/b3/testb3.h
index f768bb03606e..c334bb570c67 100644
--- a/Source/JavaScriptCore/b3/testb3.h
+++ b/Source/JavaScriptCore/b3/testb3.h
@@ -665,6 +665,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 01650d17aca5..1654704b6618 100644
--- a/Source/JavaScriptCore/b3/testb3_1.cpp
+++ b/Source/JavaScriptCore/b3/testb3_1.cpp
@@ -711,6 +711,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 06338c074c2b..46ccfbfe616f 100644
--- a/Source/JavaScriptCore/b3/testb3_6.cpp
+++ b/Source/JavaScriptCore/b3/testb3_6.cpp
@@ -861,6 +861,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 b3Pow(double x, int y)
{
if (y < 0 || y > 1000)