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

Overview

Medium
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in WebCodecs
DescriptionOut of bounds read in WebCodecs
ComponentMedia
Sub-componentBlink>Media>WebCodecs
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.
Tracker441917796
Fix commitdd13b8a2ae1d (chromium/src) +188/-20
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedJakob Košir
Reported2025-08-29 · bounty $3000 (per vendor advisory)
Fix landed2025-08-30 (public git)
Disclosed2025-10-07
Patch gap38 days — fix public in source before the advisory

Background

Coded size vs. visible rect
a media::VideoFrame allocates planes for its coded_size(), which may be padded, while visible_rect() describes the meaningful pixels. row_bytes(plane) and rows(plane) are derived from the coded size.
Odd-size alignment in `CreateFrame()`
for subsampled formats such as PIXEL_FORMAT_I420A, VideoFrame::CreateFrame rounds the coded size up to even dimensions; the new test shows 677x287 becoming 678x288. The full-resolution Y and A planes then have more row_bytes/rows than the visible area.
`libyuv::CopyPlane(src, src_stride, dst, dst_stride, width, height)`
copies height rows of width bytes. It trusts the caller that both the source and destination spans cover width x height at their strides.
WebCodecs `VideoFrame` from a buffer
the Blink constructor that builds a VideoFrame from a script-supplied ArrayBuffer plus VideoFrameBufferInit (codedWidth, codedHeight, format). In the non-transfer case it copies the pixels into a pooled frame.

Root Cause Analysis

In third_party/blink/renderer/modules/webcodecs/video_frame.cc, the non-transfer path builds the destination with frame_pool.CreateFrame(media_fmt, crop.size(), ...). It then copied every plane with libyuv::CopyPlane(src_frame->visible_data(i), src_frame->stride(i), ..., frame->row_bytes(i), frame->rows(i)), using the destination’s coded row bytes and row count as the copy extent. The surrounding comment assumes coded_size == visible_size, but the patch’s new note says “CreateFrame() may expand the coded size for odd sized frames”. With an odd-sized I420A frame (the regression test uses 61x21 backed by a tightly sized DOMArrayBuffer), the destination Y and A planes are one column and one row larger than the source. CopyPlane therefore reads past the end of each source plane, and for the last (A) plane past the end of the script-supplied buffer. That is the out-of-bounds read.

The fix sizes the copy from the source, src_frame->GetVisibleRowBytes(i) and src_frame->GetVisibleRows(i), using new VideoFrame helpers computed from visible_rect(). The same coded-vs-visible confusion is fixed in the vpx_video_decoder.cc copy, which now uses GetVisibleRowBytes/GetVisibleRows.

Key insight
The copy extent came from the destination frame’s padded coded dimensions instead of the source’s visible dimensions, so any upward alignment in CreateFrame() became an over-read of the source. The fix bounds each CopyPlane by the source’s visible rows and row bytes.

Attack Path

  1. Construct an odd-sized frame Script calls the WebCodecs VideoFrame constructor with a BufferSource and a VideoFrameBufferInit declaring an odd codedWidth/codedHeight (for example 61x21) in a subsampled format such as I420A, with a buffer sized exactly for that layout.
  2. Destination padded CachedVideoFramePool::CreateFrame creates the destination with coded size rounded up to even (62x22 for the Y and A planes), larger than the source planes.
  3. Over-read during copy The per-plane libyuv::CopyPlane loop uses frame->row_bytes(i) and frame->rows(i), reading one extra byte per row and one extra row from each full-resolution source plane. On the A plane this runs past the end of the caller’s ArrayBuffer into adjacent renderer heap.
  4. Data surfaced to script The over-read bytes land in the new frame’s padded area, and the frame’s pixels are script-readable (for example via copyTo), which could expose adjacent heap contents (inferred; the diff shows the read, not a read-back path).

Impact Assessment

The bug is an out-of-bounds heap read in the renderer process that a web page can trigger through WebCodecs with an odd-dimension subsampled frame. It could leak renderer memory into script-visible pixel data (inferred). The vpx_video_decoder.cc copy has the same coded-vs-visible pattern, but whether it overruns real libvpx buffers is not shown. The advisory rates this medium severity and does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
for
media/base/video_frame.cc
modified
if
third_party/blink/renderer/modules/webcodecs/video_frame.cc
modified
TEST
media/base/video_frame_unittest.cc
modified
for
media/base/video_frame_unittest.cc
modified
if
media/base/video_frame_unittest.cc
modified
TEST_F
third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
modified
for
third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
modified
if
third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
modified

