CVE-2026-7982
Overview
Background
- OpenH264 encoder
- the software H.264 encoder Chromium wraps in
openh264_video_encoder.ccfor WebCodecs/VideoEncoder. - `MAX_MBS_PER_FRAME` / macroblocks
- OpenH264’s internal cap (36864) on the number of 16x16 macroblocks per frame; a frame whose macroblock area exceeds it is rejected internally during preprocessing.
- `NeedsManualResizing`
- a helper deciding whether Chromium must downscale a frame itself before handing it to OpenH264 (e.g. when OpenH264’s own auto-resize preconditions are not met).
- Silent failure
- OpenH264 returns without encoding the oversized frame but leaves the output surface untouched, so whatever uninitialized bytes were in the buffer are treated as encoded output.
Root Cause Analysis
When a frame’s macroblock area exceeds OpenH264’s MAX_MBS_PER_FRAME, OpenH264 silently fails during preprocessing instead of encoding, and the encoder path consumes/emits the resulting output as if encoding had succeeded — causing uninitialized memory to be read and encoded, per the commit message and the regression test comment “didn’t just read uninitialized memory.” The pre-patch code never tested frame area against this macroblock limit: NeedsManualResizing only forced manual resizing for aspect/upscaling reasons, and the configuration validators only rejected frames smaller than 16.
The fix adds IsFrameSizeTooLarge (computing mb_width * mb_height from the 16-pixel-rounded dimensions and comparing to kOpenH264MaxMBs = 36864) and wires it in at three points: NeedsManualResizing now returns true for oversized frames so Chromium downscales them before OpenH264 sees them, and two configuration paths now reject oversized options.frame_size with EncoderStatus::Codes::kEncoderUnsupportedConfig. This works because an oversized frame is either shrunk below the macroblock cap or rejected outright, so OpenH264 never reaches the silent-preprocessing-failure state that leaked uninitialized bytes.
MAX_MBS_PER_FRAME, leaving the output buffer uninitialized; the fix adds an explicit IsFrameSizeTooLarge macroblock check that forces manual downscaling or rejects the configuration before OpenH264 is invoked.Attack Path
- Configure or submit an oversized frame
A page using WebCodecs configures an H.264
VideoEncoderwith, or feeds it, a frame whose area exceeds 36864 macroblocks (the test uses 4096x4096 against a 1024x1024 configured size). - Bypass existing checks The frame passes the only pre-patch guards (dimensions >= 16 and OpenH264 auto-resize eligibility), since no macroblock-area limit was enforced.
- Silent preprocessing failure OpenH264 aborts preprocessing without encoding, leaving the output buffer with uninitialized contents.
- Uninitialized memory encoded out The encoder emits that output as a coded frame, so uninitialized process memory is read and encoded into the output bitstream, which the application can read back (e.g. by decoding the produced buffer).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Pmedia/video/software_video_encoder_test.cc |
modified | |
ifmedia/video/software_video_encoder_test.cc |
modified | |
BindLambdaForTestingmedia/video/software_video_encoder_test.cc |
modified |
Files Changed
media/video/openh264_video_encoder.ccmedia/video/software_video_encoder_test.cc
Audit Directions
- Codec library silent-failure contractsAudit every wrapper around a third-party codec for input limits (macroblock/pixel/dimension caps) that the library enforces by silently no-opping rather than returning an error, then reading the untouched output buffer anyway.
- Output-buffer initialization on error pathsReview encoder/decoder paths that emit a buffer whenever the library “returns,” confirming the buffer is only emitted when the library actually wrote it.
- Dimension validation completenessWhere only minimum dimensions are validated, add the corresponding maximum/area checks, since asymmetric validation (min-only) is a recurring gap.
Patch
From 088809721d6dcbae6d63e23b28873ae96243c45e Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <eugene@chromium.org>
Date: Wed, 01 Apr 2026 12:48:27 -0700
Subject: [PATCH] media: Fix OpenH264 encoder silent failure on oversized frames
OpenH264 silently fails during preprocessing when a frame's area
exceeds its internal macroblock limit (MAX_MBS_PER_FRAME). This
can result in the encoder reading and encoding uninitialized memory.
This change updates NeedsManualResizing() to force manual downscaling
for frames that exceed this limit before passing them to OpenH264.
Bug: 497952533
Change-Id: I5e042ac380ee79be5a7a6e515ccc88948cf19c26
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7718887
Commit-Queue: Eugene Zemtsov <eugene@chromium.org>
Reviewed-by: Dale Curtis <dalecurtis@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608727}
---
diff --git a/media/video/openh264_video_encoder.cc b/media/video/openh264_video_encoder.cc
index c29a9fce..f83ab4a 100644
--- a/media/video/openh264_video_encoder.cc
+++ b/media/video/openh264_video_encoder.cc
@@ -148,6 +148,18 @@
}
}
+// OpenH264 silently fails during preprocessing when a frame's area
+// exceeds its internal macroblock limit (MAX_MBS_PER_FRAME in
+// third_party/openh264). We must manually resize such frames.
+// MAX_MBS_PER_FRAME is 36864.
+constexpr int kOpenH264MaxMBs = 36864;
+
+bool IsFrameSizeTooLarge(const gfx::Size& frame_size) {
+ int mb_width = (frame_size.width() + 15) / 16;
+ int mb_height = (frame_size.height() + 15) / 16;
+ return mb_width * mb_height > kOpenH264MaxMBs;
+}
+
// OpenH264 can resize frames automatically as long as
// - the input and output aspect ratios are the same and
// - the input is larger than the output in both dimensions.
@@ -156,6 +168,10 @@
return true;
}
+ if (IsFrameSizeTooLarge(src)) {
+ return true;
+ }
+
if (dst.width() > src.width() || dst.height() > src.height()) {
return true;
}
@@ -249,6 +265,14 @@
"Unsupported frame size which is less than 16"));
return;
}
+
+ if (IsFrameSizeTooLarge(options.frame_size)) {
+ std::move(done_cb).Run(EncoderStatus(
+ EncoderStatus::Codes::kEncoderUnsupportedConfig,
+ "Configured frame size exceeds OpenH264 max macroblocks"));
+ return;
+ }
+
SetUpOpenH264Params(
profile_, options,
VideoColorSpace::FromGfxColorSpace(last_frame_color_space_), ¶ms);
@@ -514,6 +538,20 @@
return;
}
+ if (options.frame_size.width() < 16 || options.frame_size.height() < 16) {
+ std::move(done_cb).Run(
+ EncoderStatus(EncoderStatus::Codes::kEncoderUnsupportedConfig,
+ "Unsupported frame size which is less than 16"));
+ return;
+ }
+
+ if (IsFrameSizeTooLarge(options.frame_size)) {
+ std::move(done_cb).Run(EncoderStatus(
+ EncoderStatus::Codes::kEncoderUnsupportedConfig,
+ "Configured frame size exceeds OpenH264 max macroblocks"));
+ return;
+ }
+
SEncParamExt params = {};
if (int err = codec_->GetDefaultParams(¶ms)) {
std::move(done_cb).Run(
diff --git a/media/video/software_video_encoder_test.cc b/media/video/software_video_encoder_test.cc
index 3e879b8..fc86111f 100644
--- a/media/video/software_video_encoder_test.cc
+++ b/media/video/software_video_encoder_test.cc
@@ -1372,6 +1372,79 @@
EXPECT_EQ(outputs_count, 3);
}
+TEST_P(H264VideoEncoderTest, OversizedFrameSilentFailure) {
+ VideoEncoder::Options options = CreateDefaultOptions();
+ options.frame_size = gfx::Size(1024, 1024);
+ options.bitrate = Bitrate::ConstantBitrate(1000000u);
+ options.framerate = 25;
+ if (codec_ == VideoCodec::kH264) {
+ options.avc.produce_annexb = true;
+ }
+
+ int total_decoded_frames = 0;
+
+ scoped_refptr<VideoFrame> reference_frame;
+
+ VideoEncoder::OutputCB encoder_output_cb = base::BindLambdaForTesting(
+ [&, this](VideoEncoderOutput output,
+ std::optional<VideoEncoder::CodecDescription> desc) {
+ auto buffer = DecoderBuffer::FromArray(std::move(output.data));
+ buffer->set_timestamp(output.timestamp);
+ buffer->set_is_key_frame(output.key_frame);
+ decoder_->Decode(std::move(buffer), DecoderStatusCB());
+ });
+
+ VideoDecoder::OutputCB decoder_output_cb =
+ base::BindLambdaForTesting([&](scoped_refptr<VideoFrame> decoded_frame) {
+ ASSERT_TRUE(reference_frame);
+
+ EXPECT_EQ(decoded_frame->timestamp(), reference_frame->timestamp());
+ EXPECT_EQ(decoded_frame->visible_rect().size(),
+ reference_frame->visible_rect().size());
+
+ // This validates that the encoder actually encoded our input frame,
+ // and didn't just read uninitialized memory.
+ if (decoded_frame->format() == reference_frame->format()) {
+ // Allow up to 1% of pixels to be different due to compression
+ // artifacts.
+ const int kAllowedDifferentPixels =
+ reference_frame->visible_rect().size().GetArea() / 100;
+ EXPECT_LE(CountDifferentPixels(*decoded_frame, *reference_frame, 10),
+ kAllowedDifferentPixels);
+ }
+ ++total_decoded_frames;
+ });
+
+ PrepareDecoder(options.frame_size, std::move(decoder_output_cb));
+
+ encoder_->Initialize(profile_, options, /*info_cb=*/base::DoNothing(),
+ std::move(encoder_output_cb),
+ ValidateStatusThenQuitCB());
+ RunUntilQuit();
+
+ // Create frames with identical colors but vastly different sizes.
+ constexpr uint32_t kXorMask = 0x123456;
+ auto oversized_frame = CreateFrame(gfx::Size(4096, 4096), pixel_format_,
+ base::TimeDelta(), kXorMask);
+ ASSERT_TRUE(oversized_frame) << "Failed to allocate oversized frame";
+ oversized_frame->set_color_space(gfx::ColorSpace::CreateREC709());
+
+ reference_frame = CreateFrame(options.frame_size, pixel_format_,
+ base::TimeDelta(), kXorMask);
+ ASSERT_TRUE(reference_frame) << "Failed to allocate reference frame";
+
+ encoder_->Encode(std::move(oversized_frame),
+ VideoEncoder::EncodeOptions(false),
+ ValidateStatusThenQuitCB());
+ RunUntilQuit();
+
+ encoder_->Flush(ValidateStatusThenQuitCB());
+ RunUntilQuit();
+ DecodeAndWaitForStatus(DecoderBuffer::CreateEOSBuffer());
+
+ EXPECT_EQ(total_decoded_frames, 1);
+}
+
TEST_P(H264VideoEncoderTest, AnnexB) {
int outputs_count = 0;
VideoEncoder::Options options = CreateDefaultOptions();
Regression Test / PoC
diff --git a/media/video/software_video_encoder_test.cc b/media/video/software_video_encoder_test.cc
index 3e879b8..fc86111f 100644
--- a/media/video/software_video_encoder_test.cc
+++ b/media/video/software_video_encoder_test.cc
@@ -1372,6 +1372,79 @@
EXPECT_EQ(outputs_count, 3);
}
+TEST_P(H264VideoEncoderTest, OversizedFrameSilentFailure) {
+ VideoEncoder::Options options = CreateDefaultOptions();
+ options.frame_size = gfx::Size(1024, 1024);
+ options.bitrate = Bitrate::ConstantBitrate(1000000u);
+ options.framerate = 25;
+ if (codec_ == VideoCodec::kH264) {
+ options.avc.produce_annexb = true;
+ }
+
+ int total_decoded_frames = 0;
+
+ scoped_refptr<VideoFrame> reference_frame;
+
+ VideoEncoder::OutputCB encoder_output_cb = base::BindLambdaForTesting(
+ [&, this](VideoEncoderOutput output,
+ std::optional<VideoEncoder::CodecDescription> desc) {
+ auto buffer = DecoderBuffer::FromArray(std::move(output.data));
+ buffer->set_timestamp(output.timestamp);
+ buffer->set_is_key_frame(output.key_frame);
+ decoder_->Decode(std::move(buffer), DecoderStatusCB());
+ });
+
+ VideoDecoder::OutputCB decoder_output_cb =
+ base::BindLambdaForTesting([&](scoped_refptr<VideoFrame> decoded_frame) {
+ ASSERT_TRUE(reference_frame);
+
+ EXPECT_EQ(decoded_frame->timestamp(), reference_frame->timestamp());
+ EXPECT_EQ(decoded_frame->visible_rect().size(),
+ reference_frame->visible_rect().size());
+
+ // This validates that the encoder actually encoded our input frame,
+ // and didn't just read uninitialized memory.
+ if (decoded_frame->format() == reference_frame->format()) {
+ // Allow up to 1% of pixels to be different due to compression
+ // artifacts.
+ const int kAllowedDifferentPixels =
+ reference_frame->visible_rect().size().GetArea() / 100;
+ EXPECT_LE(CountDifferentPixels(*decoded_frame, *reference_frame, 10),
+ kAllowedDifferentPixels);
+ }
+ ++total_decoded_frames;
+ });
+
+ PrepareDecoder(options.frame_size, std::move(decoder_output_cb));
+
+ encoder_->Initialize(profile_, options, /*info_cb=*/base::DoNothing(),
+ std::move(encoder_output_cb),
+ ValidateStatusThenQuitCB());
+ RunUntilQuit();
+
+ // Create frames with identical colors but vastly different sizes.
+ constexpr uint32_t kXorMask = 0x123456;
+ auto oversized_frame = CreateFrame(gfx::Size(4096, 4096), pixel_format_,
+ base::TimeDelta(), kXorMask);
+ ASSERT_TRUE(oversized_frame) << "Failed to allocate oversized frame";
+ oversized_frame->set_color_space(gfx::ColorSpace::CreateREC709());
+
+ reference_frame = CreateFrame(options.frame_size, pixel_format_,
+ base::TimeDelta(), kXorMask);
+ ASSERT_TRUE(reference_frame) << "Failed to allocate reference frame";
+
+ encoder_->Encode(std::move(oversized_frame),
+ VideoEncoder::EncodeOptions(false),
+ ValidateStatusThenQuitCB());
+ RunUntilQuit();
+
+ encoder_->Flush(ValidateStatusThenQuitCB());
+ RunUntilQuit();
+ DecodeAndWaitForStatus(DecoderBuffer::CreateEOSBuffer());
+
+ EXPECT_EQ(total_decoded_frames, 1);
+}
+
TEST_P(H264VideoEncoderTest, AnnexB) {
int outputs_count = 0;
VideoEncoder::Options options = CreateDefaultOptions();
Original Bug Report
Uninitialized heap memory disclosure in OpenH264 encoder via oversized frames
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A vulnerability in Chromium’s OpenH264VideoEncoder allows oversized VideoFrames to bypass manual resizing logic when using WebCodecs. This causes silent failures in OpenH264’s preprocessing stage, resulting in the encoder compressing uninitialized heap memory into a valid H.264 bitstream. An attacker can decode this bitstream to systematically leak renderer heap contents, defeating ASLR.
Affected files:
media/video/openh264_video_encoder.ccthird_party/openh264/src/codec/encoder/core/src/wels_preprocess.cppthird_party/openh264/src/codec/processing/src/common/WelsFrameWork.cppthird_party/openh264/src/codec/encoder/core/src/picture_handle.cppthird_party/openh264/src/codec/common/src/memory_align.cppthird_party/openh264/src/codec/encoder/core/src/encoder_ext.cpp
Estimated timestamp from git blame: 2026-03-12
Summary
A critical information disclosure vulnerability exists in the integration between Chromium’s WebCodecs API and the software OpenH264 encoder. By providing an oversized VideoFrame that maintains the exact aspect ratio of the configured target resolution, an attacker can bypass Chromium’s manual resizing checks. The oversized frame is passed directly to OpenH264, which silently fails during preprocessing due to internal maximum macroblock limits. Consequently, the encoder uses an uninitialized heap buffer allocated via malloc as the source picture for compression. The resulting H.264 bitstream, containing raw renderer heap data, is then delivered to the calling JavaScript context.
Root Cause Analysis
1. Chromium Resizing Bypass (media/video/openh264_video_encoder.cc)
The Encode() method determines if a frame requires manual resizing before being passed to OpenH264 by calling NeedsManualResizing(). This function contains a logical flaw:
// media/video/openh264_video_encoder.cc:154-165
bool NeedsManualResizing(const gfx::Size& src, const gfx::Size& dst) {
// ...
if (dst.width() > src.width() || dst.height() > src.height()) {
return true;
}
return VideoAspectRatio::PAR(src.width(), src.height()) !=
VideoAspectRatio::PAR(dst.width(), dst.height());
}
If an attacker configures the encoder for a 1024x1024 resolution (dst) and provides a 4096x4096 VideoFrame (src), dst > src evaluates to false (bypassing the upscaling check), and the aspect ratios match perfectly (1:1). The function incorrectly returns false, causing the oversized frame to be passed directly to codec_->EncodeFrame() without being downscaled.
2. OpenH264 Silent Preprocessing Failures (third_party/openh264/...)
When OpenH264 receives the oversized 4096x4096 frame, it detects a dimension mismatch against its 1024x1024 configuration and triggers a preprocessing reset (WelsPreprocessReset).
- Uninitialized Allocation:
WelsInitScaledPicallocates a newpScaledInputPicturebuffer usingWelsMalloc(memory_align.cpp:72). Crucially,WelsMallocwraps the standard Cmallocfunction and does not zero-initialize the memory. The buffer contains raw heap data. - Silent Failure in Memory Copy:
SingleLayerPreprocesscallsWelsMoveMemoryWrapper(wels_preprocess.cpp:383) to copy the oversized input frame into the pre-processing pipeline. InsideWelsMoveMemoryWrapper(wels_preprocess.cpp:1425), a safety check evaluates:if (iSrcWidth * iSrcHeight > (MAX_MBS_PER_FRAME << 8)) return;. The total pixels (4096 * 4096 = 16,777,216) exceed the maximum threshold (36864 * 256 = 9,437,184). The function silently returnsvoidearly without copying any pixel data. - Silent Error in Downsampling:
SingleLayerPreprocessthen attempts to downsample the frame by callingDownsamplePadding(wels_preprocess.cpp:398). This invokesm_pInterfaceVp->Process, which maps toCVpFrameWork::Process(WelsFrameWork.cpp:169). This function callsCheckValid, which evaluates a similar pixel count limit check:if ((iRectWidth * iRectHeight) > (MAX_MBS_PER_FRAME << 8)) return RET_INVALIDPARAM;. This check also fails for the 4096x4096 dimensions, returning the errorRET_INVALIDPARAMback toDownsamplePadding. - Error Discarded:
DownsamplePaddingreturns this error code, butSingleLayerPreprocessexplicitly discards it (wels_preprocess.cpp:398).
3. Data Leakage
As a result of both preprocessing steps failing silently, the output spatial layer destination buffer (pDstPic) remains completely uninitialized. SingleLayerPreprocess assigns this uninitialized buffer as the spatial layer’s source (pSrc). The core encoder (WelsCodeOneSlice) then compresses this raw heap data as if it were valid YUV pixel data. The resulting H.264 bitstream is passed back to the attacker’s JavaScript via the WebCodecs EncodedVideoChunk output callback.
By systematically alternating the colorSpace property of the VideoFrame between encode calls, an attacker can force media::OpenH264VideoEncoder::Encode to call UpdateEncoderColorSpace() (openh264_video_encoder.cc:464). This triggers a full OpenH264 codec re-initialization and fresh WelsMalloc allocations, allowing the attacker to continuously leak new, distinct regions of the renderer heap.
Suggested Exploitation Steps
(Note: These are potential steps based on code analysis; a working PoC has not been executed yet.)
- In a malicious web page, instantiate a
VideoEncoderusing the WebCodecs API, forcing the OpenH264 software implementation. - Configure the encoder with a standard target resolution, e.g., 1024x1024.
- Create a
VideoFramewith dimensions significantly larger than the configuration (e.g., 4096x4096) that maintains the exact same aspect ratio. - Call
encode()on the oversized frame. - In the
outputcallback, receive theEncodedVideoChunkcontaining the H.264 bitstream. - Decode the bitstream using a standard H.264 decoder to retrieve the uninitialized renderer heap memory (pixel data).
- To leak continuous memory regions, alternate the
colorSpaceof the inputVideoFramebetween encodes to force codec re-initialization.
Suggested Fix
- Chromium: Update
NeedsManualResizinginmedia/video/openh264_video_encoder.ccto ensure that frames larger than the target resolution in either dimension (downscaling) are manually resized if OpenH264’s internal downscaler is deemed unsafe for extremely large frames. Alternatively, add explicit bounds checking (e.g., againstMAX_MBS_PER_FRAME) before passing frames toEncodeFrame(). - OpenH264:
- Modify
WelsMallocto usecallocor explicitlymemsetthe allocated memory to zero to prevent uninitialized memory disclosures. - Update
SingleLayerPreprocessinwels_preprocess.cppto properly handle and propagate the error codes returned byDownsamplePaddingandWelsMoveMemoryWrapperinstead of silently ignoring them.
- Modify
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.