[2] [JSC] Check initial object structure in tryEnsureAbsence in DFG
The DFG certified a property absent from an object that owned it outright.
High. The compiler can be made to certify that a property is absent from an object that carries it directly, so DFG code runs with an effect model reality contradicts. The bundled test is already shaped as a fabricated-object PoC; escalation to arbitrary read/write is the conventional next step.
A JIT compiler that speculates has to prove things about object shape before it can specialize on them. In JSC, one class of proof is a condition set: a bundle of assertions about specific objects, backed by watchpoints, so that any structure transition invalidating an assertion throws the compiled code away. One of those assertions is negative — this identifier is absent from this object and everything it inherits from — and it is load-bearing well beyond property lookup, because several JS operations only call back into user code when a specific hook property exists. The invariant is that a condition set the DFG compiles against must be a true statement about runtime object shape: if the compiler proves identifier P is absent from object O, no runtime lookup of P on O may find anything.
The angle: any renderer script can build an object holding the queried property directly on itself, have the compiler certify its absence, and get user JavaScript to run at a program point the compiled code was built to believe nothing could happen at.
From the commit message:
In DFG,
tryEnsureAbsencecurrently does not check the structure of the object on which it's trying to generate the conditions that a property remains absent. It only checks the structures of the objects on the prototype chain. This is incorrect in the case where object itself contains the property we're trying to ensure absence of.
Source/JavaScriptCore/dfg/DFGGraph.cpp
JSTests/stress/dfg-ensure-absence-own-then-property.js
Patch Details
The patch modifies JSC::DFG::Graph::tryEnsureAbsence. Previously the function computed headStructure, handed it immediately to generateConditionsForPropertyMissConcurrently(), and then validated cacheability only for the objects appearing in the returned condition set — i.e. the prototype-chain objects. The patch factors the per-structure validation into a local lambda isAbsenceCacheable(Structure*), which checks typeInfo().overridesGetOwnPropertySlot(), propertyAccessesAreCacheable(), propertyAccessesAreCacheableForAbsence(), isValidOffset(structure->getConcurrently(identifier.uid(), attributes)), and hasPolyProto(). That lambda is then applied to headStructure before the generator call, bailing out with ObjectPropertyConditionSet::invalid() on failure. The loop over the returned conditions is rewritten to call the same lambda on object->structure(), so head and prototypes now go through byte-identical validation. An accompanying comment states the reason: generateConditionsForPropertyMissConcurrently only walks the prototype chain, so validate headStructure first. The commit also adds the regression test JSTests/stress/dfg-ensure-absence-own-then-property.js.
Validating every link of a delegation chain except the root the proof is anchored to, so a property present on the root itself is certified absent.
Background
Where this lives. The DFG is JSC's mid-tier optimizing JIT: functions warm up in the interpreter and baseline JIT collecting type profiles, and after enough executions (the test's 200-iteration loop) the DFG compiles an optimized version from those profiles. DFG compilation runs on a background thread while the mutator may still be running, which is why several APIs it uses carry a Concurrently suffix.
Structures. In JSC every object points to a Structure describing its property layout (names to offsets) and its prototype. Objects with the same shape share a structure, so a structure identity check is a cheap proxy for a full shape check.
Condition sets. An ObjectPropertyCondition is an assertion about a specific object — Presence (property at a given offset), Absence (property not present), Equivalence, and others. An ObjectPropertyConditionSet bundles them. The compiler validates the set at compile time and installs watchpoints, so any structure transition that invalidates a condition jettisons the compiled code. The condition machinery lives in Source/JavaScriptCore/bytecode/ObjectPropertyConditionSet.cpp and is shared with the inline-cache compiler.
generateConditionsForPropertyMissConcurrently. Given (vm, globalObject, headStructure, uid), this factory builds the condition set proving a lookup for uid starting at headStructure misses, by walking the prototype chain and emitting an Absence condition for each prototype object.
Structure predicates the lambda checks. Structure::getConcurrently(uid, attributes) is a thread-safe lookup returning the PropertyOffset at which uid lives in that structure; isValidOffset() on the result tells you whether the structure has that property as an own property. propertyAccessesAreCacheable() and propertyAccessesAreCacheableForAbsence() mark whether positive and negative property lookups respectively can be safely cached on this shape — dictionary structures and other special shapes say no. overridesGetOwnPropertySlot() is a TypeInfo flag saying the class implements custom property lookup in C++, so the structure's property table is not the whole story. Poly-proto is an object-allocation mode in which the prototype is stored in the instance rather than encoded in the structure; Structure::hasPolyProto() reports it.
Property absence as an effect proof. Several JS operations only invoke user code when a specific hook property exists: Promise.resolve(v) consults v.then, JSON.stringify consults toJSON, iteration consults Symbol.iterator, coercion consults valueOf. Proving the hook absent is what lets a compiler treat the surrounding operation as free of user-visible callbacks.
Indexing types. Objects with integer-keyed properties store them in a butterfly whose element representation is encoded in the indexing type — unboxed doubles versus boxed JSValues, for instance — and the JIT specializes element loads and stores on that representation.
Analysis
This is an unsound compiler assumption: a missing validation that lets the DFG prove a false invariant, manifesting downstream as incorrect speculation and type confusion.
tryEnsureAbsence(headStructure, "then")
───────────────────────────────────────
BEFORE AFTER
head ──(never checked)──┐ head ──► isAbsenceCacheable? ──► bail
│ own "then" getter │ │ (own "then" found)
▼ │ ▼
Object.prototype ──► checked Object.prototype ──► isAbsenceCacheable?
│ │
▼ │
null └──► set VALID ("then" is absent")
└──► compiler: Promise.resolve cannot call user JS
As the diagram shows, generateConditionsForPropertyMissConcurrently emits conditions for prototype objects only — the head object the query is rooted at is never represented by a condition in the returned set. The pre-fix validation loop iterated over the generator's output, so the single most important check, isValidOffset(structure->getConcurrently(identifier.uid(), attributes)) — "does this structure actually have the property as an own property?" — was never applied to the head. When the base object carries the queried identifier as its own property, the pre-fix code found nothing on the prototype chain, produced a valid-looking condition set, and returned it.
The DFG then compiles under "this identifier is absent from this object", and that fact is used to shape the compiler's effect model as well as its fast-path selection. At runtime the property is present, the accessor does run, and user JavaScript executes at a point where DFG-generated code assumed no effects had occurred — so abstract-interpreter state cached across that point (structures, array modes, constants) can be stale.
The hasPolyProto() omission is a second, independent unsoundness: for a poly-proto head object the structure does not pin the prototype, so a prototype-chain proof rooted at that structure does not constrain the actual chain the object walks at runtime.
The regression test is shaped exactly around the own-property case. thenable is { x: 1 } with an own accessor then installed via __defineGetter__, so then is an own property of the head object while Object.prototype has none — precisely the configuration the prototype-only walk misses. Tracing it:
- Build
thenablewith its ownthengetter, andarray = { 0: 1.1 }, whose indexed storage the compiler will specialize on. - In
opt, readarray[0]before the operation the compiler believes is effect-free, then callPromise.resolve(+tmp.toJSON === 1 ? thenable : promise).tmpalternates betweenobject1(toJSON: 1) andobject2(toJSON: {}) underflags & 1, shaping the polymorphic profile at the+tmp.toJSONsites. - Run
opt200 times with alternatingflagsso the sites reach DFG compilation with the intended profiles. - Flip
triggerso that on the final invocation thethengetter performsarray[0] = {}— a store that changes the object's element representation at a program point the compiled code was built to believe nothing could happen at. - Let the following
array[0] = 2.3023e-320execute with the pre-call specialization still in force, and havemain's trailingarray[0].xconsume the result.
If the stale specialization stores the raw bits of 2.3023e-320 into a butterfly that has since been retyped to hold boxed values, the resulting slot would hold an attacker-chosen bit pattern that the engine subsequently treats as a cell reference — the standard fabricated-object shape, and a controlled-address dereference from which arbitrary read/write is conventionally built. The precise intermediate step — that the stale abstract state is the array's element representation rather than some other cached fact — is inferred from the constants and access ordering in the test ({ 0: 1.1 }, the subnormal double, the getter's array[0] = {}); the DFG node-level fold that consumes the condition set lives in DFGByteCodeParser.cpp, which the supplied context truncates before the tryEnsureAbsence call sites.
Reachability is unambiguous: tryEnsureAbsence runs during DFG compilation of ordinary script, and the test drives the whole path from plain JavaScript with no special flags beyond noDFG(main), a harness hint rather than a precondition of the bug. Corruption is confined to the WebContent process — renderer-side memory corruption only, with no privileged or IPC entry point involved, so a separate sandbox escape would still be required.
The discovery signature points at a JIT-aware JavaScript fuzzer (Fuzzilli-style) followed by minimization. The test bears the hallmarks of fuzzer-derived and hand-reduced output: a dead 0[0] expression kept only to shape a code path, a flags bitmask driving branch polymorphism, repeated +tmp.toJSON with the result unused, throwaway Object.create(tmp) calls purely to force structure transitions, and a magic subnormal constant meaningful only when reinterpreted as a boxed value. The combination of a bugs.webkit.org id, two rdar references, and an Originally-landed-as ... rapid/safari-...-branch line indicating an out-of-band rapid-response landing is consistent with an externally reported, exploit-shaped PoC. Variant analysis is the secondary possibility — property-absence and prototype-chain condition soundness is a recurring DFG bug family, and auditing tryEnsureAbsence for head-vs-chain asymmetry is exactly what a variant hunt performs.
This vulnerability weakens the JIT's soundness guarantee, which is the foundation of memory-type safety inside the WebContent renderer. Before the fix, script could construct an object holding the queried property directly on itself and have the compiler certify its absence anyway, so DFG-generated code would run with an effect model and a set of speculations that do not match reality. An attacker who converts that mismatch into a concrete misspeculation — the shape the added regression test targets — could obtain a memory-corruption primitive in the renderer, the standard building block for arbitrary read/write and subsequent code execution.
Insight
The comment the patch adds is the whole lesson: generateConditionsForPropertyMissConcurrently only walks the prototype chain. The helper's contract quietly excludes the object it is rooted at, and the caller's validation loop iterated over the helper's output rather than over "every object the proof depends on" — so the one object that never appears in the output is the one that never got checked. Note also that the fix restores four checks at once for the head structure, not just the own-property one: overridesGetOwnPropertySlot, propertyAccessesAreCacheable, propertyAccessesAreCacheableForAbsence and hasPolyProto were all missing there, so any of them could have been the load-bearing gap for a different variant. Routing head and chain through one shared isAbsenceCacheable lambda is the right structural fix: it makes the two paths impossible to drift apart again.
Audit directions
-
Proofs that validate a generated chain but skip the seed they are rooted at. The invariant is validation must be driven by everything the conclusion depends on, not by whatever the generator emitted. Narrow: grep
Source/JavaScriptCorefor callers ofgenerateConditionsForPropertyMiss,generateConditionsForPropertyMissConcurrently,generateConditionsForPrototypePropertyHit, and the othergenerateConditionsFor*factories inObjectPropertyConditionSet.cpp— for each, check whether the caller validates theheadStructureit passed in, or only the objects in the returned set. Match tell: a caller that computesheadStructure, hands it to a generator, then loopsfor (auto& condition : result)doing structure checks, with no check applied toheadStructureitself before or outside the loop. Wider: the same shape appears in any WebKit code that seeds an iteration and validates only the iterated results — inline-cache condition building inInlineCacheCompiler.cpp,GetByStatus/InByStatus/PutByStatuschain construction, and prototype-walk loops that start atstructure->storedPrototype()and thus skip the receiver by construction; the tell is a loop whose induction starts one step past the object under test. Widest: this is the general off-by-one-at-the-head-of-an-inductive-proof class — it holds in any inheritance/delegation-chain optimizer (V8 prototype validity cells, SpiderMonkey shape and megamorphic-miss guards) and in any policy engine that checks inherited ACL entries while assuming the resource's own entry was handled elsewhere. Carry this across codebases: when a proof is stated as "P holds for X and everything X delegates to", check that the code path proving it actually evaluates X. -
Absence proofs used as effect proofs. The compiler concludes "no user code can run here" from "this hook property does not exist". What makes this dangerous is that the failure is not a wrong value but a wrong side-effect model, so every abstract-interpreter fact cached across the operation becomes suspect at once. Narrow: trace all DFG consumers of
Graph::tryEnsureAbsenceinDFGByteCodeParser.cppandDFGAbstractInterpreterInlines.hand enumerate which identifiers are proven absent (then,toJSON,Symbol.iterator,valueOf,toString) and what clobber behaviour each proof suppresses. Match tell: a site where a validObjectPropertyConditionSetcausesclobberWorld()/clobberStructures()to be skipped rather than merely folding aGetByIdtoundefined. Wider: the same class covers any optimization where a negative fact licenses skipping a barrier —clobberize.heffect declarations keyed on intrinsic recognition, inline-cache "no getter/setter on the chain" fast paths, and watchpoint-gated fast paths in builtins (ArrayPrototype,RegExpPrototype) that assume unmodified prototypes; the tell is a fast path guarded by a set-of-watchpoints predicate whose failure mode is "user code runs" rather than "wrong value". Widest: a negative capability proof is only as strong as its weakest enumeration step, and its failure mode is arbitrary re-entrancy — audit anywhere a system proves "no handler is registered" to skip a re-entrancy barrier, from Turbofan side-effect modelling keyed on prototype checks to ORM lazy-loading paths that skip transactions because "no triggers exist". -
Structure-shape predicates applied asymmetrically across objects that must all satisfy them. The invariant is if a proof quantifies over N objects, every guard must be applied to all N, ideally through a single shared predicate. Narrow: grep DFG and FTL for uses of
hasPolyProto(),overridesGetOwnPropertySlot(),propertyAccessesAreCacheable()andpropertyAccessesAreCacheableForAbsence()and look for functions that call some but not all four on a given structure, or that apply the group to one object while a sibling object in the same proof gets a shorter list. Match tell: two structure-check blocks in the same function with differing check sets, rather than one shared helper or lambda like theisAbsenceCacheablethis patch introduces. Wider: the same asymmetry shows up wherever a validation predicate is inlined at multiple call sites instead of factored — IPC message validation repeated per handler, sanitizer checks duplicated across encode and decode paths; the tell in code-search results is the same three-to-five-line check sequence appearing more than once with small variations. Widest: duplicated validation drifts; shared validation cannot. Carry the question: is there exactly one function that decides whether this thing is safe, and does every path go through it? -
Verify concurrency-safety of the newly hoisted head-structure check itself.
isAbsenceCacheableis now called onheadStructureon the DFG compiler thread viagetConcurrently, before the condition set exists to install watchpoints. Examine whether every field it reads (typeInfo(), the property table viagetConcurrently, the poly-proto bit) is stable or lock-protected against a concurrently mutating mutator thread, and whether the subsequentstructuresEnsureValidity()/isStillValid(Concurrency::ConcurrentThread)re-validation inObjectPropertyConditionSet.cppre-covers the head structure or still only covers chain objects. This one requires reasoning about the concurrent-compilation memory model rather than a grep; the match tell is any compile-thread structure read whose result licenses code generation without a corresponding condition or watchpoint that a mutator-side transition would invalidate.