← WebKit — 2026-W36

95c3cfc258782ea42c3f0a8b570e6bb317c9a256  [WebGPU] ~CommandBuffer can run on Metal completion thread, racing non-atomic CommandEncoder refcount and Device::m_commandEncoderMap

severity high class UAF confidence 0.78 signal score 18.72 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: MTLCommandBuffer completion handler / cached command buffer used after WebGPU Instance/CommandBuffer destroyed
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.
Structural signals: smartptr_member_added (+6.47), null_guard (+1.2), uaf_lifetime (+4.75), msg_strong (+3.0), msg_medium (+2.0), msg_soft (+1.0), msg_noise (-2.5), area:Source/WebGPU/ (+1.5), surgical (+1.3)

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

  1. Setup — Web content creates a WebGPU instance/device/queue and submits command buffers that encode/commit GPU work.
  2. Outstanding — A command buffer is committed and still executing on the GPU; its completed handler and a scheduleWork continuation are pending.
  3. Trigger — Content releases the WGPU instance; pre-patch wgpuInstanceRelease deref’s immediately without draining, destroying the Instance/Device/Queue.
  4. 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

  1. Reach — Unprivileged WebGPU content runs in the GPU process; it fully controls both submitting work and releasing the instance.
  2. Trigger — Release the instance while a command buffer is in flight so its completion callback runs against freed Instance/Device/Queue state.
  3. Primary primitive — Use-after-free read/write in the GPU process on a background completion thread through the orphaned object graph.
  4. 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.
  5. 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 —

Before / after

Loading diff…