← WebKit Silent-Fix Report — 2026-W25

acee67e9af  Use-after-free of StreamingCompiler::m_ticket

severity medium class UAF confidence 0.82 JSC Wasm StreamingCompiler exploitable-grade
Vassili Bykov Tue Jun 16 13:55:29 2026 -0700 full: acee67e9af1df229a33b0667c765e32afb4d0d81 bug report ↗ view on GitHub ↗
Primitive: use-after-free of DeferredWorkTimer ticket
Triage note: Message explicitly UAF; ticket is now a refcounted handle and didComplete returns early when already taken, closing a double-schedule/dangling window.
Contents

The bug at a glance

A use-after-free in the WebAssembly streaming-compilation path is reachable directly from script (WebAssembly.compileStreaming/instantiateStreaming) in any document, including a same-origin iframe. The freed objects are a JSGlobalObject and a DeferredWorkTimer TicketData whose contents are attacker-influenced, so the dangling dereference is a strong memory-corruption primitive in the WebContent process; the medium rating reflects that the race window requires iframe teardown timing and the crash is the observed outcome rather than a demonstrated controlled write.

Streaming Wasm compilation is asynchronous: a completion lambda outlives the document that started it. By tearing down the iframe (collecting its JSGlobalObject and cancelling/destroying the compilation ticket) before the streamed body finishes compiling, an attacker makes that lambda dereference dangling pointers to both the globalObject and the ticket.

Root cause

StreamingCompiler holds the deferred-work handle in m_ticket, and the completion continuation registered via consumeBodyReceivedByChunk in handleResponseOnStreamingAction captured a raw JSGlobalObject* plus, transitively through the compiler, the TicketData. When WebAssembly.compileStreaming runs inside an iframe and that iframe is removed before the streamed response finishes, the iframe’s JSGlobalObject becomes garbage and is collected, and the pending-work ticket is cancelled and destroyed by DeferredWorkTimer. The continuation lambda, however, is owned by the response’s chunk consumer and survives that teardown.

When the final chunk arrives and compilation completes, the old code called VM& vm = globalObject->vm() on the already-freed globalObject and, inside didComplete()/fail()/cancel(), did auto ticket = std::exchange(m_ticket, nullptr) on a raw DeferredWorkTimer::Ticket that could already point at freed TicketData. Both are classic use-after-free dereferences. The header comment even noted that m_ticket was stored as a PackedPtr that is not scannable by the GC, so nothing kept the TicketData alive.

The fix converts m_ticket to a ThreadSafeWeakPtr<DeferredWorkTimer::TicketData>. Every consumer now promotes the weak pointer to a RefPtr and checks both for null (ticket already destroyed) and ticket->isCancelled() (ticket present but cancelled), a sequence factored into takeTicketIfActive(). didComplete(), fail() and cancel() bail out early when the helper returns nullptr, so no work is scheduled against a dead request. In JSDOMGlobalObject.cpp the lambda no longer captures the raw globalObject; it captures only the VM pointer and re-fetches the live globalObject via compiler->globalObjectIfActive(), which returns nullptr (and makes the continuation return) once the ticket is gone or cancelled.

Key code

New promote-and-check helpers replacing the raw std::exchange of m_ticket

RefPtr<DeferredWorkTimer::TicketData> StreamingCompiler::takeTicketIfActive()
{
    auto ticket = m_ticket.get();
    m_ticket = nullptr;
    if (!ticket || ticket->isCancelled())
        return nullptr;
    return ticket;
}

JSGlobalObject* StreamingCompiler::globalObjectIfActive()
{
    auto ticket = m_ticket.get();
    if (!ticket || ticket->isCancelled())
        return nullptr;
    return uncheckedDowncast<JSGlobalObject>(ticket->dependencies()[0]);
}

