Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in Media
DescriptionOut of bounds write in Media
ComponentMedia
Sub-componentInternals>Media
Bug ClassOOB
Tracker553129739
Fix commit0898afb987d1 (chromium/src) +221/-7
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
media/gpu/h265_decoder.cc
modified
TEST_F
media/gpu/h265_decoder_unittest.cc
modified

Files Changed

  • media/gpu/h265_decoder.h
  • media/gpu/h265_decoder.cc
  • media/gpu/h265_decoder_unittest.cc
From 0898afb987d136063517a650e8b00428237bf509 Mon Sep 17 00:00:00 2001
From: Dale Curtis <[email protected]>
Date: Wed, 09 Sep 2026 17:50:27 -0700
Subject: [PATCH] Enforce IRAP picture requirement after EOS in H265Decoder

Under ITU-T H.265, an End of Sequence (EOS) or End of Bitstream (EOB)
NAL unit terminates the active coded video sequence, requiring the
subsequent picture to be an Intra Random Access Point (IRAP).
Previously, encountering an EOS or EOB NAL unit set first_picture_ to
true without transitioning decoder state to kAfterReset. This conflated
the initial bitstream picture with sequence boundaries and could allow
non-IRAP pictures following an EOS to trigger configuration changes.

This CL updates H265Decoder to transition to kAfterReset and complete
any pending frame upon receiving EOS or EOB NAL units. It also
introduces is_first_picture_after_eos_ to decouple sequence boundary
handling from first_picture_, and removes the first-picture exception
for configuration changes on non-IRAP pictures.

TAG=agy

