CVE-2026-20608
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);
+}