High watch CVSS 4.3 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in GPU
DescriptionInformation leak in GPU
ComponentGPU
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker543707066
Fix commitb261e1ba70ce (chromium/src) +585/-40
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
Creditedweihengqiuu
Reported2026-08-07 (per vendor advisory)
Fix landed2026-08-18 (public git)
Disclosed2026-08-25
Patch gap7 days — fix public in source before the advisory

Background

`VideoFrame::copyTo()`
the WebCodecs API that copies a video frame’s pixels into a caller-supplied ArrayBuffer, honoring a per-plane layout of offset and stride and copying only pixels, never the inter-row padding.
Row stride vs. row bytes
stride is the byte distance between the start of consecutive rows and may exceed the actual pixel bytes per row (row_bytes/minRowBytes), the difference being padding the caller controls.
Command-buffer readback shared memory
GPU-process pixel results are written into a shared-memory (shm) region that the client (renderer) then copies into the JS-visible destination buffer.
`RelaxedAtomicWriteMemcpy`
an atomic-safe memcpy used because the destination may be observed by JavaScript concurrently.

Root Cause Analysis

Several command-buffer client readback paths copied pixel data from the readback shared memory into the JS-visible destination as a single RelaxedAtomicWriteMemcpy of the full dst_size, treating destination and source as contiguous blocks. When the caller-specified destination layout includes padding between rows (stride > row_bytes), that one big copy also overwrites the padding regions with dst_size worth of source bytes read at the destination’s stride, so bytes from the shared-memory region beyond each row’s real pixels landed in JS-readable padding. The invariant that copyTo() must leave inter-row padding untouched (and never expose non-pixel shm contents) was violated, leaking residual shared-memory data into the web-visible ArrayBuffer.

The fix introduces RelaxedAtomicWriteMemcpyImageRowsSkippingPadding, which copies exactly row_bytes per row for height rows using stride as the step (falling back to the single memcpy only when row_bytes == stride), and reworks the readback structs/callers to carry SkImageInfo/dst_row_bytes so the correct per-row width and stride are known.

Key insight
The readback code used one contiguous RelaxedAtomicWriteMemcpy sized to the padded destination, so caller-controlled row padding was filled with shared-memory bytes instead of being skipped; the fix copies row-by-row with row_bytes < stride, leaving padding bytes untouched.

Attack Path

  1. Obtain a texture-backed VideoFrame In a renderer, acquire a GPU/texture-backed VideoFrame (e.g. via WebCodecs) whose copyTo() routes through RasterImplementation::ReadbackImagePixelsINTERNAL (the path the shipped copyTo.html test confirms hits the bug).
  2. Request a padded layout Call frame.copyTo(buf, options) with a per-plane layout whose stride exceeds the row’s real pixel bytes (and/or a nonzero offset), creating padding regions in the destination ArrayBuffer.
  3. Trigger the oversized copy The client reads the GPU result from readback shm and performs the full-dst_size RelaxedAtomicWriteMemcpy, writing source bytes into the padding regions rather than skipping them.
  4. Read the leaked bytes Inspect the padding bytes of the returned buffer from JavaScript to recover residual shared-memory contents that should never have been copied (leaked data classified by the advisory as an information leak; the precise adjacent contents are (inferred)).

Impact Assessment

A web page (renderer) can use VideoFrame.copyTo() with padded row layouts to have non-pixel bytes from the GPU readback shared memory written into JS-visible padding, yielding an information leak as the advisory states (Information leak in GPU). The disclosure crosses the GPU/renderer shared-memory boundary into script-readable memory; the exact provenance and sensitivity of the leaked bytes beyond “shared-memory contents outside the copied pixels” is (inferred). The commit notes the RGBA ReadbackImagePixelsINTERNAL path was confirmed exploitable, while the YUV CopyYUVPlane and GLES2Implementation::ReadbackARGBImagePixelsINTERNAL paths were fixed defensively without confirmed exploitation.

Changed Functions

FunctionChangeNotes
if
gpu/command_buffer/client/gles2_implementation.cc
modified
if
gpu/command_buffer/client/implementation_base.cc
modified
for
gpu/command_buffer/client/implementation_base.cc
modified
if
gpu/command_buffer/client/raster_implementation.cc
modified
if
content/test/data/gpu/webcodecs/copyTo.html
modified
for
content/test/data/gpu/webcodecs/copyTo.html
modified
TEST_F
gpu/command_buffer/client/gles2_implementation_unittest.cc
modified

Files Changed

  • gpu/command_buffer/client/implementation_base.cc
  • third_party/blink/web_tests/external/wpt/webcodecs/videoFrame-copyTo.any.js
  • content/test/data/gpu/webcodecs/copyTo.html
  • gpu/command_buffer/client/gles2_implementation_unittest.cc
  • gpu/command_buffer/client/raster_implementation.cc
  • gpu/command_buffer/client/raster_implementation_unittest.cc
  • gpu/command_buffer/client/gles2_implementation.cc
  • gpu/command_buffer/client/implementation_base.h

Audit Directions

  • memcpy sized to a padded/strided buffer
    Flag any copy into a caller-controlled destination that uses one memcpy of a stride-inclusive size instead of copying row_bytes per row; padding then carries source bytes.
  • Shared-memory-to-JS boundaries
    Audit every client-side readback that moves GPU/shm bytes into web-visible memory (RelaxedAtomicWriteMemcpy call sites) to confirm only pixel data, not layout padding or trailing region, is written.
  • Layout parameters flowing from web APIs
    Where offset/stride/row_bytes originate from JavaScript (copyTo layout, WebCodecs, WebGPU), verify the native copy respects them per-row rather than collapsing to a single block transfer.
From b261e1ba70cebb48b58560978af8a5978befe37e Mon Sep 17 00:00:00 2001
From: Kai Ninomiya <[email protected]>
Date: Mon, 17 Aug 2026 17:49:52 -0700
Subject: [PATCH] Fix incorrectly copying row padding in command buffer clients

VideoFrame::copyTo() is supposed to copy only the pixels and not the
padding bytes between rows. Doing so requires one memcpy per row instead
of one big memcpy.

- RasterImplementation::ReadbackImagePixelsINTERNAL and
  RasterImplementation::OnAsyncARGBReadbackDone:
  accessible via VideoFrame::copyTo.
  - There are many other codepaths reachable from VideoFrame::copyTo().
    The others seem to be correct.
