← WebKit Silent-Fix Report — 2026-W25

aa5589433c  [JSC] Take slow path in LiteralParser if original structure changes

severity medium class TypeConfusion confidence 0.65 JSC LiteralParser exploitable-grade
Shu-yu Guo Tue Jun 16 14:05:40 2026 -0700 full: aa5589433c390617ad88c26ba56299dec2087bbf bug report ↗ view on GitHub ↗
Primitive: stale structure/offset write after user callback
Triage note: A __defineSetter__ callback can change the object structure during parsing; using the cached structure/offset afterward is a classic re-entrancy type-confusion hazard.
Contents

The bug at a glance

This is a JSC re-entrancy type-confusion in a hot object-literal fast path where a cached structure transition (target Structure + PropertyOffset) is used to store a value AFTER user JavaScript has been allowed to run mid-parse. Using a stale offset/out-of-line-capacity against a mutated object is a classic path to controlled heap corruption in the butterfly, so the theoretical ceiling is high; the added test only asserts an incorrect result (o[1] !== 2), so the publicly demonstrated effect is corruption of parsed values / wrong state rather than a proven arbitrary write, keeping the confirmed severity at medium-to-high.

LiteralParser precomputes a property’s storage decision (an ExistingProperty holding a target Structure and PropertyOffset) BEFORE parsing that property’s value, but parsing a nested object with a proto member can invoke a user-installed proto setter that mutates the object’s live Structure. The angle is the re-entrancy window between deciding the transition and committing the write.

Root cause

LiteralParser<CharType>::parseRecursively has a fast path for building JSFinalObject literals by reusing cached Structure transitions. For each property it first computes a Variant<ExistingProperty, Identifier> named property: if originalStructure->trySingleTransition() yields a PropertyAddition transition whose transitionPropertyName matches (and m_visitedUnderscoreProto handling permits), or if Structure::addPropertyTransitionToExistingStructure finds an existing transition, it returns ExistingProperty { newStructure, offset } — the exact Structure the object should have after adding this property, plus the PropertyOffset at which the value belongs.

Crucially this decision is made before the property’s value is parsed. The code then calls parseRecursively to build the value. For a member like a:{proto:0}, parsing the nested literal assigns proto, which — because the test installs Object.prototype.defineSetter("proto", …) — runs arbitrary user code. That callback in turn does Object.prototype.defineSetter(0, …), perturbing structures so that object->structure() is no longer originalStructure by the time control returns.

Before the patch, the committing block unconditionally trusted the cached ExistingProperty. It compared originalStructure (then named structure) out-of-line capacity against newStructure, possibly called allocateMoreOutOfLineStorage and nukeStructureAndSetButterfly(vm, structure->id(), newButterfly), then wrote value at the cached offset via validateOffset(offset). If the object’s real Structure had changed underneath, the cached newStructure/offset no longer describe the object: the write lands at an offset computed for a different Structure/butterfly shape, and the object is force-set to a Structure that does not reflect its true property layout — a type confusion between the assumed and actual object shape.

The fix inserts a guard immediately after the value is parsed: if object->structure() != originalStructure && std::holds_alternative<ExistingProperty>(property), it rewrites property to an Identifier (property = Identifier::fromUid(vm, …->transitionPropertyName())), forcing the slow generic put path that re-resolves the property against the object’s current Structure. The rename from structure to originalStructure throughout makes explicit that all cached-transition reasoning must be anchored to the pre-parse Structure and revalidated afterward. The Identifier::fromUid NODELETE / SUPPRESS_NODELETE annotations are a lifetime/safer-C++ bookkeeping change enabling that new call site.

Key code

The added re-entrancy guard in LiteralParser::parseRecursively that drops to the slow path when the object’s structure changed during value parsing (LiteralParser.cpp).

            // After parseRecursively, user code may have run (e.g. due to a __proto__ setter in a
            // nested object), which may have changed the structure of the object. This invalidates
            // any cached transition, so reset it to Identifier to take the slow path.
            if (object->structure() != originalStructure && std::holds_alternative<ExistingProperty>(property)) [[unlikely]]
                property = Identifier::fromUid(vm, std::get<ExistingProperty>(property).structure->transitionPropertyName());

Patch walkthrough

  • Source/JavaScriptCore/runtime/LiteralParser.cpp — Renames the cached pre-parse Structure from structure to originalStructure across the transition lookup and the commit block, and inserts the core guard after parseRecursively returns: when the object’s structure has changed and property is still an ExistingProperty, it is reset to an Identifier so the slow path re-resolves the write against the current structure instead of using the stale transition/offset. The final commit block’s capacity comparison and nukeStructureAndSetButterfly now consistently use originalStructure.
  • Source/JavaScriptCore/runtime/Identifier.h — Marks the static Identifier::fromUid(VM&, UniquedStringImpl*) declaration with NODELETE, part of the lifetime annotation needed to safely materialize an Identifier from the transition’s property name at the new slow-path fallback site.
  • Source/JavaScriptCore/runtime/IdentifierInlines.h — Adds SUPPRESS_NODELETE to the corresponding inline definition of Identifier::fromUid so the annotation is consistent between declaration and definition.
  • JSTests/stress/literal-parser-proto-setter.js — Regression test: warms the transition cache with two evals of an identical literal, then parses a third literal whose final property value contains {proto:0}, invoking a setter that mutates structures mid-parse, and asserts the earlier property still reads back correctly (o[1] === 2).

Background

LiteralParser fast path — JSC uses LiteralParser both for JSON.parse and for eval of object-literal-shaped source. For JSFinalObjects it caches Structure transitions so a literal parsed repeatedly reuses the same shape without hash lookups or refcount churn. The optimization records where each property will live (a Structure plus a PropertyOffset) so the value can be dropped straight into the butterfly.

