Medium watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in WebCodecs
DescriptionOut of bounds read in WebCodecs
ComponentWebCodecs
Sub-componentMedia
Bug ClassOOB
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.
Tracker492213293
Fix commitc177cadab426 (chromium/src) +81/-18
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-12 · bounty NA (per vendor advisory)
Fix landed2026-03-13 (public git)
Disclosed2026-03-31
Patch gap18 days — fix public in source before the advisory

Background

I420 / YUV planar frame
a VideoFrame with separate Y, U, and V planes, each with its own stride (bytes per row); U and V are the two chroma planes.
Stride
the row pitch of a plane, which may legally differ between U and V when a frame is constructed directly (e.g. via WebCodecs VideoFrame with explicit per-plane layout).
Software encoders
libaom (AV1), libvpx (VP8/VP9), and OpenH264 code paths in media/video/ that consume a VideoFrame and often assume a single shared chroma stride for both U and V.
`GetConversionFormat`
a helper that decides whether the frame must be converted/copied to a target VideoPixelFormat before encoding, returning nullopt when no conversion is needed.

Root Cause Analysis

The software encoders fed U- and V-plane pointers to the underlying encoder libraries, which internally assume (or convert to) a single chroma stride and therefore apply the U-plane stride when walking the V plane. When a frame was created with stride(Plane::kV) != stride(Plane::kU) — easily done through WebCodecs — and the U stride was larger than the V plane’s actual allocation, the library read past the end of the V buffer, an out-of-bounds read. The pre-patch encode paths only forced a manual copy for pixel-format mismatch or resize (needs_resize), so a stride-mismatched but otherwise directly-encodable I420 frame bypassed the copy and reached the library with inconsistent strides.

The fix computes a requires_copy flag that is additionally true when frame->stride(Plane::kU) != frame->stride(Plane::kV) (for YUV planar frames with at least 3 planes), and threads it into GetConversionFormat (parameter renamed needs_resize → needs_copy) so a mismatched-stride frame is converted into a freshly allocated, single-stride temp frame before encoding. The shipped EncodeFrameWithMismatchedStrides test allocates a deliberately small V buffer with a huge U stride to reproduce the OOB read.

Key insight
The encoders trusted that U and V planes share one chroma stride and passed frames straight through, while the encoder libraries indexed the V plane with the U stride; the fix detects U/V stride mismatch and forces a normalizing manual copy before the frame reaches the library.

Attack Path

  1. Craft a mismatched-stride frame Web content uses WebCodecs to construct an I420 VideoFrame whose V-plane stride is smaller than its U-plane stride (and whose V allocation is correspondingly small).
  2. Submit to a software encoder The page configures a VideoEncoder backed by a software AV1/VP8/VP9/OpenH264 encoder and calls encode() on the frame.
  3. Bypass the old copy gate Pre-patch, the frame matches the encoder’s native format and needs no resize, so needs_resize is false and no normalizing copy is made.
  4. Out-of-bounds read The encoder library walks the V plane using the larger U stride and reads past the end of the V buffer, producing an OOB read (crash or heap over-read of adjacent memory).

Impact Assessment

An attacker controlling ordinary web content in a stock build can trigger an out-of-bounds read in the software video-encoder path by submitting a WebCodecs VideoFrame with mismatched U/V strides; the read occurs in the renderer/GPU-side media encoding code. The vendor advisory rates this medium (“Out of bounds read in WebCodecs”) and does not report in-the-wild exploitation; the diff demonstrates an OOB read only, so any information-disclosure or further-corruption primitive is (inferred).

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

Audit Directions

  • Per-plane stride assumptions
    Search encoder/decoder and image-processing code that handles planar YUV for any place that uses one plane’s stride (or a single uv_stride) for another plane; verify each plane is indexed with its own stride(Plane::kX).
  • WebCodecs-constructed frames
    Treat VideoFrames originating from WebCodecs as adversarially laid out (arbitrary strides, offsets, visible rect) and ensure every native consumer normalizes or validates layout before use.
  • Copy/convert gating conditions
    Review needs_resize/needs_copy-style gates feeding third-party libraries (libaom, libvpx, OpenH264) to ensure they cover format, size, aspect ratio, and per-plane stride consistency.
From c177cadab426b31ccc2d3a1bca86a990004b2709 Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <[email protected]>
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 <[email protected]>
Reviewed-by: Erik Språng <[email protected]>
Reviewed-by: Thomas Guilbert <[email protected]>
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 [email protected]

WebCodecs libaom, libvpx: OOB Read in AV1 and VP9 encoder when encoding I420 frames with mismatched U and V strides

Description:

Providing an I420 VideoFrame with different strides for the U and V planes to an AV1 VideoEncoder causes a severe out-of-bounds memory read (EXCEPTION_ACCESS_VIOLATION) in the underlying software encoder (libaom).

This can be triggered directly from JavaScript using the WebCodecs API by manually specifying the plane layouts and strides during VideoFrame construction.

Reproduction Steps:

  1. Open the attached HTML file (poc.html) in Chrome.
  2. The page uses WebCodecs to construct an I420 VideoFrame with a massive U stride (65536) and a normal V stride (32), then passes it to an AV1 VideoEncoder.
  3. Observe the renderer crash due to an Access Violation.

Crash Stack :

   1 Received fatal exception EXCEPTION_ACCESS_VIOLATION
   2         media!copy_and_extend_plane [0x7fff7b5c82ed+4ed] (third_party\libaom\source\libaom\av1\encoder\extend.c:34)
   3         media!av1_copy_and_extend_frame [0x7fff7b5c7502+5c2] (third_party\libaom\source\libaom\av1\encoder\extend.c:158)
   4         media!av1_lookahead_push [0x7fff7b5c8b96+1a6] (third_party\libaom\source\libaom\av1\encoder\lookahead.c:153)
   5         media!av1_receive_raw_frame [0x7fff7b5dacd0+c0] (third_party\libaom\source\libaom\av1\encoder\encoder.c:4765)
   6         media!encoder_encode [0x7fff7b5f3f39+a69] (third_party\libaom\source\libaom\av1\av1_cx_iface.c:3475)
   7         media!aom_codec_encode [0x7fff7b601ea9+b9] (third_party\libaom\source\libaom\aom\src\aom_encoder.c:191)
   8         media!media::Av1VideoEncoder::Encode [0x7fff7af43835+cf5] (media\video\av1_video_encoder.cc:602)

Root Cause:

  1. media::Av1VideoEncoder::Encode correctly maps the three independent VideoFrame strides into the aom_image_t struct (which supports independent strides for all planes).
  2. Deep inside aom_codec_encode, libaom converts the aom_image_t to its internal YV12_BUFFER_CONFIG representation using image2yuvconfig().
  3. YV12_BUFFER_CONFIG does not support independent chroma strides; it only has a single uv_stride field. image2yuvconfig() populates this field solely using the U plane stride (yv12->uv_stride = img->stride[AOM_PLANE_U];), silently ignoring the V plane stride.
  4. When av1_copy_and_extend_frame is called, it uses the inflated uv_stride (from the U plane) to read from the V buffer, resulting in an OOB read.
View on issue tracker