Files Changed

  • media/base/video_frame.cc
  • media/base/video_frame.h
  • media/base/video_frame_unittest.cc
  • media/filters/vpx_video_decoder.cc
  • media/test/pipeline_integration_test.cc
  • third_party/blink/renderer/modules/webcodecs/video_frame.cc
  • third_party/blink/renderer/modules/webcodecs/video_frame_test.cc

Audit Directions

  • Destination-derived copy extents
    Grep for CopyPlane/memcpy/CopyRow calls whose width or height come from the destination frame’s row_bytes()/rows()/coded_size() while the source is a different frame or a user buffer. Each must be bounded by the smaller of the source and destination extents.
  • Alignment that grows allocations
    Anywhere CreateFrame, frame pools, or VideoFrameLayout round dimensions up (odd sizes, kFrameSizeAlignment, subsampling), check every follow-on copy, conversion, or readback (copyTo, ImageBitmap, canvas upload) for coded-vs-visible confusion.
  • Script-sized buffers wrapped as frames
    Review the WebCodecs VideoFrame/AudioData constructors and copyTo paths that wrap a BufferSource (WrapExternalYuvaData and similar). Make sure the computed plane layout is validated against the actual buffer length for every format, including the A plane in I420A.
diff --git a/media/base/video_frame.cc b/media/base/video_frame.cc
index 8fb80072..ac36168 100644
--- a/media/base/video_frame.cc
+++ b/media/base/video_frame.cc
@@ -1270,20 +1270,26 @@
 
 // static
 void VideoFrame::UpdateHashWithFrameForTesting(crypto::hash::Hasher& hasher,
-                                               const VideoFrame& frame) {
+                                               const VideoFrame& frame,
+                                               bool visible_data_only) {
   for (size_t plane = 0; plane < NumPlanes(frame.format()); ++plane) {
-    for (int row = 0; row < frame.rows(plane); ++row) {
-      hasher.Update(frame.data_[plane].subspan(
-          base::checked_cast<size_t>(frame.stride(plane) * row),
-          base::checked_cast<size_t>(frame.row_bytes(plane))));
+    const size_t rows = base::checked_cast<size_t>(
+        visible_data_only ? frame.GetVisibleRows(plane) : frame.rows(plane));
+    const size_t row_bytes = base::checked_cast<size_t>(
+        visible_data_only ? frame.GetVisibleRowBytes(plane)
+                          : frame.row_bytes(plane));
+    const auto& plane_data = frame.data_[plane];
+    for (size_t row = 0; row < rows; ++row) {
+      hasher.Update(plane_data.subspan(frame.stride(plane) * row, row_bytes));
     }
   }
 }
 
 // static
-std::string VideoFrame::HexHashOfFrameForTesting(const VideoFrame& frame) {
+std::string VideoFrame::HexHashOfFrameForTesting(const VideoFrame& frame,
+                                                 bool visible_data_only) {
   crypto::hash::Hasher hasher(crypto::hash::HashKind::kSha256);
-  UpdateHashWithFrameForTesting(hasher, frame);  // IN-TEST
+  UpdateHashWithFrameForTesting(hasher, frame, visible_data_only);  // IN-TEST
   std::array<uint8_t, crypto::hash::kSha256Size> hash;
   hasher.Finish(hash);
   return base::ToLowerASCII(base::HexEncode(hash));
@@ -1462,6 +1468,14 @@
   return Rows(plane, format(), coded_size().height());
 }
 
+int VideoFrame::GetVisibleRowBytes(size_t plane) const {
+  return RowBytes(plane, format(), visible_rect().width());
+}
+
+int VideoFrame::GetVisibleRows(size_t plane) const {
+  return Rows(plane, format(), visible_rect().height());
+}
+
 int VideoFrame::columns(size_t plane) const {
   return Columns(plane, format(), coded_size().width());
 }
@@ -1482,7 +1496,6 @@
                           base::bits::AlignDownDeprecatedDoNotUse(
                               visible_rect_.y(), alignment.height()));
 
-  const int visible_plane_rows = Rows(plane, format(), visible_rect_.height());
   const int plane_stride = stride(plane);
   const gfx::Size subsample = SampleSize(format(), plane);
   DCHECK(offset.x() % subsample.width() == 0);
@@ -1494,8 +1507,8 @@
       BytesPerElement(format(), plane) * (offset.x() / subsample.width()));
   // In the last row, bytes between visible width and the full stride are not
   // the part of the visible plane.
-  size_t visible_plane_size = plane_stride * (visible_plane_rows - 1) +
-                              RowBytes(plane, format(), visible_rect_.width());
+  size_t visible_plane_size =
+      plane_stride * (GetVisibleRows(plane) - 1) + GetVisibleRowBytes(plane);
   return data.subspan(visible_plane_offset, visible_plane_size);
 }
 