Fixed: 553129739
Change-Id: I70e9050770c04cf097c4f50bc54b9be8643c22b4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8372909
Commit-Queue: Qiu, Jianlin <[email protected]>
Auto-Submit: Dale Curtis <[email protected]>
Reviewed-by: Qiu, Jianlin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1695052}
---
diff --git a/media/gpu/h265_decoder.cc b/media/gpu/h265_decoder.cc
index 9aab489..43cab56 100644
--- a/media/gpu/h265_decoder.cc
+++ b/media/gpu/h265_decoder.cc
@@ -180,6 +180,7 @@
 
 void H265Decoder::Reset() {
   first_picture_ = true;
+  first_picture_after_eos_ = false;
   no_rasl_output_flag_ = true;
 
   curr_pic_ = nullptr;
@@ -485,9 +486,11 @@
 
         break;
       case H265NALU::EOS_NUT:
-        first_picture_ = true;
-        [[fallthrough]];
-      case H265NALU::EOB_NUT:  // fallthrough
+      case H265NALU::EOB_NUT:
+        CHECK_ACCELERATOR_RESULT(FinishPrevFrameIfPresent());
+        first_picture_after_eos_ = true;
+        state_ = kAfterReset;
+        break;
       case H265NALU::AUD_NUT:
       case H265NALU::RSV_NVCL41:
       case H265NALU::RSV_NVCL42:
@@ -667,7 +670,7 @@
 
   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_) {
+    if (curr_slice_hdr_ && !curr_slice_hdr_->irap_pic) {
       DVLOG(1)
           << "A configuration change on a non-IRAP picture is not allowed.";
       return false;
@@ -744,7 +747,8 @@
     curr_pic_->no_rasl_output_flag_ =
         (curr_nalu_->nal_unit_type >= H265NALU::BLA_W_LP &&
          curr_nalu_->nal_unit_type <= H265NALU::IDR_N_LP) ||
-        curr_pic_->first_picture_;
+        curr_pic_->first_picture_ || first_picture_after_eos_;
+    first_picture_after_eos_ = false;
     no_rasl_output_flag_ = curr_pic_->no_rasl_output_flag_;
   } else {
     curr_pic_->no_rasl_output_flag_ = no_rasl_output_flag_;
diff --git a/media/gpu/h265_decoder.h b/media/gpu/h265_decoder.h
index 24c4961f..a6f66c6 100644
--- a/media/gpu/h265_decoder.h
+++ b/media/gpu/h265_decoder.h
@@ -333,10 +333,12 @@
   // Picture currently being processed/decoded.
   scoped_refptr<H265Picture> curr_pic_;
 
-  // Used to identify first picture in decoding order or first picture that
-  // follows an EOS NALU.
+  // Used to identify first picture in decoding order.
   bool first_picture_ = true;
 
+  // Used to identify the first picture that follows an EOS or EOB NALU.
+  bool first_picture_after_eos_ = false;
+
   // Used to keep NoRaslOutputFlag state since last IRAP, to decide if we
   // drop a RASL picture.
   bool no_rasl_output_flag_ = true;
diff --git a/media/gpu/h265_decoder_unittest.cc b/media/gpu/h265_decoder_unittest.cc
index 68c549e..d8f2d1c6 100644
--- a/media/gpu/h265_decoder_unittest.cc
+++ b/media/gpu/h265_decoder_unittest.cc
@@ -644,6 +644,214 @@
   EXPECT_TRUE(decoder_->Flush());
 }
 
+TEST_F(H265DecoderTest, EndOfSequenceRequiresIRAP) {
+  // 1. Initialize and decode 8-bit IDR frame 0.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  EXPECT_TRUE(decoder_->Flush());
+
+  // 2. Prepend EOS_NUT followed by non-IRAP frames (kFrame1, kFrame2) and then
+  // an IRAP frame (kFrame0).
+  constexpr uint8_t kEosNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x48, 0x01};
+  std::vector<uint8_t> eos_stream(std::begin(kEosNutNalu),
+                                  std::end(kEosNutNalu));
+
+  std::vector<uint8_t> frame1_data;
+  CHECK(base::OptionalUnwrapTo(
+      base::ReadFileToBytes(GetTestDataFilePath(kFrame1)), frame1_data));
+  base::Extend(eos_stream, frame1_data);
+
+  std::vector<uint8_t> frame2_data;
+  CHECK(base::OptionalUnwrapTo(
+      base::ReadFileToBytes(GetTestDataFilePath(kFrame2)), frame2_data));
+  base::Extend(eos_stream, frame2_data);
+
+  std::vector<uint8_t> frame0_data;
+  CHECK(base::OptionalUnwrapTo(
+      base::ReadFileToBytes(GetTestDataFilePath(kFrame0)), frame0_data));
+  base::Extend(eos_stream, frame0_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eos_stream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Non-IRAP frames after EOS_NUT should be skipped; only the IRAP frame
+  // (kFrame0) should be decoded.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_TRUE(decoder_->Flush());
+}
+
+TEST_F(H265DecoderTest, ConfigChangeOnNonIRAPAfterEndOfSequence) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  // Decode the first frame to establish state.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  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));
+
+  // 2. Inject EOS_NUT followed by 10-bit SPS/PPS (bit-depth config change) and
+  // a non-IRAP P-frame.
+  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));
+
+  constexpr uint8_t kEosNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x48, 0x01};
+  std::vector<uint8_t> eos_bit_depth_bitstream(std::begin(kEosNutNalu),
+                                               std::end(kEosNutNalu));
+  base::Extend(eos_bit_depth_bitstream, ten_bit_sps_pps);
+  base::Extend(eos_bit_depth_bitstream, p_frame_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eos_bit_depth_bitstream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Per HEVC spec, a picture following EOS_NUT must be an IRAP picture.
+  // The non-IRAP picture is dropped without applying configuration changes.
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  EXPECT_TRUE(decoder_->Flush());
+}
+
+TEST_F(H265DecoderTest, ConfigChangeOnNonIRAPAfterEndOfBitstream) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  // Decode the first frame to establish state.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  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));
+
+  // 2. Inject EOB_NUT followed by 10-bit SPS/PPS (bit-depth config change) and
+  // a non-IRAP P-frame.
+  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));
+
+  constexpr uint8_t kEobNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x4a, 0x01};
+  std::vector<uint8_t> eob_bit_depth_bitstream(std::begin(kEobNutNalu),
+                                               std::end(kEobNutNalu));
+  base::Extend(eob_bit_depth_bitstream, ten_bit_sps_pps);
+  base::Extend(eob_bit_depth_bitstream, p_frame_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eob_bit_depth_bitstream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Per HEVC spec, a picture following EOB_NUT must be an IRAP picture.
+  // The non-IRAP picture is dropped without applying configuration changes.
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  EXPECT_TRUE(decoder_->Flush());
+}
+
+TEST_F(H265DecoderTest, ConfigChangeOnIRAPAfterEndOfSequence) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  // Decode the first frame to establish state.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  EXPECT_TRUE(decoder_->Flush());
+
+  // 2. Inject EOS_NUT followed by 10-bit IDR frame (which includes 10-bit
+  // VPS/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));
+
+  constexpr uint8_t kEosNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x48, 0x01};
+  std::vector<uint8_t> eos_10bit_stream(std::begin(kEosNutNalu),
+                                        std::end(kEosNutNalu));
+  base::Extend(eos_10bit_stream, ten_bit_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eos_10bit_stream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Configuration change should be accepted on the IRAP frame following
+  // EOS_NUT.
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, decoder_->Decode());
+  EXPECT_EQ(10u, decoder_->GetBitDepth());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(HEVCPROFILE_MAIN10, decoder_->GetProfile());
+
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_TRUE(decoder_->Flush());
+}
+
 // This test verifies that dependent slices crossing layer boundaries
 // (different nuh_layer_id) are correctly rejected by the parser,
 // preventing unvalidated slice header state from being propagated.
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 68c549e..d8f2d1c6 100644
--- a/media/gpu/h265_decoder_unittest.cc
+++ b/media/gpu/h265_decoder_unittest.cc
@@ -644,6 +644,214 @@
   EXPECT_TRUE(decoder_->Flush());
 }
 
