177d2cad35 [JSC] Reject dictionary structures in `tryEnsureAbsence` in DFG
Triage note: Dictionary structures do not transition on property add, so proving absence is unsound; rejecting them fixes a JIT correctness/type-confusion bug.
Contents
The bug at a glance
High. This tightens the head-structure check added in 314147@main (78c04ea7a1): that check accepted cacheable dictionary structures, but adding a property to a dictionary does not transition its structure, so a proven absence of ’then’ stays cached even after a ’then’ getter is added post-compilation, with no watchpoint or CheckStructure catching it. Promise.resolve() folded into NewResolvedPromise then skips the user’s ’then’ getter, and running the getter later on the slow path executes user code where the compiler assumed no side effects, a JIT correctness/type-confusion bug reachable from plain script.
Dictionary structures are shared, non-transitioning shapes: property additions mutate the structure in place instead of creating a new structure. An absence proof over a dictionary is therefore not enforceable by structure identity, so proving ’then’ absent on a dictionary object is unsound because a later object.defineGetter(’then’, …) leaves the structure (and any CheckStructure) unchanged while adding the property.
Root cause
Commit 78c04ea7a1 added isAbsenceCacheable(headStructure) to DFG Graph::tryEnsureAbsence so the base object’s own structure is validated before an absence proof is cached. However, isAbsenceCacheable only rejected structures failing propertyAccessesAreCacheable()/propertyAccessesAreCacheableForAbsence(), and propertyAccessesAreCacheable() only excludes uncacheable dictionaries; a cacheable dictionary structure passed all checks.
Dictionary structures do not transition on property addition. Normal (non-dictionary) structures form a transition tree: adding a property creates a new Structure, and the DFG’s absence proof is backed by CheckStructure / structure-transition watchpoints that fire when the shape changes. For a dictionary, object.defineGetter(’then’, fn) adds the ’then’ property in place without changing the Structure pointer, so neither a CheckStructure guard nor a transition watchpoint observes the addition. The previously generated proof that ’then’ is absent remains ‘valid’ even though the property now exists.
The security impact runs through Promise.resolve(). When the DFG proves that the argument object has no own ’then’ (and none on its prototype chain), it folds Promise.resolve(object) into NewResolvedPromise, i.e. it constructs an already-resolved promise directly, skipping the specification step that reads the object’s then and, if callable, treats it as a thenable. If a ’then’ getter is added after compilation to a dictionary-structured object, the optimized code wrongly takes the no-then fast path and never invokes the user getter. Worse, when the getter does eventually run on the operation slow path, it executes user JavaScript at a program point where the compiler assumed no side effects could occur, breaking the effect model the surrounding compiled code relies on, a soundness violation exploitable as type confusion.
The fix adds a single clause to isAbsenceCacheable: ‘if (structure->isDictionary()) return false;’. This rejects any absence proof whose head structure is a dictionary, forcing the general (non-folded) path that consults the object’s real ’then’. Prototype-chain structures were already safe because generateConditionsForPropertyMissConcurrently already rejects dictionaries; only the head structure needed this extra guard.
Key code
Dictionary structures rejected for absence proofs (DFGGraph.cpp)
if (!structure->propertyAccessesAreCacheableForAbsence())
return false;
if (structure->isDictionary())
return false;
unsigned attributes;
if (isValidOffset(structure->getConcurrently(identifier.uid(), attributes)))
return false;
Patch walkthrough
Source/JavaScriptCore/dfg/DFGGraph.cpp— Inside the isAbsenceCacheable lambda in tryEnsureAbsence (introduced by 78c04ea7a1), a new clause ‘if (structure->isDictionary()) return false;’ is inserted after the propertyAccessesAreCacheableForAbsence check and before the getConcurrently offset check. Because the lambda is applied to headStructure, this makes tryEnsureAbsence reject dictionary-structured base objects, whose in-place property additions cannot be caught by CheckStructure or transition watchpoints.JSTests/stress/dfg-ensure-absence-dictionary-then-property.js— New regression test: createDictionaryObject() adds 1000 properties to force a cacheable dictionary structure; opt(object) does Promise.resolve(object) and is warmed until DFG-compiled; then object.defineGetter(’then’, …) adds a then getter without transitioning the structure; a final opt(object) must observe the getter (getterCalled), failing pre-patch because the stale absence proof folds Promise.resolve into NewResolvedPromise and skips the getter.
Background
Dictionary structure — A JSObject shape optimized for many/dynamic properties; property additions mutate the structure in place rather than creating a new one, so structure identity does not reflect property changes.
Structure transition watchpoint / CheckStructure — The DFG guards speculated shapes by checking the Structure pointer and installing watchpoints that fire on transitions; these are ineffective for dictionaries, which do not transition on add.
NewResolvedPromise folding — When Promise.resolve’s argument is proven to have no thenable ’then’, the DFG can fold the call to directly build a resolved promise, skipping the spec’s then lookup/call.
propertyAccessesAreCacheable — A structure predicate that excludes only uncacheable dictionaries, so cacheable dictionaries still pass, which is why an explicit isDictionary() reject was needed.
Vulnerability window
- Prior fix — 78c04ea7a1 added head-structure validation but its predicate still accepted cacheable dictionary structures.
- Warm up — opt(object) runs until DFG-compiled, proving ’then’ absent on the dictionary-structured object and folding Promise.resolve into NewResolvedPromise.
- Mutate in place — object.defineGetter(’then’, …) adds a then getter; the dictionary structure pointer does not change.
- Stale proof — No CheckStructure/watchpoint fires; the optimized code keeps taking the no-then fast path.
- Unsound execution — The user getter is skipped by the fast path, and when it does run on the slow path it executes side effects where none were assumed, a type-confusion soundness break.
- Fix — isDictionary() reject forces the real then lookup, restoring soundness.
Proof of concept
Verbatim dfg-ensure-absence-dictionary-then-property.js. createDictionaryObject forces a cacheable dictionary structure; opt warms until DFG compiles Promise.resolve(object) with a proven-absent ’then’ folded into NewResolvedPromise. Adding a ’then’ getter after compilation does not transition the dictionary structure, so pre-patch the optimized path skips the getter and getterCalled stays false, throwing. Post-patch the dictionary is rejected and the getter fires.
function createDictionaryObject() {
const object = { x: 42 };
// Adding enough properties forces a transition to a cacheable dictionary structure.
for (let i = 0; i < 1000; i++)
object['p' + i] = i;
return object;
}
let getterCalled = false;
function opt(object) {
object.x;
return Promise.resolve(object);
}
noInline(opt);
function main() {
const object = createDictionaryObject();
for (let i = 0; i < testLoopCount; i++)
opt(object);
for (let i = 0; i < 1e6 && numberOfDFGCompiles(opt) < 1; i++)
opt(object);
object.__defineGetter__('then', function () {
getterCalled = true;
});
opt(object);
if (!getterCalled)
throw new Error("Promise.resolve() must observe the 'then' getter added after compilation");
}
main();
Exploitation
- Prime fold — Warm a function doing Promise.resolve(dictObject) until the DFG folds it to NewResolvedPromise on the proven absence of ’then’.
- Add then in place — Define a ’then’ getter on the dictionary object after compilation; the structure does not transition so the proof stays cached.
- Effect-model break — The getter runs at a slow-path point where the compiler assumed no side effects, letting attacker JS mutate state (e.g. object shapes/array types) the compiled code treats as invariant, yielding type confusion.
- Escalate — As with other JIT effect/absence bugs, steer the confusion toward addrof/fakeobj and arbitrary read/write.
Detection & hunting
For defenders and SOC / detection engineers:
- then getter added post-DFG on dictionary objects —
- Absence proofs over dictionary structures —
Audit directions
- All absence/miss optimizations —
- Dictionary non-transition assumptions —
- NewResolvedPromise preconditions —