← All issues

[3] Use-after-free of StreamingCompiler::m_ticket across iframe teardown

Severity: High | Component: JavaScriptCore Wasm streaming compiler | acee67e

Rated High because the diff converts a raw deferred-work ticket and a raw JSGlobalObject capture into liveness-checked weak references, fixing a reliable dereference of freed realm-scoped state after iframe teardown; escalation to a stronger memory-corruption primitive would require an attacker to reliably reclaim the freed allocations, which the diff does not establish.

This patch fixes a UAF when the Wasm streaming compiler is invoked in an iframe which is disposed of before compilation finishes. The result of streaming compilation is expected by a lambda created when compilation starts. The lambda captures a raw pointer to the globalObject and (transitively via the streaming compiler) a raw pointer to the TicketData. It is possible for the lambda to outlive those two objects: if the iframe is removed before compilation finishes, the iframe's globalObject is collected and the ticket is cancelled and destroyed. When compilation finishes, the lambda dereferences dangling pointers.

Source/JavaScriptCore/wasm/WasmStreamingCompiler.h

- DeferredWorkTimer::Ticket m_ticket;
+ ThreadSafeWeakPtr<DeferredWorkTimer::TicketData> m_ticket;

Source/JavaScriptCore/wasm/WasmStreamingCompiler.cpp

void StreamingCompiler::didComplete()
{
auto result = makeValidationResult(*m_plan);
- auto ticket = std::exchange(m_ticket, nullptr);
+ auto ticket = takeTicketIfActive();
+ if (!ticket)
+ return;
...
+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]);
+}

Source/WebCore/bindings/js/JSDOMGlobalObject.cpp

- inputResponse->consumeBodyReceivedByChunk([globalObject, compiler = WTF::move(compiler)](auto&& result) mutable {
- VM& vm = globalObject->vm();
+ inputResponse->consumeBodyReceivedByChunk([vmPtr = &vm, compiler = WTF::move(compiler)](auto&& result) mutable {
+ auto& vm = *vmPtr;
JSLockHolder lock(vm);
+ auto* globalObject = compiler->globalObjectIfActive();
+ if (!globalObject)
+ return;

m_ticket changes from a raw DeferredWorkTimer::Ticket to a ThreadSafeWeakPtr<TicketData>. A new takeTicketIfActive() promotes the weak pointer to a RefPtr and returns null if the ticket was destroyed or cancelled, clearing m_ticket. The use sites didComplete(), fail(), and cancel() now early-return on a null result instead of unconditionally using std::exchange(m_ticket, nullptr). A second helper globalObjectIfActive() fetches the live JSGlobalObject* from the ticket's dependency list. In JSDOMGlobalObject.cpp, the consumeBodyReceivedByChunk lambda stops capturing the raw globalObject, capturing only VM* and the compiler, and re-derives the global object via compiler->globalObjectIfActive().

Raw pointer to an asynchronously-owned object outliving its owner across an iframe-teardown boundary, dereferenced without a liveness re-check.

WebAssembly.compileStreaming/instantiateStreaming accept a fetch Response and compile the module incrementally as the body streams in. DeferredWorkTimer is JSC's mechanism for posting a deferred result back onto the JS thread; addPendingWork returns a TicketData (typedef'd Ticket) that keeps the target promise and its dependency objects (such as the requesting JSGlobalObject) alive. ThreadSafeWeakPtr<T>::get() atomically promotes to a RefPtr<T> if the object is still alive and returns null otherwise. An iframe forms its own realm with its own JSGlobalObject; removing the iframe from the DOM makes that global object collectible. Streaming compilation runs on a background worklist, so completion callbacks can fire after the originating realm has gone away.

This is a use-after-free from a dangling raw pointer surviving owner destruction across an asynchronous boundary. The TicketData and JSGlobalObject both have lifetimes tied to the requesting iframe/realm. When streaming compilation is started in an iframe that is then removed before completion, the iframe's JSGlobalObject is garbage-collected and the deferred-work ticket is cancelled and destroyed.

🔒

The lifetime and ownership story behind this dangling-pointer bug is traced across the iframe-teardown boundary, with an assessment of how far the crash could be pushed.

Subscribe to read more

🔒

Multiple reusable audit patterns identified for finding sibling async-lifetime bugs, with concrete deferred-work and binding-callback starting points.

Subscribe to read more