+TEST_F(H265DecoderTest, EndOfSequenceRequiresIRAP) {
+  // 1. Initialize and decode 8-bit IDR frame 0.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  EXPECT_TRUE(decoder_->Flush());
+
+  // 2. Prepend EOS_NUT followed by non-IRAP frames (kFrame1, kFrame2) and then
+  // an IRAP frame (kFrame0).
+  constexpr uint8_t kEosNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x48, 0x01};
+  std::vector<uint8_t> eos_stream(std::begin(kEosNutNalu),
+                                  std::end(kEosNutNalu));
+
+  std::vector<uint8_t> frame1_data;
+  CHECK(base::OptionalUnwrapTo(
+      base::ReadFileToBytes(GetTestDataFilePath(kFrame1)), frame1_data));
+  base::Extend(eos_stream, frame1_data);
+
+  std::vector<uint8_t> frame2_data;
+  CHECK(base::OptionalUnwrapTo(
+      base::ReadFileToBytes(GetTestDataFilePath(kFrame2)), frame2_data));
+  base::Extend(eos_stream, frame2_data);
+
+  std::vector<uint8_t> frame0_data;
+  CHECK(base::OptionalUnwrapTo(
+      base::ReadFileToBytes(GetTestDataFilePath(kFrame0)), frame0_data));
+  base::Extend(eos_stream, frame0_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eos_stream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Non-IRAP frames after EOS_NUT should be skipped; only the IRAP frame
+  // (kFrame0) should be decoded.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_TRUE(decoder_->Flush());
+}
+
+TEST_F(H265DecoderTest, ConfigChangeOnNonIRAPAfterEndOfSequence) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  // Decode the first frame to establish state.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  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));
+
+  // 2. Inject EOS_NUT followed by 10-bit SPS/PPS (bit-depth config change) and
+  // a non-IRAP P-frame.
+  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));
+
+  constexpr uint8_t kEosNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x48, 0x01};
+  std::vector<uint8_t> eos_bit_depth_bitstream(std::begin(kEosNutNalu),
+                                               std::end(kEosNutNalu));
+  base::Extend(eos_bit_depth_bitstream, ten_bit_sps_pps);
+  base::Extend(eos_bit_depth_bitstream, p_frame_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eos_bit_depth_bitstream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Per HEVC spec, a picture following EOS_NUT must be an IRAP picture.
+  // The non-IRAP picture is dropped without applying configuration changes.
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  EXPECT_TRUE(decoder_->Flush());
+}
+
+TEST_F(H265DecoderTest, ConfigChangeOnNonIRAPAfterEndOfBitstream) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  // Decode the first frame to establish state.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  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));
+
+  // 2. Inject EOB_NUT followed by 10-bit SPS/PPS (bit-depth config change) and
+  // a non-IRAP P-frame.
+  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));
+
+  constexpr uint8_t kEobNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x4a, 0x01};
+  std::vector<uint8_t> eob_bit_depth_bitstream(std::begin(kEobNutNalu),
+                                               std::end(kEobNutNalu));
+  base::Extend(eob_bit_depth_bitstream, ten_bit_sps_pps);
+  base::Extend(eob_bit_depth_bitstream, p_frame_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eob_bit_depth_bitstream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Per HEVC spec, a picture following EOB_NUT must be an IRAP picture.
+  // The non-IRAP picture is dropped without applying configuration changes.
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  EXPECT_TRUE(decoder_->Flush());
+}
+
+TEST_F(H265DecoderTest, ConfigChangeOnIRAPAfterEndOfSequence) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  // Decode the first frame to establish state.
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, Decode());
+  EXPECT_TRUE(decoder_->Flush());
+
+  // 2. Inject EOS_NUT followed by 10-bit IDR frame (which includes 10-bit
+  // VPS/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));
+
+  constexpr uint8_t kEosNutNalu[] = {0x00, 0x00, 0x00, 0x01, 0x48, 0x01};
+  std::vector<uint8_t> eos_10bit_stream(std::begin(kEosNutNalu),
+                                        std::end(kEosNutNalu));
+  base::Extend(eos_10bit_stream, ten_bit_data);
+
+  auto buffer = DecoderBuffer::CopyFrom(eos_10bit_stream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // Configuration change should be accepted on the IRAP frame following
+  // EOS_NUT.
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, decoder_->Decode());
+  EXPECT_EQ(10u, decoder_->GetBitDepth());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(HEVCPROFILE_MAIN10, decoder_->GetProfile());
+
+  EXPECT_CALL(*accelerator_, CreateH265Picture()).Times(1);
+  EXPECT_CALL(*accelerator_, SubmitFrameMetadata(_, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitSlice(_, _, _, _, _, _, _, _, _, _, _, _))
+      .Times(1);
+  EXPECT_CALL(*accelerator_, SubmitDecode(_)).Times(1);
+  EXPECT_CALL(*accelerator_, OutputPicture(_)).Times(1);
+  EXPECT_EQ(AcceleratedVideoDecoder::kRanOutOfStreamData, decoder_->Decode());
+  EXPECT_TRUE(decoder_->Flush());
+}
+
 // This test verifies that dependent slices crossing layer boundaries
 // (different nuh_layer_id) are correctly rejected by the parser,
 // preventing unvalidated slice header state from being propagated.