diff --git a/media/base/video_frame.h b/media/base/video_frame.h
index 72b870c..56d47377 100644
--- a/media/base/video_frame.h
+++ b/media/base/video_frame.h
@@ -478,12 +478,16 @@
   static size_t Columns(size_t plane, VideoPixelFormat format, int width);
 
   // Given a crypto/hash Hasher, hash in the pixels from a single VideoFrame.
+  // If `visible_data_only` is true only the frame's visible area will be
+  // hashed, if false then the entire coded frame area will be hashed.
   static void UpdateHashWithFrameForTesting(crypto::hash::Hasher& hasher,
-                                            const VideoFrame& frame);
+                                            const VideoFrame& frame,
+                                            bool visible_data_only = true);
 
   // Convenience wrapper around UpdateHashWithFrameForTesting(): produces the
   // SHA-256 hash of a single video frame's pixels, as a lowercase hex string.
-  static std::string HexHashOfFrameForTesting(const VideoFrame& frame);
+  static std::string HexHashOfFrameForTesting(const VideoFrame& frame,
+                                              bool visible_data_only = true);
 
   // Returns true if |frame| is accessible mapped in the VideoFrame memory
   // space.
@@ -627,6 +631,10 @@
   int row_bytes(size_t plane) const;
   int rows(size_t plane) const;
 
+  // Similar to row_bytes() and rows(), but instead refers to the visible area.
+  int GetVisibleRowBytes(size_t plane) const;
+  int GetVisibleRows(size_t plane) const;
+
   // Returns the number of columns for a given plane.
   int columns(size_t plane) const;
 
diff --git a/media/filters/vpx_video_decoder.cc b/media/filters/vpx_video_decoder.cc
index 69481802..55e269d 100644
--- a/media/filters/vpx_video_decoder.cc
+++ b/media/filters/vpx_video_decoder.cc
@@ -620,8 +620,8 @@
     libyuv::CopyPlane(planes[plane], strides[plane],
                       (*video_frame)->GetWritableVisibleData(plane),
                       (*video_frame)->stride(plane),
-                      (*video_frame)->row_bytes(plane),
-                      (*video_frame)->rows(plane));
+                      (*video_frame)->GetVisibleRowBytes(plane),
+                      (*video_frame)->GetVisibleRows(plane));
   }
 
   return true;
diff --git a/third_party/blink/renderer/modules/webcodecs/video_frame.cc b/third_party/blink/renderer/modules/webcodecs/video_frame.cc
index c3d072b..7815456 100644
--- a/third_party/blink/renderer/modules/webcodecs/video_frame.cc
+++ b/third_party/blink/renderer/modules/webcodecs/video_frame.cc
@@ -1035,22 +1035,23 @@
     // Set up the copy to be minimally-sized. Note: The parameters to the
     // CopyPlane() method below depend on coded_size == visible_size.
     gfx::Rect crop = src_visible_rect;
-    gfx::Size dest_coded_size = crop.size();
     gfx::Rect dest_visible_rect = gfx::Rect(crop.size());
 
     // The array buffer hasn't been transferred, we need to allocate and
     // copy pixel data.
     auto& frame_pool = CachedVideoFramePool::From(*execution_context);
-    frame = frame_pool.CreateFrame(media_fmt, dest_coded_size,
+    frame = frame_pool.CreateFrame(media_fmt, /*coded_size=*/crop.size(),
                                    dest_visible_rect, display_size, timestamp);
 
+    // Note: CreateFrame() may expand the coded size for odd sized frames.
+
     if (!frame) {
       exception_state.ThrowDOMException(
           DOMExceptionCode::kOperationError,
           String::Format("Failed to create a VideoFrame with format: %s, "
                          "coded size: %s, visibleRect: %s, display size: %s.",
                          VideoPixelFormatToString(media_fmt).c_str(),
-                         dest_coded_size.ToString().c_str(),
+                         crop.size().ToString().c_str(),
                          dest_visible_rect.ToString().c_str(),
                          display_size.ToString().c_str()));
       return nullptr;
@@ -1059,7 +1060,8 @@
     for (wtf_size_t i = 0; i < media::VideoFrame::NumPlanes(media_fmt); i++) {
       libyuv::CopyPlane(src_frame->visible_data(i), src_frame->stride(i),
                         frame->GetWritableVisibleData(i), frame->stride(i),
-                        frame->row_bytes(i), frame->rows(i));
+                        /*width=*/src_frame->GetVisibleRowBytes(i),
+                        /*height=*/src_frame->GetVisibleRows(i));
     }
   }
 