- RasterImplementation::AsyncYUVReadbackRequest::CopyYUVPlane and
  GLES2Implementation::ReadbackARGBImagePixelsINTERNAL:
  also seemed to do this wrong, but I have not looked into exactly how
  they're used, so I'm not certain they're important or whether they
  have a security impact. I'm only guessing they are important based on
  the similar comments that said they write into JS-visible memory
  (which is how I stumbled on them).

Tests:
- raster_implementation_unittest tests generated using Gemini, but
  manually verified that each of the four tests fails when the fix it
  targets is reverted (e.g. see how the first three were tested in
  patchset 4: https://crrev.com/c/8255469/4).
  - Manual drive-by fix some of the initialization sites of
    MockTransferBuffer::ExpectedMemoryInfo, so that the spanified form
    can be used instead of raw pointers in the new test.
- content/test/data/gpu/webcodecs/copyTo.html test also generated using
  Gemini, but manually verified that it hits the bug in
  RasterImplementation::ReadbackImagePixelsINTERNAL. It doesn't seem to
  hit any of the others on my device (Pixel 3).
- WPT test for copyTo() on VideoFrames that are CPU-backed (which was
  already fine) and texture-backed (which is what this fixes).
  Unfortunately this test does not seem to catch the issue on CQ, but
  the texture-backed one does at least catch it locally on my Mac.

Fixed: 543707066
Change-Id: Iea2e75cdb75175f250ab5472a22c2f46819e6e84
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8255469
Reviewed-by: Eugene Zemtsov <[email protected]>
Commit-Queue: Kai Ninomiya <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1680958}
---
diff --git a/gpu/command_buffer/client/gles2_implementation.cc b/gpu/command_buffer/client/gles2_implementation.cc
index c2ce32c9..fbfc14a 100644
--- a/gpu/command_buffer/client/gles2_implementation.cc
+++ b/gpu/command_buffer/client/gles2_implementation.cc
@@ -59,6 +59,7 @@
 #include "gpu/command_buffer/common/swap_buffers_complete_params.h"
 #include "gpu/command_buffer/common/sync_token.h"
 #include "third_party/skia/include/core/SkAlphaType.h"
+#include "third_party/skia/include/core/SkColorType.h"
 #include "third_party/skia/include/gpu/ganesh/GrTypes.h"
 #include "ui/gfx/color_space.h"
 #include "ui/gfx/geometry/rect.h"
@@ -4649,12 +4650,15 @@
   if (!*readback_result) {
     return GL_FALSE;
   }
-  // We need to use `RelaxedAtomicWriteMemcpy` because we might be writing into
-  // memory observed by JS at the same time.
   auto dst = UNSAFE_TODO(base::span(static_cast<uint8_t*>(pixels), dst_size));
   auto src = UNSAFE_TODO(
       base::span(static_cast<uint8_t*>(shm_address) + pixels_offset, dst_size));
-  base::subtle::RelaxedAtomicWriteMemcpy(dst, src);
+  size_t min_row_bytes =
+      dst_width *
+      SkColorTypeBytesPerPixel(static_cast<SkColorType>(dst_sk_color_type));
+  RelaxedAtomicWriteMemcpyImageRowsSkippingPadding(
+      /*dst=*/dst, /*src=*/src, /*row_bytes=*/min_row_bytes,
+      /*height=*/dst_height, /*stride=*/dst_row_bytes);
   return GL_TRUE;
 }
 
diff --git a/gpu/command_buffer/client/implementation_base.cc b/gpu/command_buffer/client/implementation_base.cc
index 4ffc1aff..764e31c 100644
--- a/gpu/command_buffer/client/implementation_base.cc
+++ b/gpu/command_buffer/client/implementation_base.cc
@@ -427,4 +427,24 @@
   }
 }
 
+void RelaxedAtomicWriteMemcpyImageRowsSkippingPadding(base::span<uint8_t> dst,
+                                                      base::span<uint8_t> src,
+                                                      size_t row_bytes,
+                                                      size_t height,
+                                                      size_t stride) {
+  // We need to use `RelaxedAtomicWriteMemcpy` because we might be writing into
+  // memory observed by JS at the same time.
+  if (row_bytes == stride) {
+    base::subtle::RelaxedAtomicWriteMemcpy(dst, src);
+  } else {
+    // If there's padding between rows, copy each row separately to leave
+    // padding untouched
+    for (size_t y = 0; y < height; ++y) {
+      base::subtle::RelaxedAtomicWriteMemcpy(
+          dst.subspan(y * stride, row_bytes),
+          src.subspan(y * stride, row_bytes));
+    }
+  }
+}
+
 }  // namespace gpu
diff --git a/gpu/command_buffer/client/implementation_base.h b/gpu/command_buffer/client/implementation_base.h
index 06e66b6..d28ae61 100644
--- a/gpu/command_buffer/client/implementation_base.h
+++ b/gpu/command_buffer/client/implementation_base.h
@@ -161,6 +161,14 @@
   base::WeakPtrFactory<ImplementationBase> weak_ptr_factory_{this};
 };
 
+// Helper for copying 2D pixel data without touching the padding between rows.
+GLES2_IMPL_EXPORT void RelaxedAtomicWriteMemcpyImageRowsSkippingPadding(
+    base::span<uint8_t> dst,
+    base::span<uint8_t> src,
+    size_t row_bytes,
+    size_t height,
+    size_t stride);
+
 }  // namespace gpu
 
 #endif  // GPU_COMMAND_BUFFER_CLIENT_IMPLEMENTATION_BASE_H_
diff --git a/gpu/command_buffer/client/raster_implementation.cc b/gpu/command_buffer/client/raster_implementation.cc
index 3583b0ad..a4c1139f0 100644
--- a/gpu/command_buffer/client/raster_implementation.cc
+++ b/gpu/command_buffer/client/raster_implementation.cc
@@ -413,13 +413,15 @@
 
 struct RasterImplementation::AsyncARGBReadbackRequest {
   AsyncARGBReadbackRequest(void* dst_pixels,
-                           GLuint dst_size,
+                           const SkImageInfo& dst_info,
+                           GLuint dst_row_bytes,
                            GLuint pixels_offset,
                            GLuint finished_query,
                            std::unique_ptr<ScopedMappedMemoryPtr> shared_memory,
                            base::OnceCallback<void(bool)> callback)
       : dst_pixels(dst_pixels),
-        dst_size(dst_size),
+        dst_info(dst_info),
+        dst_row_bytes(dst_row_bytes),
         pixels_offset(pixels_offset),
         shared_memory(std::move(shared_memory)),
         callback(std::move(callback)),
@@ -433,7 +435,8 @@
   }
 
   raw_ptr<void> dst_pixels;
