Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in Media
DescriptionOut of bounds write in Media
ComponentMedia
Bug ClassOOB
Tracker540024134
Fix commit2d157885607b (chromium/src) +85/-26
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
if
media/gpu/h265_decoder.cc
modified

Files Changed

  • media/gpu/h265_decoder.cc
  • media/gpu/h265_decoder.h
  • media/gpu/h265_decoder_unittest.cc
  • media/parsers/h265_nalu_parser.h
  • media/parsers/h265_parser.cc
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;
Loading diff…

Regression Test / PoC

shipped with the fix
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());
 }
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • media/gpu/h265_decoder.cc
  • media/parsers/h265_parser.cc
  • media/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.)

  1. 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 in pic_size_in_ctbs_y = 256 total CTBs.
    • SPS_B: Configured with the same resolution (1024x1024) but ctb_log2_size_y = 4 (CTB size 16x16), resulting in pic_size_in_ctbs_y = 4096 total CTBs.
  2. Initialize Session: The decoder processes an IDR (IRAP) frame utilizing SPS_A. The accelerator builds active_format_ from SPS_A, and the VideoToolboxDecompressionSessionManager instantiates a hardware decoding session optimized for a 256-CTB layout.
  3. Inject SPS_B: The stream transmits SPS_B, which overwrites sps_id = 0 in the parser’s cached state.
  4. Process non-IRAP Frame: The stream presents a non-IRAP TRAIL_R slice utilizing pps_id = 0 (pointing to sps_id = 0 / SPS_B).
    • H265Decoder::ProcessPPS() computes is_config_change = false because resolution and basic profiles match, allowing decoding to proceed on the non-IRAP frame.
    • VideoToolboxH265Accelerator::SubmitFrameMetadata() marks frame_is_keyframe_ = false.
  5. Craft OOB Slice Address: The TRAIL_R picture contains a second slice segment (first_slice_segment_in_pic_flag = 0) with slice_segment_address = 4095.
    • Chromium’s parser validates slice_segment_address against SPS_B’s limits (pic_size_in_ctbs_y - 1 = 4095) and permits the value.
  6. State Desynchronization: Inside SubmitDecode(), ExtractChangedParameterSetData() notices that the active SPS has changed, updates active_sps_data_[0] to SPS_B, and appends SPS_B to combined_nalu_data.
    • However, because frame_is_keyframe_ is false, CreateFormat() is not called. active_format_ remains configured with the stale SPS_A properties (256 CTBs max).
  7. OOB Write: The CMSampleBuffer containing the stale active_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.

View on issue tracker