← WebKit Silent-Fix Report — 2026-W21

fb8bfead60  [JSC] incorrect side-effect modeling for Spread(SetObjectUse) in DFG abstract interpreter

severity medium class TypeConfusion confidence 0.60 JSC DFG/FTL exploitable-grade
Kai Tamkun Wed May 20 17:33:34 2026 -0700 full: fb8bfead603ee6308af1dbfb0619cbaa40f79dee bug report ↗ view on GitHub ↗
Primitive: incorrect side-effect modeling for Spread(SetObjectUse)
Triage note: JIT effect-modeling error (dev-flagged).
Contents

The bug at a glance

This is a genuine JIT type-confusion soundness bug in the DFG abstract interpreter. After commit 313031@main allowed Set spreads to invoke a user-defined Symbol.iterator, the AI’s assumption that Spread(SetObjectUse) has no side effects became false, so it kept folding a CheckStructure whose guarding OSR exit was the only thing preventing a following GetByOffset from reading a stale slot after the iterator mutated the object’s structure. A stale-slot read/write under a folded structure check is a classic path to type confusion and thus to arbitrary read/write and RCE in the renderer. High severity, characteristic of a weaponizable JSC bug.

The angle is a cross-commit invariant break: an earlier feature (user Symbol.iterator on Set spread) silently invalidated a side-effect model the abstract interpreter still relied on. The AI treated Spread over a SetObjectUse as effect-free (didFoldClobberWorld) unconditionally, but the lowered slow path operationSpreadSet can now re-enter arbitrary JS, mutate structure, and leave a folded CheckStructure guarding a now-wrong GetByOffset.

Root cause

In the DFG, CheckStructure proves an object has a particular Structure; a later GetByOffset then reads a property by its fixed slot offset, trusting that proof. The abstract interpreter (AI) models each node’s effects: clobberWorld() means the node may run arbitrary code and invalidates all structure proofs (forcing re-checks / preventing folding), whereas didFoldClobberWorld() asserts the node was proven effect-free so proofs may be retained and a redundant CheckStructure can be folded away. Folding a CheckStructure is only sound if nothing between the check and the dependent GetByOffset can change the structure.

Spread over a Set (Spread with child1().useKind() == SetObjectUse) was modeled as having no side effects, with the comment ‘SetObjectUse has no side effects since we iterate directly over internal storage,’ so executeEffects called didFoldClobberWorld() for it. That was true when Set spreads always iterated the Set’s internal storage. Commit 313031@main changed the lowering: Sets that do NOT carry the original Set structure are routed to operationSpreadSet, which falls back to the generic JS iterator protocol and can therefore invoke a user-defined Symbol.iterator. A user iterator is arbitrary JS – it can run Object.defineProperty on another object to trigger a structure transition. So Spread(SetObjectUse) can now clobber the world, but the AI still promised it didn’t.

The exploitation shape (mirrored by the added test): in f(set, o), the JIT does x = o.a (installing a CheckStructure on o and a GetByOffset for ‘a’), then arr = […set], then returns o.a. If the second o.a’s CheckStructure is folded because the AI believes the intervening Spread is effect-free, then a Set with an own Symbol.iterator that calls Object.defineProperty(obj, ‘a’, { get() {…} }) mutates obj’s structure mid-spread. The folded CheckStructure no longer fires an OSR exit, and the following GetByOffset reads o.a at the old fixed slot offset even though ‘a’ is now an accessor at a different location – a stale-slot read, i.e. type confusion between the old and new structure.

The fix makes the fold conditional on proving the operand actually carries the original Set structure – the exact condition under which the lowering elides its runtime structure check and thus never reaches the JS-iterator slow path. executeEffects now fetches globalObject->setStructureConcurrently() and checks whether forNode(node->child1()).m_structure.isSubsetOf(RegisteredStructureSet(registerStructure(originalSetStructure))). Only if that holds (canFold) does it call didFoldClobberWorld(); otherwise it now calls clobberWorld(), correctly invalidating structure proofs across the node. The safety argument in the new comment: instances proven to carry the original Set structure have no own Symbol.iterator, and any mutation of Set.prototype[Symbol.iterator] is covered by prototype-change watchpoints installed during compilation, so the fast (folded) case can never reach a user iterator.

Key code

Conditional fold: only retain structure proofs when the Set carries the original Set structure (DFGAbstractInterpreterInlines.h)