diff --git a/media/base/video_frame_unittest.cc b/media/base/video_frame_unittest.cc
index 2b906fa..01280fe0 100644
--- a/media/base/video_frame_unittest.cc
+++ b/media/base/video_frame_unittest.cc
@@ -240,7 +240,9 @@
            frame->stride(plane) * frame->rows(plane));
   }
 
-  EXPECT_EQ(VideoFrame::HexHashOfFrameForTesting(*frame.get()), expected_hash);
+  EXPECT_EQ(VideoFrame::HexHashOfFrameForTesting(*frame,
+                                                 /*visible_data_only=*/false),
+            expected_hash);
 }
 
 TEST(VideoFrame, CreateFrame) {
@@ -785,6 +787,75 @@
   EXPECT_EQ(677, frame->coded_size().width());
 }
 
+TEST(VideoFrame, CreateFrame_OddSize) {
+  constexpr gfx::Size odd_size(677, 287);
+  constexpr auto kTimestamp = base::TimeDelta();
+
+  // I420A frames should have the Y, A planes aligned upward to an even size,
+  // since it's required by the subsampled U,V planes.
+  auto frame = VideoFrame::CreateFrame(
+      PIXEL_FORMAT_I420A, odd_size, gfx::Rect(odd_size), odd_size, kTimestamp);
+  ASSERT_TRUE(frame);
+  EXPECT_EQ(gfx::Size(678, 288), frame->coded_size());
+  for (int plane = 0; plane < 4; plane++) {
+    // CreateFrame() will always allocate to an even coded size for non-sampled
+    // planes, so the visible row data should be less than the coded row data.
+    if (plane == VideoFrame::Plane::kY || plane == VideoFrame::Plane::kA) {
+      EXPECT_LT(frame->GetVisibleRowBytes(plane), frame->row_bytes(plane));
+      EXPECT_LT(frame->GetVisibleRows(plane), frame->rows(plane));
+    } else {
+      EXPECT_EQ(frame->GetVisibleRowBytes(plane), frame->row_bytes(plane));
+      EXPECT_EQ(frame->GetVisibleRows(plane), frame->rows(plane));
+    }
+  }
+
+  // I444A frames don't have subsampling, so no adjustment is needed.
+  frame = VideoFrame::CreateFrame(PIXEL_FORMAT_I444A, odd_size,
+                                  gfx::Rect(odd_size), odd_size, kTimestamp);
+  ASSERT_TRUE(frame);
+  EXPECT_EQ(odd_size, frame->coded_size());
+  for (int plane = 0; plane < 4; plane++) {
+    EXPECT_EQ(frame->GetVisibleRowBytes(plane), frame->row_bytes(plane));
+    EXPECT_EQ(frame->GetVisibleRows(plane), frame->rows(plane));
+  }
+}
+
+TEST(VideoFrame, RowBytes) {
+  constexpr gfx::Size kCodedSize(16, 14);
+  constexpr gfx::Rect kVisibleRect(4, 4, 8, 8);
+
+  // Just spot test I420A for simplicity.
+  scoped_refptr<VideoFrame> frame =
+      VideoFrame::CreateFrame(PIXEL_FORMAT_I420A, kCodedSize, kVisibleRect,
+                              kVisibleRect.size(), base::TimeDelta());
+  ASSERT_TRUE(frame);
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kY), kCodedSize.width());
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kU), kCodedSize.width() / 2);
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kV), kCodedSize.width() / 2);
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kA), kCodedSize.width());
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kY), kCodedSize.height());
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kU), kCodedSize.height() / 2);
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kV), kCodedSize.height() / 2);
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kA), kCodedSize.height());
+
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kY),
+            kVisibleRect.width());
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kU),
+            kVisibleRect.width() / 2);
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kV),
+            kVisibleRect.width() / 2);
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kA),
+            kVisibleRect.width());
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kY),
+            kVisibleRect.height());
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kU),
+            kVisibleRect.height() / 2);
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kV),
+            kVisibleRect.height() / 2);
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kA),
+            kVisibleRect.height());
+}
+
 TEST(VideoFrame, AllocationSize_OddSize) {
   const gfx::Size size(3, 5);
 
diff --git a/media/test/pipeline_integration_test.cc b/media/test/pipeline_integration_test.cc
index fb0c86b..a69a08b 100644
--- a/media/test/pipeline_integration_test.cc
+++ b/media/test/pipeline_integration_test.cc
@@ -3035,7 +3035,8 @@
   ASSERT_EQ(PIPELINE_OK, StartPipelineWithHlsManifest("hls/mp_ts_avc1.m3u8"));
   Play();
   ASSERT_TRUE(WaitUntilOnEnded());
-  EXPECT_EQ("00df0aa6796123f535402c39c20baa3fe1c2ad02fe1f015f84878977b30931d2",
+  // 320x192 video of which only 320x180 is visible.
+  EXPECT_EQ("9537d9d2592aa801cff8fceb2af9f6e3c5226df089e16f8f789d43e1fdec7ba2",
             GetVideoHash());
 }
 #endif
diff --git a/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc b/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
index 7fa9293d..15030a2f 100644
--- a/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
+++ b/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
@@ -18,6 +18,7 @@
 #include "third_party/blink/renderer/bindings/modules/v8/v8_union_blob_htmlcanvaselement_htmlimageelement_htmlvideoelement_imagebitmap_imagedata_offscreencanvas_svgimageelement_videoframe.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_union_cssimagevalue_htmlcanvaselement_htmlimageelement_htmlvideoelement_imagebitmap_offscreencanvas_svgimageelement_videoframe.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_decoder_config.h"
+#include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_buffer_init.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_copy_to_options.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_init.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_metadata.h"
@@ -114,6 +115,78 @@
   EXPECT_EQ(nullptr, blink_frame->frame());
 }
 
