CVE-2026-87638
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmedia/gpu/h265_decoder.cc |
modified |
Files Changed
media/gpu/h265_decoder.ccmedia/gpu/h265_decoder.hmedia/gpu/h265_decoder_unittest.ccmedia/parsers/h265_nalu_parser.hmedia/parsers/h265_parser.cc
Patch
From 2d157885607bfcd825e0c211ea81b8b7d9d7e73e Mon Sep 17 00:00:00 2001 From: Eugene Zemtsov <[email protected]> Date: Mon, 03 Aug 2026 18:40:29 -0700 Subject: [PATCH] media: Reject all non-IRAP H.265 SPS configuration changes H265Decoder::ProcessPPS checked only a subset of SPS fields when detecting configuration changes, omitting parameters like CTB size (ctb_log2_size_y). Per HEVC spec, SPS parameters must not change within a sequence (on non-IRAP pictures). This change updates H265Decoder to store the active H265SPS and use all its fields for config change detection, ensuring any mid-sequence SPS modification is rejected. Bug: 540024134 Change-Id: I26c70eb67eb24148bbfd0e39067b7b6933d7ae87 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8176990 Commit-Queue: Eugene Zemtsov <[email protected]> Reviewed-by: Qiu, Jianlin <[email protected]> Reviewed-by: Dale Curtis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1673024} --- diff --git a/media/gpu/h265_decoder.cc b/media/gpu/h265_decoder.cc index 750a4ad..9aab489 100644 --- a/media/gpu/h265_decoder.cc +++ b/media/gpu/h265_decoder.cc @@ -202,6 +202,7 @@ parser_.Reset(); accelerator_->Reset(); + active_sps_.reset(); decoder_buffer_.reset(); secure_handle_ = 0; @@ -654,10 +655,15 @@ new_color_space != picture_color_space_; } - const bool is_config_change = - pic_size_ != new_pic_size || dpb_.max_num_pics() != sps->max_dpb_size || - profile_ != new_profile || bit_depth_ != new_bit_depth || - chroma_sampling_ != new_chroma_sampling; + bool is_config_change = false; + if (parser_.validate_extended_bitstream()) { + is_config_change = !active_sps_ || *active_sps_ != *sps; + } else { + is_config_change = pic_size_ != new_pic_size || + dpb_.max_num_pics() != sps->max_dpb_size || + profile_ != new_profile || bit_depth_ != new_bit_depth || + chroma_sampling_ != new_chroma_sampling; + } if (is_config_change) { // Only color space changes are allowed on non-IRAP pictures. @@ -681,6 +687,7 @@ << VideoChromaSamplingToString(new_chroma_sampling); profile_ = new_profile; bit_depth_ = new_bit_depth; + active_sps_ = std::make_unique<H265SPS>(*sps); pic_size_ = new_pic_size; chroma_sampling_ = new_chroma_sampling; picture_color_space_ = new_color_space; diff --git a/media/gpu/h265_decoder.h b/media/gpu/h265_decoder.h index 981663c..24c4961f 100644 --- a/media/gpu/h265_decoder.h +++ b/media/gpu/h265_decoder.h @@ -388,6 +388,8 @@ VideoCodecProfile profile_; // Bit depth of input bitstream. uint8_t bit_depth_ = 0; + // Active SPS of input bitstream. + std::unique_ptr<H265SPS> active_sps_; // Chroma sampling format of input bitstream VideoChromaSampling chroma_sampling_ = VideoChromaSampling::kUnknown; // Video color space of input bitstream. diff --git a/media/gpu/h265_decoder_unittest.cc b/media/gpu/h265_decoder_unittest.cc index 103981e6..68c549e 100644 --- a/media/gpu/h265_decoder_unittest.cc +++ b/media/gpu/h265_decoder_unittest.cc @@ -576,36 +576,70 @@ EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1); EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1); EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode()); - - // 2. Inject 10-bit SPS/PPS followed by an 8-bit P-frame (non-IRAP). - std::vector<uint8_t> ten_bit_sps_pps; - auto ten_bit_file = GetTestDataFilePath(k10BitFrame0); - std::vector<uint8_t> ten_bit_data; - CHECK(base::OptionalUnwrapTo(base::ReadFileToBytes(ten_bit_file), - ten_bit_data)); - // NALU at 0, type 32 (VPS) - // NALU at 28, type 33 (SPS) - // NALU at 73, type 34 (PPS) - // NALU at 84, type 39 (SEI) - // We want VPS + SPS + PPS. - base::Extend(ten_bit_sps_pps, base::span(ten_bit_data).first(84u)); + EXPECT_TRUE(decoder_->Flush()); std::vector<uint8_t> p_frame_data; auto p_frame_file = GetTestDataFilePath(kFrame1); CHECK(base::OptionalUnwrapTo(base::ReadFileToBytes(p_frame_file), p_frame_data)); - std::vector<uint8_t> malicious_bitstream = ten_bit_sps_pps; - base::Extend(malicious_bitstream, p_frame_data); + // 2. Inject 10-bit SPS/PPS (bit-depth config change) followed by P-frame + // (non-IRAP). + std::vector<uint8_t> ten_bit_sps_pps; + auto ten_bit_file = GetTestDataFilePath(k10BitFrame0); + std::vector<uint8_t> ten_bit_data; + CHECK(base::OptionalUnwrapTo(base::ReadFileToBytes(ten_bit_file), + ten_bit_data)); + base::Extend(ten_bit_sps_pps, base::span(ten_bit_data).first(84u)); - auto buffer = DecoderBuffer::CopyFrom(malicious_bitstream); + std::vector<uint8_t> bit_depth_bitstream = ten_bit_sps_pps; + base::Extend(bit_depth_bitstream, p_frame_data); + + auto buffer1 = DecoderBuffer::CopyFrom(bit_depth_bitstream); EXPECT_CALL(*accelerator_, SetStream(_, _)); - decoder_->SetStream(1, buffer); + decoder_->SetStream(1, buffer1); - // 3. Verify that ConfigChange is NOT allowed on the P-frame. + // Verify bit-depth ConfigChange is NOT allowed on P-frame. EXPECT_EQ(AcceleratedVideoDecoder::kDecodeError, decoder_->Decode()); - EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize()); - EXPECT_EQ(8u, decoder_->GetBitDepth()); + + // Reset decoder state so we can test a second invalid config change stream. + decoder_->Reset(); + + // 3. Inject SPS with modified CTB size (CTB log size config change) followed + // by P-frame (non-IRAP). + H26xAnnexBBitstreamBuilder builder; + H265SPS sps = {}; + sps.sps_video_parameter_set_id = 0; + sps.sps_max_sub_layers_minus1 = 0; + sps.sps_temporal_id_nesting_flag = true; + sps.profile_tier_level.general_profile_idc = 1; + sps.profile_tier_level.general_level_idc = 120; + sps.sps_seq_parameter_set_id = 0; + sps.chroma_format_idc = 1; + sps.pic_width_in_luma_samples = 320; + sps.pic_height_in_luma_samples = 184; + sps.log2_min_luma_coding_block_size_minus3 = 1; // Changed CTB log size + sps.log2_diff_max_min_luma_coding_block_size = 1; + sps.log2_min_luma_transform_block_size_minus2 = 0; + sps.log2_diff_max_min_luma_transform_block_size = 0; + sps.max_transform_hierarchy_depth_inter = 0; + sps.max_transform_hierarchy_depth_intra = 0; + sps.log2_max_pic_order_cnt_lsb_minus4 = 4; + sps.sps_max_dec_pic_buffering_minus1[0] = 1; + + BuildPackedH265SPS(builder, sps); + builder.Flush(); + + std::vector<uint8_t> ctb_bitstream(builder.data().begin(), + builder.data().end()); + base::Extend(ctb_bitstream, p_frame_data); + + auto buffer2 = DecoderBuffer::CopyFrom(ctb_bitstream); + EXPECT_CALL(*accelerator_, SetStream(_, _)); + decoder_->SetStream(2, buffer2); + + // Verify CTB log size ConfigChange is NOT allowed on P-frame. + EXPECT_EQ(AcceleratedVideoDecoder::kDecodeError, decoder_->Decode()); EXPECT_TRUE(decoder_->Flush()); } diff --git a/media/parsers/h265_nalu_parser.h b/media/parsers/h265_nalu_parser.h index 5bef60a..dc2b9a2 100644 --- a/media/parsers/h265_nalu_parser.h +++ b/media/parsers/h265_nalu_parser.h @@ -146,6 +146,10 @@ // from AdvanceToNextNALU(). std::vector<SubsampleEntry> GetCurrentSubsamples(); + bool validate_extended_bitstream() const { + return validate_extended_bitstream_; + } + protected: H264BitReader br_; diff --git a/media/parsers/h265_parser.cc b/media/parsers/h265_parser.cc index 8d65703..7f505c7 100644 --- a/media/parsers/h265_parser.cc +++ b/media/parsers/h265_parser.cc @@ -193,14 +193,20 @@ } H265SPS::H265SPS() = default; - +H265SPS::H265SPS(const H265SPS&) = default; +H265SPS& H265SPS::operator=(const H265SPS&) = default; H265SPS::H265SPS(H265SPS&&) noexcept = default;
Regression Test / PoC
diff --git a/media/gpu/h265_decoder_unittest.cc b/media/gpu/h265_decoder_unittest.cc
index 103981e6..68c549e 100644
--- a/media/gpu/h265_decoder_unittest.cc
+++ b/media/gpu/h265_decoder_unittest.cc
@@ -576,36 +576,70 @@
EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
-
- // 2. Inject 10-bit SPS/PPS followed by an 8-bit P-frame (non-IRAP).
- std::vector<uint8_t> ten_bit_sps_pps;
- auto ten_bit_file = GetTestDataFilePath(k10BitFrame0);
- std::vector<uint8_t> ten_bit_data;
- CHECK(base::OptionalUnwrapTo(base::ReadFileToBytes(ten_bit_file),
- ten_bit_data));
- // NALU at 0, type 32 (VPS)
- // NALU at 28, type 33 (SPS)
- // NALU at 73, type 34 (PPS)
- // NALU at 84, type 39 (SEI)
- // We want VPS + SPS + PPS.
- base::Extend(ten_bit_sps_pps, base::span(ten_bit_data).first(84u));
+ EXPECT_TRUE(decoder_->Flush());
std::vector<uint8_t> p_frame_data;
auto p_frame_file = GetTestDataFilePath(kFrame1);
CHECK(base::OptionalUnwrapTo(base::ReadFileToBytes(p_frame_file),
p_frame_data));
- std::vector<uint8_t> malicious_bitstream = ten_bit_sps_pps;
- base::Extend(malicious_bitstream, p_frame_data);
+ // 2. Inject 10-bit SPS/PPS (bit-depth config change) followed by P-frame
+ // (non-IRAP).
+ std::vector<uint8_t> ten_bit_sps_pps;
+ auto ten_bit_file = GetTestDataFilePath(k10BitFrame0);
+ std::vector<uint8_t> ten_bit_data;
+ CHECK(base::OptionalUnwrapTo(base::ReadFileToBytes(ten_bit_file),
+ ten_bit_data));
+ base::Extend(ten_bit_sps_pps, base::span(ten_bit_data).first(84u));
- auto buffer = DecoderBuffer::CopyFrom(malicious_bitstream);
+ std::vector<uint8_t> bit_depth_bitstream = ten_bit_sps_pps;
+ base::Extend(bit_depth_bitstream, p_frame_data);
+
+ auto buffer1 = DecoderBuffer::CopyFrom(bit_depth_bitstream);
EXPECT_CALL(*accelerator_, SetStream(_, _));
- decoder_->SetStream(1, buffer);
+ decoder_->SetStream(1, buffer1);
- // 3. Verify that ConfigChange is NOT allowed on the P-frame.
+ // Verify bit-depth ConfigChange is NOT allowed on P-frame.
EXPECT_EQ(AcceleratedVideoDecoder::kDecodeError, decoder_->Decode());
- EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
- EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+ // Reset decoder state so we can test a second invalid config change stream.
+ decoder_->Reset();
+
+ // 3. Inject SPS with modified CTB size (CTB log size config change) followed
+ // by P-frame (non-IRAP).
+ H26xAnnexBBitstreamBuilder builder;
+ H265SPS sps = {};
+ sps.sps_video_parameter_set_id = 0;
+ sps.sps_max_sub_layers_minus1 = 0;
+ sps.sps_temporal_id_nesting_flag = true;
+ sps.profile_tier_level.general_profile_idc = 1;
+ sps.profile_tier_level.general_level_idc = 120;
+ sps.sps_seq_parameter_set_id = 0;
+ sps.chroma_format_idc = 1;
+ sps.pic_width_in_luma_samples = 320;
+ sps.pic_height_in_luma_samples = 184;
+ sps.log2_min_luma_coding_block_size_minus3 = 1; // Changed CTB log size
+ sps.log2_diff_max_min_luma_coding_block_size = 1;
+ sps.log2_min_luma_transform_block_size_minus2 = 0;
+ sps.log2_diff_max_min_luma_transform_block_size = 0;
+ sps.max_transform_hierarchy_depth_inter = 0;
+ sps.max_transform_hierarchy_depth_intra = 0;
+ sps.log2_max_pic_order_cnt_lsb_minus4 = 4;
+ sps.sps_max_dec_pic_buffering_minus1[0] = 1;
+
+ BuildPackedH265SPS(builder, sps);
+ builder.Flush();
+
+ std::vector<uint8_t> ctb_bitstream(builder.data().begin(),
+ builder.data().end());
+ base::Extend(ctb_bitstream, p_frame_data);
+
+ auto buffer2 = DecoderBuffer::CopyFrom(ctb_bitstream);
+ EXPECT_CALL(*accelerator_, SetStream(_, _));
+ decoder_->SetStream(2, buffer2);
+
+ // Verify CTB log size ConfigChange is NOT allowed on P-frame.
+ EXPECT_EQ(AcceleratedVideoDecoder::kDecodeError, decoder_->Decode());
EXPECT_TRUE(decoder_->Flush());
}
Original Bug Report
Potential VideoToolboxH265Accelerator state desync on SPS CTB size change inside non-IRAP frames
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential synchronization issue in VideoToolboxH265Accelerator allows a stale CMVideoFormatDescription to be reused when Coding Tree Block (CTB) size changes on a non-IRAP frame. This occurs because format description regeneration is skipped for non-IRAP frames, even when a changed Sequence Parameter Set (SPS) is detected. Consequently, Apple’s hardware decoder may decode slices with addresses exceeding the session’s originally configured limits, potentially leading to out-of-bounds memory access in the macOS GPU process.
Affected files:
media/gpu/mac/video_toolbox_h265_accelerator.ccmedia/gpu/h265_decoder.ccmedia/parsers/h265_parser.ccmedia/gpu/mac/video_toolbox_decompression_session_manager.mm
Estimated timestamp from git blame: 2023-09-20
Root Cause Analysis
In media/gpu/mac/video_toolbox_h265_accelerator.cc, SubmitDecode() only rebuilds the CMVideoFormatDescription format description (active_format_) on IRAP (keyframe) frames, even after detecting that the active SPS bytes have changed:
// media/gpu/mac/video_toolbox_h265_accelerator.cc:274-279
if (!active_format_ || (combined_nalu_data.size() && frame_is_keyframe_)) {
combined_nalu_data.clear();
if (!CreateFormat(pic)) {
return Status::kFail;
}
}
frame_is_keyframe_ is set from slice_hdr->irap_pic at line 182. The assumption that configuration changes can only occur at keyframes is enforced by H265Decoder::ProcessPPS() for some SPS fields, but not all:
// media/gpu/h265_decoder.cc:657-668
const bool is_config_change =
pic_size_ != new_pic_size || dpb_.max_num_pics() != sps->max_dpb_size ||
profile_ != new_profile || bit_depth_ != new_bit_depth ||
chroma_sampling_ != new_chroma_sampling;
if (is_config_change) {
// Only color space changes are allowed on non-IRAP pictures.
if (curr_slice_hdr_ && !curr_slice_hdr_->irap_pic && !first_picture_) {
DVLOG(1) << "A configuration change on a non-IRAP picture is not allowed.";
return false;
}
}
Importantly, ctb_log2_size_y / pic_size_in_ctbs_y are not checked by is_config_change. Two SPSes with identical dimensions, profile, bit-depth, and chroma sampling, but differing log2_min_luma_coding_block_size_minus3 or log2_diff_max_min_luma_coding_block_size can bypass this check on a non-IRAP frame without triggering a configuration change in H265Decoder.
Potential Trigger Path
(Note: These are potential steps as our tooling does not currently have runtime execution capabilities.)
- Stream Setup: The attacker serves an HEVC Annex-B stream containing two distinct SPS blocks sharing
sps_id = 0:- SPS_A: Configured with a resolution of 1024x1024,
ctb_log2_size_y = 6(CTB size 64x64), resulting inpic_size_in_ctbs_y = 256total CTBs. - SPS_B: Configured with the same resolution (1024x1024) but
ctb_log2_size_y = 4(CTB size 16x16), resulting inpic_size_in_ctbs_y = 4096total CTBs.
- SPS_A: Configured with a resolution of 1024x1024,
- Initialize Session: The decoder processes an IDR (IRAP) frame utilizing SPS_A. The accelerator builds
active_format_from SPS_A, and theVideoToolboxDecompressionSessionManagerinstantiates a hardware decoding session optimized for a 256-CTB layout. - Inject SPS_B: The stream transmits SPS_B, which overwrites
sps_id = 0in the parser’s cached state. - Process non-IRAP Frame: The stream presents a non-IRAP
TRAIL_Rslice utilizingpps_id = 0(pointing tosps_id = 0/ SPS_B).H265Decoder::ProcessPPS()computesis_config_change = falsebecause resolution and basic profiles match, allowing decoding to proceed on the non-IRAP frame.VideoToolboxH265Accelerator::SubmitFrameMetadata()marksframe_is_keyframe_ = false.
- Craft OOB Slice Address: The
TRAIL_Rpicture contains a second slice segment (first_slice_segment_in_pic_flag = 0) withslice_segment_address = 4095.- Chromium’s parser validates
slice_segment_addressagainst SPS_B’s limits (pic_size_in_ctbs_y - 1 = 4095) and permits the value.
- Chromium’s parser validates
- State Desynchronization: Inside
SubmitDecode(),ExtractChangedParameterSetData()notices that the active SPS has changed, updatesactive_sps_data_[0]to SPS_B, and appends SPS_B tocombined_nalu_data.- However, because
frame_is_keyframe_isfalse,CreateFormat()is not called.active_format_remains configured with the stale SPS_A properties (256 CTBs max).
- However, because
- OOB Write: The
CMSampleBuffercontaining the staleactive_format_is passed to the decompression session manager. The manager bypasses regeneration because the format description pointer has not changed. The hardware/software decoder processes a slice segment targeting CTB 4095 within a session configured only for 256 CTBs, potentially leading to out-of-bounds memory access in Apple’s closed-source decoder inside the macOS GPU process.
Suggested Fix
To prevent state desynchronization, VideoToolboxH265Accelerator should rebuild the CMVideoFormatDescription whenever a parameter set change is detected, regardless of whether the frame is a keyframe. This mirrors the safe behavior implemented in the H.264 sibling (VideoToolboxH264Accelerator):
// media/gpu/mac/video_toolbox_h265_accelerator.cc
if (!active_format_ || combined_nalu_data.size()) {
combined_nalu_data.clear();
if (!CreateFormat(pic)) {
return Status::kFail;
}
}
Evaluated with Chrome root at commit: 94d9235ebe3b7276e5284f0dc5d55577ff949908
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.