Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in WebCodecs
DescriptionOut of bounds read in WebCodecs
ComponentWebCodecs
Bug ClassOOB
Tracker491655161
Fix commitc177cadab426 (chromium/src) +81/-18
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-31

Changed Functions

FunctionChangeNotes
switch
media/video/av1_video_encoder.cc
modified
if
media/video/av1_video_encoder.cc
modified
if
media/video/openh264_video_encoder.cc
modified
TEST_P
media/video/software_video_encoder_test.cc
modified
switch
media/video/vpx_video_encoder.cc
modified
if
media/video/vpx_video_encoder.cc
modified

Files Changed

  • media/video/av1_video_encoder.cc
  • media/video/openh264_video_encoder.cc
  • media/video/software_video_encoder_test.cc
  • media/video/vpx_video_encoder.cc
From c177cadab426b31ccc2d3a1bca86a990004b2709 Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <eugene@chromium.org>
Date: Thu, 12 Mar 2026 17:08:27 -0700
Subject: [PATCH] media: Fix OOB read in software encoders when U-stride != V-stride

When an I420 VideoFrame is created with different strides for the U and
V planes (e.g., via WebCodecs), passing it to software encoders (AV1,
VPX, OpenH264) can cause an out-of-bounds read. These encoder libraries
often assume or internally convert to a representation with a single
chroma stride, ignoring the V plane stride.

This change forces a manual copy of the frame before encoding if the U
and V strides do not match, ensuring safe processing.

Bug: 492213293, 491655161
Change-Id: Ifcf324ff2201fbb56d53e65cc98261790b9b170b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7664025
Commit-Queue: Eugene Zemtsov <eugene@chromium.org>
Reviewed-by: Erik Språng <sprang@chromium.org>
Reviewed-by: Thomas Guilbert <tguilbert@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1598780}
---