+TEST_F(VideoFrameTest, ConstructorOddSize) {
+  V8TestingScope scope;
+
+  constexpr auto kOddSize = gfx::Size(61, 21);
+  const auto kOddUVSize = gfx::Size(std::ceil(kOddSize.width() / 2.0),
+                                    std::ceil(kOddSize.height() / 2.0));
+  const size_t allocation_size =
+      kOddSize.Area64() * 2 + kOddUVSize.Area64() * 2;
+
+  auto* array_buffer = DOMArrayBuffer::Create(allocation_size, 1);
+
+  // Fill buffer with random data for hash and extents testing.
+  base::RandBytes(array_buffer->ByteSpan());
+
+  std::string media_frame_hash;
+  {
+    const size_t kYAPlaneByteSize = kOddSize.Area64();
+    const size_t kUVPlaneByteSize = kOddUVSize.Area64();
+    auto src_media_frame = media::VideoFrame::WrapExternalYuvaData(
+        media::PIXEL_FORMAT_I420A, kOddSize, gfx::Rect(kOddSize), kOddSize,
+        kOddSize.width(), kOddUVSize.width(), kOddUVSize.width(),
+        kOddSize.width(), array_buffer->ByteSpan().first(kYAPlaneByteSize),
+        array_buffer->ByteSpan().subspan(kYAPlaneByteSize, kUVPlaneByteSize),
+        array_buffer->ByteSpan().subspan(kYAPlaneByteSize + kUVPlaneByteSize,
+                                         kUVPlaneByteSize),
+        array_buffer->ByteSpan().subspan(
+            kYAPlaneByteSize + kUVPlaneByteSize * 2, kYAPlaneByteSize),
+        base::TimeDelta());
+    ASSERT_TRUE(src_media_frame);
+    media_frame_hash =
+        media::VideoFrame::HexHashOfFrameForTesting(*src_media_frame);
+  }
+
+  auto* init = VideoFrameBufferInit::Create();
+  init->setTimestamp(0);
+  init->setCodedWidth(kOddSize.width());
+  init->setCodedHeight(kOddSize.height());
+  init->setFormat("I420A");
+  init->setDisplayWidth(kOddSize.width());
+  init->setDisplayHeight(kOddSize.height());
+
+  // Test non-transfer constructor first then the transfer constructor.
+  for (bool test_transfer : {false, true}) {
+    SCOPED_TRACE(test_transfer);
+    if (test_transfer) {
+      HeapVector<Member<DOMArrayBuffer>> transfer;
+      transfer.push_back(Member<DOMArrayBuffer>(array_buffer));
+      init->setTransfer(std::move(transfer));
+    }
+
+    VideoFrame* blink_frame = VideoFrame::Create(
+        scope.GetScriptState(),
+        MakeGarbageCollected<V8AllowSharedBufferSource>(array_buffer), init,
+        scope.GetExceptionState());
+    ASSERT_TRUE(blink_frame);
+
+    EXPECT_LE(static_cast<unsigned>(kOddSize.width()),
+              blink_frame->codedWidth());
+    EXPECT_LE(static_cast<unsigned>(kOddSize.height()),
+              blink_frame->codedHeight());
+    EXPECT_EQ(static_cast<unsigned>(kOddSize.width()),
+              blink_frame->displayWidth());
+    EXPECT_EQ(static_cast<unsigned>(kOddSize.height()),
+              blink_frame->displayHeight());
+
+    auto blink_media_frame = blink_frame->frame();
+    EXPECT_EQ(media_frame_hash,
+              media::VideoFrame::HexHashOfFrameForTesting(*blink_media_frame));
+    blink_frame->close();
+  }
+}
+
 TEST_F(VideoFrameTest, CopyToRGB) {
   V8TestingScope scope;
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/media/base/video_frame_unittest.cc b/media/base/video_frame_unittest.cc
index 2b906fa..01280fe0 100644
--- a/media/base/video_frame_unittest.cc
+++ b/media/base/video_frame_unittest.cc
@@ -240,7 +240,9 @@
            frame->stride(plane) * frame->rows(plane));
   }
 
