← All reports

[2] [JSC] Check initial object structure in tryEnsureAbsence in DFG

HighJSC DFG JITTypeConfusion

The DFG certified a property absent from an object that owned it outright.

78c04ea

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, tryEnsureAbsence currently 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

if (!headStructure)
return ObjectPropertyConditionSet::invalid();
 
+ auto isAbsenceCacheable = [&](Structure* structure) {
+ if (structure->typeInfo().overridesGetOwnPropertySlot())
+ return false;
+ if (!structure->propertyAccessesAreCacheable())
+ return false;
+ if (!structure->propertyAccessesAreCacheableForAbsence())
+ return false;
+ unsigned attributes;
+ if (isValidOffset(structure->getConcurrently(identifier.uid(), attributes)))
+ return false;
+ if (structure->hasPolyProto())
+ return false;
+ return true;
+ };
+
+ // generateConditionsForPropertyMissConcurrently only walks the prototype chain, so validate
+ // headStructure first.
+ if (!isAbsenceCacheable(headStructure))
+ return ObjectPropertyConditionSet::invalid();
+
auto result = generateConditionsForPropertyMissConcurrently(globalObject->vm(), globalObject, headStructure, identifier.uid());
if (!result.isValid())
return result;
@@
if (!object)
return ObjectPropertyConditionSet::invalid();
 
- auto* structure = object->structure();
- if (structure->typeInfo().overridesGetOwnPropertySlot())
- return ObjectPropertyConditionSet::invalid();
- ...
- unsigned attributes;
- PropertyOffset offset = structure->getConcurrently(identifier.uid(), attributes);
- if (isValidOffset(offset))
- return ObjectPropertyConditionSet::invalid();
- if (structure->hasPolyProto())
+ if (!isAbsenceCacheable(object->structure()))
return ObjectPropertyConditionSet::invalid();
}
return result;

JSTests/stress/dfg-ensure-absence-own-then-property.js

+function opt(container1, object2, array, thenable, flags) {
+ const promise = new Promise(() => {});
+ container1.x;
+ thenable.x;
+ ...
+ +tmp.toJSON;
+ +tmp.toJSON;
+ array[0];
+ Promise.resolve(+tmp.toJSON === 1 ? thenable : promise);
+ array[0] = 2.3023e-320;
+}
+
+function main() {
+ ...
+ const thenable = { x: 1 };
+ const array = { 0: 1.1 };
+ let trigger = false;
+ thenable.__defineGetter__('then', () => {
+ if (trigger) {
+ array[0] = {};
+ }
+ });
+ JSON.stringify(container1);
+ for (let i = 0; i < 200; i++)
+ opt(container1, object2, array, thenable, i);
+ trigger = true;
+ opt(container1, object2, array, thenable, 0);
+ array[0].x;
+}

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.

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.

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:

  1. Build thenable with its own then getter, and array = { 0: 1.1 }, whose indexed storage the compiler will specialize on.
  2. In opt, read array[0] before the operation the compiler believes is effect-free, then call Promise.resolve(+tmp.toJSON === 1 ? thenable : promise). tmp alternates between object1 (toJSON: 1) and object2 (toJSON: {}) under flags & 1, shaping the polymorphic profile at the +tmp.toJSON sites.
  3. Run opt 200 times with alternating flags so the sites reach DFG compilation with the intended profiles.
  4. Flip trigger so that on the final invocation the then getter performs array[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.
  5. Let the following array[0] = 2.3023e-320 execute with the pre-call specialization still in force, and have main's trailing array[0].x consume 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.

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.