Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in Codecs
DescriptionOut of bounds write in Codecs
ComponentCodecs
Bug ClassOOB
Tracker490229299
Fix commitaca89b5f3d8d (chromium/src) +72/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-12

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.cc
  • media/gpu/h265_decoder_unittest.cc
From aca89b5f3d8d090d400089d9d06e4e3a48704b2d Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <[email protected]>
Date: Mon, 09 Mar 2026 22:05:13 -0700
Subject: [PATCH] media: Fail H.265 structural config changes on non-IRAP pictures

According to the HEVC specification (7.4.2.4), structural configuration
changes (resolution, profile, bit depth, etc.) must only occur at the
start of a new Coded Video Sequence (CVS), which requires an IRAP
picture.

Previously, these changes were ignored on non-IRAP pictures, leading to
hardware decoder mismatches. This update ensures that if a structural
change is detected on a non-IRAP picture, we fail decoding immediately
with an error, as this indicates an invalid bitstream for the supported
profiles.

Color space changes continue to be allowed mid-sequence.

Bug: 490229299
Change-Id: I14a5f39eeabfb56f4fa46f08cc0f6e0210fd53d0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7644891
Reviewed-by: Qiu, Jianlin <[email protected]>
Reviewed-by: Dale Curtis <[email protected]>
Commit-Queue: Eugene Zemtsov <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1596836}
---

diff --git a/media/gpu/h265_decoder.cc b/media/gpu/h265_decoder.cc
index 67fbe7a..ac23da0 100644
--- a/media/gpu/h265_decoder.cc
+++ b/media/gpu/h265_decoder.cc
@@ -366,7 +366,13 @@
           }
 
           state_ = kTryPreprocessCurrentSlice;
-          if (curr_slice_hdr_->irap_pic) {
+        }
+
+        if (state_ == kTryPreprocessCurrentSlice) {
+          CHECK_ACCELERATOR_RESULT(PreprocessCurrentSlice());
+          state_ = kEnsurePicture;
+
+          if (curr_slice_hdr_->first_slice_segment_in_pic_flag) {
             bool need_new_buffers = false;
             if (!ProcessPPS(curr_slice_hdr_->slice_pic_parameter_set_id,
                             &need_new_buffers)) {
@@ -380,11 +386,6 @@
           }
         }
 
-        if (state_ == kTryPreprocessCurrentSlice) {
-          CHECK_ACCELERATOR_RESULT(PreprocessCurrentSlice());
-          state_ = kEnsurePicture;
-        }
-
         if (state_ == kEnsurePicture) {
           if (curr_pic_) {
             // |curr_pic_| already exists, so skip to ProcessCurrentSlice().
@@ -631,11 +632,24 @@
                             new_color_space != picture_color_space_;
   }
 
-  if (pic_size_ != new_pic_size || dpb_.max_num_pics() != sps->max_dpb_size ||
+  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 || is_color_space_change) {
-    if (!Flush())
+      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;
+    }
+  }
+
+  if (is_config_change || is_color_space_change) {
+    if (!Flush()) {
+      return false;
+    }
     DVLOG(1) << "Codec profile: " << GetProfileName(new_profile)
              << ", level(x30): " << sps->profile_tier_level.general_level_idc
              << ", DPB size: " << sps->max_dpb_size
diff --git a/media/gpu/h265_decoder_unittest.cc b/media/gpu/h265_decoder_unittest.cc
index 984304d..0124e23 100644
--- a/media/gpu/h265_decoder_unittest.cc
+++ b/media/gpu/h265_decoder_unittest.cc
@@ -549,4 +549,53 @@
   EXPECT_TRUE(decoder_->Flush());
 }
 
+TEST_F(H265DecoderTest, ConfigChangeOnNonIRAP) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  // 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());
+
+  // 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));
+
+  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);
+
+  auto buffer = DecoderBuffer::CopyFrom(malicious_bitstream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // 3. Verify that ConfigChange is NOT allowed on the P-frame.
+  EXPECT_EQ(AcceleratedVideoDecoder::kDecodeError, decoder_->Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  EXPECT_TRUE(decoder_->Flush());
+}
+
 }  // namespace media
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 984304d..0124e23 100644
--- a/media/gpu/h265_decoder_unittest.cc
+++ b/media/gpu/h265_decoder_unittest.cc
@@ -549,4 +549,53 @@
   EXPECT_TRUE(decoder_->Flush());
 }
 
+TEST_F(H265DecoderTest, ConfigChangeOnNonIRAP) {
+  // 1. Initialize with 8-bit stream.
+  SetInputFrameFiles({kSpsPps, kFrame0});
+  EXPECT_EQ(AcceleratedVideoDecoder::kConfigChange, Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+
+  // 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());
+
+  // 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));
+
+  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);
+
+  auto buffer = DecoderBuffer::CopyFrom(malicious_bitstream);
+  EXPECT_CALL(*accelerator_, SetStream(_, _));
+  decoder_->SetStream(1, buffer);
+
+  // 3. Verify that ConfigChange is NOT allowed on the P-frame.
+  EXPECT_EQ(AcceleratedVideoDecoder::kDecodeError, decoder_->Decode());
+  EXPECT_EQ(gfx::Size(320, 184), decoder_->GetPicSize());
+  EXPECT_EQ(8u, decoder_->GetBitDepth());
+
+  EXPECT_TRUE(decoder_->Flush());
+}
+
 }  // namespace media
