[3] GetByStatus walked the prototype chain for direct property access
A private field read that answers with the prototype's value
High. One analysis routine served two operations whose only real difference is how far the lookup may search, and a later change taught it to search further. The result is compiled code that can report an own-property hit where the language guarantees a miss — a brand-check bypass; memory-safety escalation depends on a consumer that trusts the check as proof of layout.
JavaScript property loads come in two flavours: normal lookup, which consults the object and then walks its prototype chain, and own-property-only lookup, which must stop at the object itself. GetByStatus is the compiler's summary of what a property-get bytecode does — a State plus a list of GetByVariants describing which base shapes it applies to and where the value lives — and the DFG's abstract interpreter and constant-folding phase both consult it to decide whether an access can be inlined or folded to a fixed offset. The soundness rule those phases live under is that an optimizing tier may only rewrite an operation into something observationally equivalent to its specified behaviour.
The angle: class private-field reads and self-hosted builtin own-property probes can be made to return a prototype's value in DFG-compiled code where the language guarantees undefined or a thrown TypeError — a brand-check bypass reachable from ordinary script.
When computing the GetByStatus, we should check if the property lookup is a direct property access before doing a prototype walk since direct accesses are not supposed to consult the prototype. Originally landed as
305413.572@rapid/safari-7624.2.5.110-branch.
Source/JavaScriptCore/bytecode/GetByStatus.cpp
Source/JavaScriptCore/dfg/DFGNode.h
Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp
Patch Details
The change threads a new lookup-scope discriminator through the structure-set overload of GetByStatus::computeFor, teaches the DFG node itself to report which scope its opcode implies, and updates the three call sites to pass it.
GetByStatus.h gains enum class LookupMode : bool { Normal, Direct } and the overload becomes computeFor(JSGlobalObject*, const StructureSet&, CacheableIdentifier, LookupMode). In GetByStatus.cpp, the unconditional if (auto result = attempToFold()) return result.value(); is gated on mode == LookupMode::Normal, and the stale comment claiming the routine "only looks into direct properties" — along with its TODO to split GetById from GetByIdDirect once prototype chains are supported — is deleted.
DFGNode.h gains Node::propertyLookupMode(), mapping GetByIdDirect, GetByIdDirectFlush and GetPrivateNameById to LookupMode::Direct and GetById, GetByIdFlush, GetByIdMegamorphic to LookupMode::Normal, with RELEASE_ASSERT_NOT_REACHED() for anything else. The two DFG consumers — AbstractInterpreter<>::executeEffects and ConstantFoldingPhase::foldConstants — now pass node->propertyLookupMode(), and ByteCodeParser::parseBlock's op_get_from_scope global-property path passes LookupMode::Normal explicitly, with a comment noting that a global property lookup is supposed to consult the global object's prototype chain.
Sharing one lookup routine between two operations with different lookup scopes, so a prototype-chain search silently leaks into an own-property-only access.
Background
Where this lives.
GetByStatus is JSC's bytecode-level summary of a property get: its State (Simple, Megamorphic, LikelyTakesSlowPath, and so on) plus a list of GetByVariants. Each GetByVariant holds a StructureSet (which base shapes it applies to), a PropertyOffset (where in the object's storage the value lives), and an ObjectPropertyConditionSet — per GetByVariant.h, "A non-empty condition set means that this is a prototype load".
Structures.
A Structure describes an object's shape and maps property names to offsets; structure->getConcurrently(uid) looks up an own property offset on a structure from a compiler thread.
The three get operations.
GetById implements normal JavaScript property lookup, consulting the object and then its prototype chain. GetByIdDirect implements an own-property-only load, used by JSC's self-hosted builtin JavaScript so builtins can inspect an object's own state without invoking prototype getters. GetPrivateNameById implements a class private-field read (obj.#x), which by language rules only succeeds when the field is installed on that very object.
Abstract interpretation and folding.
The DFG abstract interpreter (AbstractInterpreter<>::executeEffects) walks the graph proving facts about each node's value; a proven fact lets later phases elide runtime checks. ConstantFoldingPhase consumes those facts and rewrites nodes — given a Simple single-variant status, it can replace a generic get with CheckStructure plus a fixed-offset GetByOffset, or with a JSConstant.
op_get_from_scope.
The bytecode for reading a variable from a scope object, including global-object properties.
Analysis
The bug is a semantic mismatch: the optimizer modelled an own-property-only operation with prototype-chain lookup semantics.
child = Object.create(p) Lookup scope per opcode
┌──────────────┐ GetById : child -> p -> ...
│ child │ own: (none) GetByIdDirect : child only
│ [[Proto]] ──┼──┐ GetPrivateNameById : child only
└──────────────┘ │
v
┌──────────────┐
│ p │ own: #x @ offset 0
└──────────────┘
Pre-fix fold of a Direct-mode node over child's structure set:
attempToFold() walks to p, returns Simple variant with a
non-empty conditionSet and p's offset -> compiled code reads p.#x
For a GetByIdDirect / GetPrivateNameById node whose base has a proven finite structure set (value.m_structure.isFinite() in executeEffects, baseValue.m_structure.toStructureSet() in foldConstants), the property may be absent from every structure in the set. Correct direct-access semantics for that state is undefined for GetByIdDirect or a TypeError for a private-name load on an object lacking the field. Pre-fix, attempToFold() ran anyway and walked the prototype chain of those structures; if a prototype carried the identifier, computeFor returned a Simple status whose variant describes the prototype chain and whose offset points into the prototype's storage. ConstantFoldingPhase then rewrites the node into a prototype load guarded by structure and condition checks, and the abstract interpreter narrows the node's result to whatever the prototype's property proves.
Two consequences follow from one root cause: the compiled code computes a wrong value for a correctly guarded input, and the abstract interpreter asserts a result type the operation's true semantics cannot produce — the classic "AI lies" precondition for eliding downstream type checks.
The exact shape of the value attempToFold() returns is derived rather than read directly: the added comment confirms the function performs a prototype walk and GetByVariant.h documents that a non-empty condition set means a prototype load, but attempToFold()'s body sits past the truncation point of the supplied GetByStatus.cpp.
Reachability is from web content on both node families: GetPrivateNameById is emitted for ordinary class private-field reads, and GetByIdDirect/GetByIdDirectFlush are emitted inside self-hosted builtins that ordinary JS calls into, so a page can drive either to DFG tier with a hot loop. The trigger shape:
class C { #x = 1; static read(o) { return o.#x; } }.- Build an instance
pthat owns#x. - Build a look-alike
child = Object.create(p)whose structure does not own#x. - Drive
C.readhot on a base whose abstract structure set ischild's, wrapping the throwing case in try/catch so the function keeps tiering up.
Pre-fix, foldConstants calls computeFor with baseValue.m_structure.toStructureSet(), attempToFold() walks child's prototype chain, finds #x on p, and returns a Simple variant with p's offset. The DFG emits a guarded prototype load, so compiled C.read(child) could return 1 where the interpreter throws a TypeError. The same shape applies to own-property probes inside builtins: a Object.create(realInstance) look-alike whose own-property probe should read undefined could instead read the prototype's value under DFG, defeating the probe's use as an instance brand check.
Escalation beyond the logic bypass depends on a consumer that treats the bypassed check as proof of layout: if a builtin or downstream JIT-visible path proceeds to touch fixed internal-field slots on the look-alike after the bogus probe succeeds, that could yield a type-confused access. The builtin sources are not part of the supplied context, so that rung is a projected direction. A second, independent projection anchors on the abstract-interpreter call site: executeEffects narrows the node's AbstractValue from the folded prototype variant, so it could prove a type the true direct-access semantics cannot produce, and any downstream edge whose check is elided on that proof could then operate on a value of the wrong type.
This vulnerability weakens the soundness boundary between the interpreter's language semantics and DFG-compiled code. An own-property-only load could be rewritten into a prototype-chain load, so an object that does not own a property could appear — in compiled code only — to own it. An attacker reaching this state from web content could bypass own-property and private-field brand checks that JavaScript code, including JSC's own self-hosted builtins, relies on to distinguish genuine instances from crafted look-alikes, and could read private or internal state the language guarantees is inaccessible. If a downstream consumer of such a bypassed check then assumes a concrete object layout, the divergence could escalate from a logic bypass to a memory-safety primitive.
The deleted comment is the story: the shared routine documented its own correctness precondition ("this function only looks into direct properties") and prescribed the remedy ("we should split this for GetById and GetByIdDirect"). A later change added the prototype-walking attempToFold() to that same function and invalidated the precondition without touching the comment. The fix is exactly the split the TODO asked for, expressed as a parameter rather than as two functions — and Node::propertyLookupMode()'s RELEASE_ASSERT_NOT_REACHED() default means any future node type routed through this path must consciously declare its lookup semantics.
Audit directions
- One analysis routine shared by several bytecode operations whose semantics differ in scope. Audit the other
*Status::computeForfamilies inSource/JavaScriptCore/bytecode/—PutByStatus,InByStatus,DeleteByStatus,CheckPrivateBrandStatus,SetPrivateBrandStatus,InstanceOfStatus— for a single overload consumed by both a direct/private variant and a normal variant of the same op. Narrow tell: acomputeForwhose callers inDFGConstantFoldingPhase.cpp/DFGAbstractInterpreterInlines.hcover severalNodeTypes in onecasegroup but pass no discriminator distinguishing them. Wider tell: the same class shows up wherever a lookup helper takes a name plus a container but not a scope flag (own vs inherited, enumerable vs all, string-keyed vs symbol-keyed) —ObjectPropertyConditionSetbuilders andComplexGetStatus::computeForare the shapes to navigate to. Widest: this is the general "shared resolver with an implicit scope parameter" class, applicable to Python'sgetattrvs__dict__fast paths, V8'sLookupIteratorconfiguration modes, or any ORM sharing one field resolver between own-columns and joined relations. The invariant to carry: if a resolver can search more places than the caller's operation is allowed to see, the search scope must be an explicit parameter, not a comment. - Comments that record a correctness precondition for shared code, later invalidated by an unrelated change to the same function. Grep
Source/JavaScriptCore/bytecode/andSource/JavaScriptCore/dfg/for comments containing "also used for", "only looks", "we should split", and "when supporting", and check whether the code below them still satisfies the stated precondition. Narrow tell: a comment asserting a property of the function ("this only handles X") sitting above a body that now also handles not-X. Wider tell: the same class covers any invariant documented in prose instead of asserted in code — look for functions with aFIXME/TODOnaming a split or a separate path that never happened, since those name the exact future change that breaks them. Widest: a precondition stated only in a comment is not enforced, so treat every "this is safe because…" comment as an unasserted invariant and check it against the current body; this transfers to Linux kernel locking comments, Rustunsafejustification blocks, and Go's// caller must holdconventions. Verification of each hit is manual reading, not grep-confirmable. - Abstract-interpretation facts derived from a status summary that models a different operation than the node being interpreted. Audit every site in
DFGAbstractInterpreterInlines.handDFGConstantFoldingPhase.cppthat constructs a*Statusfromvalue.m_structure.toStructureSet()rather than from profiling data, and confirm that every field of the status is derived from the node's own opcode semantics —node->cacheableIdentifier()already is,LookupModenow is, but check for further implicit assumptions (strictness,viaGlobalProxy, private-brand requirements). Narrow tell: acomputeForcall inside aswitch (node->op())arm covering multiple ops where the arguments contain nonode->op()-derived discriminator. Wider tell: any optimizer phase that maps N distinct IR opcodes onto one shared semantic model object — check whether the model carries enough discriminators to reconstruct each opcode's contract. Widest: this is the general "optimizer summary loses an operand of the semantics" class, applicable to LLVM'sMemoryLocation/alias-analysis summaries and SpiderMonkey's MIR alias sets; a transfer function shared across opcodes must take every distinguishing operand explicitly, or it computes the union of their behaviours. - Investigate whether the language-level own-property and private-field brand checks have any other JIT path that can observe a prototype-supplied value. Start from the
GetByIdDirect,GetByIdDirectFlushandGetPrivateNameByIdnode types enumerated in the newNode::propertyLookupMode()and trace every DFG/FTL phase that consumes them, then repeat for the private-brand nodes (CheckPrivateBrand,SetPrivateBrand) and their*Statushelpers. Match tell: any phase that reaches aGetByVariantwith a non-emptyconditionSet()while handling one of the Direct-mode node types — that combination is by construction impossible after this fix, so a hit is a live variant.