Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionIncorrect boundary conditions in the Graphics: WebGPU component
ComponentDOM
Bug ClassLogic Error
Tracker2045410
Fix commit94b01edf180a (firefox) +52/-25
CISA KEVNot listed
CreditedAtsushi Sada
Disclosed2026-07-21

Background

WebGPU snapshot readback
the GPU/parent-process operation that copies a rendered WebGPU texture back into a shared-memory buffer so content can read the pixels.
Stride
the byte width of one image row including any trailing padding, which is normally >= the actual pixel bytes (width * 4).
`ipc::Shmem`
a shared-memory region used to hand image data across the process boundary between the GPU/parent process and content.
`MOZ_RELEASE_ASSERT`
a Firefox assertion that aborts in all builds (including release) when its condition is false, turning a memory-safety violation into a controlled crash.

Root Cause Analysis

Every affected row-copy loop computed min_stride = std::min(src_stride, aDestStride) and did memcpy(dst, src, min_stride) per row, an approach whose own comment admitted it “might still copy some padding bytes.”

The invariant that was violated is that only the meaningful pixel bytes (width * 4) should cross the process boundary and that every destination byte must be explicitly initialized. When src_stride exceeded width * 4 (source row padding) and the destination stride was at least as wide, memcpy pulled adjacent source padding into the destination; conversely, when the destination row was wider than what was copied, the trailing destination bytes were left uninitialized, exposing stale shared-memory contents.

The fix recomputes the true bytesPerRow as width * 4, copies exactly that many bytes, and memsets the remaining destination padding to zero, so no source padding leaks and no destination byte is left stale. It additionally guards both strides with MOZ_RELEASE_ASSERT(src_stride >= bytesPerRow) and MOZ_RELEASE_ASSERT(aDestStride >= bytesPerRow), converting any boundary miscalculation into a safe abort rather than an out-of-bounds access.

Key insight
The single mistake was using the smaller of the two strides as the copy length instead of the true payload width (width * 4), which both copied source padding and left destination padding uninitialized; the fix pins the copy to exactly bytesPerRow and zero-fills the rest.

Attack Path

  1. Trigger a snapshot A malicious page uses WebGPU to render into a texture and requests a readback/snapshot that routes through one of the GetSnapshot or Readback*Callback copy loops in the parent process.
  2. Exploit stride mismatch The source texture’s src_stride includes padding beyond width * 4, or the destination stride differs, so min_stride no longer equals the real pixel width.
  3. Copy or expose padding The per-row memcpy of min_stride bytes drags neighboring source padding into the shared buffer, and/or leaves trailing destination bytes uninitialized with stale contents.
  4. Read back the leaked bytes The content process reads the shared-memory image (e.g. via canvas/pixel readback) and recovers the padding bytes as image data.

Impact Assessment

An attacker running untrusted content gains a moderate information disclosure: fragments of adjacent or uninitialized memory from the GPU/parent process leak into image pixels the content process can read. It requires only that the page can drive WebGPU rendering and a snapshot/readback, and that a stride padding mismatch exists between source and destination buffers. No memory corruption or code execution is indicated by the diff; the assertions further ensure malformed strides abort instead of overflowing.

Changed Functions

FunctionChangeNotes
for
dom/webgpu/SharedTextureD3D11.cpp
modified
if
dom/webgpu/SharedTextureD3D11.cpp
modified
for
dom/webgpu/SharedTextureDMABuf.cpp
modified
if
dom/webgpu/SharedTextureDMABuf.cpp
modified
for
dom/webgpu/SharedTextureMacIOSurface.cpp
modified
if
dom/webgpu/SharedTextureMacIOSurface.cpp
modified
for
dom/webgpu/ipc/WebGPUParent.cpp
modified
if
dom/webgpu/ipc/WebGPUParent.cpp
modified
for
gfx/layers/RemoteTextureMap.cpp
modified
if
gfx/layers/RemoteTextureMap.cpp
modified

Files Changed

  • dom/webgpu/SharedTextureD3D11.cpp
  • dom/webgpu/SharedTextureDMABuf.cpp
  • dom/webgpu/SharedTextureMacIOSurface.cpp
  • dom/webgpu/ipc/WebGPUParent.cpp
  • gfx/layers/RemoteTextureMap.cpp

Audit Directions

  • Stride-minimum copies
    Flag any std::min(src_stride, dst_stride) used as a memcpy length in image row loops; the length should be the real payload width (width * bytesPerPixel), not a stride.
  • Uninitialized destination padding
    When a destination row is wider than the copied pixels, ensure the trailing bytes are explicitly memset to zero before the buffer crosses a process or trust boundary.
  • Cross-process pixel buffers
    Audit other GetSnapshot/readback paths that write into ipc::Shmem to confirm strides are release-asserted >= width * bytesPerPixel so malformed dimensions abort instead of over-reading.
