← All issues

[16] [JSC] Fix data race in WaiterListManager::unregister

Severity: Medium | Component: JSC WaiterListManager | 21ab50e

Rated Medium because the diff caches the realm pointer in the Waiter at construction time so the cancellation decision does not read across VM boundaries; pre-fix, WaiterListManager::unregister on one VM's sweep thread read m_dependencies mid-modification by another VM's GC End phase, yielding a torn/freed-pointer dereference under specific scheduling.

Waiter caches the realm pointer at construction (before publication to any list). WaiterListManager::unregister consults the cached pointer instead of ticket->target()->realm().

Source/JavaScriptCore/runtime/WaiterListManager.cpp

- JSGlobalObject* targetRealm = waiter->ticket(listLocker)->target()->realm();
+ JSGlobalObject* targetRealm = waiter->cachedRealm();

Cross-VM read-from-mutating storage: one VM's sweep thread iterated waiters belonging to a foreign VM and dereferenced m_dependencies while the foreign VM's GC End phase mutated it.

Waiter gains a JSGlobalObject* member set at construction. unregister reads only this cached pointer when deciding whether to cancel. The list lock continues to serialize structural changes to the WaiterList.

WaiterListManager is the per-process registry that backs Atomics.wait / Atomics.waitAsync on SharedArrayBuffers. Because SABs span VMs (main window, iframes, dedicated workers), a single WaiterList (keyed on the SAB pointer) can hold Waiters from multiple VMs. DeferredWorkTimer::TicketData::m_dependencies (a FixedVector<JSCell*>) is the GC-managed pin for the promise/target across the wait.

Pre-fix, WaiterListManager::unregister ran on the unregistering VM's sweep thread and iterated every waiter on the shared list. For each it called waiter->ticket(listLocker)->target() — implemented as bit_cast<JSObject*>(m_dependencies.last()) — to compare the target's realm to the unregistering global object.

The owning VM could simultaneously be in its GC End phase running TicketData::cancelAndClear(), which mutates / frees m_dependencies. The list lock and m_taskLock belong to different VMs, so neither serializes the two threads. The read could observe a torn or freed backing store, returning a stale JSObject*, on which the code then called ->realm() — a second UAF dereference — before comparing to globalObject.

VM A (sweep)                         VM B (GC End)
─────────────                        ──────────────
list lock                            m_taskLock
read m_dependencies.last()  ←──────  cancelAndClear() mutates m_dependencies
deref → realm()             ←──────  freed memory

The fix sidesteps the cross-VM read entirely: the realm is cached in the Waiter at construction, so readers acquiring the list lock always see the completed write.

This weakens the GC/scheduling invariant across VMs in the SAB waiter registry. The primitive is a race-driven UAF read across VM boundaries.