-  GLuint dst_size;
+  SkImageInfo dst_info;
+  GLuint dst_row_bytes;
   GLuint pixels_offset;
   std::unique_ptr<ScopedMappedMemoryPtr> shared_memory;
   base::OnceCallback<void(bool)> callback;
@@ -489,14 +492,14 @@
       return;
     }
 
-    CopyYUVPlane(output_rect.height(), y_plane_stride, y_plane_offset,
-                 shm_address, y_plane_data);
+    CopyYUVPlane(output_rect.width(), output_rect.height(), y_plane_stride,
+                 y_plane_offset, shm_address, y_plane_data);
 
     // U and V planes are half the size of the Y plane.
-    CopyYUVPlane(output_rect.height() / 2, u_plane_stride, u_plane_offset,
-                 shm_address, u_plane_data);
-    CopyYUVPlane(output_rect.height() / 2, v_plane_stride, v_plane_offset,
-                 shm_address, v_plane_data);
+    CopyYUVPlane(output_rect.width() / 2, output_rect.height() / 2,
+                 u_plane_stride, u_plane_offset, shm_address, u_plane_data);
+    CopyYUVPlane(output_rect.width() / 2, output_rect.height() / 2,
+                 v_plane_stride, v_plane_offset, shm_address, v_plane_data);
 
     readback_successful = true;
   }
@@ -524,20 +527,21 @@
   bool readback_successful = false;
 
  private:
-  void CopyYUVPlane(GLuint plane_height,
+  void CopyYUVPlane(GLuint plane_width,
+                    GLuint plane_height,
                     int plane_stride,
                     GLuint plane_offset,
                     void* in_buffer,
                     uint8_t* out_buffer) {
     // RasterDecoder writes the pixels into |in_buffer| with the requested
     // stride so we can copy the whole block here.
-    // We need to use `RelaxedAtomicWriteMemcpy` because we might be writing
-    // into memory observed by JS at the same time.
     size_t plane_size = plane_height * plane_stride;
     auto dst = UNSAFE_TODO(base::span(out_buffer, plane_size));
     auto src = UNSAFE_TODO(base::span(
         static_cast<uint8_t*>(in_buffer) + plane_offset, plane_size));
-    base::subtle::RelaxedAtomicWriteMemcpy(dst, src);
+    RelaxedAtomicWriteMemcpyImageRowsSkippingPadding(
+        /*dst=*/dst, /*src=*/src, /*row_bytes=*/plane_width,
+        /*height=*/plane_height, /*stride=*/plane_stride);
   }
 };
 
@@ -1509,7 +1513,7 @@
     EndQueryEXT(GL_COMMANDS_ISSUED_CHROMIUM);
 
     auto request = std::make_unique<AsyncARGBReadbackRequest>(
-        dst_pixels, dst_size, pixels_offset, query,
+        dst_pixels, dst_info, dst_row_bytes, pixels_offset, query,
         std::move(scoped_shared_memory), std::move(readback_done));
     auto* request_ptr = request.get();
     argb_request_queue_.push(std::move(request));
@@ -1522,13 +1526,14 @@
     if (!*readback_result) {
       return false;
     }
-    // We need to use `RelaxedAtomicWriteMemcpy` because we might be writing
-    // into memory observed by JS at the same time.
     auto dst = UNSAFE_TODO(
         base::span<uint8_t>(static_cast<uint8_t*>(dst_pixels), dst_size));
     auto src = UNSAFE_TODO(base::span<uint8_t>(
         static_cast<uint8_t*>(shm_address) + pixels_offset, dst_size));
-    base::subtle::RelaxedAtomicWriteMemcpy(dst, src);
+    RelaxedAtomicWriteMemcpyImageRowsSkippingPadding(
+        /*dst=*/dst, /*src=*/src, /*row_bytes=*/dst_info.minRowBytes(),
+        /*height=*/dst_info.height(),
+        /*stride=*/dst_row_bytes);
   }
 
   return true;
@@ -1553,16 +1558,19 @@
         static_cast<cmds::ReadbackARGBImagePixelsINTERNALImmediate::Result*>(
             request->shared_memory->address());
     if (*result) {
-      // We need to use `RelaxedAtomicWriteMemcpy` because we might be writing
-      // into memory observed by JS at the same time.
-      size_t plane_size = request->dst_size;
+      size_t dst_size =
+          request->dst_info.computeByteSize(request->dst_row_bytes);
       auto dst = UNSAFE_TODO(base::span<uint8_t>(
-          static_cast<uint8_t*>(request->dst_pixels.get()), plane_size));
+          static_cast<uint8_t*>(request->dst_pixels.get()), dst_size));
       auto src = UNSAFE_TODO(base::span<uint8_t>(
           static_cast<uint8_t*>(request->shared_memory->address()) +
               request->pixels_offset,
-          plane_size));
-      base::subtle::RelaxedAtomicWriteMemcpy(dst, src);
+          dst_size));
+      RelaxedAtomicWriteMemcpyImageRowsSkippingPadding(
+          /*dst=*/dst, /*src=*/src,
+          /*row_bytes=*/request->dst_info.minRowBytes(),
+          /*height=*/request->dst_info.height(),
+          /*stride=*/request->dst_row_bytes);
       request->readback_successful = true;
     }
 
diff --git a/content/test/data/gpu/webcodecs/copyTo.html b/content/test/data/gpu/webcodecs/copyTo.html
index efef2057..5bca638 100644
--- a/content/test/data/gpu/webcodecs/copyTo.html
+++ b/content/test/data/gpu/webcodecs/copyTo.html
@@ -198,6 +198,78 @@
       worker.terminate();
     }
 
