Released February 11, 2026

CVE-2026-20608

CVSS 5.5 severity medium CWE-770CWE-120 vector CVSS:3.1/AV:L/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: HanQing from TSDubhe and Nan Wang (@eternalsakura13)

Key insight. Private-field slots share the PropertyTable with ordinary properties but must be exempt from user-triggered seal/freeze semantics; treating them like normal properties marks engine-managed storage ReadOnly/DontDelete and breaks the private-field access invariant that such slots are always writable.

Patch Details

diff --git a/JSTests/stress/private-names-seal-freeze.js b/JSTests/stress/private-names-seal-freeze.js
new file mode 100644
index 000000000000..6849fa1490df
--- /dev/null
+++ b/JSTests/stress/private-names-seal-freeze.js
@@ -0,0 +1,18 @@
+class C {
+  #field;
+  setField(v) {
+    this.#field = v;
+  }
+}
+
+for (let i = 0; i < 10000; i++) {
+  let c = new C();
+  Object.seal(c);
+  c.setField(i);
+}
+
+for (let i = 0; i < 10000; i++) {
+  let c = new C();
+  Object.freeze(c);
+  c.setField(i);
+}
diff --git a/Source/JavaScriptCore/runtime/PropertyTable.cpp b/Source/JavaScriptCore/runtime/PropertyTable.cpp
index 1cda3936aa91..6de4a5f291db 100644
--- a/Source/JavaScriptCore/runtime/PropertyTable.cpp
+++ b/Source/JavaScriptCore/runtime/PropertyTable.cpp
@@ -159,7 +159,8 @@ PropertyTable::~PropertyTable()
 void PropertyTable::seal()
 {
     forEachPropertyMutable([&](auto& entry) {
-        entry.setAttributes(entry.attributes() | static_cast<unsigned>(PropertyAttribute::DontDelete));
+        if (!PropertyName(entry.key()).isPrivateName())
+            entry.setAttributes(entry.attributes() | static_cast<unsigned>(PropertyAttribute::DontDelete));
         return IterationStatus::Continue;
     });
 }
@@ -167,10 +168,12 @@ void PropertyTable::seal()
 void PropertyTable::freeze()
 {
     forEachPropertyMutable([&](auto& entry) {
-        if (!(entry.attributes() & PropertyAttribute::Accessor))
-            entry.setAttributes(entry.attributes() | static_cast<unsigned>(PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly));
-        else
-            entry.setAttributes(entry.attributes() | static_cast<unsigned>(PropertyAttribute::DontDelete));
+        if (!PropertyName(entry.key()).isPrivateName()) {
+            if (!(entry.attributes() & PropertyAttribute::Accessor))
+                entry.setAttributes(entry.attributes() | static_cast<unsigned>(PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly));
+            else
+                entry.setAttributes(entry.attributes() | static_cast<unsigned>(PropertyAttribute::DontDelete));
+        }
         return IterationStatus::Continue;
     });
 }
