Medium webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected process crash
DescriptionA use-after-free issue was addressed with improved memory management.
ComponentJSC
Bug ClassUAF
Tracker316347
Fix commit353f0e1145bf (webkit) +96/-1
CISA KEVNot listed
CreditedHossein Lotfi (@hosselot) of TrendAI Zero Day Initiative
Disclosed2026-09-14

Background

B3 `ReduceStrength`
A B3 JIT optimization phase (Source/JavaScriptCore/b3/B3ReduceStrength.cpp) performing strength reduction and other peephole transforms over B3 IR Values.
Select specialization (`specializeSelect`)
When a Check is reached from a Select with a constant arm within selectSpecializationBound, the phase splits the block at the Check, clones the values between the Select and the Check into then/else arms (substituting the Select’s arms), and replaces the originals with Phis. Void-typed values in that range (e.g. an intermediate Check) are removed from the original block via m_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 a ValueKey to the matching Values (Matches) so a later equivalent computation can reuse an earlier one via findMatch.
`Value::key()` / lifetime
A Value’s ValueKey is derived from its opcode/operands; deleteValue destroys the Value. B3 Values are owned by the Procedure (not refcounted), so a raw Value* left in another structure after deleteValue dangles.

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.

Key insight
Select specialization deleted intermediate 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

  1. Delivery Attacker runs JavaScript/WebAssembly on a victim page whose hot code compiles through B3 (web-reachable).
  2. Shape the IR Arrange code that lowers to a Select with a constant arm followed, within selectSpecializationBound, by an intermediate Check and a triggering Check, plus a later Check on the same condition so it is a CSE candidate.
  3. Trigger specialization JIT tier-up runs ReduceStrength, which specializes the Select, deletes the intermediate Void Check, but leaves its pointer in PureCSE’s map.
  4. Dangling match The later equivalent Check triggers findMatch, which returns the freed Value* from the stale CSE entry.
  5. Use-after-free The phase consults/relinks the destroyed Value, operating on reclaimed memory.
  6. Primitive Use-after-free of a B3 Value during 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

Runs in the renderer (Safari web content) process at ordinary web privilege within the B3 JIT compiler. The diff confirms the advisory’s use-after-free class: a 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.cpp
  • Source/JavaScriptCore/b3/B3PureCSE.h
  • Source/JavaScriptCore/b3/B3ReduceStrength.cpp
  • Source/JavaScriptCore/b3/testb3.h
  • Source/JavaScriptCore/b3/testb3_1.cpp
  • Source/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 freed Value*.
  • Key-before-mutation
    Review other CSE/dedup interactions where cloneValue or in-place mutation happens before the structure keyed on the old value is updated.
  • Specialization/cloning transforms
    Examine other transforms that split blocks and clone value ranges (loop/select/check specialization) for the same delete-without-evict pattern.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…