[2] LiteralParser commits a cached transition invalidated by a `__proto__` setter
The literal parser knew __proto__ could run script, and guarded the wrong thing.
High. A hidden-class snapshot taken before a nested parse is committed after that parse has run attacker JavaScript, producing a script-reachable object whose declared shape disagrees with its real backing store. Escalation to an info leak turns on which indexing-type pairing the attacker can arrange.
Hidden-class engines describe every object's layout with a shared shape descriptor, and every fast path in the engine — interpreter, inline caches, JIT speculation — trusts that descriptor to match the object's actual storage. JSC's LiteralParser handles JSON.parse and the evaluation of pure object literals; its object fast path builds objects by walking cached shape transitions and writing values directly at the resolved offsets rather than going through the generic property-store machinery. That fast path resolves the transition for a property before parsing that property's value, on the expectation that the receiver's shape is still the one it snapshotted when the write finally lands.
The angle: any page can eval an object literal with a __proto__ member and a prototype-installed setter to obtain a JS object whose Structure describes a layout its butterfly no longer has — a type confusion reachable from plain script.
LiteralParser has a fast path for caching transitions if we're parsing a literal with an existing transition. This is done before the object literal is actually parsed. During actual parsing, user code may run due to setters for __proto__, which may invalidate the original object's structure and thus its cached transition. This PR fixes it by taking the slow path if the structure changes.
Source/JavaScriptCore/runtime/LiteralParser.cpp
JSTests/stress/literal-parser-proto-setter.js
Patch Details
In LiteralParser<CharType, reviverMode>::parseRecursively, the object-literal fast path snapshots the receiver's shape into a local before parsing the property value. The patch renames that local from structure to originalStructure, making the staleness explicit at all four use sites, and adds a re-validation step immediately after the recursive parseRecursively call that produces the property value: if object->structure() != originalStructure and the previously computed property variant still holds an ExistingProperty { structure, offset }, the variant is overwritten with Identifier::fromUid(vm, std::get<ExistingProperty>(property).structure->transitionPropertyName()), forcing the generic property-store path instead of the cached-transition fast path.
The cached-transition computation itself is unchanged: it still either follows originalStructure->trySingleTransition() when the transition is a plain TransitionKind::PropertyAddition with no attributes, or calls Structure::addPropertyTransitionToExistingStructure(originalStructure, ident, 0, offset). Downstream, the fast-path block that compares originalStructure->outOfLineCapacity() against newStructure->outOfLineCapacity(), calls object->allocateMoreOutOfLineStorage(...), and calls object->nukeStructureAndSetButterfly(vm, originalStructure->id(), newButterfly) is now only reachable when the snapshot is still current. Collaterally, Identifier::fromUid(VM&, UniquedStringImpl*) gains a NODELETE declaration in Identifier.h and SUPPRESS_NODELETE on its inline definition in IdentifierInlines.h.
Applying a cached object-layout descriptor that was captured before a callback boundary to an object whose layout the callback already changed.
Background
Structure.
JSC's hidden-class object. It records a JSObject's property names, their offsets, the object's indexing type, and its out-of-line (butterfly) capacity. Objects sharing a shape share a Structure.
Property-addition transitions.
Adding a property produces a new Structure derived from the old one. Transitions are cached, so repeatedly building the same object shape reuses the same chain. Structure::trySingleTransition() returns the single cached outgoing transition when a structure has exactly one; transitionPropertyName() and transitionOffset() describe which property that transition adds and where it lands. Structure::addPropertyTransitionToExistingStructure() looks up an already-existing transition for a given name without creating one.
Butterfly.
The out-of-line storage block hanging off a JSObject; named properties beyond the inline slots grow leftward and indexed elements grow rightward. outOfLineCapacity() is how many named-property slots the current butterfly holds, and allocateMoreOutOfLineStorage(vm, oldCapacity, newCapacity) reallocates and copies. nukeStructureAndSetButterfly temporarily marks the structure ID as "nuked" so concurrent threads do not observe a butterfly/structure pair mid-swap.
Indexing type.
Part of the Structure; it tells the engine how to interpret the indexed portion of the butterfly. Contiguous and ArrayStorage have different butterfly layouts — ArrayStorage prepends a header holding vector length, public length, index bias, and a sparse-map pointer.
"Having a bad time".
JSC's global de-optimization event, triggered when an indexed accessor is installed on a prototype such as Object.prototype. It converts objects with fast indexing types over to slow indexed storage so that indexed stores consult the prototype chain, rewriting existing objects' indexing types and, where the layouts differ, their butterflies.
LiteralParser fast path.
eval of a parenthesized object/array literal and JSON.parse both go through LiteralParser. Its object fast path knows the receiver is a JSFinalObject and builds it by walking cached transitions and writing values straight into the resolved offsets.
__proto__ in an object literal.
LiteralParser tracks m_visitedUnderscoreProto and treats __proto__ members specially, since storing that key can dispatch to an accessor rather than defining a plain data property. Object.prototype.__defineSetter__("__proto__", fn) replaces the built-in accessor with a user function.
Re-entrancy. A point where native engine code calls into JavaScript, which may synchronously run and mutate engine state before returning.
Analysis
The bug is a time-of-check-to-time-of-use gap across a JS re-entrancy point, producing a Structure/butterfly desynchronization — a type confusion, not merely a wrong-value bug.
Before the fix:
originalStructure = object->structure() shape S0, Contiguous
│
├─► resolve ExistingProperty{ S0->S1, offset } (cached transition)
│
└─► parseRecursively(value) ── {__proto__: 0} ──► user setter runs
│
Object.prototype.__defineSetter__(0, fn)
│
global "having a bad time" invalidation
rewrites object: S0 -> S0', butterfly relaid
│
┌─────────────────────────────────────────────────────┘
▼
commit ExistingProperty{ S1, offset } ← S1 derived from S0, not S0'
allocateMoreOutOfLineStorage(vm, S0->outOfLineCapacity(), ...)
nukeStructureAndSetButterfly(vm, S0->id(), ...) ← stale StructureID
result: object carries S1 (old indexing type) over S0' butterfly layout
User code running inside the nested parse cannot name the half-built object, but it can mutate it globally. Installing an indexed accessor on Object.prototype trips JSC's global "having a bad time" invalidation, which walks the heap and rewrites the indexing type — and, for a Contiguous→ArrayStorage conversion, the butterfly layout — of objects that had fast indexed storage. The literal under construction has index-like keys "0", "1", "5", so it is exactly such an object. When control returns, the pre-fix code applied the stale ExistingProperty anyway: comparing capacities against the pre-invalidation structure, potentially calling allocateMoreOutOfLineStorage with a stale old capacity as the current size, calling nukeStructureAndSetButterfly with an ID the object no longer has, storing at the stale offset, and installing a transition target derived from the pre-invalidation lineage.
The fix re-reads object->structure() after the recursion and, on any mismatch, degrades to the Identifier slow path, which re-derives the transition from the receiver's current structure.
The regression test is hand-shaped to guarantee the fast path is taken. Walking it:
Object.prototype.__defineSetter__("__proto__", fn)replaces the inherited__proto__accessor with attacker JS.ks = '"0":null,"1":2,"5":3'gives the literal three index-like keys with holes, so the constructedJSFinalObjectgets indexed storage.- The two warm-up
eval("({" + ks + ",a:1})")calls prime the transition chain, so that on the third run the transition lookup for keyasucceeds and returnsExistingProperty { newStructure, offset }rather than falling to theIdentifierpath. - On the third eval, the parser reaches key
a, resolves and caches thatExistingProperty, then recurses intoparseRecursivelyfor the value{__proto__:0}. - Storing
__proto__on the nested object dispatches the user setter, which runsObject.prototype.__defineSetter__(0, function(){})— installing an indexed accessor on the prototype chain and tripping the engine-wide indexed-storage de-optimization. - Control returns with
object->structure() != originalStructure, and pre-fix the staleExistingPropertyis committed anyway.
The test observes the desync at the language level: o[1] no longer reads back 2. That the specific cause is reading the indexed slot under the wrong indexing type, rather than the stale-offset store or the mis-sized copy, is not distinguished by the test — it establishes only that the value reads back incorrectly. Likewise, the attribution to "having a bad time" specifically is domain inference; the diff's comment establishes only that user code ran and may have changed the object's structure.
The escalation direction: if the pre- and post-invalidation indexing types have different butterfly layouts — the Contiguous versus ArrayStorage case — then indexed reads on the resulting object would be resolved under the old layout while the storage carries the new one, and reading a slot that overlaps the ArrayStorage header could expose a length field or the sparse-map pointer as a JSValue, which would provide a heap-address disclosure primitive. Symmetrically, an indexed store through the mismatched fast path could overwrite those header words, which might enable out-of-bounds indexed access by inflating the vector length. Realising either would require the attacker to (a) steer the pre-invalidation object into an indexing type whose layout differs from the post-invalidation one, (b) survive the stale allocateMoreOutOfLineStorage / nukeStructureAndSetButterfly sequence without an immediate crash, and (c) groom the heap so the confused slot lines up with something worth reading or corrupting. Separately, the stale outOfLineCapacity() used as the source size could mis-size a named-property copy if the invalidation changed the object's actual out-of-line capacity.
This vulnerability weakens JSC's core object-model type safety: the invariant that a JSObject's Structure accurately describes its butterfly layout and indexing type. Before the fix, web content could obtain a fully reachable JS object whose declared shape disagrees with its actual backing store, because the literal parser committed a shape decision made before user JavaScript rewrote the object. All subsequent engine code — the interpreter's indexed fast paths, inline caches, and DFG/FTL speculation — trusts the Structure, so an attacker who lands a useful shape pairing could read or write memory that the declared shape says belongs to the object but that the real butterfly layout maps elsewhere. That is a stepping stone toward information disclosure and out-of-bounds indexed access inside the WebContent process, not merely a correctness bug.
The fast path already knew that __proto__ is a re-entrancy hazard — the transition lambda explicitly refuses to follow a transition whose transitionPropertyName() is underscoreProto, and it consults m_visitedUnderscoreProto. That guard protected the choice of transition but not the freshness of the snapshot the transition was derived from. This is a recurring shape: once a component grows an explicit re-entrancy guard for one invariant, the neighbouring invariants that depend on the same window tend to go unaudited, because the presence of the guard reads as "this path was already thought about." Worth noting too that the mutation here never touches the object through a reference — global de-optimization events rewrite objects in flight that user code has no handle on, so "the object is not exposed to script yet" is not a valid reason to skip re-validation.
Audit directions
-
Shape descriptors captured before a call that can run user code. The invariant is any cached hidden-class, offset, or capacity is invalidated by a re-entrancy boundary and must be re-read, not reused. Narrow: grep
JavaScriptCore/runtimefor locals of typeStructure*(and pairedPropertyOffsetlocals) assigned fromobject->structure()and still live across a call toparseRecursively,put,putDirect,defineOwnProperty,callGetter,call, ortoPrimitive. Start with the rest ofLiteralParser.cpp,ObjectConstructor.cpp(defineProperties,Object.assign/spread fast paths), andJSONObject.cpp's reviver walk. Wider: the same class shows up whenever any derived fact about an object — inline capacity, indexing type, butterfly pointer,PropertyTableslot,Watchpointstate — is computed once and consumed after control has left the engine, so also inspect array bulk operations and IC-building code where a shape decision precedes a user-visible callback. Widest: this is the general shape-snapshot-outlives-the-mutation-window class present in every hidden-class engine — V8'sMap/TransitionArraycaching aroundObject.definePropertyinterceptors, SpiderMonkeyShapelookups around proxy traps, and any ORM or serializer caching a schema descriptor across a user hook. In code review, the tell is aStructure*local declared above such a call and dereferenced below it with no interveningobject->structure()re-read. -
Global de-optimization events that rewrite objects the current C++ frame holds. The invariant is an object being built in native code is not private just because no JS reference to it exists. Narrow: grep for
haveABadTime/havingABadTimeandObjectsWithBrokenIndexingFinder, enumerate the ways script triggers them (indexed accessor on a prototype, prototype-chain mutation,Object.freeze/seal, dictionary transitions viadelete), then check each native object-construction site that assumes indexing-type stability across a nested store. Wider: the same class covers any heap-walking invalidation — structure dictionary-ization, watchpoint firing that swaps out inline caches, GC-triggered reshaping — so audit anywhere a fast path cachesindexingType()orbutterfly()before an operation that can allocate or call out. Widest: global invalidation events retroactively rewrite in-flight objects, which holds for V8's protector cells plus elements-kind normalization and for SpiderMonkey's object-flag propagation. Match tell on every rung: a heap-wide mutation pass whose triggers are script-reachable, paired with native code that assumes per-object layout stability for the duration of a call. -
Re-entrancy guards that cover one invariant but not its neighbours. Here
m_visitedUnderscoreProtoand the explicittransitionPropertyName() != vm.propertyNames->underscorePrototest already acknowledged that a__proto__store can run script, yet the structure-freshness invariant went unchecked in the same window. Narrow: grep JavaScriptCore for members and locals named likevisited*,*Reentrant*,inCallback,m_isParsingand, for each, list every invariant that holds across the window the flag protects — verify each is re-established, not just the one the flag was introduced for. Wider: the same shape appears wherever a partial mitigation exists — aDeferGCscope covering allocation but not structure mutation, an exception check covering throwing but not side effects, or aDisallowVMEntry/DisallowGCscope narrower than the actual hazard window. Widest: the presence of one hazard guard is evidence the hazard is real, not evidence it is handled. In code review, a defensive check whose condition names a specific hazard — one property name, one callback kind — sitting in a window where the hazard class is general deserves scrutiny. -
Offset/capacity pairs committed against a structure they were not derived from. The invariant is an offset is meaningful only relative to the structure it was derived from. Narrow: in
JavaScriptCore/runtime, examine every call site ofallocateMoreOutOfLineStorageandnukeStructureAndSetButterflyand confirm theoldCapacityargument and theStructureIDargument are read from the object's current structure at the moment of the call, not from an earlier snapshot; the tell is either argument sourced from a local rather than from a freshobject->structure(). Wider: the same class covers any paired (container, index) or (allocation, size) tuple where the two halves are sampled at different times, includingPropertyOffsetvalues carried acrossStructureflattening or dictionary transitions. Widest: this is the general index-and-container-sampled-at-different-times class, holding for any codebase pairing a handle with a size — Rust slice index caching around a&mutreborrow, or any serialization format caching a field index across a schema reload. Match tell: two values that must be co-consistent, produced by separate reads separated by a call that can mutate either.