if (node->child1().useKind() == SetObjectUse) {
    // The lowering routes Sets that don't carry the original Set structure to operationSpreadSet,
    // which falls back to the JS iterator protocol. We can retain structure proofs across this
    // node only when the operand is proven to carry the original Set structure (the same condition
    // under which the lowering elides its runtime structure check). Such instances have no own
    // Symbol.iterator, and any mutation to Set.prototype[Symbol.iterator] invalidates this code
    // via the prototype-change watchpoints installed during compilation, so the slow path can
    // never reach a user-defined iterator from here.
    bool canFold = false;
    JSGlobalObject* globalObject = m_graph.globalObjectFor(node->origin.semantic);
    if (Structure* originalSetStructure = globalObject->setStructureConcurrently()) {
        if (forNode(node->child1()).m_structure.isSubsetOf(RegisteredStructureSet(m_graph.registerStructure(originalSetStructure))))
            canFold = true;
    }
    if (canFold)
        didFoldClobberWorld();
    else
        clobberWorld();
} else
    clobberWorld();

Patch walkthrough

  • Source/JavaScriptCore/dfg/DFGAbstractInterpreterInlines.h — In AbstractInterpreter::executeEffects, the Spread handling for SetObjectUse no longer unconditionally calls didFoldClobberWorld(). It now computes canFold only when the operand’s abstract structure set is a subset of the global object’s original Set structure (globalObject->setStructureConcurrently()); if so it folds (didFoldClobberWorld), otherwise it calls clobberWorld() to invalidate structure proofs across the potentially-side-effecting spread.
  • JSTests/stress/spread-set-own-symbol-iterator-side-effects.js — New regression test: builds a Set with an own Symbol.iterator that mutates obj via Object.defineProperty during the spread, warms up f with a plain Set to trigger structure-check folding, then calls f with the malicious Set and asserts the returned o.a is the correct value (1) rather than a stale-slot read.

Background

DFG abstract interpreter and clobberWorld / didFoldClobberWorld — The DFG AI is JSC’s flow-sensitive type/structure analysis. Each node declares its effects to the AI: clobberWorld() models ’this may execute arbitrary JS’ and drops all cached structure proofs so later checks cannot be folded; didFoldClobberWorld() asserts the node’s potential clobber was proven not to happen so proofs survive and redundant CheckStructures can be removed. Mislabeling a side-effecting node as didFoldClobberWorld is a soundness hole: the optimizer will delete safety checks it still needs.

CheckStructure / GetByOffset stale slot — CheckStructure guards that an object has a specific Structure; GetByOffset then reads a property at a fixed byte offset determined by that structure. If a structure transition happens between a (folded-away) CheckStructure and the GetByOffset, the offset no longer names the same property – the read (or write) hits a stale slot, confusing a value of one type/shape for another. This is the canonical JSC primitive that attackers grow into fake-object / arbitrary read-write.

Spread lowering: canDoFastSpread and operationSpreadSet — For […set], the DFG can emit a fast spread that iterates the Set’s internal storage directly, or fall back to operationSpreadSet, which uses the generic JS iterator protocol. The choice hinges on whether the operand carries the original Set structure (an unmodified Set with no own iterator). Commit 313031@main made the fallback able to honor a user-defined Symbol.iterator, which is exactly what turns the slow path into an arbitrary-code side effect the AI must model.

setStructureConcurrently and prototype-change watchpoints — globalObject->setStructureConcurrently() returns the canonical Set structure in a way safe to query from the concurrent JIT thread. The fold is sound only for operands proven to be that structure, because such Sets have no own Symbol.iterator, and any redefinition of Set.prototype[Symbol.iterator] is guarded by prototype-change watchpoints that invalidate the compiled code – so the fast path provably cannot reach a user iterator, while the slow path (unproven structure) can and must clobberWorld.

RegisteredStructureSet::isSubsetOf — The AI tracks each value’s possible structures as an abstract set (forNode(child).m_structure). isSubsetOf(RegisteredStructureSet(originalSetStructure)) asks whether every structure the operand could have is the original Set structure. Only then is folding the structure check justified; if the set is wider (or clobbered/Top), the operand might be a modified Set that routes to the user-iterator slow path.