diff --git a/media/video/av1_video_encoder.cc b/media/video/av1_video_encoder.cc
index bba5e59fb..c91d30a 100644
--- a/media/video/av1_video_encoder.cc
+++ b/media/video/av1_video_encoder.cc
@@ -49,16 +49,16 @@
 // pixel format. If no conversion is needed returns nullopt.
 std::optional<VideoPixelFormat> GetConversionFormat(VideoCodecProfile profile,
                                                     VideoPixelFormat format,
-                                                    bool needs_resize) {
+                                                    bool needs_copy) {
   switch (profile) {
     case AV1PROFILE_PROFILE_MAIN:
       if ((format != PIXEL_FORMAT_NV12 && format != PIXEL_FORMAT_I420) ||
-          needs_resize) {
+          needs_copy) {
         return PIXEL_FORMAT_I420;
       }
       break;
     case AV1PROFILE_PROFILE_HIGH:
-      if (format != PIXEL_FORMAT_I444 || needs_resize) {
+      if (format != PIXEL_FORMAT_I444 || needs_copy) {
         return PIXEL_FORMAT_I444;
       }
       break;
@@ -465,12 +465,22 @@
     return;
   }
 
+  bool requires_copy = frame->visible_rect().size() != options_.frame_size ||
+                       (IsYuvPlanar(frame->format()) &&
+                        VideoFrame::NumPlanes(frame->format()) >= 3 &&
+                        frame->stride(VideoFrame::Plane::kU) !=
+                            frame->stride(VideoFrame::Plane::kV));
+
   // Format conversion or resizing may be necessary to get the frame into the
   // form needed by libaom for encoding.
   if (auto conversion_format =
-          GetConversionFormat(profile_, frame->format(),
-                              /*needs_resize=*/frame->visible_rect().size() !=
-                                  options_.frame_size)) {
+          GetConversionFormat(profile_, frame->format(), requires_copy)) {
+    // In cases where we need to
+    // - enlarge the frame
+    // - change the pixel format
+    // - change the aspect ratio or
+    // - use matching U and V strides
+    // we are forced to convert and rescale manually.
     auto temp_frame = frame_pool_.CreateFrame(
         *conversion_format, options_.frame_size, gfx::Rect(options_.frame_size),
         options_.frame_size, frame->timestamp());
diff --git a/media/video/openh264_video_encoder.cc b/media/video/openh264_video_encoder.cc
index d949d3d5..c29a9fce 100644
--- a/media/video/openh264_video_encoder.cc
+++ b/media/video/openh264_video_encoder.cc
@@ -427,12 +427,18 @@
     }
   }
 
-  if (frame->format() != PIXEL_FORMAT_I420 ||
-      NeedsManualResizing(frame->visible_rect().size(), options_.frame_size)) {
+  bool requires_copy =
+      frame->format() != PIXEL_FORMAT_I420 ||
+      NeedsManualResizing(frame->visible_rect().size(), options_.frame_size) ||
+      frame->stride(VideoFrame::Plane::kU) !=
+          frame->stride(VideoFrame::Plane::kV);
+
+  if (requires_copy) {
     // In cases where we need to
     // - enlarge the frame
-    // - change the pixel format or
-    // - change the aspect ratio
+    // - change the pixel format
+    // - change the aspect ratio or
+    // - use matching U and V strides
     // we are forced to convert and rescale manually.
     auto i420_frame = frame_pool_.CreateFrame(
         PIXEL_FORMAT_I420, options_.frame_size, gfx::Rect(options_.frame_size),
diff --git a/media/video/software_video_encoder_test.cc b/media/video/software_video_encoder_test.cc
index fc82aab2..4ec61cd 100644
--- a/media/video/software_video_encoder_test.cc
+++ b/media/video/software_video_encoder_test.cc
@@ -7,6 +7,7 @@
 #include <memory>
 #include <string>
 
+#include "base/containers/heap_array.h"
 #include "base/feature_list.h"
 #include "base/functional/callback_helpers.h"
 #include "base/logging.h"
@@ -1092,6 +1093,42 @@
   EXPECT_EQ(chunks.size(), total_frames_count);
 }
 
+TEST_P(SoftwareVideoEncoderTest, EncodeFrameWithMismatchedStrides) {
+  VideoEncoder::Options options = CreateDefaultOptions();
+  options.frame_size = gfx::Size(64, 64);
+
+  encoder_->Initialize(profile_, options, /*info_cb=*/base::DoNothing(),
+                       /*output_cb=*/base::DoNothing(),
+                       ValidateStatusThenQuitCB());
+  RunUntilQuit();
+
+  // Create a frame with mismatched strides
+  gfx::Size size(64, 64);
+  size_t y_stride = 64;
+  size_t u_stride = 65536;  // Large U stride
+  size_t v_stride = 32;
+
+  // We allocate memory for the data. To cause an OOB read crash if the U
+  // stride is used for the V plane, we allocate a small buffer for the V plane.
+  auto y_data = base::HeapArray<uint8_t>::WithSize(y_stride * size.height());
+  auto u_data =
+      base::HeapArray<uint8_t>::WithSize(u_stride * (size.height() / 2));
+  auto v_data =
+      base::HeapArray<uint8_t>::WithSize(v_stride * (size.height() / 2));
+
+  auto frame = VideoFrame::WrapExternalYuvData(
+      PIXEL_FORMAT_I420, size, gfx::Rect(size), size, y_stride, u_stride,
+      v_stride, y_data, u_data, v_data, base::TimeDelta());
+  frame->AddDestructionObserver(
+      base::BindOnce([](base::HeapArray<uint8_t>, base::HeapArray<uint8_t>,
+                        base::HeapArray<uint8_t>) {},
+                     std::move(y_data), std::move(u_data), std::move(v_data)));
+
+  encoder_->Encode(std::move(frame), VideoEncoder::EncodeOptions(false),
+                   ValidateStatusThenQuitCB());
+  RunUntilQuit();
+}
+
 TEST_P(SoftwareVideoEncoderTest, ReconfigureWithResizingNumberOfThreads) {
   int outputs_count = 0;
   VideoEncoder::Options options = CreateDefaultOptions();
diff --git a/media/video/vpx_video_encoder.cc b/media/video/vpx_video_encoder.cc
index 681b1b6..a661729e 100644
--- a/media/video/vpx_video_encoder.cc
+++ b/media/video/vpx_video_encoder.cc
@@ -258,29 +258,29 @@
 // pixel format. If no conversion is needed returns nullopt.
 std::optional<VideoPixelFormat> GetConversionFormat(VideoCodecProfile profile,
                                                     VideoPixelFormat format,
-                                                    bool needs_resize) {
+                                                    bool needs_copy) {
   switch (profile) {
     case VP8PROFILE_ANY:
     case VP9PROFILE_PROFILE0:
       if ((format != PIXEL_FORMAT_NV12 && format != PIXEL_FORMAT_I420) ||
-          needs_resize) {
+          needs_copy) {
         return PIXEL_FORMAT_I420;
       }
       break;
     case VP9PROFILE_PROFILE1:
-      if (format != PIXEL_FORMAT_I444 || needs_resize) {
+      if (format != PIXEL_FORMAT_I444 || needs_copy) {
         return PIXEL_FORMAT_I444;
       }
       break;
     case VP9PROFILE_PROFILE2:
-      if (format != PIXEL_FORMAT_YUV420P10 || needs_resize) {
+      if (format != PIXEL_FORMAT_YUV420P10 || needs_copy) {
         // VideoFrameConverter doesn't support 10bit yet, so output I420 then
         // convert to I010.
         return PIXEL_FORMAT_I420;
       }
       break;
     case VP9PROFILE_PROFILE3:
-      if (format != PIXEL_FORMAT_YUV444P10 || needs_resize) {
+      if (format != PIXEL_FORMAT_YUV444P10 || needs_copy) {
         // VideoFrameConverter doesn't support 10bit yet, so output I444 then
         // convert to I410.
         return PIXEL_FORMAT_I444;
@@ -596,12 +596,22 @@
     return;
   }
 
+  bool requires_copy = frame->visible_rect().size() != options_.frame_size ||
+                       (IsYuvPlanar(frame->format()) &&
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/media/video/software_video_encoder_test.cc b/media/video/software_video_encoder_test.cc
index fc82aab2..4ec61cd 100644
--- a/media/video/software_video_encoder_test.cc
+++ b/media/video/software_video_encoder_test.cc
@@ -7,6 +7,7 @@
 #include <memory>
 #include <string>
 
+#include "base/containers/heap_array.h"
 #include "base/feature_list.h"
 #include "base/functional/callback_helpers.h"
 #include "base/logging.h"
@@ -1092,6 +1093,42 @@
   EXPECT_EQ(chunks.size(), total_frames_count);
 }
 
+TEST_P(SoftwareVideoEncoderTest, EncodeFrameWithMismatchedStrides) {
+  VideoEncoder::Options options = CreateDefaultOptions();
+  options.frame_size = gfx::Size(64, 64);
+
+  encoder_->Initialize(profile_, options, /*info_cb=*/base::DoNothing(),
+                       /*output_cb=*/base::DoNothing(),
+                       ValidateStatusThenQuitCB());
+  RunUntilQuit();
+
+  // Create a frame with mismatched strides
+  gfx::Size size(64, 64);
+  size_t y_stride = 64;
+  size_t u_stride = 65536;  // Large U stride
+  size_t v_stride = 32;
+
+  // We allocate memory for the data. To cause an OOB read crash if the U
+  // stride is used for the V plane, we allocate a small buffer for the V plane.
+  auto y_data = base::HeapArray<uint8_t>::WithSize(y_stride * size.height());
+  auto u_data =
+      base::HeapArray<uint8_t>::WithSize(u_stride * (size.height() / 2));
+  auto v_data =
+      base::HeapArray<uint8_t>::WithSize(v_stride * (size.height() / 2));
+
+  auto frame = VideoFrame::WrapExternalYuvData(
+      PIXEL_FORMAT_I420, size, gfx::Rect(size), size, y_stride, u_stride,
+      v_stride, y_data, u_data, v_data, base::TimeDelta());
+  frame->AddDestructionObserver(
+      base::BindOnce([](base::HeapArray<uint8_t>, base::HeapArray<uint8_t>,
+                        base::HeapArray<uint8_t>) {},
+                     std::move(y_data), std::move(u_data), std::move(v_data)));
+
+  encoder_->Encode(std::move(frame), VideoEncoder::EncodeOptions(false),
+                   ValidateStatusThenQuitCB());
+  RunUntilQuit();
+}
+
 TEST_P(SoftwareVideoEncoderTest, ReconfigureWithResizingNumberOfThreads) {
   int outputs_count = 0;
   VideoEncoder::Options options = CreateDefaultOptions();
Loading diff…

Original Bug Report

reported by je...@gmail.com

Heap Buffer Over-Read in OpenH264 Encoder Due to U/V Stride Mismatch

Heap Buffer Over-Read in OpenH264 Encoder Due to U/V Stride Mismatch

Summary

OpenH264’s encoder preprocessing copies the V chroma plane using the U plane’s stride instead of the V plane’s own stride. When an I420 frame has a U stride that is significantly larger than its V stride, the V plane copy reads far beyond the allocated buffer. This is reachable from the web via the WebCodecs VideoEncoder API by constructing an I420 VideoFrame from an ArrayBuffer with custom per-plane strides and using the transfer option to preserve them. The resulting out-of-bounds read crashes the renderer process. The crash reproduces on both ASAN builds and standard release Chrome. Affected platforms: all platforms where OpenH264 is enabled (Linux, Windows, macOS, Android, ChromeOS).

Bisect

Introducing Commit: 70e5e62f3dbd19f0e9300fa7bba670c7ee93dcd2 (OpenH264 repository)

  • Date: 2013-12-09
  • Author: Ethan Hugg <ehugg@cisco.com>
  • Note: This is the initial commit of the OpenH264 repository. The bug has existed since OpenH264’s first public release.

Root Cause

The SSourcePicture structure used by OpenH264’s public API declares per-plane strides:

// third_party/openh264/src/codec/api/wels/codec_app_def.h
typedef struct Source_Picture_s {
  int       iColorFormat;
  int       iStride[4];       // stride for each plane pData
  unsigned char*  pData[4];   // plane pData
  int       iPicWidth;
  int       iPicHeight;
  // ...
} SSourcePicture;

Chromium’s OpenH264 encoder wrapper correctly populates all three strides from the incoming VideoFrame:

// https://source.chromium.org/chromium/chromium/src/+/main:media/video/openh264_video_encoder.cc;l=473
picture_stride[0] = frame->stride(VideoFrame::Plane::kY);
picture_stride[1] = frame->stride(VideoFrame::Plane::kU);
picture_stride[2] = frame->stride(VideoFrame::Plane::kV);

However, inside OpenH264, WelsMoveMemoryWrapper reads only iStride[1] (the U stride) and uses it as the shared stride for both U and V plane copies:

// https://source.chromium.org/chromium/chromium/src/+/main:third_party/openh264/src/codec/encoder/core/src/wels_preprocess.cpp;l=1416
const int32_t kiSrcStrideUV = kpSrc->iStride[1];  // BUG: ignores iStride[2]

This single kiSrcStrideUV value is then passed to WelsMoveMemory_c, which uses it to advance both pSrcU and pSrcV pointers between rows:

// https://source.chromium.org/chromium/chromium/src/+/main:third_party/openh264/src/codec/encoder/core/src/wels_preprocess.cpp;l=1364
for (j = iHeight2; j; j--) {
    WelsMemcpy (pDstU, pSrcU, iWidth2);
    WelsMemcpy (pDstV, pSrcV, iWidth2);
    pDstU += iDstStrideUV;
    pDstV += iDstStrideUV;
    pSrcU += iSrcStrideUV;   // uses U stride for both
    pSrcV += iSrcStrideUV;   // should use V stride here
}

When U stride is much larger than V stride, the V plane copy reads far beyond the V plane’s allocation. In the PoC, a 64x64 I420 frame is constructed with U stride=65536 and V stride=32. The V plane occupies 1024 bytes, but the copy routine advances by 65536 bytes per row, requiring approximately 2MB of readable memory from the V plane start. This produces an out-of-bounds read of approximately 1.94MB.

The WebCodecs VideoFrame constructor allows creating frames from an ArrayBuffer with custom per-plane layout. When the transfer option is used, the frame wraps the original ArrayBuffer memory directly via VideoFrame::WrapExternalDataWithLayout, preserving the caller-supplied strides without normalization. The WebCodecs stride validation in video_frame_layout.cc checks per-plane minimum stride constraints but does not enforce any relationship between U and V strides.

The out-of-bounds read offset and length are fully attacker-controlled. The attacker chooses the U stride value via the layout parameter, which directly determines how far past the V plane allocation each row read advances:

layout: [
  { offset: offsetY, stride: strideY },    // Y plane
  { offset: offsetU, stride: 65536 },      // U plane — this stride is used for V copy too
  { offset: offsetV, stride: 32 },         // V plane — actual stride ignored by OpenH264
]

The attached PoC uses a U stride of 65536 producing a ~1.94MB over-read, but any stride value is accepted.

chrome://crashes id

e325cfe035f61694

Reproduce

Do NOT use ClusterFuzz to reproduce this bug. ClusterFuzz builds do not include proprietary_codecs and will not have OpenH264 compiled in, so the vulnerable code path is unreachable. As an alternative, a crash ID from standard release Chrome is provided in the “chrome://crashes id” section above.

This crash reproduces on standard release Chrome (tested on Chrome 146.0.7680.71 on macOS), where it manifests as an “Aw, Snap!” renderer crash (SIGBUS, error code 10).

The open-source Chromium build defaults proprietary_codecs to false to avoid distributing patent-encumbered codecs (H.264, AAC, etc.) without a license. Google Chrome ships with proprietary_codecs = true and ffmpeg_branding = "Chrome" under its own patent licensing agreements. To reproduce with an ASAN build, these flags must be set to match the production Chrome configuration.

Tested on commit f51a685e768b632262beaf8bd95387fffe096655. No source modifications are required. Open poc.html in Chrome to trigger the crash.

The args.gn:

is_asan = true
is_debug = false
dcheck_always_on = false
target_cpu = "x64"
is_component_build = true
proprietary_codecs = true
rtc_use_h264 = true
media_use_openh264 = true
ffmpeg_branding = "Chrome"
autoninja -C ~/chromium/src/out/asan-release chrome
cd ~/chromium/src
ASAN_OPTIONS=detect_odr_violation=0 out/asan-release/chrome --user-data-dir=/tmp/poc-$(date +%s) poc.html

Crash log:

Received signal 11 SEGV_ACCERR 7a3c00215000
#5 0x7f8c738c4881 memcpy (libc.so.6+0xc4880)
#6 0x561eff6e945c __asan_memcpy (chrome+0x67e945b)
#7 0x7f8ccf90f911 WelsMoveMemoryWrapper (wels_preprocess.cpp)
#8 0x7f8ccf90b487 SingleLayerPreprocess (wels_preprocess.cpp)
#9 0x7f8ccf868ee1 WelsEncoderEncodeExt (encoder_ext.cpp)
#10 0x7f8ccf923bc3 CWelsH264SVCEncoder::EncodeFrameInternal (welsEncoderExt.cpp)
#11 0x7f8ccf9238a4 CWelsH264SVCEncoder::EncodeFrame (welsEncoderExt.cpp)
#12 0x7f8ccf2feb49 OpenH264VideoEncoder::Encode (openh264_video_encoder.cc)

The crash manifests as SEGV rather than a standard ASAN heap-buffer-overflow report because V8 sandbox allocates ArrayBuffer backing stores via PartitionAlloc with kNoMemoryToolOverride, which disables ASAN shadow memory tracking for these allocations.

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker