70e05bc9fbbf66f5dbf746ee1706b0158c994ed1 [WebGPU] ~CommandBuffer can run on Metal completion thread, racing non-atomic CommandEncoder refcount and Device::m_commandEncoderMap
Triage note: wgpuInstanceRelease() now calls waitForCommandBufferCompletions() to [waitUntilCompleted] on all retained in-flight command buffers before deref, Instance retains CommandBuffers, and the completion handler's scheduleWork lambda switches from a strong capture to ThreadSafeWeakPtr with a null check. Fixes a use-after-free of GPU-process objects outliving their in-flight command buffers.
The bug at a glance
WebGPU GPU-process lifetime bug at WGPUInstance teardown. At wgpuInstanceRelease the instance (and the objects its command buffers reference) could be destroyed while a committed Metal command buffer was still in flight on the GPU, and a completion/scheduleWork callback could run against an already-freed CommandBuffer. The patch makes the instance retain its CommandBuffers and block on all committed MTLCommandBuffers before deref, and narrows the scheduleWork capture to a ThreadSafeWeakPtr with a null check. The proven defect is a use-after-free / destruction-ordering error on GPU-process objects; exploitability beyond a GPU-process crash is (inferred) given confidence 0.6 and that this is teardown-timing dependent.
Release of the top-level WGPUInstance did not wait for GPU work it had kicked off, so the Metal driver (and the completion handler) could touch WebKit objects after the C++ owners were gone.
Root cause
The invariant is that no MTLCommandBuffer may still be committed/in-flight, and no completion-driven scheduleWork callback may still be pending against a CommandBuffer, once the owning Instance and its graph are destroyed. Pre-patch wgpuInstanceRelease() simply called deref() on the instance; nothing joined outstanding GPU work.
Pre-patch sequence: 1. CommandBuffer::commit adds a completed-handler capturing protectedThis = protect(*this) and, inside it, schedules work on the queue capturing protectedThis strongly again. 2. The command buffer is committed to Metal and runs asynchronously. 3. Web content releases the WGPUInstance; deref() tears down the instance and the device/queue graph. 4. The GPU is still executing, or the queued scheduleWork item is still pending; when the driver’s completion handler or the scheduleWork lambda runs it dereferences CommandBuffer state (m_cachedCommandBuffer, m_commandEncoder) whose backing graph has been destroyed -> UAF in the GPU process. Lifetime types: Instance is ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr; CommandBuffer is RefCounted and captured via protect(). The strong scheduleWork capture extended CommandBuffer lifetime onto the queue thread but did nothing to join the Metal-side in-flight work at instance release.
The fix adds instance-level bookkeeping: CommandBuffer::commit now calls instance->retainCommandBuffer(*this, m_commandBuffer), storing a std::pair<Ref, WeakObjCPtr<id» in m_retainedCommandBufferInstances (pruning dead WeakObjCPtr entries first). wgpuInstanceRelease() now calls waitForCommandBufferCompletions() before deref(): it std::exchanges the retained command-buffer and retained-device containers and, for every entry whose MTLCommandBuffer status >= MTLCommandBufferStatusCommitted, [commandBuffer waitUntilCompleted]. Separately, the completion handler’s scheduleWork lambda is changed from capturing protectedThis strongly to weakThis = ThreadSafeWeakPtr { protectedThis.get() } with RefPtr protectedThis { weakThis.get() }; if (!protectedThis) return;, so a scheduleWork item that outlives the CommandBuffer safely no-ops instead of resurrecting/using a dead object. Note retainDevice also drops its Locker(m_lock) and retainedDeviceInstances loses its WTF_GUARDED_BY_LOCK annotation, implying the retain bookkeeping is being moved to a single-threaded (main) discipline (inferred).
Key code
Wait for in-flight command buffers before instance deref; weak scheduleWork capture
void wgpuInstanceRelease(WGPUInstance instance)
{
protect(WebGPU::fromAPI(instance))->waitForCommandBufferCompletions();
WebGPU::fromAPI(instance).deref();
}
// completion handler, inside CommandBuffer::commit:
protectedThis->m_device->getQueue()->scheduleWork([weakThis = ThreadSafeWeakPtr { protectedThis.get() }, kernelStartTime, kernelEndTime]() mutable {
RefPtr protectedThis { weakThis.get() };
if (!protectedThis)
return;
protectedThis->m_cachedCommandBuffer = nil;
...
});
Fix approach
Two-part fix. (1) Join outstanding GPU work at teardown: the Instance now owns a list of (Ref, WeakObjCPtr) populated at commit via retainCommandBuffer, and wgpuInstanceRelease calls waitForCommandBufferCompletions() before deref so every still-committed MTLCommandBuffer is drained with waitUntilCompleted; the retained-device container is drained the same way. (2) Break the dangling-callback window: the completion->scheduleWork continuation no longer keeps CommandBuffer alive with a strong capture but holds a ThreadSafeWeakPtr and bails on a null get(). Semantic change: instance release becomes a synchronization point that blocks the releasing thread until the GPU finishes, and a late scheduleWork callback becomes a no-op rather than a resurrection. retainDevice’s lock is removed, shifting this bookkeeping to an assumed single-thread discipline.
Patch walkthrough
Source/WebGPU/WebGPU/CommandBuffer.mm— commit() now registers the command buffer with the instance (instance->retainCommandBuffer) and changes the queued scheduleWork continuation to capture a ThreadSafeWeakPtr instead of a strong RefPtr, returning early if the CommandBuffer is gone.Source/WebGPU/WebGPU/Instance.h— Forward-declares CommandBuffer, adds retainCommandBuffer()/waitForCommandBufferCompletions(), adds the m_retainedCommandBufferInstances vector, and drops the WTF_GUARDED_BY_LOCK annotation from retainedDeviceInstances.Source/WebGPU/WebGPU/Instance.mm— Implements waitForCommandBufferCompletions() (exchange+drain both retained containers, waitUntilCompleted on any status >= Committed) and retainCommandBuffer() (prune dead WeakObjCPtrs, append the new pair); removes the Locker from retainDevice; and makes wgpuInstanceRelease call waitForCommandBufferCompletions before deref.
Background
WGPUInstance / wgpuInstanceRelease — The root WebGPU handle; releasing it is expected to tear down the device/queue/command-buffer graph. It is the last reference to a lot of GPU-process state.
MTLCommandBuffer in-flight status — A committed Metal command buffer executes asynchronously on the GPU; status progresses NotEnqueued->Enqueued->Committed->Scheduled->Completed. waitUntilCompleted blocks until it reaches Completed.
addCompletedHandler — A Metal callback invoked when the GPU finishes a command buffer; it may run on an arbitrary thread after the CPU-side owners have been released unless lifetime is arranged.
ThreadSafeWeakPtr — A weak reference to a ThreadSafeRefCounted object; get() returns a RefPtr that is null if the object was destroyed, letting a callback detect that its target is gone instead of using freed memory.
scheduleWork — Queue::scheduleWork defers a work item (e.g. recording GPU execution windows on canvas textures) back onto a WebGPU work thread; the item can run after the triggering command buffer would otherwise be freed.
Vulnerability window
- Setup — Web content drives WebGPU: creates a device/queue, encodes and commits command buffers that reference textures/encoders.
- In-flight — Command buffers are committed to Metal and execute asynchronously; completion handlers and scheduleWork continuations are outstanding.
- Release — Script releases the WGPUInstance; pre-patch deref tears down the graph immediately without joining GPU work.
- Trigger — The driver completion handler / scheduleWork lambda runs and touches CommandBuffer state whose owners were destroyed -> UAF.
- Mitigated — Post-patch, release blocks on all committed command buffers first and the late callback weak-checks and no-ops.
Proof of concept
No test ships in the diff. Conceptual trigger (web path inferred): from WebGPU JS, submit a workload heavy enough to stay in flight, then drop all references to the GPUDevice/adapter so the underlying WGPUInstance is released while the GPU is still executing (or while a scheduleWork continuation is queued). Pre-patch the completion/scheduleWork path dereferences freed CommandBuffer state. Reliability depends on racing instance release against GPU completion, which is why this is timing-dependent.
Exploitation
- Reach — WebGPU is web-exposed; the code runs in the GPU process that owns the Metal command buffers.
- Trigger — Race: release the WGPUInstance while a committed command buffer is in flight or a scheduleWork item is pending.
- Primitive — Use-after-free: a completion/continuation callback reads/writes CommandBuffer fields (m_cachedCommandBuffer, m_commandEncoder) after destruction.
- Reclaim (inferred) — Escalation past a crash hinges on reclaiming the freed CommandBuffer allocation with attacker-controlled bytes before the callback dereferences it - unproven here and gated by the narrow teardown-timing window.
- Scope — All effects are confined to the GPU process; impact is GPU-process memory corruption, not direct renderer/UIProcess control.
Detection & hunting
For defenders and detection engineers:
- Instance release without drain (legacy) —
- Callback on freed CommandBuffer —
- Outstanding committed buffers at release —
Remaining risk in the patch
- Lock removal on retain bookkeeping — retainDevice drops its Locker(m_lock) and retainedDeviceInstances loses WTF_GUARDED_BY_LOCK, so retainCommandBuffer/waitForCommandBufferCompletions now assume single-thread (main) access to m_retainedCommandBufferInstances; if commit can run off that thread the container itself becomes racy (the diff does not prove the threading discipline).
- WeakObjCPtr pruning only at append — retainCommandBuffer prunes dead WeakObjCPtr entries only when a new buffer is committed, so the retained vector can grow between commits; not a safety hole but a lifetime-of-wait concern.
- Status gate — waitForCommandBufferCompletions only waits on status >= MTLCommandBufferStatusCommitted; a command buffer enqueued-but-not-yet-committed (or whose WeakObjCPtr already nulled) is skipped, which is correct only if such buffers cannot reference soon-to-be-freed objects (not shown).
Audit directions
- Other async GPU callbacks — Audit every addCompletedHandler/addScheduledHandler and Queue::scheduleWork continuation for strong-vs-weak capture and for whether teardown joins the work.
- GPU-process object teardown ordering — Check Device/Queue/PresentationContext release paths for the same ‘destroy while GPU work in flight’ gap that instance release had.
- Retain-container threading — Verify that the now-unlocked retainedDeviceInstances / m_retainedCommandBufferInstances are only touched on one thread across all commit and release call sites.