[4] JSC DeferredWorkTimer hands raw tickets to Wasm background threads
A dead Wasm ticket comes back as someone else's live job.
High. The primitive is not a plain dangling read: the freed ticket's allocator slot is recycled, so a stale pointer can match a live, unrelated ticket and steal its dispatch — unrooting whatever cells that victim was keeping alive. Reaching it needs a Wasm compile still in flight across a collection that reclaims its global object.
Asynchronous JS APIs that finish on a background thread need somewhere to park the promise and its operands until the VM's own run loop can resolve them, and something has to keep those garbage-collected objects reachable in the meantime. DeferredWorkTimer is that parking lot: a VM-level scheduler holding one ticket per pending job — Wasm compile/instantiate/validate, Atomics.waitAsync, FinalizationRegistry cleanup — where each ticket carries a dependency vector the collector visits, so the cells it names stay alive. The only strong reference to a ticket lives in the timer's set on the VM's main thread, and the background thread finishing the work names its ticket when it asks for dispatch.
The angle: a WebAssembly.compile still running on a worklist thread when its global object is collected leaves that thread holding a freed ticket address, and a recycled allocation at that address lets the stale task displace a live job and run against cells nothing is rooting.
Patch Details
The change removes the unsafe representation rather than patching its uses. addPendingWork() now returns a WeakTicket instead of a bare TicketData*. scheduleWorkSoonIfActive() replaces scheduleWorkSoon(Ticket, Task&&) as the single enqueue path and promotes-and-checks the weak reference before queueing. The task queue changes from Deque<std::tuple<Ticket, Task>> holding a bare pointer to holding a Ref<Ticket>, so a queued ticket cannot be freed between enqueue and dispatch. Finally, the three JSWebAssembly.cpp async entry points — webAssemblyModuleValidateAsync, instantiate, compileAndInstantiate — stop capturing promise, globalObject, instance, and importObject in their createSharedTask lambdas and re-derive those operands from the now-guaranteed-live ticket inside the task body.
A strong reference held only by the owner while a raw address of the same object escapes to another thread, with allocator slot reuse turning the stale address into a false identity match.
Background
Tickets and rooting. A ticket is the handle for one pending async job. Its dependency vector is visited from JSGlobalObject::visitChildrenImpl, which is what keeps the promise, global object, and other operands alive across the asynchronous gap. Cancelled tickets are skipped by that visit.
Ownership shape before the fix. The timer held UncheckedKeyHashSet<Ref<TicketData>> m_pendingTickets on the VM's main thread; that set was the only strong reference. addPendingWork() handed callers a TicketData*.
Thread-safe refcounting and weak promotion. TicketData is ThreadSafeRefCounted, so refcount updates are atomic across threads. A ThreadSafeWeakPtr<T> does not keep the object alive but can be promoted — atomically converted to a RefPtr if and only if the object is still live. WasmStreamingCompiler already stored a ThreadSafeWeakPtr<TicketData> m_ticket and promoted it via takeTicketIfActive().
TZone allocation. Ticket is WTF_MAKE_TZONE_ALLOCATED, meaning it is served from a type-segregated allocator zone. Freed slots are recycled for subsequent allocations of the same type, so a fresh TicketData can land at exactly the address a destroyed one occupied.
Wasm worklist threads. Streaming and non-streaming Wasm compilation run on background worklist threads. Their completion callbacks originate there, off the VM's run loop.
Analysis
The root cause is that addPendingWork() handed out a raw pointer to an object whose only strong reference lived elsewhere, and two exposures followed from that representation. First, the three JSWebAssembly.cpp async paths copied the bare TicketData* into createSharedTask lambdas running on worklist threads, alongside raw GC pointers whose only marking root was that same ticket's dependency vector. Second — and this caught even callers doing the weak-promotion dance correctly — the old scheduleWorkSoon(Ticket, Task&&) immediately degraded the promoted RefPtr back to a bare pointer when appending to the task deque, so an unowned address sat parked in the queue across the enqueue-to-dispatch window.
VM main thread Wasm worklist thread
-------------- --------------------
addPendingWork() -> TicketData* -------> raw ptr captured in lambda
GC: globalObject unreachable
cancelPendingWorkSafe(): mark cancelled
doWork() trailing sweep:
last Ref dropped -> TZone slot freed
unrelated addPendingWork()
reuses the same slot
scheduleWorkSoon(stalePtr, task)
doWork(): m_pendingTickets.find(stalePtr)
matches the LIVE ticket at that address
-> victim evicted, stale task dispatched
-> victim's dependencies stop being rooted
The free path matters for reproduction. When the associated JSGlobalObject becomes unreachable while background compilation is still running, cancelPendingWorkSafe() walks globalObject->m_weakTickets, marks each ticket cancelled, and arms a 0s timer — the added test's own comment attributes this to end-of-GC. At that point the tickets have no queued task yet, so the free does not come from the in-loop ticket->isCancelled() branch; it comes from the trailing sweep at the end of doWork() that drops cancelled entries out of m_pendingTickets, releasing the last Ref<TicketData> and returning the object's TZone slot.
The background thread, still holding the raw pointer, later calls the pre-fix scheduleWorkSoon(stalePtr, ...). That call does not itself dereference the ticket — it takes m_taskLock, appends the address, and consults isScheduled() / m_currentlyRunningTask. The dangling dereference materialises later in doWork(), at the pointer-keyed lookup m_pendingTickets.find(ticket) and the subsequent ticket->isCancelled() / ticket->target() — and only when the lookup matches. If it misses, the entry is skipped and nothing is dereferenced, so slot reuse is load-bearing for reaching any of this.
When it matches, two things follow. doWork() removes that unrelated, live ticket from m_pendingTickets and runs the stale task in its place, so the innocent work item loses its only strong reference and its dependency vector stops rooting the cells it was keeping alive — and ASSERT(ticket == pendingTicket->ptr()) passes on address equality alone. Then the stale JSWebAssembly.cpp lambda body, which pre-fix ignores its ticket parameter entirely and operates on its own captured raw promise / globalObject / instance / importObject, runs against cells whose ticket-based marking root was removed when the ticket was cancelled and destroyed.
The result is a use-after-free of a ThreadSafeRefCounted object that escalates into pointer-identity confusion via slot reuse, and from there into a use-after-free on the JS cells the destroyed ticket had been rooting. Winning it requires arranging the collection to reclaim the global object while a compile is still outstanding and getting a fresh ticket allocated into the freed slot before dispatch — both influenced by script through allocation pressure and by keeping multiple async Wasm jobs in flight.
This vulnerability weakens the guarantee that the collector's reachability graph covers everything an in-flight async job touches: a job can be dispatched whose operands are, by construction, no longer rooted by anything.
Audit directions
- Raw pointers to refcounted objects crossing a thread boundary. The safe form is a
Ref/RefPtror aThreadSafeWeakPtrthat is promoted at the point of use; a bareT*captured into a cross-thread lambda has no story for what keeps the object alive. Audit thecreateSharedTaskandWorkQueue::dispatchcall sites in JSC and WTF for captures typed as raw pointers toThreadSafeRefCountedtypes. In review, a lambda capture list mixingprotectedThis-style Refs with one bare pointer is worth a comment about which owner covers the bare one. - Pointer-keyed lookup used as identity. With a slot-recycling allocator, address equality proves nothing about identity, so a
HashSet<Ref<T>>probed by raw pointer can return a live, unrelated object. Look for containers keyed on pointer identity where the key can outlive its referent, and forASSERT(ptr == entry->ptr())used as the only confirmation. That assertion shape is the visual tell — it looks like a check and validates nothing. - Schedulers whose cancelled entries stop rooting. When a scheduler both owns the GC root and supports cancellation, the cancel path silently removes reachability for anything still referencing those cells. Enumerate the other
DeferredWorkTimerconsumers —Atomics.waitAsync,FinalizationRegistrycleanup, host timers — and check what each captures independently of its ticket.