diff --git a/dom/webgpu/SharedTextureD3D11.cpp b/dom/webgpu/SharedTextureD3D11.cpp
index e5d27e77898..87619a1c747 100644
--- a/dom/webgpu/SharedTextureD3D11.cpp
+++ b/dom/webgpu/SharedTextureD3D11.cpp
@@ -206,11 +206,15 @@ void SharedTextureD3D11::GetSnapshot(const ipc::Shmem& aDestShmem,
   uint8_t* dst = aDestShmem.get<uint8_t>();
 
   const size_t src_stride = static_cast<size_t>(map.RowPitch);
-  // note that this might still copy some padding bytes
-  const size_t min_stride = std::min(src_stride, aDestStride);
+  const size_t bytesPerRow = static_cast<size_t>(mWidth) * 4;
+  MOZ_RELEASE_ASSERT(src_stride >= bytesPerRow);
+  MOZ_RELEASE_ASSERT(aDestStride >= bytesPerRow);
 
   for (uint32_t y = 0; y < mHeight; y++) {
-    memcpy(dst, src, min_stride);
+    memcpy(dst, src, bytesPerRow);
+    if (bytesPerRow < aDestStride) {
+      memset(dst + bytesPerRow, 0, aDestStride - bytesPerRow);
+    }
     src += src_stride;
     dst += aDestStride;
   }
diff --git a/dom/webgpu/SharedTextureDMABuf.cpp b/dom/webgpu/SharedTextureDMABuf.cpp
index bdd1c84886c..7ce3c967808 100644
--- a/dom/webgpu/SharedTextureDMABuf.cpp
+++ b/dom/webgpu/SharedTextureDMABuf.cpp
@@ -146,11 +146,15 @@ void SharedTextureDMABuf::GetSnapshot(const ipc::Shmem& aDestShmem,
   uint8_t* dst = aDestShmem.get<uint8_t>();
 
   const size_t src_stride = static_cast<size_t>(map.GetStride());
-  // note that this might still copy some padding bytes
-  const size_t min_stride = std::min(src_stride, aDestStride);
+  const size_t bytesPerRow = static_cast<size_t>(mWidth) * 4;
+  MOZ_RELEASE_ASSERT(src_stride >= bytesPerRow);
+  MOZ_RELEASE_ASSERT(aDestStride >= bytesPerRow);
 
   for (uint32_t y = 0; y < mHeight; y++) {
-    memcpy(dst, src, min_stride);
+    memcpy(dst, src, bytesPerRow);
+    if (bytesPerRow < aDestStride) {
+      memset(dst + bytesPerRow, 0, aDestStride - bytesPerRow);
+    }
     src += src_stride;
     dst += aDestStride;
   }
diff --git a/dom/webgpu/SharedTextureMacIOSurface.cpp b/dom/webgpu/SharedTextureMacIOSurface.cpp
index 1dd14953558..874449a0f94 100644
--- a/dom/webgpu/SharedTextureMacIOSurface.cpp
+++ b/dom/webgpu/SharedTextureMacIOSurface.cpp
@@ -85,16 +85,20 @@ void SharedTextureMacIOSurface::GetSnapshot(const ipc::Shmem& aDestShmem,
     return;
   }
 
-  const size_t bytesPerRow = mSurface->GetBytesPerRow();
+  const size_t src_stride = mSurface->GetBytesPerRow();
   uint8_t* src = (uint8_t*)mSurface->GetBaseAddress();
   uint8_t* dst = aDestShmem.get<uint8_t>();
 
-  // note that this might still copy some padding bytes
-  const size_t min_stride = std::min(bytesPerRow, aDestStride);
+  const size_t bytesPerRow = static_cast<size_t>(mWidth) * 4;
+  MOZ_RELEASE_ASSERT(src_stride >= bytesPerRow);
+  MOZ_RELEASE_ASSERT(aDestStride >= bytesPerRow);
 
   for (uint32_t y = 0; y < mHeight; y++) {
-    memcpy(dst, src, min_stride);
-    src += bytesPerRow;
+    memcpy(dst, src, bytesPerRow);
+    if (bytesPerRow < aDestStride) {
+      memset(dst + bytesPerRow, 0, aDestStride - bytesPerRow);
+    }
+    src += src_stride;
     dst += aDestStride;
   }
 
diff --git a/dom/webgpu/ipc/WebGPUParent.cpp b/dom/webgpu/ipc/WebGPUParent.cpp
index 556757ee03b..660ef6a6949 100644
--- a/dom/webgpu/ipc/WebGPUParent.cpp
+++ b/dom/webgpu/ipc/WebGPUParent.cpp
@@ -1078,19 +1078,25 @@ static void ReadbackPresentCallback(uint8_t* userdata,
       uint8_t* src = mapped.ptr;
       uint8_t* dst = mappedData.data;
 
-      const uint32_t dst_stride = mappedData.stride;
+      const size_t dst_stride = static_cast<size_t>(mappedData.stride);
       // `mappedData.stride` is computed via
       // `ImageDataSerializer::ComputeRGBStride` and returns 0 if it overflows
       MOZ_RELEASE_ASSERT(dst_stride != 0);
 
-      // note that this might still copy some padding bytes
-      const uint32_t min_stride = std::min(data->mBufferStride, dst_stride);
+      const size_t src_stride = static_cast<size_t>(data->mBufferStride);
+      const size_t bytesPerRow =
+          static_cast<size_t>(data->mDesc.size().width) * 4;
+      MOZ_RELEASE_ASSERT(src_stride >= bytesPerRow);
+      MOZ_RELEASE_ASSERT(dst_stride >= bytesPerRow);
 
       // The height is in bounds for both buffers since we just requested a new
       // destination buffer with the same height of the source.
       for (auto row = 0; row < size.height; ++row) {
-        memcpy(dst, src, min_stride);
-        src += data->mBufferStride;
+        memcpy(dst, src, bytesPerRow);
+        if (bytesPerRow < dst_stride) {
+          memset(dst + bytesPerRow, 0, dst_stride - bytesPerRow);
+        }
+        src += src_stride;
         dst += dst_stride;
       }
       req->mRemoteTextureOwner->PushTexture(req->mTextureId, req->mOwnerId,
@@ -1176,13 +1182,17 @@ static void ReadbackSnapshotCallback(uint8_t* userdata,
   uint8_t* dst = req->mDestShmem.get<uint8_t>();
 
   const size_t src_stride = static_cast<size_t>(data->mBufferStride);
-  // note that this might still copy some padding bytes
-  const size_t min_stride = std::min(src_stride, req->mDestStride);
+  const size_t bytesPerRow = static_cast<size_t>(data->mDesc.size().width) * 4;
+  MOZ_RELEASE_ASSERT(src_stride >= bytesPerRow);
+  MOZ_RELEASE_ASSERT(req->mDestStride >= bytesPerRow);
 
   // The height is in bounds for both buffers since we previously created a new
   // destination buffer with the same height of the source.
   for (auto row = 0; row < data->mDesc.size().height; ++row) {
-    memcpy(dst, src, min_stride);
+    memcpy(dst, src, bytesPerRow);
+    if (bytesPerRow < req->mDestStride) {
+      memset(dst + bytesPerRow, 0, req->mDestStride - bytesPerRow);
+    }
     src += src_stride;
     dst += req->mDestStride;
   }
diff --git a/gfx/layers/RemoteTextureMap.cpp b/gfx/layers/RemoteTextureMap.cpp
index 3635a685505..ae65668d34f 100644
--- a/gfx/layers/RemoteTextureMap.cpp
+++ b/gfx/layers/RemoteTextureMap.cpp
@@ -557,15 +557,20 @@ void RemoteTextureMap::GetLatestBufferSnapshot(
     uint8_t* src = bufferTextureHost->GetBuffer();
     uint8_t* dst = aDestShmem.get<uint8_t>();
 
-    const Maybe<int32_t> src_stride = ImageDataSerializer::GetRGBStride(
+    const Maybe<int32_t> maybe_src_stride = ImageDataSerializer::GetRGBStride(
         bufferTextureHost->GetBufferDescriptor());
-    MOZ_RELEASE_ASSERT(src_stride.isSome());
-    // note that this might still copy some padding bytes
-    const size_t min_stride = std::min(size_t(src_stride.value()), aDestStride);
+    MOZ_RELEASE_ASSERT(maybe_src_stride.isSome());
+    const size_t src_stride = static_cast<size_t>(maybe_src_stride.value());
+    const size_t bytesPerRow = static_cast<size_t>(src_size.width) * 4;
+    MOZ_RELEASE_ASSERT(src_stride >= bytesPerRow);
+    MOZ_RELEASE_ASSERT(aDestStride >= bytesPerRow);
 
     for (int y = 0; y < src_size.height; y++) {
-      memcpy(dst, src, min_stride);
-      src += src_stride.value();
+      memcpy(dst, src, bytesPerRow);
+      if (bytesPerRow < aDestStride) {
+        memset(dst + bytesPerRow, 0, aDestStride - bytesPerRow);
+      }
+      src += src_stride;
       dst += aDestStride;
     }
   }
Loading diff…