Loading diff…

Original Bug Report

reported by [email protected]

Potential OOB DMA write in H265Decoder via bypassed resolution checks on non-IRAP frames

Flapjack (go/flapjack), an LLM-powered static analysis tool, has identified the following potential security issue.

Overview: The H265Decoder fails to check for resolution changes when processing non-IRAP frames. An attacker can inject a mid-stream Sequence Parameter Set (SPS) with larger dimensions that unconditionally overwrites the active SPS, causing the hardware to decode a larger frame into a smaller, previously allocated DMA buffer.

Affected files:

  • media/parsers/h265_parser.cc
  • media/gpu/h265_decoder.cc
  • media/parsers/h265_parser.h

Estimated timestamp from git blame: 2023-10-10

Description

A highly exploitable memory safety vulnerability exists in the way media::H265Decoder handles mid-stream Sequence Parameter Set (SPS) changes for HEVC/H.265 video streams.

In media/parsers/h265_parser.cc, the ParseSPS method parses an SPS NALU and stores it in the active_sps_ map, keyed strictly by the sps_seq_parameter_set_id. If a new SPS with the same ID is provided, it unconditionally overwrites the existing SPS without validating if the resolution or other critical parameters have changed.

Simultaneously, in media/gpu/h265_decoder.cc, the decoder checks for sequence parameter changes (like resolution changes) by calling ProcessPPS(). If a change is detected, it returns kConfigChange, prompting the GPU process to re-allocate the pool of hardware DMA buffers (e.g., V4L2 or VAAPI surfaces) to fit the new dimensions. However, this check is only performed for IRAP (Instantaneous Decoding Refresh) pictures. If a non-IRAP slice (such as a TRAIL_R predicted frame) is processed, the ProcessPPS() check is skipped:

// media/gpu/h265_decoder.cc
if (curr_slice_hdr_->irap_pic) { // <--- Flaw: Only checks on IRAP pictures
  bool need_new_buffers = false;
  if (!ProcessPPS(curr_slice_hdr_->slice_pic_parameter_set_id, &need_new_buffers)) {
    SET_ERROR_AND_RETURN();
  }
  if (need_new_buffers) {
    curr_pic_ = nullptr;
    return kConfigChange;
  }
}

Impact

An attacker can trigger a highly reliable Out-of-Bounds (OOB) hardware DMA write, leading to Sandbox Escape and Privilege Escalation (to the GPU process or kernel).

Because the OOB write is performed by the hardware DMA engine, it completely bypasses C++ memory safety mitigations like MiraclePtr and user-space bounds checks.

Note on original report: The original vulnerability report suggested using an enhancement layer NALU (nuh_layer_id > 0) to overwrite the base layer. While effective on macOS where IsAlphaLayerSupported() is true, on Linux/ChromeOS platforms (V4L2/VAAPI), NALUs with nuh_layer_id > 0 are explicitly skipped by the decoder. The exploit is actually simpler and universal: the attacker simply uses nuh_layer_id == 0 for the malicious mid-stream SPS, which achieves the same overwrite and works across all platforms.

Steps to Trigger

  1. Establish legitimate stream: The attacker provides a valid 1080p SPS, PPS, and an IRAP slice. ProcessPPS() is called, and the decoder allocates a pool of 1080p hardware DMA buffers.
  2. Mid-stream SPS Injection: The attacker injects a malicious SPS NALU into the bitstream with sps_seq_parameter_set_id = 0, but specifying a 4K resolution (e.g., 3840x2160). H265Parser::ParseSPS unconditionally overwrites active_sps_[0] with this new 4K SPS.
  3. Non-IRAP Slice: The attacker immediately provides a non-IRAP slice (e.g., TRAIL_R) referencing PPS ID 0.
  4. Bypass Reallocation: Because curr_slice_hdr_->irap_pic is false, ProcessPPS() is skipped. The decoder does not return kConfigChange and the hardware buffer pool remains sized for 1080p.
  5. Fetch Hardware Buffer: The decoder acquires a surface via CreateH265Picture(), which is backed by a 1080p DMA buffer.
  6. Fetch Malicious Parameters: In StartNewFrame(), the decoder fetches the active SPS (parser_.GetSPS(curr_sps_id_)). It retrieves the malicious 4K SPS.
  7. Hardware Submission: The decoder submits the frame metadata to the hardware delegate (e.g., V4L2VideoDecoderDelegateH265::SubmitFrameMetadata). The delegate copies the 4K dimensions from the SPS directly into the hardware driver’s command structures.
  8. OOB DMA Write: The hardware DMA engine is instructed to decode a 4K frame into the provided 1080p buffer. The hardware writes decoded macroblocks past the bounds of the allocated physical memory, corrupting adjacent kernel slabs or GPU process heap memory.

Evaluated with Chrome root at commit: 818af3d59a508e1d651545a5bed0175ff7406aa3


Results from Flapjack so far have been promising, but it can be wrong in its deductions. At this time, it does not produce proof of concepts or fuzzer tests. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve Flapjack’s 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