Vulnerability window

  1. Prior feature (313031@main) — Set spreads are allowed to invoke a user-defined Symbol.iterator via the operationSpreadSet slow path, making Spread(SetObjectUse) able to run arbitrary JS in the unproven-structure case.
  2. Stale assumption — The DFG AI still models all Spread(SetObjectUse) as effect-free (didFoldClobberWorld) with the comment about iterating internal storage, retaining structure proofs across the node.
  3. Bug — A CheckStructure between two property reads gets folded across the spread; a Set with an own Symbol.iterator mutates a neighboring object’s structure mid-spread, so the following GetByOffset reads a stale slot – type confusion (bug 315132, rdar://177136593).
  4. Fix — executeEffects folds only when the operand’s structure set is a subset of the original Set structure (setStructureConcurrently); otherwise it clobberWorld()s, invalidating proofs across the potentially-effectful spread.
  5. Regression test — spread-set-own-symbol-iterator-side-effects.js reproduces the stale read under –useConcurrentJIT=0 and asserts the correct value, locking in the fix.

Proof of concept

Verbatim added stress test. It warms f() with a plain Set so the DFG compiles f with x=o.a and return o.a sharing a folded CheckStructure on o across the […set] spread. It then calls f with setWithOwnIterator, whose own Symbol.iterator generator runs Object.defineProperty(obj,‘a’,{get…}) during the spread, transitioning obj’s structure. Pre-patch the folded CheckStructure does not OSR-exit and the final o.a reads a stale slot (wrong value / type confusion); post-patch the spread clobbers the world, the structure check is not folded, and o.a correctly returns 1. Pre-creating the {a}->Accessor transition ensures the warmup structure isn’t a watchable leaf so the transition is a real mutation.

//@ runDefault("--useConcurrentJIT=0")

function shouldBe(actual, expected) {
    if (actual !== expected)
        throw new Error("bad value");
}

let obj;

// Pre-create the {a}->Accessor transition so the warmup structure isn't a watchable leaf.
{
    let t = { a: 1.1 };
    Object.defineProperty(t, "a", { get() { return 1; } });
}

let setWithOwnIterator = new Set([1]);
setWithOwnIterator[Symbol.iterator] = function* () {
    Object.defineProperty(obj, "a", { get() { return 1; }, configurable: true });
    yield 1;
};

function f(set, o) {
    let x = o.a;
    let arr = [...set];
    return o.a;
}
noInline(f);

let plainSet = new Set([1]);
for (let i = 0; i < testLoopCount; i++)
    f(plainSet, { a: 1.1 });

obj = { a: 1.1 };
shouldBe(f(setWithOwnIterator, obj), 1);

Exploitation

  1. Setup / grooming — Define a Set with an own Symbol.iterator (or otherwise force the unproven-structure slow path) and a victim object whose structure the iterator will transition mid-spread. Warm a noInlined function so the DFG folds a CheckStructure across the spread.
  2. Trigger the stale slot — Invoke the compiled function with the malicious Set so the user iterator runs Object.defineProperty on the victim during […set], changing its structure while the folded CheckStructure stays live. The dependent GetByOffset then reads/writes the wrong slot.
  3. Escalate the confusion — Choose old/new structures so the stale offset reinterprets one field type as another (e.g. double-vs-pointer or inline-vs-out-of-line), the standard JSC route from a stale-slot read/write to a fake JSCell, addrof/fakeobj, and ultimately arbitrary read/write in the renderer.
  4. Reliability — The test uses –useConcurrentJIT=0 to make compilation deterministic; a real exploit would drive tier-up deterministically and shape structures to make the confused slot yield a controlled primitive. This is a classic, weaponizable DFG type-confusion, not a mere info leak.

Detection & hunting

For defenders and SOC / detection engineers:

  • Set with own Symbol.iterator plus adjacent property reads under JIT — Static/dynamic scanning for scripts that assign an own Symbol.iterator (especially a generator) to a Set and spread it ([…set]) while reading a property of another object before and after the spread is a high-signal heuristic for this exploit pattern.
  • Structure mutation inside an iterator during spread — Instrument or fuzz for Object.defineProperty / property-shape transitions executed from within a Symbol.iterator that is driven by a spread of a Set, which is the precise mutation-under-folded-check condition.
  • JSC crashes / OSR anomalies in Spread paths — On unpatched builds, correlate renderer crashes or ASan reports in DFG GetByOffset following operationSpreadSet with subsequent memory-disclosure behavior.

Audit directions

  • Other nodes assumed effect-free that now call user code — Re-audit every AI site that calls didFoldClobberWorld() unconditionally for a useKind-guarded node (Spread variants, iteration helpers, ToArray/CreateThis-style nodes) against recent features that may have introduced a user-callable slow path, mirroring how 313031@main invalidated SetObjectUse.
  • canDoFastSpread vs AI fold agreement — Confirm the AI’s fold condition matches the lowering’s runtime structure-check elision for all spread kinds (Array, arguments, Set, Map), so the AI never retains proofs where the lowering can take a JS-iterator fallback.
  • Concurrent-safe structure queries — Verify setStructureConcurrently and similar *Concurrently accessors are used everywhere the AI reasons about built-in structures from the compiler thread, and that a null return (structure not yet cached) is handled conservatively (canFold=false), as this patch does.
  • Prototype-change watchpoint coverage — Validate that the claimed prototype-change watchpoints on Set.prototype[Symbol.iterator] are actually installed on the compiled code for the fast path, since the fold’s soundness argument depends on them firing on any Set.prototype iterator mutation.

Before / after

Loading diff…