Loading diff…

Original Bug Report

reported by [email protected]

Format mismatch in VideoToolbox H265 decoder via in-stream EOS_NUT configuration change

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: An in-stream EOS_NUT NAL unit in H265Decoder resets first_picture_ without transitioning the decoder state to kAfterReset, allowing mid-stream SPS configuration changes on non-IRAP pictures. Because VideoToolboxH265Accelerator only regenerates CMFormatDescription and session metadata on keyframes, and VideoToolboxVideoDecoder discards kConfigChange, the decoder fails to update the session format. Consequently, the macOS GPU process may submit 10/12-bit or resized slice payloads to an active VideoToolbox decompression session configured for 8-bit output, potentially causing memory corruption across the driver boundary.

Affected files:

  • media/gpu/mac/video_toolbox_h265_accelerator.cc
  • media/gpu/h265_decoder.cc
  • media/gpu/mac/video_toolbox_video_decoder.cc
  • media/gpu/mac/video_toolbox_decompression_session_manager.mm

Estimated timestamp from git blame: 2023-09-20

Potential Issue: Bypass of VideoToolbox H.265 Format Regeneration via EOS_NUT

Note: The following findings and reproduction steps are based on static code analysis. Our analysis tooling does not currently have the environment capabilities to execute code or run a live dynamic proof-of-concept.

Summary

A state desynchronization issue exists between media::H265Decoder and media::VideoToolboxH265Accelerator in the macOS GPU process. When an in-stream EOS_NUT (End of Sequence) NAL unit is encountered, H265Decoder resets first_picture_ = true without setting state_ = kAfterReset. This allows a subsequent non-IRAP picture (such as a TRAIL_R I-slice) with a modified SPS (e.g., bit depth change from 8-bit to 10-bit) to bypass non-IRAP configuration change guards. Because VideoToolboxH265Accelerator only regenerates format descriptions and updates session metadata on keyframes, and VideoToolboxVideoDecoder ignores kConfigChange, the VideoToolbox session remains configured for 8-bit output while receiving 10-bit bitstream data.


Vulnerability Details

  1. Flawed EOS_NUT State Handling:
    In media/gpu/h265_decoder.cc:487-489:

    case H265NALU::EOS_NUT:
      first_picture_ = true;
      [[fallthrough]];
    

    Unlike H265Decoder::Reset() (line 213), EOS_NUT sets first_picture_ = true without setting state_ = kAfterReset. As a result, non-IRAP slice parsing is not aborted at line 376 (if (!curr_slice_hdr_->irap_pic && state_ == kAfterReset)).

  2. Configuration Change Guard Bypass:
    In media/gpu/h265_decoder.cc:668-675:

    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;
      }
    }
    

    Because first_picture_ is true, the condition !first_picture_ evaluates to false, allowing a non-IRAP picture with a new SPS (such as 10-bit Main 10) to pass ProcessPPS and trigger Flush() and *need_new_buffers = true.

  3. kConfigChange Discarded:
    H265Decoder::Decode returns kConfigChange (media/gpu/h265_decoder.cc:399). In media/gpu/mac/video_toolbox_video_decoder.cc:273-274, case kConfigChange: continue; simply loops without tearing down or re-negotiating the VideoToolbox decompression session.

  4. Keyframe-Gated Format Creation Bypass:
    On re-entry, H265Decoder creates curr_pic_, clears first_picture_, and calls SubmitFrameMetadata (media/gpu/mac/video_toolbox_h265_accelerator.cc:140), setting frame_is_keyframe_ = slice_hdr->irap_pic (false for TRAIL_R). In VideoToolboxH265Accelerator::SubmitDecode (media/gpu/mac/video_toolbox_h265_accelerator.cc:197-202):

    // Create a new format description if necessary.
    // We assume that session metadata can only change at a keyframe.
    if (!active_format_ || (combined_nalu_data.size() && frame_is_keyframe_)) {
      combined_nalu_data.clear();
      if (!CreateFormat(pic)) {
        return Status::kFail;
      }
    }
    

    Because active_format_ is already non-null and frame_is_keyframe_ is false, CreateFormat(pic) is skipped. active_format_ and active_session_metadata_ retain their initial 8-bit configurations.

  5. Persistence of Stale Session:
    ExtractForInbandUpdate (media/gpu/mac/video_toolbox_nalu_util.cc:62-68) updates active_data_ for the SPS ID during this frame. Consequently, subsequent frames (even legitimate IDR keyframes using the same SPS parameters) produce empty combined_nalu_data, preventing the format regeneration gate from firing in future frames.

  6. Mismatched Session Submission:
    VideoToolboxDecompressionSessionManager::Process (media/gpu/mac/video_toolbox_decompression_session_manager.mm:158) compares format descriptions by pointer (format != active_format_.get()). Since the pointer is unchanged, the active session is retained and VTDecompressionSessionDecodeFrame is called with 10-bit slice data and in-band 10-bit parameter sets directed into an 8-bit NV12 pixel buffer pool and decode context.


Potential Impact

The macOS GPU process submits inconsistent format and bitstream parameters to Apple’s closed-source VideoToolbox / AppleVTD framework. If the underlying decoder allocates destination surfaces sized for 8-bit NV12 and attempts to decode 10-bit/12-bit or higher-resolution slice data into those surfaces, this could potentially result in out-of-bounds read/write memory corruption inside the sandboxed GPU process.


Suggested Reproduction Steps (Theoretical)

  1. In a web page, instantiate an HEVC decoder via WebCodecs (Annex B format) or Media Source Extensions (MSE).
  2. Feed an initial conformant 8-bit Main Profile HEVC stream ([VPS 0] [SPS 0 (8-bit)] [PPS 0] [IDR slice]) to establish an active 8-bit VTDecompressionSession (kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange).
  3. Append an H265NALU::EOS_NUT NAL unit.
  4. Immediately follow with a new SPS 0 (Main 10, 10-bit), PPS 0, and a non-IRAP slice (TRAIL_R with slice_type = 2 (I-slice) and empty reference picture set).
  5. Observe whether ProcessPPS returns kConfigChange, VideoToolboxH265Accelerator bypasses CreateFormat, and VTDecompressionSessionDecodeFrame receives the 10-bit bitstream within the active 8-bit session.

Suggested Fix

  1. In media/gpu/mac/video_toolbox_h265_accelerator.cc:
    Do not gate CreateFormat and active_session_metadata_ regeneration strictly on frame_is_keyframe_. Instead, check whether critical session parameters (such as bit_depth, chroma_sampling, or visible rect) have changed compared to active_session_metadata_ (similar to the per-frame checks in media/gpu/mac/video_toolbox_av1_accelerator.cc:199-257).

  2. In media/gpu/h265_decoder.cc:
    Review EOS_NUT handling. If EOS_NUT resets first_picture_ = true, ensure that decoder state appropriately requires an IRAP picture before accepting configuration changes, or set state_ = kAfterReset on EOS_NUT so non-IRAP pictures cannot proceed until an IRAP picture is received.

Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c


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