+    // Test that padding bytes (stride/offset padding) are not overwritten.
+    {
+      const PAD = 0xAA;
+      const options = {
+        layout: [
+          {offset: 5, stride: frame.displayWidth * 4 + 16}, // plenty of padding
+        ],
+      };
+      if (frame.format === 'I420' || frame.format === 'I420A') {
+        options.layout = [
+          {offset: 5, stride: frame.displayWidth + 16},
+          {offset: (frame.displayWidth + 16) * frame.displayHeight + 10, stride: frame.displayWidth / 2 + 16},
+          {offset: (frame.displayWidth + 16) * frame.displayHeight + (frame.displayWidth / 2 + 16) * (frame.displayHeight / 2) + 20, stride: frame.displayWidth / 2 + 16},
+        ];
+        if (frame.format === 'I420A') {
+          options.layout.push({
+            offset: (frame.displayWidth + 16) * frame.displayHeight + (frame.displayWidth / 2 + 16) * (frame.displayHeight / 2) * 2 + 30,
+            stride: frame.displayWidth + 16
+          });
+        }
+      } else if (frame.format === 'NV12') {
+        options.layout = [
+          {offset: 5, stride: frame.displayWidth + 16},
+          {offset: (frame.displayWidth + 16) * frame.displayHeight + 10, stride: frame.displayWidth + 16},
+        ];
+      }
+
+      let size_with_padding = frame.allocationSize(options);
+      let buf = new ArrayBuffer(size_with_padding);
+      let view = new Uint8Array(buf);
+      view.fill(PAD);
+
+      let layout = await frame.copyTo(buf, options);
+
+      // Verify that the layout matches what we specified
+      for (let plane = 0; plane < layout.length; plane++) {
+        TEST.assert(layout[plane].offset === options.layout[plane].offset, "offset mismatch");
+        TEST.assert(layout[plane].stride === options.layout[plane].stride, "stride mismatch");
+      }
+
+      // We want to verify that the bytes between rows (the padding bytes) are untouched (still PAD).
+      for (let plane = 0; plane < layout.length; plane++) {
+        let plane_offset = layout[plane].offset;
+        let stride = layout[plane].stride;
+        let plane_height = frame.displayHeight;
+        let plane_width = frame.displayWidth;
+        if (plane > 0 && (frame.format === 'I420' || frame.format === 'I420A' || frame.format === 'NV12')) {
+          plane_height /= 2;
+          if (frame.format === 'I420' || frame.format === 'I420A') {
+            plane_width /= 2;
+          }
+        }
+        let bytes_per_pixel = 1;
+        if (frame.format === 'RGBA' || frame.format === 'RGBX' || frame.format === 'BGRA' || frame.format === 'BGRX') {
+          bytes_per_pixel = 4;
+        } else if (frame.format === 'NV12' && plane === 1) {
+          bytes_per_pixel = 2; // UV interleaved
+        }
+
+        let row_bytes = plane_width * bytes_per_pixel;
+        for (let y = 0; y < plane_height; ++y) {
+          let row_start = plane_offset + y * stride;
+          for (let x = row_bytes; x < stride; ++x) {
+            let pad_idx = row_start + x;
+            if (pad_idx < size_with_padding) {
+              TEST.assert(view[pad_idx] === PAD, `Padding byte overwritten at index ${pad_idx} for plane ${plane}, row ${y}, x ${x}. Expected ${PAD}, got ${view[pad_idx]}`);
+            }
+          }
+        }
+      }
+    }
+
     // Validate pixels
     if (!arg.validate_camera_frames && source_type == 'camera') {
       TEST.log("Skip copyTo result validation");
diff --git a/gpu/command_buffer/client/gles2_implementation_unittest.cc b/gpu/command_buffer/client/gles2_implementation_unittest.cc
index 51380ad6c..0de18a57 100644
--- a/gpu/command_buffer/client/gles2_implementation_unittest.cc
+++ b/gpu/command_buffer/client/gles2_implementation_unittest.cc
@@ -17,6 +17,7 @@
 #include <array>
 #include <memory>
 
+#include "base/bits.h"
 #include "base/compiler_specific.h"
 #include "base/containers/heap_array.h"
 #include "base/containers/span.h"
@@ -403,7 +404,8 @@
     ExpectedMemoryInfo mem;
 
     // Temporarily allocate memory and expect that memory block to be reused.
-    mem.ptr = gl_->mapped_memory_->Alloc(size, &mem.id, &mem.offset).data();
+    mem.span = gl_->mapped_memory_->Alloc(size, &mem.id, &mem.offset);
+    mem.ptr = mem.span.data();
     gl_->mapped_memory_->Free(mem.ptr);
 
     return mem;
@@ -3864,6 +3866,75 @@
                                  "but written again"));
 }
 
