← All reports

[4] JSC DeferredWorkTimer hands raw tickets to Wasm background threads

HighJSC runtimeUAF

A dead Wasm ticket comes back as someone else's live job.

15c117a

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.

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.

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.

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.