Patch walkthrough

  • Source/JavaScriptCore/wasm/WasmStreamingCompiler.cpp — m_ticket becomes a weak handle: the constructor stores the ThreadSafeWeakPtr, and ~StreamingCompiler, didComplete, fail and cancel are rewritten to promote-and-check. The new takeTicketIfActive() atomically takes the ticket, nulls m_ticket, and returns nullptr if it is missing or cancelled; didComplete/fail/cancel now return early on nullptr instead of scheduling work against a dead ticket. globalObjectIfActive() is added to fetch dependencies()[0] only when the ticket is still live.
  • Source/JavaScriptCore/wasm/WasmStreamingCompiler.h — Changes the m_ticket member type from a raw DeferredWorkTimer::Ticket to ThreadSafeWeakPtr<DeferredWorkTimer::TicketData>, declares the private takeTicketIfActive() helper, and exports the new globalObjectIfActive() method.
  • Source/WebCore/bindings/js/JSDOMGlobalObject.cpp — In handleResponseOnStreamingAction the chunk-consumer lambda stops capturing the raw globalObject pointer; it captures &vm instead and, on each invocation, obtains a still-live globalObject through compiler->globalObjectIfActive(), returning immediately if that yields nullptr.

Background

DeferredWorkTimer and TicketData — DeferredWorkTimer is JSC’s mechanism for scheduling work that must run later on the main thread while keeping the involved JS objects (a promise plus a dependency set) alive. addPendingWork returns a Ticket referring to a TicketData that owns those dependencies. When a document is torn down, its pending work is cancelled and the TicketData can be destroyed, which is exactly the lifetime gap this bug exploited.

Streaming WebAssembly compilation — WebAssembly.compileStreaming and instantiateStreaming compile a module from a Response body as it arrives over the network. StreamingCompiler consumes the body chunk by chunk via consumeBodyReceivedByChunk and finishes asynchronously, so the completion path can run long after the initiating script and its document context are gone.

ThreadSafeWeakPtr — A WTF smart pointer that observes a ThreadSafeRefCounted object without keeping it alive; get() returns a RefPtr that is either null (object destroyed) or a strong reference safe to use. Storing m_ticket as ThreadSafeWeakPtr<TicketData> lets the compiler detect that the ticket has been freed instead of dereferencing a dangling PackedPtr.

Iframe global object lifetime — Each iframe has its own JSGlobalObject. Removing the iframe from the DOM makes that global object unreachable and eligible for garbage collection. Any asynchronous callback that captured the raw global object pointer therefore risks dereferencing freed memory once GC runs, which is why the fix re-derives the global object from a live ticket rather than capturing it.

Vulnerability window

  1. Setup — Script in an iframe calls WebAssembly.compileStreaming (or instantiateStreaming) on a Response whose body is delivered in chunks, creating a StreamingCompiler and a DeferredWorkTimer ticket that captures the iframe’s globalObject as dependency[0].
  2. Registration — handleResponseOnStreamingAction registers a chunk-consumer lambda that (pre-patch) captured the raw globalObject pointer and, via the compiler, the raw ticket.
  3. Teardown — The iframe is removed before the streamed body completes; the iframe globalObject is collected and the pending-work ticket is cancelled/destroyed by DeferredWorkTimer.
  4. Completion — A later chunk finishes the stream; the surviving lambda dereferences the freed globalObject (globalObject->vm()) and didComplete/fail exchange and use the dangling raw ticket - use-after-free.
  5. Fix — m_ticket becomes a ThreadSafeWeakPtr; takeTicketIfActive() and globalObjectIfActive() reject a null-or-cancelled ticket, and the lambda re-fetches a live globalObject, so completion after teardown simply returns.

Triggering

The commit states ’the failure scenario is not directly testable’ and adds no layout test. Conceptual trigger: create a same-origin iframe, run WebAssembly.compileStreaming(fetch(wasmURL)) inside it against a slow/chunked response, then synchronously remove the iframe and force GC (or wait) so the iframe globalObject is collected and the ticket cancelled before the response body completes; the completion path then dereferences the freed globalObject/ticket.

Exploitation

  1. Trigger — Start streaming Wasm compilation in an iframe and control the network response so completion is delayed until after the iframe is detached and its globalObject collected.
  2. Free — Iframe removal frees the JSGlobalObject and destroys the DeferredWorkTimer TicketData while the chunk-consumer lambda still holds raw pointers to both.
  3. Use — On stream completion the lambda dereferences the freed globalObject and the raw ticket; observed as a WebContent-process crash. Turning this into a controlled write would require grooming the freed globalObject/TicketData allocations, which is not demonstrated here.

Detection & hunting

For defenders and SOC / detection engineers:

  • ASan use-after-free at Wasm streaming completion
  • Crashes correlated with iframe removal during compileStreaming

Audit directions

  • Async continuations capturing globalObject
  • Raw DeferredWorkTimer::Ticket holders
  • consumeBodyReceivedByChunk consumers

Before / after

Loading diff…