CVE-2026-20608
Overview
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.
Attack Path
- 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. - 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.
- 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. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
forEachPropertyMutableSource/JavaScriptCore/runtime/PropertyTable.cpp |
modified | |
forEachPropertySource/JavaScriptCore/runtime/PropertyTable.cpp |
modified | |
CJSTests/stress/private-names-seal-freeze.js |
modified | |
setFieldJSTests/stress/private-names-seal-freeze.js |
modified | |
forJSTests/stress/private-names-seal-freeze.js |
modified |
Files Changed
JSTests/stress/private-names-seal-freeze.jsSource/JavaScriptCore/runtime/PropertyTable.cpp
Audit Directions
- Other PropertyTable mutators/iteratorsAudit 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 aPropertyName(entry.key()).isPrivateName()guard where private slots should be exempt. - preventExtensions / other integrity operationsCheck JSObject/Structure implementations of preventExtensions, defineOwnProperty, and structure transitions that iterate properties, grepping for
PropertyAttribute::DontDelete/ReadOnlyOR-assignments applied across all entries without a private-name filter. - Symbol-keyed and other special keysLook for places that assume
isPrivateName()is the only special key class; grep forisSymbol(),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 assumptionsReview 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.
Patch
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);
+}
Regression Test / PoC
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);
+}