Structure and PropertyOffset — A Structure describes a JSObject’s property layout: which properties exist, their attributes, and the offset each occupies (inline or out-of-line in the butterfly). A PropertyOffset is only valid relative to a specific Structure. Storing a value at an offset resolved for structure A into an object that actually has structure B writes to the wrong slot, corrupting adjacent object state.

trySingleTransition / addPropertyTransitionToExistingStructure — These reuse existing shape transitions: trySingleTransition returns the sole cached child transition of a Structure, and addPropertyTransitionToExistingStructure looks up whether adding a named property already has a known target Structure. Both let the parser avoid creating new Structures for common literal shapes and yield the target Structure plus the new property’s offset.

proto setter re-entrancy — In object literals, proto has special meaning and can be intercepted: Object.prototype.defineSetter("proto", fn) installs an accessor so that assigning proto during literal construction runs fn. This turns literal parsing — normally a leaf operation — into a callback into arbitrary JavaScript, which can redefine properties and mutate Structures while the parser holds cached assumptions.

nukeStructureAndSetButterfly — A JSObject primitive that atomically swaps an object’s Structure id and its Butterfly, used when out-of-line storage must grow to fit a new property. If invoked with a Structure/butterfly pair derived from a stale transition, it installs a shape that does not match the object’s real contents, the essence of the type confusion this patch prevents.

Vulnerability window

  1. Optimization introduced — LiteralParser gained a fast path caching single/existing Structure transitions to build literal objects without per-property shape resolution, computing the storage decision before the property value is parsed.
  2. Latent re-entrancy hazard — The cached ExistingProperty (target Structure + offset) was committed unconditionally after value parsing, implicitly assuming the object’s Structure could not change during that parse.
  3. Trigger discovered — A nested object value containing proto can invoke a user-installed proto setter mid-parse, which mutates structures (e.g. by defining an accessor for an index), so object->structure() diverges from the cached originalStructure.
  4. Reported — Filed as webkit.org/b/310231 (rdar://172857687), fixed by Shu-yu Guo, reviewed by Keith Miller.
  5. Fix landed — Commit adds a post-parse guard that reverts to the slow Identifier path whenever the structure changed, plus fromUid lifetime annotations; canonical 315327@main, originally shipped on the safari-7624 branch as 305413.524.

Proof of concept

The two warmup evals populate the transition cache for the literal shape with properties 0,1,5,a. The third eval parses the same shape but with a:{proto:0}; assigning proto in the nested literal fires the installed setter, which defines an accessor for index 0 and mutates structures while the outer object is mid-build. On a vulnerable build the stale cached transition/offset is used and the object is left in a corrupted state so o[1] no longer reads back 2; the assertion throws. It demonstrates the confusion but proves incorrect-value corruption, not a specific controlled write.

let fired = false;
Object.prototype.__defineSetter__("__proto__", function(v) {
    if (fired) return;
    fired = true;
    Object.prototype.__defineSetter__(0, function(){});
});

let ks = '"0":null,"1":2,"5":3';

eval("({"+ks+",a:1})");
eval("({"+ks+",a:1})");

let o = eval("({"+ks+",a:{__proto__:0}})");

if (o[1] !== 2) {
    throw new Error("incorrect eval result");
}

Exploitation

  1. Setup — Install an Object.prototype proto setter and warm the LiteralParser transition cache with repeated evals of a chosen literal shape, so the fast path caches a concrete target Structure and offset for the last property.
  2. Trigger — Eval a literal whose final property value is a nested object containing proto, driving parseRecursively to run the attacker callback after the transition decision but before the value is committed.
  3. Corruption — Inside the callback, redefine properties/indices to mutate the in-progress object’s Structure so the cached ExistingProperty no longer matches; the pre-patch commit path then writes at a stale offset and/or force-installs a mismatched Structure via nukeStructureAndSetButterfly.
  4. Escalation (inferred, unproven) — A stale PropertyOffset write into a butterfly whose real capacity differs is the raw material for an out-of-bounds / type-confused write, the standard precursor to addrof/fakeobj primitives; the shipped test only demonstrates value corruption (crash-or-wrong-result), so full escalation is not established by the patch.

Detection & hunting

For defenders and SOC / detection engineers:

  • Crash or JSC assertion in butterfly/offset validation — Look for aborts hitting validateOffset or ASSERT(newStructure != structure/originalStructure) in LiteralParser commit, and for JSFinalObject shape mismatches surfacing during subsequent property reads after eval/JSON.parse of literals containing proto.
  • Structure-id vs contents mismatch under GC/marking — Objects created by the fast path that carry a Structure inconsistent with their real property set may cause secondary crashes during marking or property access; correlate such faults with recent eval of object literals.
  • proto accessor installed on Object.prototype — Content that calls Object.prototype.defineSetter("proto", …) before heavy eval/JSON.parse activity is a strong behavioral indicator for attempts to hit this re-entrancy window.

Audit directions

  • Other cached-transition fast paths — Audit every place that resolves a Structure transition or PropertyOffset before running code that can re-enter (value parsing, toPrimitive, proxy traps, getters/setters) and commits the write afterward without revalidating object->structure().
  • proto handling in literal/JSON construction — Review m_visitedUnderscoreProto logic and all proto special-casing in LiteralParser for further windows where a setter can run mid-construction while shape assumptions are held.
  • reviverMode / JSON.parse parity — Confirm the same guard is effective under both parserMode==StrictJSON and the eval object-literal mode, and under reviver callbacks that can likewise mutate objects between parse and store.
  • nukeStructureAndSetButterfly callers — Enumerate callers that pass a Structure id and butterfly derived from cached transitions and verify none can be reached with a Structure that no longer matches the live object.

Before / after

Loading diff…