← WebKit Silent-Fix Report — 2026-W23

21ab50e40f062d97c1d1eb47f5d42e51bc201203  [JSC] Data race in WaiterListManager::unregister

severity high class Race confidence 0.90 JSC Atomics/WaiterListManager exploitable-grade
Kai Tamkun Tue Jun 2 10:14:28 2026 -0700 full: 21ab50e40f062d97c1d1eb47f5d42e51bc201203 bug report ↗ view on GitHub ↗
Primitive: Data race / UAF in WaiterListManager::unregister reading cancellable ticket target
Triage note: unregister() called ticket->target()->realm() while holding only the list lock; target() reads m_dependencies that DeferredWorkTimer::cancelAndClear() can free concurrently (new comment documents the lock contract). Web-reachable via Atomics.waitAsync across workers/iframes, so the async-waiter path is fixed to carry m_globalObject instead of dereferencing the ticket.
Contents

The bug at a glance

The race is reachable from ordinary web content: Atomics.waitAsync across a Worker and a cross-origin iframe (as the regression test demonstrates) is enough to register async waiters whose tickets are freed on a different VM’s GC/sweep thread. WaiterListManager::unregister runs on one VM’s thread and dereferenced ticket->target()->realm(), racing another VM freeing the ticket’s m_dependencies via cancelAndClear, i.e. a cross-thread data race that becomes a use-after-free read of a freed JSObject. High/CVSS 8.1 is consistent: no user interaction, memory-corruption potential in the WebContent process, but exploitability is gated by thread-timing rather than a deterministic primitive.

This is a lock-scope mismatch: unregister() held only the WaiterList’s lock, but the field it needed — the waiter’s owning JSGlobalObject — lived behind the ticket’s m_dependencies, which is owned and freed by a different VM under a different lock. To decide whether an async waiter belonged to the JSGlobalObject being torn down, the code chased ticket->target()->realm(), reaching across VM boundaries into memory that DeferredWorkTimer::cancelAndClear on another thread could free at any moment. The fix stops the cross-VM chase entirely: JSC::Waiter now caches its JSGlobalObject* at construction (from promise->realm()), and unregister compares the cached pointer instead of dereferencing the ticket. A new lock-contract comment on TicketData::target() codifies exactly when it is legal to touch it.

Root cause

Atomics.waitAsync creates an async JSC::Waiter that wraps a DeferredWorkTimer ticket (a Ref<TicketData>) representing the promise to resolve when the wait completes. Waiters live on per-memory-address WaiterList objects managed by the process-wide WaiterListManager, which is shared across all VMs (workers, the main page, iframes) that touch the same SharedArrayBuffer memory.

When a JSGlobalObject is torn down, WaiterListManager::unregister(JSGlobalObject*) walks every WaiterList, takes each list’s lock, and removes the waiters that belong to that global object. To identify ownership the old code, holding only list->lock, evaluated waiter->ticket(listLocker) and then ticket->target()->realm() == globalObject. TicketData::target() reads m_dependencies (returning the last dependency JSObject, whose realm() gives the owning global). But m_dependencies belongs to a possibly different VM’s DeferredWorkTimer, and that VM can run DeferredWorkTimer::cancelAndClear() during its GC End phase to free the dependencies — concurrently, because list->lock does not synchronize the other VM’s GC.

The result is a cross-VM data race: one VM’s unregister thread reads ticket->target() / m_dependencies while another VM’s GC frees it. The read can dereference freed memory (use-after-free) or observe a torn/half-cleared m_dependencies. The regression test wires this up with plain web APIs — a Worker calling Atomics.waitAsync on a SharedArrayBuffer, plus a cross-origin iframe calling waitAsync on the same buffer, then removing the iframe, terminating the worker, and forcing GC — so two VMs contend over the same waiter tickets during teardown.