@@ -179,7 +182,7 @@ bool PropertyTable::isSealed() const
 {
     bool result = true;
     forEachProperty([&](const auto& entry) {
-        if ((entry.attributes() & PropertyAttribute::DontDelete) != static_cast<unsigned>(PropertyAttribute::DontDelete)) {
+        if (!PropertyName(entry.key()).isPrivateName() && (entry.attributes() & PropertyAttribute::DontDelete) != static_cast<unsigned>(PropertyAttribute::DontDelete)) {
             result = false;
             return IterationStatus::Done;
         }
@@ -192,13 +195,15 @@ bool PropertyTable::isFrozen() const
 {
     bool result = true;
     forEachProperty([&](const auto& entry) {
-        if (!(entry.attributes() & PropertyAttribute::DontDelete)) {
-            result = false;
-            return IterationStatus::Done;
-        }
-        if (!(entry.attributes() & (PropertyAttribute::ReadOnly | PropertyAttribute::Accessor))) {
-            result = false;
-            return IterationStatus::Done;
+        if (!PropertyName(entry.key()).isPrivateName()) {
+            if (!(entry.attributes() & PropertyAttribute::DontDelete)) {
+                result = false;
+                return IterationStatus::Done;
+            }
+            if (!(entry.attributes() & (PropertyAttribute::ReadOnly | PropertyAttribute::Accessor))) {
+                result = false;
+                return IterationStatus::Done;
+            }
         }
         return IterationStatus::Continue;
     });

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-20608
Bugzilla 303357
Component JSC
Bug Class LogicError
Severity medium
Commit 672cdd38781cce18…
Advisory Apple Advisory

Terminology

  • PropertyTable — The per-structure table in JSC that records each own property’s key, storage offset, and attribute bits, and which also stores engine-internal private-name slots.
  • Private name — An engine-internal, non-spec-visible key used to back JavaScript class private fields (#x), managed by dedicated private-field opcodes rather than ordinary property access.
  • PropertyAttribute (DontDelete/ReadOnly/Accessor) — Attribute bits on a property controlling deletability (DontDelete = non-configurable), writability (ReadOnly = non-writable), and whether it is a getter/setter.
  • Object.seal / Object.freeze — ECMAScript operations that make an object’s own properties non-configurable (seal) and additionally non-writable for data properties (freeze); by spec they apply only to normal own properties.
  • LogicError crash — A non-memory-safety fault where a violated internal invariant trips an assertion/release-assert, terminating the process rather than corrupting memory.

Root Cause Analysis

The patch touches JSC’s PropertyTable, which backs the layout of an object’s properties, including engine-internal private-name slots created for JavaScript class private fields (e.g. #field). PropertyTable::seal() and PropertyTable::freeze() are invoked when user code runs Object.seal()/Object.freeze() on an object. Per the ECMAScript spec these operations apply only to normal, spec-visible own properties: seal marks them non-configurable (DontDelete) and freeze additionally marks data properties non-writable (ReadOnly). Before the fix, both routines iterated over EVERY entry in the property table via forEachPropertyMutable and unconditionally OR’d in DontDelete (seal) or DontDelete|ReadOnly (freeze), with no check for whether the entry’s key was a private name. Private fields are stored in the same property table as ordinary properties but are not real object properties; they are engine-managed storage that the private-field access opcodes assume they fully control. When freeze stamped ReadOnly onto a private-field slot, a subsequent private-field write such as this.#field = v (the PutPrivateName / put_private_name path) hit a slot that the engine believed must always be writable, violating that internal invariant. The mismatch between the private-field machinery’s assumption (‘private slots are engine-controlled and writable’) and the attribute now present on the slot (ReadOnly/DontDelete) leads to an assertion failure / release-assert crash — the LogicError described. The read-side predicates isSealed()/isFrozen() had the mirror problem: they inspected private-name entries when deciding whether an object was sealed or frozen, so an object holding private fields could be classified inconsistently with how seal/freeze had (or should have) treated it. The fix wraps every one of these attribute mutations and checks in if (!PropertyName(entry.key()).isPrivateName()), so private-name slots are skipped entirely: seal/freeze no longer alter their attributes, and isSealed/isFrozen no longer consider them. This restores the invariant that private-field slots are exempt from user-triggered seal/freeze semantics and remain writable/engine-managed, matching the regression test where 10000 instances of a class with a private field are sealed or frozen and then have their private field written. The concrete crashing opcode and assertion live in the private-field write path (not shown in this diff); that mechanism is an inference from the test and the attribute change, though the attribute corruption itself is exactly what the patch shows.

Attack Path

1. Define a class with a private field

Author JS declares a class with an instance private field (e.g. class C { #field; setField(v){ this.#field = v; } }), which causes JSC to allocate a private-name slot in the instance’s PropertyTable.

2. Seal or freeze an instance

Call Object.seal(obj) or Object.freeze(obj) on an instance. PropertyTable::seal/freeze iterate all entries and, pre-patch, stamp DontDelete (seal) and DontDelete|ReadOnly (freeze) onto the private-field slot as well.

3. Trigger a private-field write on the frozen/sealed slot

Invoke a method that executes this.#field = v. The private-name put path operates on a slot that is now marked ReadOnly/DontDelete, contradicting its invariant that private storage is always engine-writable.

4. Force the assertion/crash

Repeat in a hot loop (the test uses 10000 iterations) so JIT tiers and the private-field write path are exercised; the invariant violation surfaces as a release-assert / unexpected termination of the WebContent process.

Impact Assessment

This is a controlled crash (denial of service) in the WebContent process, matching the advisory’s ‘unexpected process crash’ and LogicError classification. The primitive is weak from a memory-corruption standpoint: the patch shows only attribute-bit corruption on private-field slots leading to an invariant violation in the private-field write path, not an out-of-bounds or use-after-free write, so there is no evident path to memory disclosure or code execution. It is confined to the sandboxed WebContent process; no sandbox escape is implied by the diff.

Audit Directions

Other PropertyTable mutators/iterators

Audit every use of forEachPropertyMutable / forEachProperty in PropertyTable.cpp and Structure that bulk-edits or inspects attributes; grep for setAttributes(, entry.attributes(), and confirm each has a PropertyName(entry.key()).isPrivateName() guard where private slots should be exempt.

preventExtensions / other integrity operations

Check JSObject/Structure implementations of preventExtensions, defineOwnProperty, and structure transitions that iterate properties, grepping for PropertyAttribute::DontDelete/ReadOnly OR-assignments applied across all entries without a private-name filter.

Symbol-keyed and other special keys

Look for places that assume isPrivateName() is the only special key class; grep for isSymbol(), isPrivateName(), and well-known-symbol handling near attribute changes to ensure other engine-internal keys are similarly excluded from freeze/seal semantics.

Private-field access path assumptions

Review put_private_name / get_private_name and their DFG/FTL nodes; grep for PutPrivateName, DefinePrivateField, and any ASSERT that a private slot is writable, to find other places that could crash if a private slot ever carried ReadOnly/DontDelete.

Changed Functions

Function File Change Note
PropertyTable::seal Source/JavaScriptCore/runtime/PropertyTable.cpp modified Now skips private-name entries before OR’ing in DontDelete, so private-field slots are no longer marked non-configurable by Object.seal.
PropertyTable::freeze Source/JavaScriptCore/runtime/PropertyTable.cpp modified Wraps the DontDelete/ReadOnly attribute stamping in a private-name guard, preventing private-field slots from being made ReadOnly and thereby preventing the crashing private-field write.
PropertyTable::isSealed Source/JavaScriptCore/runtime/PropertyTable.cpp modified Excludes private-name entries from the DontDelete check so an object with private fields is still correctly reported as sealed.
PropertyTable::isFrozen Source/JavaScriptCore/runtime/PropertyTable.cpp modified Excludes private-name entries from the DontDelete/ReadOnly checks so frozen-state reporting stays consistent with the seal/freeze changes.

Files Changed

Source Files

  • Source/JavaScriptCore/runtime/PropertyTable.cpp

Test Files

  • JSTests/stress/private-names-seal-freeze.js

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/JSTests/stress/private-names-seal-freeze.js b/JSTests/stress/private-names-seal-freeze.js
new file mode 100644
index 000000000000..6849fa1490df
--- /dev/null
+++ b/JSTests/stress/private-names-seal-freeze.js
@@ -0,0 +1,18 @@
+class C {
+  #field;
+  setField(v) {
+    this.#field = v;
+  }
+}
+
+for (let i = 0; i < 10000; i++) {
+  let c = new C();
+  Object.seal(c);
+  c.setField(i);
+}
+
+for (let i = 0; i < 10000; i++) {
+  let c = new C();
+  Object.freeze(c);
+  c.setField(i);
+}