-  EXPECT_EQ(VideoFrame::HexHashOfFrameForTesting(*frame.get()), expected_hash);
+  EXPECT_EQ(VideoFrame::HexHashOfFrameForTesting(*frame,
+                                                 /*visible_data_only=*/false),
+            expected_hash);
 }
 
 TEST(VideoFrame, CreateFrame) {
@@ -785,6 +787,75 @@
   EXPECT_EQ(677, frame->coded_size().width());
 }
 
+TEST(VideoFrame, CreateFrame_OddSize) {
+  constexpr gfx::Size odd_size(677, 287);
+  constexpr auto kTimestamp = base::TimeDelta();
+
+  // I420A frames should have the Y, A planes aligned upward to an even size,
+  // since it's required by the subsampled U,V planes.
+  auto frame = VideoFrame::CreateFrame(
+      PIXEL_FORMAT_I420A, odd_size, gfx::Rect(odd_size), odd_size, kTimestamp);
+  ASSERT_TRUE(frame);
+  EXPECT_EQ(gfx::Size(678, 288), frame->coded_size());
+  for (int plane = 0; plane < 4; plane++) {
+    // CreateFrame() will always allocate to an even coded size for non-sampled
+    // planes, so the visible row data should be less than the coded row data.
+    if (plane == VideoFrame::Plane::kY || plane == VideoFrame::Plane::kA) {
+      EXPECT_LT(frame->GetVisibleRowBytes(plane), frame->row_bytes(plane));
+      EXPECT_LT(frame->GetVisibleRows(plane), frame->rows(plane));
+    } else {
+      EXPECT_EQ(frame->GetVisibleRowBytes(plane), frame->row_bytes(plane));
+      EXPECT_EQ(frame->GetVisibleRows(plane), frame->rows(plane));
+    }
+  }
+
+  // I444A frames don't have subsampling, so no adjustment is needed.
+  frame = VideoFrame::CreateFrame(PIXEL_FORMAT_I444A, odd_size,
+                                  gfx::Rect(odd_size), odd_size, kTimestamp);
+  ASSERT_TRUE(frame);
+  EXPECT_EQ(odd_size, frame->coded_size());
+  for (int plane = 0; plane < 4; plane++) {
+    EXPECT_EQ(frame->GetVisibleRowBytes(plane), frame->row_bytes(plane));
+    EXPECT_EQ(frame->GetVisibleRows(plane), frame->rows(plane));
+  }
+}
+
+TEST(VideoFrame, RowBytes) {
+  constexpr gfx::Size kCodedSize(16, 14);
+  constexpr gfx::Rect kVisibleRect(4, 4, 8, 8);
+
+  // Just spot test I420A for simplicity.
+  scoped_refptr<VideoFrame> frame =
+      VideoFrame::CreateFrame(PIXEL_FORMAT_I420A, kCodedSize, kVisibleRect,
+                              kVisibleRect.size(), base::TimeDelta());
+  ASSERT_TRUE(frame);
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kY), kCodedSize.width());
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kU), kCodedSize.width() / 2);
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kV), kCodedSize.width() / 2);
+  ASSERT_EQ(frame->row_bytes(VideoFrame::Plane::kA), kCodedSize.width());
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kY), kCodedSize.height());
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kU), kCodedSize.height() / 2);
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kV), kCodedSize.height() / 2);
+  ASSERT_EQ(frame->rows(VideoFrame::Plane::kA), kCodedSize.height());
+
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kY),
+            kVisibleRect.width());
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kU),
+            kVisibleRect.width() / 2);
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kV),
+            kVisibleRect.width() / 2);
+  ASSERT_EQ(frame->GetVisibleRowBytes(VideoFrame::Plane::kA),
+            kVisibleRect.width());
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kY),
+            kVisibleRect.height());
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kU),
+            kVisibleRect.height() / 2);
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kV),
+            kVisibleRect.height() / 2);
+  ASSERT_EQ(frame->GetVisibleRows(VideoFrame::Plane::kA),
+            kVisibleRect.height());
+}
+
 TEST(VideoFrame, AllocationSize_OddSize) {
   const gfx::Size size(3, 5);
diff --git a/media/test/pipeline_integration_test.cc b/media/test/pipeline_integration_test.cc
index fb0c86b..a69a08b 100644
--- a/media/test/pipeline_integration_test.cc
+++ b/media/test/pipeline_integration_test.cc
@@ -3035,7 +3035,8 @@
   ASSERT_EQ(PIPELINE_OK, StartPipelineWithHlsManifest("hls/mp_ts_avc1.m3u8"));
   Play();
   ASSERT_TRUE(WaitUntilOnEnded());
-  EXPECT_EQ("00df0aa6796123f535402c39c20baa3fe1c2ad02fe1f015f84878977b30931d2",
+  // 320x192 video of which only 320x180 is visible.
+  EXPECT_EQ("9537d9d2592aa801cff8fceb2af9f6e3c5226df089e16f8f789d43e1fdec7ba2",
             GetVideoHash());
 }
 #endif
diff --git a/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc b/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
index 7fa9293d..15030a2f 100644
--- a/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
+++ b/third_party/blink/renderer/modules/webcodecs/video_frame_test.cc
@@ -18,6 +18,7 @@
 #include "third_party/blink/renderer/bindings/modules/v8/v8_union_blob_htmlcanvaselement_htmlimageelement_htmlvideoelement_imagebitmap_imagedata_offscreencanvas_svgimageelement_videoframe.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_union_cssimagevalue_htmlcanvaselement_htmlimageelement_htmlvideoelement_imagebitmap_offscreencanvas_svgimageelement_videoframe.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_decoder_config.h"
+#include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_buffer_init.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_copy_to_options.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_init.h"
 #include "third_party/blink/renderer/bindings/modules/v8/v8_video_frame_metadata.h"
@@ -114,6 +115,78 @@
   EXPECT_EQ(nullptr, blink_frame->frame());
 }
 