The fix removes the need to dereference the ticket during unregister. JSC::Waiter gains a member JSGlobalObject* m_globalObject, initialized in the async constructor Waiter(JSPromise*) to promise->realm() — computed on the promise’s own VM, before the Waiter is inserted into any list. A new accessor globalObject() returns it. unregister now tests waiter->isAsync() && waiter->globalObject() == globalObject, comparing a stable cached pointer under the list lock, and only then calls cancelAndClear. Because m_globalObject is written before the Waiter is published to any list, any thread that later acquires list->lock is guaranteed to see the finished write, so no cross-VM dereference of freed ticket state occurs. A comment on TicketData::target() now documents the three conditions under which touching target()/m_dependencies is safe (holding m_taskLock; inside a scheduleWorkSoon task lambda; or GC End prevented from running) and warns never to call it from a foreign VM’s thread.

Key code

unregister compares a cached JSGlobalObject instead of chasing the freed ticket

// Waiter(JSPromise*) ctor now caches the global at construction:
     : m_vm(&promise->vm())
+    , m_globalObject(promise->realm())
     , m_ticket(...addPendingWork(...))
     , m_isAsync(true)

// WaiterListManager::unregister:
-    if (waiter->isAsync()) {
-        if (auto ticket = waiter->ticket(listLocker); ticket && !ticket->isCancelled() && ticket->target()->realm() == globalObject) {
-            ...
-            waiter->cancelAndClear(listLocker);
-            return true;
-        }
-    }
+    if (waiter->isAsync() && waiter->globalObject() == globalObject) {
+        ...
+        waiter->cancelAndClear(listLocker);
+        return true;
+    }
     return false;

Patch walkthrough

  • Source/JavaScriptCore/runtime/WaiterListManager.cpp — The async Waiter constructor Waiter(JSPromise*) now initializes m_globalObject(promise->realm()), caching the owning global on the promise’s own VM at construction. WaiterListManager::unregister is rewritten to gate on waiter->isAsync() && waiter->globalObject() == globalObject instead of ticket->target()->realm() == globalObject, eliminating the cross-VM dereference of ticket state under only the list lock. Waiter::dump is updated to print the cached m_globalObject rather than the now-forbidden ticket->target()->realm().
  • Source/JavaScriptCore/runtime/WaiterListManager.h — Adds the JSGlobalObject* m_globalObject { nullptr } member and a globalObject() accessor (asserting m_isAsync). A detailed comment records the cross-VM race being fixed and the publication-safety argument: m_globalObject is written before the Waiter joins any list, so readers holding list->lock always observe the completed write.
  • Source/JavaScriptCore/runtime/DeferredWorkTimer.h — Documents the locking contract for TicketData::target(): it reads m_dependencies which cancelAndClear() can free concurrently, and is safe only under m_taskLock, inside a scheduleWorkSoon task lambda, or when GC End is prevented — and never from a foreign VM’s thread. This captures the invariant unregister was violating.
  • LayoutTests/js/regress-311234.html — Regression test reproducing the race with web-reachable primitives: a Worker and a cross-origin-style iframe both call Atomics.waitAsync on a shared SharedArrayBuffer, after which the iframe is removed, the worker terminated, and GC forced, driving concurrent teardown of waiters across VMs. The expected render-tree text file simply asserts a clean, non-crashing load.

Background

Atomics.waitAsync — Web API that returns a promise resolving when a location in a SharedArrayBuffer is notified. It creates an async JSC::Waiter backed by a DeferredWorkTimer ticket, and can be called from any context sharing the buffer — main page, workers, iframes — spanning multiple VMs.

WaiterListManager — Process-wide singleton mapping shared-memory addresses to WaiterList objects. Its unregister(JSGlobalObject*) tears down waiters for a dying global and runs on that global’s VM thread while other VMs may still be operating on the same lists.

DeferredWorkTimer::TicketData / cancelAndClear — TicketData holds m_dependencies (JSObjects kept alive for deferred work). cancelAndClear() frees them and runs during a VM’s GC End phase under that VM’s locks — unsynchronized with another VM holding a WaiterList lock.

realm() vs cached globalObject — Previously ownership was derived dynamically as ticket->target()->realm(); the fix snapshots that global once (promise->realm()) into Waiter::m_globalObject so it never has to touch ticket state on a foreign thread.

