Medium CVSS 5.5 webkit Logic Error 🔧 Commit mapped

Overview

Medium
Severity
5.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentJSC
Sub-componentJSC Runtime
Bug ClassLogic Error
Tracker303357
Fix commit672cdd38781c (WebKit/WebKit) +36/-13
CWECWE-770, CWE-120 (Buffer overflow)
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedHanQing from TSDubhe and Nan Wang (@eternalsakura13)
Disclosed2026-02-11

Background

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.

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.

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.

Changed Functions

FunctionChangeNotes
forEachPropertyMutable
Source/JavaScriptCore/runtime/PropertyTable.cpp
modified
forEachProperty
Source/JavaScriptCore/runtime/PropertyTable.cpp
modified
C
JSTests/stress/private-names-seal-freeze.js
modified
setField
JSTests/stress/private-names-seal-freeze.js
modified
for
JSTests/stress/private-names-seal-freeze.js
modified

Files Changed

  • JSTests/stress/private-names-seal-freeze.js
  • Source/JavaScriptCore/runtime/PropertyTable.cpp

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.
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;
     });
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);
+}
Loading diff…

Regression Test / PoC

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

Original Bug Report

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