+TEST_F(GLES2ImplementationTest, ReadbackARGBImagePixelsINTERNALPadding) {
+  gpu::Mailbox mailbox = gpu::Mailbox::Generate();
+
+  GLuint dst_width = 2;
+  GLuint dst_height = 2;
+  GLuint dst_sk_color_type = 4;  // kRGBA_8888_SkColorType
+  GLuint dst_sk_alpha_type = 1;  // kPremul_SkAlphaType
+  GLuint dst_row_bytes =
+      12;  // 2 pixels * 4 bytes/pixel = 8 bytes. Row padding = 4 bytes.
+  GLuint dst_size = dst_height * dst_row_bytes;
+
+  GLuint color_space_offset = base::bits::AlignUp(
+      sizeof(cmds::ReadbackARGBImagePixelsINTERNAL::Result), sizeof(uint64_t));
+  GLuint mailbox_offset = color_space_offset;
+  GLuint pixels_offset = base::bits::AlignUp(
+      mailbox_offset + sizeof(gpu::Mailbox), sizeof(uint64_t));
+
+  GLuint total_size =
+      pixels_offset +
+      base::bits::AlignUp(dst_size, static_cast<GLuint>(sizeof(uint64_t)));
+
+  ExpectedMemoryInfo mem = GetExpectedMappedMemory(total_size);
+
+  std::vector<uint8_t> dst_pixels(dst_size, 0xAA);
+
+  EXPECT_CALL(*command_buffer(), OnFlush())
+      .WillOnce([mem, pixels_offset, dst_size]() {
+        // Write 1 to readback_result (at the beginning of shm).
+        auto* result =
+            reinterpret_cast<cmds::ReadbackARGBImagePixelsINTERNAL::Result*>(
+                mem.ptr);
+        *result = 1;
+
+        // Write test data to the pixel portion of the shared memory.
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/test/data/gpu/webcodecs/copyTo.html b/content/test/data/gpu/webcodecs/copyTo.html
index efef2057..5bca638 100644
--- a/content/test/data/gpu/webcodecs/copyTo.html
+++ b/content/test/data/gpu/webcodecs/copyTo.html
@@ -198,6 +198,78 @@
       worker.terminate();
     }
 
+    // Test that padding bytes (stride/offset padding) are not overwritten.
+    {
+      const PAD = 0xAA;
+      const options = {
+        layout: [
+          {offset: 5, stride: frame.displayWidth * 4 + 16}, // plenty of padding
+        ],
+      };
+      if (frame.format === 'I420' || frame.format === 'I420A') {
+        options.layout = [
+          {offset: 5, stride: frame.displayWidth + 16},
+          {offset: (frame.displayWidth + 16) * frame.displayHeight + 10, stride: frame.displayWidth / 2 + 16},
+          {offset: (frame.displayWidth + 16) * frame.displayHeight + (frame.displayWidth / 2 + 16) * (frame.displayHeight / 2) + 20, stride: frame.displayWidth / 2 + 16},
+        ];
+        if (frame.format === 'I420A') {
+          options.layout.push({
+            offset: (frame.displayWidth + 16) * frame.displayHeight + (frame.displayWidth / 2 + 16) * (frame.displayHeight / 2) * 2 + 30,
+            stride: frame.displayWidth + 16
+          });
+        }
+      } else if (frame.format === 'NV12') {
+        options.layout = [
+          {offset: 5, stride: frame.displayWidth + 16},
+          {offset: (frame.displayWidth + 16) * frame.displayHeight + 10, stride: frame.displayWidth + 16},
+        ];
+      }
+
+      let size_with_padding = frame.allocationSize(options);
+      let buf = new ArrayBuffer(size_with_padding);
+      let view = new Uint8Array(buf);
+      view.fill(PAD);
+
+      let layout = await frame.copyTo(buf, options);
+
+      // Verify that the layout matches what we specified
+      for (let plane = 0; plane < layout.length; plane++) {
+        TEST.assert(layout[plane].offset === options.layout[plane].offset, "offset mismatch");
+        TEST.assert(layout[plane].stride === options.layout[plane].stride, "stride mismatch");
+      }
+
+      // We want to verify that the bytes between rows (the padding bytes) are untouched (still PAD).
+      for (let plane = 0; plane < layout.length; plane++) {
+        let plane_offset = layout[plane].offset;
+        let stride = layout[plane].stride;
+        let plane_height = frame.displayHeight;
+        let plane_width = frame.displayWidth;
+        if (plane > 0 && (frame.format === 'I420' || frame.format === 'I420A' || frame.format === 'NV12')) {
+          plane_height /= 2;
+          if (frame.format === 'I420' || frame.format === 'I420A') {
+            plane_width /= 2;
+          }
+        }
+        let bytes_per_pixel = 1;
+        if (frame.format === 'RGBA' || frame.format === 'RGBX' || frame.format === 'BGRA' || frame.format === 'BGRX') {
+          bytes_per_pixel = 4;
+        } else if (frame.format === 'NV12' && plane === 1) {
+          bytes_per_pixel = 2; // UV interleaved
+        }
+
+        let row_bytes = plane_width * bytes_per_pixel;
+        for (let y = 0; y < plane_height; ++y) {
+          let row_start = plane_offset + y * stride;
+          for (let x = row_bytes; x < stride; ++x) {
+            let pad_idx = row_start + x;
+            if (pad_idx < size_with_padding) {
+              TEST.assert(view[pad_idx] === PAD, `Padding byte overwritten at index ${pad_idx} for plane ${plane}, row ${y}, x ${x}. Expected ${PAD}, got ${view[pad_idx]}`);
+            }
+          }
+        }
+      }
+    }
+
     // Validate pixels
     if (!arg.validate_camera_frames && source_type == 'camera') {
       TEST.log("Skip copyTo result validation");
diff --git a/gpu/command_buffer/client/gles2_implementation_unittest.cc b/gpu/command_buffer/client/gles2_implementation_unittest.cc
index 51380ad6c..0de18a57 100644
--- a/gpu/command_buffer/client/gles2_implementation_unittest.cc
+++ b/gpu/command_buffer/client/gles2_implementation_unittest.cc
@@ -17,6 +17,7 @@
 #include <array>
 #include <memory>
 
+#include "base/bits.h"
 #include "base/compiler_specific.h"
 #include "base/containers/heap_array.h"
 #include "base/containers/span.h"
@@ -403,7 +404,8 @@
     ExpectedMemoryInfo mem;
 
     // Temporarily allocate memory and expect that memory block to be reused.
-    mem.ptr = gl_->mapped_memory_->Alloc(size, &mem.id, &mem.offset).data();
+    mem.span = gl_->mapped_memory_->Alloc(size, &mem.id, &mem.offset);
+    mem.ptr = mem.span.data();
     gl_->mapped_memory_->Free(mem.ptr);
 
     return mem;
@@ -3864,6 +3866,75 @@
                                  "but written again"));
 }
 