+TEST_F(VideoFrameTest, ConstructorOddSize) {
+  V8TestingScope scope;
+
+  constexpr auto kOddSize = gfx::Size(61, 21);
+  const auto kOddUVSize = gfx::Size(std::ceil(kOddSize.width() / 2.0),
+                                    std::ceil(kOddSize.height() / 2.0));
+  const size_t allocation_size =
+      kOddSize.Area64() * 2 + kOddUVSize.Area64() * 2;
+
+  auto* array_buffer = DOMArrayBuffer::Create(allocation_size, 1);
+
+  // Fill buffer with random data for hash and extents testing.
+  base::RandBytes(array_buffer->ByteSpan());
+
+  std::string media_frame_hash;
+  {
+    const size_t kYAPlaneByteSize = kOddSize.Area64();
+    const size_t kUVPlaneByteSize = kOddUVSize.Area64();
+    auto src_media_frame = media::VideoFrame::WrapExternalYuvaData(
+        media::PIXEL_FORMAT_I420A, kOddSize, gfx::Rect(kOddSize), kOddSize,
+        kOddSize.width(), kOddUVSize.width(), kOddUVSize.width(),
+        kOddSize.width(), array_buffer->ByteSpan().first(kYAPlaneByteSize),
+        array_buffer->ByteSpan().subspan(kYAPlaneByteSize, kUVPlaneByteSize),
+        array_buffer->ByteSpan().subspan(kYAPlaneByteSize + kUVPlaneByteSize,
+                                         kUVPlaneByteSize),
+        array_buffer->ByteSpan().subspan(
+            kYAPlaneByteSize + kUVPlaneByteSize * 2, kYAPlaneByteSize),
+        base::TimeDelta());
+    ASSERT_TRUE(src_media_frame);
+    media_frame_hash =
+        media::VideoFrame::HexHashOfFrameForTesting(*src_media_frame);
+  }
+
+  auto* init = VideoFrameBufferInit::Create();
+  init->setTimestamp(0);
+  init->setCodedWidth(kOddSize.width());
+  init->setCodedHeight(kOddSize.height());
+  init->setFormat("I420A");
+  init->setDisplayWidth(kOddSize.width());
+  init->setDisplayHeight(kOddSize.height());
+
+  // Test non-transfer constructor first then the transfer constructor.
+  for (bool test_transfer : {false, true}) {
+    SCOPED_TRACE(test_transfer);
+    if (test_transfer) {
+      HeapVector<Member<DOMArrayBuffer>> transfer;
+      transfer.push_back(Member<DOMArrayBuffer>(array_buffer));
+      init->setTransfer(std::move(transfer));
+    }
+
+    VideoFrame* blink_frame = VideoFrame::Create(
+        scope.GetScriptState(),
+        MakeGarbageCollected<V8AllowSharedBufferSource>(array_buffer), init,
+        scope.GetExceptionState());
+    ASSERT_TRUE(blink_frame);
+
+    EXPECT_LE(static_cast<unsigned>(kOddSize.width()),
+              blink_frame->codedWidth());
+    EXPECT_LE(static_cast<unsigned>(kOddSize.height()),
+              blink_frame->codedHeight());
+    EXPECT_EQ(static_cast<unsigned>(kOddSize.width()),
+              blink_frame->displayWidth());
+    EXPECT_EQ(static_cast<unsigned>(kOddSize.height()),
+              blink_frame->displayHeight());
+
+    auto blink_media_frame = blink_frame->frame();
+    EXPECT_EQ(media_frame_hash,
+              media::VideoFrame::HexHashOfFrameForTesting(*blink_media_frame));
+    blink_frame->close();
+  }
+}
+
 TEST_F(VideoFrameTest, CopyToRGB) {
   V8TestingScope scope;
Loading diff…

Original Bug Report

reported by [email protected]

WebCodecs VideoFrame constructor crashes browser when non-even width and height are used

Steps to reproduce the problem

Constructing a VideoFrame from array buffer source with non-even width and height crashes the browser. If only one of width and height is non-even it works. Happens only on non-gpu accelerated ARM linux, e.g. AWS Graviton.

This code:

const width = 1139; const height = 641; const buffer = new Uint8Array(2widthheight + 2 * (Math.ceil(width / 2) * Math.ceil(height / 2))); const i420aFrame = new VideoFrame(buffer, { format: ‘I420A’, codedWidth: width, codedHeight: height, displayWidth: width, displayHeight: height, timestamp: 0, });

Potentially reproducible when running Chrome without GPU accelerated video decode on arm linux. Easiest to reproduce with puppeteer on arm linux, try opening this page: https://cache-ssl.celtra.io/api/blobs/3cf5aa9adedb3b14054869474264e73c004c1bbd472aa8ce052abd76fddbaf8a/reproduction.html. It’s the JS code from above, hosted on https so that WebCodecs API is available.

Puppeteer script for reproduction: https://gist.github.com/jkosir/3134cbe9c95df268ebbcda3c9475f03b . Must be run on arm linux, also note that puppeteer install does not support arm linux and chrome binary must be installed with system package manager or obtained in other way.

Problem Description

Constructing a VideoFrame from array buffer source with non-even width and height crashes the browser. If only one of width and height is non-even it works. Happens only on non-gpu accelerated ARM linux, e.g. AWS Graviton. Works as expected in 134.0.6998.165 (and some alter versions, was not able to bisect), reproducible in 138.0.7204.100 and above stable versions.

Stacktrace on crash: Received signal 11 SEGV_ACCERR fbec001c2000 #0 0xbc3885f311e8 base::debug::CollectStackTrace() #1 0xbc3885f20bc0 base::debug::StackTrace::StackTrace() #2 0xbc3885f31094 base::debug::(anonymous namespace)::StackDumpSignalHandler() #3 0xfd2981cfc8f8 ([vdso]+0x8f7) #4 0xbc38868bf3bc CopyRow_NEON #5 0xbc38868a41b0 CopyRow_Any_NEON #6 0xbc388689e8a0 CopyPlane #7 0xbc388987d778 blink::VideoFrame::Create() #8 0xbc38892bab8c blink::(anonymous namespace)::v8_video_frame::ConstructorOverload2() #9 0xbc38839d01ac v8::internal::FunctionCallbackArguments::CallOrConstruct() #10 0xbc38839cfcb0 v8::internal::(anonymous namespace)::HandleApiCallHelper<>() #11 0xbc38839cf5fc v8::internal::Builtin_HandleApiConstruct() #12 0xbc38f7eb30e4 <unknown> [end of stack trace]

I suspect it has something to do with this change in video_frame.cc: https://github.com/chromium/chromium/commit/a4a5b472dbe783f4a668916c28d0aa6c56680ce9. Tried building chromium with older libyuv (that implements the NEON copy) version, as used in 134.0.6998.165, same issue persists.

Summary

WebCodecs VideoFrame constructor crashes browser when non-even width and height are used

Additional Data

Category: JavaScript
Chrome Channel: Stable
Regression: Yes \

View on issue tracker