← All issues

[5] Fix mapAsync/unmap race in RemoteBuffer

Severity: Medium | Component: WebGPU RemoteBuffer (GPU process) | 9c6bfd6

Rated Medium because the observable effect is a state desynchronization allowing post-unmap buffer access via the WebGPU API, and the escalation to stale GPU buffer content read/write depends on unverified backing implementation behavior (getBufferContents() on an unmapped buffer), reducing confidence in the corruption primitive despite the straightforward trigger path.

A race was introduced between mapAsync() and unmap() — this is a follow-up fix to Security Fix #1 above (3aed028). When unmap() was called while a mapAsync was pending, the completion callback would still set m_isMapped = true, leaving the RemoteBuffer in a state where it appeared validly mapped after an explicit unmap.

Source/WebKit/GPUProcess/graphics/WebGPU/RemoteBuffer.cpp

protect(m_backing)->mapAsync(mapModeFlags, offset, size, [protectedThis = Ref<RemoteBuffer>(*this), callback = WTF::move(callback), mapModeFlags] (bool success) mutable {
+ bool mapWasPending = protectedThis->m_pendingMap;
+ protectedThis->m_pendingMap = false;
if (!success) {
callback(false);
return;
}
 
- protectedThis->m_isMapped = true;
- protectedThis->m_mapModeFlags = mapModeFlags;
+ if (mapWasPending) {
+ protectedThis->m_isMapped = true;
+ protectedThis->m_mapModeFlags = mapModeFlags;
+ }
callback(true);
});
}
void RemoteBuffer::unmap()
{
- if (m_isMapped)
+ if (m_isMapped || m_pendingMap)
protect(m_backing)->unmap();
m_isMapped = false;
+ m_pendingMap = false;
m_mapModeFlags = { };
}

Two changes fix the race. In mapAsync's completion callback, m_pendingMap is captured into a local mapWasPending before being cleared, and the m_isMapped/m_mapModeFlags transition only occurs if the map was still pending at callback time — if unmap() cleared m_pendingMap in the meantime, the callback becomes a no-op. In unmap(), the condition for calling protect(m_backing)->unmap() is broadened from m_isMapped to m_isMapped || m_pendingMap, and m_pendingMap is explicitly cleared. This also resolves the observation from Security Fix #1 that m_pendingMap was never reset to false.

  Before (race):                          After (fixed):
  mapAsync() → m_pendingMap = true        mapAsync() → m_pendingMap = true
  unmap()                                 unmap()
    m_isMapped? no → skip backing unmap     m_isMapped || m_pendingMap? yes
    m_isMapped = false                      → backing->unmap()
    (m_pendingMap still true!)              m_isMapped = false
                                            m_pendingMap = false
  callback fires (success):
    m_isMapped = true  ← STALE!           callback fires (success):
    m_mapModeFlags = flags                  mapWasPending = m_pendingMap (false)
                                            m_pendingMap = false
  Result: m_isMapped=true after unmap()     mapWasPending? no → skip transition
  → copy/copyWithCopy guards pass
  → writes to logically unmapped buffer   Result: m_isMapped stays false ✓

TOCTOU state desynchronization between an asynchronous operation's initiation flag and its completion callback, with an intervening cancellation that fails to invalidate the pending operation.

WebGPU's buffer mapping lifecycle requires that mapAsync() initiates an asynchronous GPU-side mapping operation, and unmap() cancels any pending map and transitions to unmapped state. RemoteBuffer lives in the GPU process and maintains shadow state (m_isMapped, m_pendingMap, m_mapModeFlags) to track the mapping lifecycle for IPC with the WebContent process. The correctness of these boolean flags as proxies for the actual backing buffer state is the sole enforcement point for buffer access control in the GPU process.

This bug is a direct consequence of the m_pendingMap flag introduced in Security Fix #1. The first fix correctly deferred m_isMapped transitions to the async callback but introduced a new problem: unmap() only checked m_isMapped (which was false during the pending phase), so it never called protect(m_backing)->unmap() and never cleared m_pendingMap. When the async callback later fired with success=true, it unconditionally set m_isMapped = true — even though unmap() had already been called.

The race is straightforward to trigger from web content: call buffer.mapAsync() followed immediately by buffer.unmap(). The specification requires that unmap() cancels the pending map, but the pre-fix RemoteBuffer didn't propagate the cancellation to its state tracking.

🔒

Explores the downstream memory implications of the state desynchronization and what operations become reachable on a logically unmapped buffer

Subscribe to read more

🔒

Multiple related state-tracking patterns identified across WebGPU proxy objects, with concrete variant locations

Subscribe to read more