+TEST_F(GLES2ImplementationTest, ReadbackARGBImagePixelsINTERNALPadding) {
+  gpu::Mailbox mailbox = gpu::Mailbox::Generate();
+
+  GLuint dst_width = 2;
+  GLuint dst_height = 2;
+  GLuint dst_sk_color_type = 4;  // kRGBA_8888_SkColorType
+  GLuint dst_sk_alpha_type = 1;  // kPremul_SkAlphaType
+  GLuint dst_row_bytes =
+      12;  // 2 pixels * 4 bytes/pixel = 8 bytes. Row padding = 4 bytes.
+  GLuint dst_size = dst_height * dst_row_bytes;
+
+  GLuint color_space_offset = base::bits::AlignUp(
+      sizeof(cmds::ReadbackARGBImagePixelsINTERNAL::Result), sizeof(uint64_t));
+  GLuint mailbox_offset = color_space_offset;
+  GLuint pixels_offset = base::bits::AlignUp(
+      mailbox_offset + sizeof(gpu::Mailbox), sizeof(uint64_t));
+
+  GLuint total_size =
+      pixels_offset +
+      base::bits::AlignUp(dst_size, static_cast<GLuint>(sizeof(uint64_t)));
+
+  ExpectedMemoryInfo mem = GetExpectedMappedMemory(total_size);
+
+  std::vector<uint8_t> dst_pixels(dst_size, 0xAA);
+
+  EXPECT_CALL(*command_buffer(), OnFlush())
+      .WillOnce([mem, pixels_offset, dst_size]() {
+        // Write 1 to readback_result (at the beginning of shm).
+        auto* result =
+            reinterpret_cast<cmds::ReadbackARGBImagePixelsINTERNAL::Result*>(
+                mem.ptr);
+        *result = 1;
+
+        // Write test data to the pixel portion of the shared memory.
+        auto src_pixels = mem.span.subspan(pixels_offset, dst_size);
+        // Fill src_pixels with distinct values, e.g. 1 to dst_size
+        for (size_t i = 0; i < dst_size; ++i) {
+          src_pixels[i] = static_cast<uint8_t>(i + 1);
+        }
+      })
+      .RetiresOnSaturation();
+
+  GLboolean success = gl_->ReadbackARGBImagePixelsINTERNAL(
+      mailbox.name, /*dst_color_space=*/nullptr,
+      /*dst_color_space_size=*/0, dst_size, dst_width, dst_height,
+      dst_sk_color_type, dst_sk_alpha_type, dst_row_bytes, /*src_x=*/0,
+      /*src_y=*/0, /*plane_index=*/0, dst_pixels.data());
+
+  EXPECT_TRUE(success);
+
+  // Expected output:
+  // Row 1 (pixels: 0 to 7) copied from src_pixels (0 to 7): 1, 2, 3, 4, 5, 6,
+  // 7, 8. Row 1 (padding: 8 to 11) untouched: 0xAA, 0xAA, 0xAA, 0xAA. Row 2
+  // (pixels: 12 to 19) copied from src_pixels (12 to 19): 13, 14, 15, 16, 17,
+  // 18, 19, 20. Row 2 (padding: 20 to 23) untouched: 0xAA, 0xAA, 0xAA, 0xAA.
+
+  std::vector<uint8_t> expected_pixels(dst_size, 0xAA);
+  size_t min_row_bytes = 8;  // 2 pixels * 4 bytes/pixel = 8.
+  for (size_t y = 0; y < dst_height; ++y) {
+    for (size_t x = 0; x < min_row_bytes; ++x) {
+      size_t dst_idx = y * dst_row_bytes + x;
+      size_t src_idx = y * dst_row_bytes + x;
+      expected_pixels[dst_idx] = static_cast<uint8_t>(src_idx + 1);
+    }
+  }
+
+  EXPECT_EQ(dst_pixels, expected_pixels);
+}
+
 #include "gpu/command_buffer/client/gles2_implementation_unittest_autogen.h"
 
 }  // namespace gles2
diff --git a/gpu/command_buffer/client/raster_implementation_unittest.cc b/gpu/command_buffer/client/raster_implementation_unittest.cc
index e8b306e..d9fd57a 100644
--- a/gpu/command_buffer/client/raster_implementation_unittest.cc
+++ b/gpu/command_buffer/client/raster_implementation_unittest.cc
@@ -16,6 +16,7 @@
 #include <array>
 #include <memory>
 
+#include "base/bits.h"
 #include "base/compiler_specific.h"
 #include "base/containers/heap_array.h"
 #include "base/containers/span.h"
@@ -254,7 +255,8 @@
     ExpectedMemoryInfo mem;
 
     // Temporarily allocate memory and expect that memory block to be reused.
-    mem.ptr = gl_->mapped_memory_->Alloc(size, &mem.id, &mem.offset).data();
+    mem.span = gl_->mapped_memory_->Alloc(size, &mem.id, &mem.offset);
+    mem.ptr = mem.span.data();
     gl_->mapped_memory_->Free(mem.ptr);
 
     return mem;
@@ -275,6 +277,19 @@
     return gl_->GetBucketContents(bucket_id, data);
   }
 
+  bool ReadbackImagePixelsINTERNAL(const gpu::Mailbox& source_mailbox,
+                                   const SkImageInfo& dst_info,
+                                   GLuint dst_row_bytes,
+                                   int src_x,
+                                   int src_y,
+                                   int plane_index,
+                                   base::OnceCallback<void(bool)> readback_done,
+                                   void* dst_pixels) {
+    return gl_->ReadbackImagePixelsINTERNAL(
+        source_mailbox, dst_info, dst_row_bytes, src_x, src_y, plane_index,
+        std::move(readback_done), dst_pixels);
+  }
+
   static SharedMemoryLimits SharedMemoryLimitsForTesting() {
     SharedMemoryLimits limits;
     limits.command_buffer_size = kCommandBufferSizeBytes;
@@ -879,6 +894,275 @@
   EXPECT_TRUE(NoCommandsWritten());
 }
 
