95c3cfc258782ea42c3f0a8b570e6bb317c9a256 [WebGPU] ~CommandBuffer can run on Metal completion thread, racing non-atomic CommandEncoder refcount and Device::m_commandEncoderMap
Triage note: wgpuInstanceRelease now calls waitForCommandBufferCompletions() and Instance::retainCommandBuffer keeps in-flight MTLCommandBuffers alive; the scheduleWork continuation switches from a strong capture to ThreadSafeWeakPtr with a null check. Fixes a GPU-process lifetime bug where command-buffer completion ran against freed state.
The bug at a glance
A WebGPU GPU-process lifetime bug (Source/WebGPU/WebGPU/CommandBuffer.mm, Instance.{h,mm}): an in-flight MTLCommandBuffer’s completion handler and the scheduleWork continuation it posts could run after the owning WGPU Instance (and the Device/Queue it owns) were destroyed on wgpuInstanceRelease, dereferencing torn-down state. The fix makes the Instance retain in-flight MTLCommandBuffers and waitUntilCompleted on release, and converts the scheduleWork continuation’s strong self-capture to a ThreadSafeWeakPtr with a null check. The proven defect is a completion/continuation lifetime error; the escalation to a use-after-free is supported by the retain-and-wait remedy, though the exact freed object reached through the continuation is beyond the supplied diff (inferred). Severity high as a GPU-process UAF reachable from untrusted WebGPU content that releases its instance while work is outstanding.
Metal command-buffer completion callbacks fire on the GPU’s schedule, which is not tied to the JS/WebGPU object lifecycle — releasing the Instance did not drain outstanding command buffers, so their callbacks outlived the objects they touch.
Root cause
A CommandBuffer registers [m_commandBuffer addCompletedHandler:…] which, when the GPU finishes, posts further work via m_device->getQueue()->scheduleWork(…). Instance owns the Device/Queue and the schedule-work machinery; m_instance is held as a ThreadSafeWeakPtr (see sibling Queue in commit 97c5e65ca78e). wgpuInstanceRelease simply deref’d the Instance.
Pre-patch sequence: 1. a CommandBuffer commits; its completed handler captures protect(*this) (keeping the CommandBuffer alive) and the posted scheduleWork lambda captures protectedThis strongly. 2. JS releases the WGPU Instance; wgpuInstanceRelease deref’s it with no wait, so the Instance (and the Device/Queue/schedule machinery it owns) can be destroyed while the MTLCommandBuffer is still executing. 3. The GPU completes the command buffer and the completion handler / scheduleWork continuation runs, reaching through protectedThis->m_device->getQueue() and recordGPUExecutionWindowOnCanvasTextures into Device/Queue/encoder state whose owning Instance is gone. 4. That torn-down state is used-after-free on the background completion thread.
The fix adds Instance::retainCommandBuffer(CommandBuffer&, id) which parks the in-flight MTLCommandBuffer (and a Ref) in m_retainedCommandBufferInstances, called right when the completed handler is installed. wgpuInstanceRelease now calls waitForCommandBufferCompletions() before deref, which std::exchanges out the retained command buffers/devices and waitUntilCompleted on each, so no completion handler can run after the Instance is torn down. The scheduleWork continuation is also hardened: it captures weakThis = ThreadSafeWeakPtr{protectedThis.get()} and returns early if weakThis.get() is null, so even a late continuation cannot deref a freed CommandBuffer.
Key code
Instance drains in-flight command buffers on release; continuation goes weak + null-checked
// wgpuInstanceRelease now waits before deref
protect(WebGPU::fromAPI(instance))->waitForCommandBufferCompletions();
WebGPU::fromAPI(instance).deref();
// scheduleWork continuation no longer keeps a strong self-ref
protectedThis->m_device->getQueue()->scheduleWork([weakThis = ThreadSafeWeakPtr { protectedThis.get() }, kernelStartTime, kernelEndTime]() mutable {
RefPtr protectedThis { weakThis.get() };
if (!protectedThis)
return;
Fix approach
Two complementary guards. (1) The Instance now tracks outstanding work: retainCommandBuffer appends {Ref, WeakObjCPtr} to m_retainedCommandBufferInstances (pruning dead entries first), and waitForCommandBufferCompletions() — called from wgpuInstanceRelease before deref — exchanges the retained command-buffer and device containers out and waitUntilCompleted on each, so release blocks until the GPU has finished and all completion handlers have fired; the Instance cannot be destroyed with work outstanding. (2) The scheduleWork continuation that clears m_cachedCommandBuffer and records the execution window now captures a ThreadSafeWeakPtr instead of a strong Ref and bails if the CommandBuffer is gone. Note the fix also drops the Locker from retainDevice and makes retainedDeviceInstances/m_retainedCommandBufferInstances non-WTF_GUARDED_BY_LOCK, moving these to be touched on a single (commit) path.
Patch walkthrough
Source/WebGPU/WebGPU/CommandBuffer.mm— On installing the completed handler, calls m_device->instance()->retainCommandBuffer(*this, m_commandBuffer); the scheduleWork continuation switches from capturing protectedThis strongly to ThreadSafeWeakPtr{protectedThis.get()} with an early-return null check before touching m_cachedCommandBuffer/m_commandEncoder.Source/WebGPU/WebGPU/Instance.h— Forward-declares CommandBuffer; declares retainCommandBuffer and waitForCommandBufferCompletions; adds m_retainedCommandBufferInstances (Vector of {Ref, WeakObjCPtr}) and makes retainedDeviceInstances no longer WTF_GUARDED_BY_LOCK.Source/WebGPU/WebGPU/Instance.mm— Implements waitForCommandBufferCompletions (std::exchange both containers and waitUntilCompleted each), retainCommandBuffer (prune-then-append), removes the Locker from retainDevice, and makes wgpuInstanceRelease call waitForCommandBufferCompletions() before deref; imports CommandBuffer.h.
Background
MTLCommandBuffer completion handler — A Metal callback invoked on a system thread when GPU execution finishes; its timing is unrelated to the WebGPU object lifecycle, so objects it captures must be kept alive or weakly referenced until it runs.
WGPU Instance / Device / Queue ownership — The Instance owns the Device and Queue and the scheduleWork mechanism; releasing the Instance can destroy them, orphaning any callback that reaches through a (weak) back-pointer to them.
ThreadSafeWeakPtr — A thread-safe weak reference used across WebGPU; .get() returns a RefPtr that is null if the object is gone, which the fixed continuation checks before use. m_instance is held this way, so the Instance can vanish under a live CommandBuffer.
waitUntilCompleted — Blocks the calling thread until an MTLCommandBuffer finishes; the fix uses it in waitForCommandBufferCompletions to drain in-flight work before the Instance is torn down.
wgpuInstanceRelease — The WebGPU C-API deref for an instance; invoked (transitively) when web content drops its GPU objects, which is how untrusted content controls the teardown timing.
Vulnerability window
- Setup — Web content creates a WebGPU instance/device/queue and submits command buffers that encode/commit GPU work.
- Outstanding — A command buffer is committed and still executing on the GPU; its completed handler and a scheduleWork continuation are pending.
- Trigger — Content releases the WGPU instance; pre-patch wgpuInstanceRelease deref’s immediately without draining, destroying the Instance/Device/Queue.
- UAF — The GPU finishes and the completion handler / continuation runs, dereferencing the now-freed Instance-owned state on a background thread.
Proof of concept
No test ships in the diff. Conceptual trigger (inferred web path): from WebGPU JS, submit a non-trivial GPU workload on a queue so a command buffer is in flight, then drop all references to the device/queue/adapter so the underlying WGPU Instance is released while the command buffer is still executing; the race between GPU completion and Instance teardown drives the UAF. Reliability needs the release to win the race against GPU completion.
Exploitation
- Reach — Unprivileged WebGPU content runs in the GPU process; it fully controls both submitting work and releasing the instance.
- Trigger — Release the instance while a command buffer is in flight so its completion callback runs against freed Instance/Device/Queue state.
- Primary primitive — Use-after-free read/write in the GPU process on a background completion thread through the orphaned object graph.
- Secondary — If the freed allocation can be reclaimed before the callback fires, the continuation’s stores (m_cachedCommandBuffer = nil, recordGPUExecutionWindowOnCanvasTextures) write into attacker-influenced memory.
- Open question — Escalation past a crash depends on widening/repeating the teardown race to reliably reclaim the freed object with controlled contents and on which exact object the continuation dereferences (not fully pinned by the diff) — inferred.
Detection & hunting
For defenders and detection engineers:
- Completion handler after instance destruction —
- ASAN in the GPU process —
Remaining risk in the patch
- Unlocked retained-buffer containers — The fix removes the Locker from retainDevice and makes retainedDeviceInstances / m_retainedCommandBufferInstances non-GUARDED_BY_LOCK; if retainCommandBuffer (commit path) and waitForCommandBufferCompletions (release path) can run on different threads, the Vector mutation is itself racy — the patch assumes single-threaded access that the diff does not prove.
- ThreadSafeWeakPtr window on the continuation — The continuation null-checks weakThis, which prevents touching a freed CommandBuffer, but it still reaches protectedThis->m_device->getQueue(); whether m_device/getQueue remain valid is only guaranteed by the new wait-on-release, so a path that frees them without going through waitForCommandBufferCompletions would re-open the window.
Audit directions
- Other addCompletedHandler/scheduleWork captures —
- Teardown draining symmetry —