Vulnerability window

  1. Feature baseline — Atomics.waitAsync waiters are stored in shared WaiterLists; unregister identifies ownership by dereferencing the ticket’s target realm under only the list lock.
  2. Race window — With multiple VMs sharing a buffer, one VM’s unregister reads ticket->target()->m_dependencies while another VM’s GC End phase frees them via cancelAndClear — a data race / UAF.
  3. Discovery — Tracked as bug 311234 / rdar://173161138, reproduced by the Worker + iframe + forced-GC test; originally landed on the safari-7624.2.5.110 branch (rdar://176059262).
  4. Fix — Waiter caches m_globalObject at construction from promise->realm(); unregister compares the cached pointer, and DeferredWorkTimer.h documents when target() may be touched (314376@main).

Proof of concept

This is the committed regression test js/regress-311234.html, which is a race reproducer rather than a memory-control exploit. It registers async waiters in two VMs (a Worker and an iframe) on one SharedArrayBuffer, then tears both down and forces GC so unregister on one VM races the other VM freeing ticket dependencies. It demonstrates reachability from web content; it does not, and cannot on its own, yield a controlled read/write.

<!-- webkit-test-runner [ jscOptions=--useSharedArrayBuffer=true ] -->
<script>
const sab = new SharedArrayBuffer(4);
const worker = new Worker(URL.createObjectURL(new Blob([`
    onmessage = e => {
        Atomics.waitAsync(new Int32Array(e.data), 0, 0);
        postMessage("ready");
    };
`])));
worker.onmessage = () => {
    const f = document.createElement("iframe");
    document.body.appendChild(f);
    f.contentWindow.__sab__ = sab;
    f.contentWindow.eval("Atomics.waitAsync(new Int32Array(__sab__), 0, 0)");
    document.body.removeChild(f);   // tears down the iframe's global -> unregister
    worker.terminate();             // tears down the worker's VM concurrently
    GCController.collect();          // frees ticket m_dependencies via cancelAndClear
};
worker.postMessage(sab);
</script>

Exploitation

  1. Set up cross-VM waiters — Script arranges two VMs (e.g. a Worker and an iframe) to each register an Atomics.waitAsync waiter on the same SharedArrayBuffer, populating shared WaiterLists with tickets owned by different VMs.
  2. Race teardown against GC — Removing the iframe (unregistering its global) while the other VM’s GC End phase runs cancelAndClear on the shared tickets creates the window where unregister reads ticket->target()->m_dependencies as it is freed.
  3. Reuse (theoretical) — Exploiting the freed JSObject read would require winning the thread race deterministically and grooming the freed allocation across VM heaps; the bug is a racy read with no supplied write primitive, so weaponization is speculative and timing-bound.

Detection & hunting

For defenders and SOC / detection engineers:

  • TSan/ASan reports on WaiterListManager::unregister — A data-race or use-after-free with one stack in WaiterListManager::unregister reading TicketData::target()/m_dependencies and another in DeferredWorkTimer::cancelAndClear on a different thread is the signature.
  • Crashes on JSGlobalObject/global teardown with SAB in use — Segfaults during global-object teardown in pages using Atomics.waitAsync across workers/iframes should be examined for concurrent ticket freeing.
  • Lock-contract assertion — Add assertions per the new DeferredWorkTimer.h comment that TicketData::target() is only reached under m_taskLock, in a scheduleWorkSoon lambda, or with GC End prevented; violations flag foreign-thread dereferences.

Audit directions

  • Other WaiterListManager paths touching tickets under list->lock — Review every site that dereferences ticket()/target()/dependencies while holding only a WaiterList lock (notify, timeout dispatch, dump) to ensure they honor the documented target() contract or use cached state.
  • Cross-VM state reachable via shared WaiterLists — Enumerate fields read across VM boundaries for waiters and confirm each is either immutable-after-publish or protected by a lock held by both the reader and the freeing VM.
  • DeferredWorkTimer dependency lifetime — Audit all callers of cancelAndClear and TicketData::target() for the assumption that m_dependencies outlives the read, especially anything running on GC or sweep threads of a foreign VM.

Before / after

Loading diff…