+// https://crbug.com/543707066
+TEST_F(RasterImplementationTest, ReadbackImagePixelsSyncPadding) {
+  gpu::Mailbox mailbox = gpu::Mailbox::Generate();
+  SkImageInfo dst_info = SkImageInfo::MakeN32Premul(2, 2);
+  GLuint dst_row_bytes =
+      12;  // 2 pixels * 4 bytes/pixel = 8 bytes. Row padding = 4 bytes.
+
+  GLuint color_space_offset = base::bits::AlignUp(
+      sizeof(cmds::ReadbackARGBImagePixelsINTERNALImmediate::Result),
+      sizeof(uint64_t));
+  GLuint pixels_offset = color_space_offset;
+  GLuint dst_size = dst_info.computeByteSize(dst_row_bytes);
+  GLuint total_size =
+      pixels_offset +
+      base::bits::AlignUp(dst_size, static_cast<GLuint>(sizeof(uint64_t)));
+
+  ExpectedMemoryInfo mem = GetExpectedMappedMemory(total_size);
+
+  std::vector<uint8_t> dst_pixels(dst_row_bytes * dst_info.height(), 0xAA);
+
+  EXPECT_CALL(*command_buffer(), OnFlush())
+      .WillOnce([mem, pixels_offset, dst_size]() {
+        // Write 1 to readback_result (at the beginning of shm).
+        auto* result = reinterpret_cast<
+            cmds::ReadbackARGBImagePixelsINTERNALImmediate::Result*>(mem.ptr);
+        *result = 1;
+
+        // Write test data to the pixel portion of the shared memory.
+        auto src_pixels = mem.span.subspan(pixels_offset, dst_size);
+        // Fill src_pixels with distinct values, e.g. 1 to dst_size
+        for (size_t i = 0; i < dst_size; ++i) {
+          src_pixels[i] = static_cast<uint8_t>(i + 1);
+        }
+      })
+      .RetiresOnSaturation();
+
+  bool success = gl_->ReadbackImagePixels(mailbox, dst_info, dst_row_bytes,
+                                          /*src_x=*/0, /*src_y=*/0,
+                                          /*plane_index=*/0, dst_pixels.data());
+
+  EXPECT_TRUE(success);
+
+  // Expected output:
+  // Row 1 (pixels: 0 to 7) copied from src_pixels (0 to 7): 1, 2, 3, 4, 5, 6,
+  // 7, 8. Row 1 (padding: 8 to 11) untouched: 0xAA, 0xAA, 0xAA, 0xAA. Row 2
+  // (pixels: 12 to 19) copied from src_pixels (12 to 19): 13, 14, 15, 16, 17,
+  // 18, 19, 20. Row 2 (padding: 20 to 23) untouched: 0xAA, 0xAA, 0xAA, 0xAA.
+
+  std::vector<uint8_t> expected_pixels(dst_row_bytes * dst_info.height(), 0xAA);
+  for (int y = 0; y < dst_info.height(); ++y) {
+    for (size_t x = 0; x < dst_info.minRowBytes(); ++x) {
+      size_t dst_idx = y * dst_row_bytes + x;
+      size_t src_idx = y * dst_row_bytes + x;
+      expected_pixels[dst_idx] = static_cast<uint8_t>(src_idx + 1);
+    }
+  }
+
+  EXPECT_EQ(dst_pixels, expected_pixels);
+}
+
+// https://crbug.com/543707066
+TEST_F(RasterImplementationTest, ReadbackImagePixelsAsyncPadding) {
+  gpu::Mailbox mailbox = gpu::Mailbox::Generate();
+  SkImageInfo dst_info = SkImageInfo::MakeN32Premul(2, 2);
+  GLuint dst_row_bytes =
+      12;  // 2 pixels * 4 bytes/pixel = 8 bytes. Row padding = 4 bytes.
+
+  GLuint color_space_offset = base::bits::AlignUp(
+      sizeof(cmds::ReadbackARGBImagePixelsINTERNALImmediate::Result),
+      sizeof(uint64_t));
+  GLuint pixels_offset = color_space_offset;
+  GLuint dst_size = dst_info.computeByteSize(dst_row_bytes);
+  GLuint total_size =
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

VideoFrame.copyTo() leaks large stale image regions across origins in the same renderer process, via ReadbackImagePixelsINTERNAL


Report description

ReadbackImagePixelsINTERNAL returns unwritten transfer-pool memory as copyTo() padding, leaking cross-origin image pixels


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules


The problem

Please describe the technical details of the vulnerability

The problem

VideoFrame.copyTo() accepts a per-plane destination stride, which the page is entitled to choose — that part is as specified. The defect is downstream: the GPU readback that implements it sizes its staging allocation from that stride, writes only height runs of width × 4 bytes, and then copies the whole allocation into the page’s ArrayBuffer. The (rows − 1) × (stride − width × 4) bytes of inter-row padding are never written by this readback and never cleared. The allocation is a recycled block from the renderer’s transfer pool: those bytes are not indeterminate, they are whatever the block’s previous user left there. The same pool carries decoded image uploads.

The consequence is that a page can read a cross-origin image it is not allowed to read. The attached PoC uses a victim origin whose image is credentialed: http://localhost:8001/private.png returns a private image when that origin’s session cookie is present and a public placeholder otherwise. The attacker page, on http://localhost:8000, causes the browser to fetch it — the cookie is sent because the two are same-site — and draws it. Chrome correctly refuses to let the page read that canvas back, with getImageData() and toDataURL() both throwing SecurityError. The page then calls copyTo() on a different, untainted, same-origin canvas, and the padding in the returned buffer contains the private image. The reconstruction is exported with toDataURL(), which succeeds.

Measured with the attached PoC on Chrome 151.0.7922.77 (Stable), default flags. The same page run twice, differing only in whether the browser holds the victim origin’s cookie:

matches the private image matches the public placeholder
control — readback before anything is loaded 0.0 % 0.0 %
without the victim origin’s cookie 0.0 % 98.1 %
with the victim origin’s cookie 98.1 % 0.0 %

The page recovers whichever image the victim origin actually returned. An attacker’s own server requesting /private.png receives only the placeholder.

recovered.png (attached) is what the PoC exported in the cookie case; secret.png is what the victim origin served. They are the same picture; the short green dashes across the recovered image are the 0.4 % of the buffer this readback writes.

The page controls the amount returned: (rows − 1) × (stride − width × 4) bytes per plane per call, about 4 MB per call in the attached PoC, bounded by the layout validation that stride × rows fit in INT_MAX.

Environment

Build Chrome 151.0.7922.77 (Stable), Chrome for Testing win64
Also reproduces on Chrome 153.0.7995.0 ASAN (win32-release_x64_asan-…-1674964), byte-identical output
OS Windows 11
GPU NVIDIA GeForce RTX 4080
Flags none

Nothing beyond loading the page is required: no service worker, no cache priming, no user gesture beyond page load, no extension, no browser or profile restart, and no special response headers on the victim image. The attacker origin and the victim origin are different origins on the same site, so the victim origin’s SameSite=Lax cookie is sent with the image request; that is what makes the response user-specific.

The victim image is a subresource of the attacker’s document and has no renderer process of its own; while the PoC runs the only utility processes are storage.mojom.StorageService and network.mojom.NetworkService. The image is decoded in the attacker page’s renderer, which is the renderer whose transfer pool the readback draws from.


Analysis

RasterImplementation::ReadbackImagePixelsINTERNAL (gpu/command_buffer/client/raster_implementation.cc):

GLuint dst_size = dst_info.computeByteSize(dst_row_bytes);      // page-chosen stride
GLuint total_size =
    pixels_offset +
    base::bits::AlignUp(dst_size, static_cast<GLuint>(sizeof(uint64_t)));

std::unique_ptr<ScopedMappedMemoryPtr> scoped_shared_memory =
    std::make_unique<ScopedMappedMemoryPtr>(total_size, helper(), mapped_memory_.get());
...
*readback_result = 0;                                           // the only zeroing: 4 bytes
...
auto dst = base::span<uint8_t>(static_cast<uint8_t*>(dst_pixels), dst_size);
auto src = base::span<uint8_t>(static_cast<uint8_t*>(shm_address) + pixels_offset, dst_size);
base::subtle::RelaxedAtomicWriteMemcpy(dst, src);               // full dst_size returned
  • dst_size is (rows − 1) × stride + width × 4, computed from the stride the page supplied.
  • The block comes from MappedMemoryManager::Alloc (gpu/command_buffer/client/mapped_memory.cc), which sub-allocates from recycled MemoryChunks. There is no memset in that file; only a brand-new chunk is OS-fresh.
  • The GPU writes rows runs of width × 4 bytes at stride pitch. The (rows − 1) × (stride − width × 4) bytes between rows are never written.
  • The whole dst_size is then copied to the caller.

The same pool serves the transfer cache — raster_implementation.cc:

return transfer_cache_.MapEntry(mapped_memory_.get(), serialized_size);

ClientImageTransferCacheEntry carries decoded image pixels, so drawing a cross-origin image writes those pixels into this pool; the block is then freed and can be handed to a subsequent readback.

The recycled block is reused by size, which is why the readback lands on the image’s block only when the staging allocation is close to, and not larger than, the image’s decoded size. The PoC measures this rather than assuming it — see the table it prints.


Bisection

git log -S "computeByteSize(dst_row_bytes)" -- gpu/command_buffer/client/raster_implementation.cc returns two commits, and git blame puts the current line on the second:

commit date change
6ef95d6a6 2020-07-17 “Add synchronous SkImage readback through RasterInterface” — introduces the dst_row_bytes parameter
69e8a3d19d 2021-04-29 “Add support for async ARGB readback in OOPR mode” — introduces GLuint dst_size = dst_info.computeByteSize(dst_row_bytes);

The line has not been modified since, and git log over mapped_memory.cc shows no commit that zeroes recycled blocks.


Using the PoC

Attached: index.html, poc.js, server.py, secret.png, public.png, recovered.png. Put them in one directory and run:

python3 server.py

Then, in the browser under test:

  1. open http://localhost:8001/login once — this sets the victim origin’s session cookie;
  2. open http://localhost:8000/ and let it run.

Everything is rendered into the page; DevTools are not needed. The run takes a few minutes and shows its progress; there is a Run again button. Running step 2 without step 1 is a useful negative control: the page then recovers the public placeholder instead, and says so.

server.py serves two origins: the attacker page on http://localhost:8000/ and the victim site on http://localhost:8001/, which exposes /login and /private.png. /private.png returns secret.png when the cookie is present and public.png otherwise. The victim handler sends no CORS headers:

def end_headers(self):
    self.send_header("Cache-Control", "no-store")
    # deliberately nothing else: no access-control-allow-origin,
    # no cross-origin-resource-policy, no timing-allow-origin
    super().end_headers()
$ curl -I http://localhost:8001/private.png
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.10.12
Content-Type: image/png
Content-Length: 13784                      <- the public placeholder: no cookie was sent
Cache-Control: no-store

$ curl -s http://localhost:8001/private.png                             | wc -c
13784
$ curl -s -H "Cookie: poc_session=1" http://localhost:8001/private.png  | wc -c
16839                                      <- the private image

Impact analysis

What the page shows

  1. The victim image is cross-origin and the page cannot read it. Both of these must throw, and the page prints what was caught:

    getImageData() -> SecurityError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D':
                      The canvas has been tainted by cross-origin data.
    toDataURL()    -> SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement':
                      Tainted canvases may not be exported.
    
  2. Control — the same readback before the victim image has ever been loaded: 0.0 %. It is sized for a 2048×2048 allocation so that its own buffer cannot be the one recycled into the measured runs.

  3. Result — the best measured run, its match percentage, and whether toDataURL() of the reconstruction succeeded.

  4. Size sensitivity — four matched configurations and one deliberately mismatched one, each measured:

    victim canvas its decoded size staging allocation private image public placeholder
    256×256 262,144 262,084 92.4 % 0.0 %
    512×512 1,048,576 1,048,576 96.2 % 0.0 %
    1024×1024 4,194,304 4,194,292 98.1 % 0.0 %
    1024×1024 4,194,304 4,194,292 98.1 % 0.0 %
    1024×1024, readback sized 4× too large 4,194,304 16,777,156 varies 0.0 %

    The matched rows are consistent across runs. The oversized row is not a reliable negative — it has returned both 0.0 % and 75.3 % on this machine — which is why the PoC measures every row on every run rather than relying on the numbers above. The control, taken before anything is loaded, is 0.0 % on every run.

    Without /login the private and placeholder columns swap, which identifies the recovered bytes as the credentialed response rather than anything the attacker could fetch itself.

  5. Images — the victim image as the browser renders it, next to the reconstruction, which is on a same-origin canvas and was exported with toDataURL().

The essential sequence

// 1. draw the cross-origin image; the page cannot read this canvas
const img = new Image();
img.src = "http://localhost:8001/private.png";  // page is on http://localhost:8000
await img.decode();
const tainted = document.createElement("canvas");
tainted.width = tainted.height = 1024;
tainted.getContext("2d").drawImage(img, 0, 0);   // repeated a few times, then released

// 2. read back a small frame from a separate, untainted, same-origin canvas,
//    with a stride far larger than the tight stride
const src = document.createElement("canvas");
src.width = src.height = 64;
const g = src.getContext("2d");
g.fillStyle = "#00ff00";
g.fillRect(0, 0, 64, 64);

const frame  = new VideoFrame(src, {timestamp: 0});
const stride = Math.floor((1024*1024*4 - 64*4) / 63);          // sized from the victim's decoded size
const buf    = new Uint8Array(new ArrayBuffer(stride * 64));   // V8 zero-fills this
await frame.copyTo(buf, {rect: {x:0, y:0, width:64, height:64},
                         layout: [{offset: 0, stride}]});

// 3. buf now contains the cross-origin image; paint it 1024 wide and toDataURL() it

Attachments

  • index.html, poc.js — the proof of concept
  • server.py — serves both origins; the victim handler sends no CORS headers
  • secret.png — what the victim origin serves to a session holding its cookie
  • public.png — what it serves otherwise
  • recovered.png — what the PoC recovered and exported with toDataURL()

The cause

What version of Chrome have you found the security issue in?

153.0.7995.0 (Developer Build) (64-bit), 151.0.7922.77 (Stable), Windows 11, 64-bit

No, it is not related to a crash.

Choose the type of vulnerability

Information Leak

View on issue tracker