CVE-2026-17770
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmedia/base/mac/video_frame_mac.mm |
modified | |
formedia/base/mac/video_frame_mac.mm |
modified | |
TESTmedia/base/mac/video_frame_mac_unittest.cc |
modified |
Files Changed
media/base/mac/video_frame_mac.mmmedia/base/mac/video_frame_mac_unittest.cc
Patch
From 487614534deaa920ccf056053c83fcc5f658bc34 Mon Sep 17 00:00:00 2001
From: Dale Curtis <dalecurtis@chromium.org>
Date: Wed, 03 Jun 2026 17:50:56 -0700
Subject: [PATCH] Ensure CVPixelBuffers are only created with full size final rows
This updates our wrapping code to use the full coded data along
with a crop rect instead of using visible_data() which may leave
the last row undersized.
Fixed: 513103345
Change-Id: I2e12093a463b9f56782bd5f98c08b921314a01de
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7849909
Reviewed-by: ccameron chromium <ccameron@chromium.org>
Commit-Queue: Dale Curtis <dalecurtis@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1641352}
---
diff --git a/media/base/mac/video_frame_mac.mm b/media/base/mac/video_frame_mac.mm
index 480a532..958e97aa 100644
--- a/media/base/mac/video_frame_mac.mm
+++ b/media/base/mac/video_frame_mac.mm
@@ -9,7 +9,9 @@
#include <stdint.h>
#include <algorithm>
+#include <vector>
+#include "base/apple/bridging.h"
#include "base/compiler_specific.h"
#include "base/logging.h"
#include "base/strings/sys_string_conversions.h"
@@ -93,8 +95,9 @@
return pixel_buffer;
}
- const gfx::Rect& visible_rect = frame->visible_rect();
- bool crop_needed = visible_rect != gfx::Rect(frame->coded_size());
+ const auto& coded_size = frame->coded_size();
+ const auto& visible_rect = frame->visible_rect();
+ const bool crop_needed = visible_rect != gfx::Rect(coded_size);
if (!crop_needed) {
// If the frame has a mappable SharedImage, yank out its IOSurface if it
@@ -164,18 +167,15 @@
DCHECK_LE(num_planes, kMaxPlanes);
// Build arrays for each plane's data pointer, dimensions and byte alignment.
- void* plane_ptrs[kMaxPlanes];
- size_t plane_widths[kMaxPlanes];
- size_t plane_heights[kMaxPlanes];
- size_t plane_bytes_per_row[kMaxPlanes];
+ std::vector<void*> plane_ptrs(num_planes);
+ std::vector<size_t> plane_widths(num_planes);
+ std::vector<size_t> plane_heights(num_planes);
+ std::vector<size_t> plane_bytes_per_row(num_planes);
for (int plane_i = 0; plane_i < num_planes; ++plane_i) {
- UNSAFE_TODO(plane_ptrs[plane_i]) =
- const_cast<uint8_t*>(frame->visible_data(plane_i));
- gfx::Size plane_size =
- VideoFrame::PlaneSize(video_frame_format, plane_i, visible_rect.size());
- UNSAFE_TODO(plane_widths[plane_i]) = plane_size.width();
- UNSAFE_TODO(plane_heights[plane_i]) = plane_size.height();
- UNSAFE_TODO(plane_bytes_per_row[plane_i]) = frame->stride(plane_i);
+ plane_ptrs[plane_i] = const_cast<uint8_t*>(frame->data(plane_i));
+ plane_widths[plane_i] = frame->columns(plane_i);
+ plane_heights[plane_i] = frame->rows(plane_i);
+ plane_bytes_per_row[plane_i] = frame->stride(plane_i);
}
// CVPixelBufferCreateWithPlanarBytes needs a dummy plane descriptor or the
@@ -188,15 +188,43 @@
// give it a smart pointer to the frame, so instead pass a raw pointer and
// increment the frame's reference count manually.
CVReturn result = CVPixelBufferCreateWithPlanarBytes(
- kCFAllocatorDefault, visible_rect.width(), visible_rect.height(),
- cv_format, descriptor, 0, num_planes, plane_ptrs, plane_widths,
- plane_heights, plane_bytes_per_row, &CvPixelBufferReleaseCallback,
- frame.get(), nullptr, pixel_buffer.InitializeInto());
+ kCFAllocatorDefault, coded_size.width(), coded_size.height(), cv_format,
+ descriptor, 0, num_planes, plane_ptrs.data(), plane_widths.data(),
+ plane_heights.data(), plane_bytes_per_row.data(),
+ &CvPixelBufferReleaseCallback, frame.get(), nullptr,
+ pixel_buffer.InitializeInto());
if (result != kCVReturnSuccess) {
DLOG(ERROR) << " CVPixelBufferCreateWithPlanarBytes failed: " << result;
return base::apple::ScopedCFTypeRef<CVPixelBufferRef>(nullptr);
}
+ // We must guarantee that every row of the CVPixelBuffer has the full stride,
+ // so we can't directly pass visible_data() pointers in. We must instead pass
+ // the full coded data along with the crop rect.
+ if (crop_needed) {
+ // Unlike our visible rect, the clean aperture offsets are relative to the
+ // center of image. There's not a lot of documentation on this calculation,
+ // but see crabby_avifCleanApertureBoxConvertCropRect() for another impl.
+ double horizontal_offset =
+ visible_rect.x() - (coded_size.width() - visible_rect.width()) / 2.0;
+ double vertical_offset =
+ visible_rect.y() - (coded_size.height() - visible_rect.height()) / 2.0;
+ NSDictionary* clean_aperture = @{
+ base::apple::CFToNSPtrCast(kCVImageBufferCleanApertureWidthKey) :
+ @(visible_rect.width()),
+ base::apple::CFToNSPtrCast(kCVImageBufferCleanApertureHeightKey) :
+ @(visible_rect.height()),
+ base::apple::CFToNSPtrCast(
+ kCVImageBufferCleanApertureHorizontalOffsetKey) :
+ @(horizontal_offset),
+ base::apple::CFToNSPtrCast(kCVImageBufferCleanApertureVerticalOffsetKey) :
+ @(vertical_offset)
+ };
+ CVBufferSetAttachment(pixel_buffer.get(), kCVImageBufferCleanApertureKey,
+ base::apple::NSToCFPtrCast(clean_aperture),
+ kCVAttachmentMode_ShouldPropagate);
+ }
+
// The CVPixelBuffer now references the data of the frame, so increment its
// reference count manually. The release callback set on the pixel buffer will
// release the frame.
diff --git a/media/base/mac/video_frame_mac_unittest.cc b/media/base/mac/video_frame_mac_unittest.cc
index 7c046bb2..2cdce776 100644
--- a/media/base/mac/video_frame_mac_unittest.cc
+++ b/media/base/mac/video_frame_mac_unittest.cc
@@ -7,7 +7,9 @@
#include <stddef.h>
#include <utility>
+#include <vector>
+#include "base/apple/foundation_util.h"
#include "base/compiler_specific.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
@@ -128,29 +130,70 @@
}
TEST(VideoFrameMac, CorrectlyWrapsFramesWithPadding) {
- const gfx::Size coded_size(kWidth, kHeight);
- const gfx::Rect visible_rect(kVisibleRectOffset, kVisibleRectOffset,
- kWidth - 2 * kVisibleRectOffset,
- kHeight - 2 * kVisibleRectOffset);
+ const gfx::Size coded_size(kWidth, kHeight); // 64x48
+ const gfx::Rect visible_rect(
+ kVisibleRectOffset, kVisibleRectOffset, kWidth - 2 * kVisibleRectOffset,
+ kHeight - 2 * kVisibleRectOffset); // (8, 8, 48, 32)
auto frame =
VideoFrame::CreateFrame(PIXEL_FORMAT_I420, coded_size, visible_rect,
visible_rect.size(), kTimestamp);
- ASSERT_TRUE(frame.get());
+ ASSERT_TRUE(frame);
FillFrameWithPredictableValues(*frame);
auto pb = WrapVideoFrameInCVPixelBuffer(frame);
ASSERT_TRUE(pb.get());
EXPECT_EQ(kCVPixelFormatType_420YpCbCr8Planar,
CVPixelBufferGetPixelFormatType(pb.get()));
- EXPECT_EQ(visible_rect.width(),
+
+ // 1. CVPixelBuffer should reflect the full CODED size under Approach #2
+ EXPECT_EQ(coded_size.width(),
static_cast<int>(CVPixelBufferGetWidth(pb.get())));
- EXPECT_EQ(visible_rect.height(),
+ EXPECT_EQ(coded_size.height(),
static_cast<int>(CVPixelBufferGetHeight(pb.get())));
+ // 2. Retrieve and verify the Clean Aperture crop dict using base helpers
+ CFDictionaryRef clean_aperture =
+ base::apple::CFCast<CFDictionaryRef>(CVBufferCopyAttachment(
+ pb.get(), kCVImageBufferCleanApertureKey, nullptr));
+ ASSERT_NE(clean_aperture, nullptr);
+
+ // Verify Width (48)
+ double width = 0;
+ CFNumberRef width_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureWidthKey);
+ ASSERT_NE(width_num, nullptr);
+ CFNumberGetValue(width_num, kCFNumberDoubleType, &width);
+ EXPECT_EQ(width, visible_rect.width());
+
+ // Verify Height (32)
+ double height = 0;
+ CFNumberRef height_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureHeightKey);
+ ASSERT_NE(height_num, nullptr);
+ CFNumberGetValue(height_num, kCFNumberDoubleType, &height);
+ EXPECT_EQ(height, visible_rect.height());
+
+ // Verify Horizontal Offset: 8 - (64 - 48) / 2.0 = 0
+ double horiz_off = 0;
+ CFNumberRef horiz_off_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureHorizontalOffsetKey);
+ ASSERT_NE(horiz_off_num, nullptr);
+ CFNumberGetValue(horiz_off_num, kCFNumberDoubleType, &horiz_off);
+ EXPECT_EQ(horiz_off, 0.0);
+
+ // Verify Vertical Offset: 8 - (48 - 32) / 2.0 = 0
+ double vert_off = 0;
+ CFNumberRef vert_off_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureVerticalOffsetKey);
+ ASSERT_NE(vert_off_num, nullptr);
+ CFNumberGetValue(vert_off_num, kCFNumberDoubleType, &vert_off);
Regression Test / PoC
diff --git a/media/base/mac/video_frame_mac_unittest.cc b/media/base/mac/video_frame_mac_unittest.cc
index 7c046bb2..2cdce776 100644
--- a/media/base/mac/video_frame_mac_unittest.cc
+++ b/media/base/mac/video_frame_mac_unittest.cc
@@ -7,7 +7,9 @@
#include <stddef.h>
#include <utility>
+#include <vector>
+#include "base/apple/foundation_util.h"
#include "base/compiler_specific.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
@@ -128,29 +130,70 @@
}
TEST(VideoFrameMac, CorrectlyWrapsFramesWithPadding) {
- const gfx::Size coded_size(kWidth, kHeight);
- const gfx::Rect visible_rect(kVisibleRectOffset, kVisibleRectOffset,
- kWidth - 2 * kVisibleRectOffset,
- kHeight - 2 * kVisibleRectOffset);
+ const gfx::Size coded_size(kWidth, kHeight); // 64x48
+ const gfx::Rect visible_rect(
+ kVisibleRectOffset, kVisibleRectOffset, kWidth - 2 * kVisibleRectOffset,
+ kHeight - 2 * kVisibleRectOffset); // (8, 8, 48, 32)
auto frame =
VideoFrame::CreateFrame(PIXEL_FORMAT_I420, coded_size, visible_rect,
visible_rect.size(), kTimestamp);
- ASSERT_TRUE(frame.get());
+ ASSERT_TRUE(frame);
FillFrameWithPredictableValues(*frame);
auto pb = WrapVideoFrameInCVPixelBuffer(frame);
ASSERT_TRUE(pb.get());
EXPECT_EQ(kCVPixelFormatType_420YpCbCr8Planar,
CVPixelBufferGetPixelFormatType(pb.get()));
- EXPECT_EQ(visible_rect.width(),
+
+ // 1. CVPixelBuffer should reflect the full CODED size under Approach #2
+ EXPECT_EQ(coded_size.width(),
static_cast<int>(CVPixelBufferGetWidth(pb.get())));
- EXPECT_EQ(visible_rect.height(),
+ EXPECT_EQ(coded_size.height(),
static_cast<int>(CVPixelBufferGetHeight(pb.get())));
+ // 2. Retrieve and verify the Clean Aperture crop dict using base helpers
+ CFDictionaryRef clean_aperture =
+ base::apple::CFCast<CFDictionaryRef>(CVBufferCopyAttachment(
+ pb.get(), kCVImageBufferCleanApertureKey, nullptr));
+ ASSERT_NE(clean_aperture, nullptr);
+
+ // Verify Width (48)
+ double width = 0;
+ CFNumberRef width_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureWidthKey);
+ ASSERT_NE(width_num, nullptr);
+ CFNumberGetValue(width_num, kCFNumberDoubleType, &width);
+ EXPECT_EQ(width, visible_rect.width());
+
+ // Verify Height (32)
+ double height = 0;
+ CFNumberRef height_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureHeightKey);
+ ASSERT_NE(height_num, nullptr);
+ CFNumberGetValue(height_num, kCFNumberDoubleType, &height);
+ EXPECT_EQ(height, visible_rect.height());
+
+ // Verify Horizontal Offset: 8 - (64 - 48) / 2.0 = 0
+ double horiz_off = 0;
+ CFNumberRef horiz_off_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureHorizontalOffsetKey);
+ ASSERT_NE(horiz_off_num, nullptr);
+ CFNumberGetValue(horiz_off_num, kCFNumberDoubleType, &horiz_off);
+ EXPECT_EQ(horiz_off, 0.0);
+
+ // Verify Vertical Offset: 8 - (48 - 32) / 2.0 = 0
+ double vert_off = 0;
+ CFNumberRef vert_off_num = base::apple::GetValueFromDictionary<CFNumberRef>(
+ clean_aperture, kCVImageBufferCleanApertureVerticalOffsetKey);
+ ASSERT_NE(vert_off_num, nullptr);
+ CFNumberGetValue(vert_off_num, kCFNumberDoubleType, &vert_off);
+ EXPECT_EQ(vert_off, 0.0);
+
CVPixelBufferLockBaseAddress(pb.get(), 0);
for (size_t i = 0; i < VideoFrame::NumPlanes(frame->format()); ++i) {
+ // 3. Plane dimensions in CVPixelBuffer should reflect the full coded size
const gfx::Size plane_size =
- VideoFrame::PlaneSize(frame->format(), i, visible_rect.size());
+ VideoFrame::PlaneSize(frame->format(), i, coded_size);
EXPECT_EQ(plane_size.width(),
static_cast<int>(CVPixelBufferGetWidthOfPlane(pb.get(), i)));
EXPECT_EQ(plane_size.height(),
@@ -158,17 +201,21 @@
uint8_t* plane_ptr = reinterpret_cast<uint8_t*>(
CVPixelBufferGetBaseAddressOfPlane(pb.get(), i));
- EXPECT_EQ(frame->visible_data(i), plane_ptr);
+
+ // 4. Pointer should match frame->data() instead of frame->visible_data()
+ ASSERT_EQ(frame->data(i), plane_ptr);
+
const size_t stride =
static_cast<size_t>(CVPixelBufferGetBytesPerRowOfPlane(pb.get(), i));
- EXPECT_EQ(frame->stride(i), stride);
- const int offset = kVisibleRectOffset / ((i == 0) ? 1 : 2);
+ ASSERT_EQ(frame->stride(i), stride);
+
+ // 5. Verify pixel contents across the full coded frame
+ auto frame_data = frame->data_span(i);
for (int h = 0; h < plane_size.height(); ++h) {
const int row_index = h * stride;
for (int w = 0; w < plane_size.width(); ++w) {
const int index = row_index + w;
- EXPECT_EQ(static_cast<uint8_t>((w + offset) ^ (h + offset)),
- UNSAFE_TODO(plane_ptr[index]));
+ EXPECT_EQ(static_cast<uint8_t>(w ^ h), frame_data[index]);
}
}
}
Original Bug Report
Potential GPU process OOB read in WrapVideoFrameInCVPixelBuffer on macOS
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic error in WrapVideoFrameInCVPixelBuffer causes an over-advertisement of a video frame’s stride when a frame has a non-zero horizontal offset. This can lead to the macOS VideoToolbox framework performing an out-of-bounds read of the GPU process memory.
Affected files:
media/base/mac/video_frame_mac.mmmedia/base/video_frame.ccmedia/mojo/mojom/video_frame_mojom_traits.ccmedia/mojo/services/mojo_video_encode_accelerator_service.ccmedia/gpu/mac/vt_video_encode_accelerator_mac.mm
Estimated timestamp from git blame: 2016-03-28
Description
A potential memory safety vulnerability exists in the macOS-specific video frame wrapping logic within the GPU process. The function WrapVideoFrameInCVPixelBuffer in media/base/mac/video_frame_mac.mm miscalculates the memory bounds of a CVPixelBuffer when the source VideoFrame has a non-zero X-offset in its visible_rect.
Root Cause Analysis
In media/base/mac/video_frame_mac.mm, WrapVideoFrameInCVPixelBuffer() builds a CVPixelBuffer to pass to the macOS VideoToolbox API. To handle cropped frames, it sets the base address for each plane using frame->visible_data(plane_i), which returns a pointer offset by the visible_rect origin. However, the code continues to use the full coded stride (frame->stride(plane_i)) for the plane_bytes_per_row parameter.
// media/base/mac/video_frame_mac.mm
for (int plane_i = 0; plane_i < num_planes; ++plane_i) {
UNSAFE_TODO(plane_ptrs[plane_i]) =
const_cast<uint8_t*>(frame->visible_data(plane_i));
// ...
UNSAFE_TODO(plane_bytes_per_row[plane_i]) = frame->stride(plane_i); // Potential Vulnerability
}
If a VideoFrame is backed by shared memory and the mapping is sized minimally for the frame’s coded dimensions, this combination of an offset pointer and a full stride causes the system API to believe the buffer extends past the actual end of the mapping.
For example, consider a 1024x1024 frame with a stride of 1024. If the visible_rect starts at x=512, the pointer passed to the OS is BaseAddress + 512. If the visible height is 1024, the OS is told there are 1024 rows, each with a stride of 1024. On the final row, the OS will attempt to read 1024 bytes starting from BaseAddress + 512 + (1023 * 1024). However, the original allocation only extends to BaseAddress + (1024 * 1024). This results in an out-of-bounds read of 512 bytes.
Potential Exploit Scenario
An attacker who has compromised a renderer process could potentially follow these steps:
- Initialize Encoder: Bind to
media::mojom::VideoEncodeAcceleratorProviderand initialize an encoder (e.g.,PIXEL_FORMAT_NV12). - Craft Frame: Create a
VideoFramebacked by a shared memory region sized exactly for the coded dimensions. - Apply Crop: Set the
visible_rectof the frame with a non-zeroxoffset (e.g.,visible_rect = (512, 0, 512, 1024)). - Trigger OOB Read: Send the frame for encoding via Mojo. In the GPU process,
VTVideoEncodeAcceleratorwill callWrapVideoFrameInCVPixelBuffer(), and the resulting over-advertisedCVPixelBufferwill be passed to the system encoder. - Information Leak: If the system encoder reads the over-advertised region, the OOB memory from the GPU process may be encoded into the output video bitstream and returned to the renderer.
Impact
This issue allows an out-of-bounds read in the GPU process. Because the GPU process is shared across origins, a successful exploit could lead to the disclosure of cross-origin data or a process crash. On macOS, the GPU process is sandboxed, but an information leak here could assist in a broader attack chain.
Suggested Fix
In media/base/mac/video_frame_mac.mm, WrapVideoFrameInCVPixelBuffer should be updated to ensure that the combination of the base pointer offset and the advertised stride does not exceed the allocated buffer size. If the visible_rect has a non-zero x offset, the function should either:
- Reduce the advertised
plane_bytes_per_rowif the underlying API allows it while maintaining alignment. - Perform a copy of the visible data into a new, correctly sized buffer before wrapping.
- Reject frames where the horizontal offset would lead to an OOB read when using the coded stride.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.