Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Media
DescriptionUse after free in Media
ComponentMedia
Sub-componentMedia
Bug ClassUAF
Tracker562223549
Fix commit4ce7fbe870de (chromium/src) +132/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
TEST_F
media/parsers/h265_parser_unittest.cc
modified

Files Changed

  • media/parsers/h265_parser_unittest.cc
  • media/parsers/h265_parser.cc
From 4ce7fbe870def2e4eebef82d640b95248775a576 Mon Sep 17 00:00:00 2001
From: Dale Curtis <[email protected]>
Date: Thu, 17 Sep 2026 21:37:50 -0700
Subject: [PATCH] [media] Validate RPS bit length across slices in H265Parser

HEVC section 7.4.7.1 requires that all syntax elements for short-term
reference picture set derivation have the same values for all coded
slice segment NAL units of a coded picture.

Chromium's cross-slice consistency check in H265Parser previously
compared only the derived H265StRefPicSet values, omitting the encoded
RPS bit lengths (st_rps_bits and lt_rps_bits). Because stateless
hardware decoders (V4L2, VAAPI, D3D11) receive the first slice's RPS bit
length and use it to skip the RPS data across subsequent slice headers,
slices encoding the same derived RPS in different bit lengths can desync
hardware slice header parsing.

This CL enforces that non-first slice segments match the prior slice
header's st_rps_bits and lt_rps_bits, rejecting mismatched streams with
kInvalidStream.

Fixed: 562223549
Change-Id: If3f0c8af66fdfb92dace1ecf6805221d7968ff47
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8427785
Reviewed-by: Qiu, Jianlin <[email protected]>
Auto-Submit: Dale Curtis <[email protected]>
Commit-Queue: Qiu, Jianlin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1700467}
---
diff --git a/media/parsers/h265_parser.cc b/media/parsers/h265_parser.cc
index edbbc0f0..ecea541 100644
--- a/media/parsers/h265_parser.cc
+++ b/media/parsers/h265_parser.cc
@@ -1518,6 +1518,8 @@
     // derivation shall have the same values for all coded slice segment
     // NAL units of a codec picture.
     EQ_OR_RETURN(shdr, prior_shdr, st_ref_pic_set);
+    EQ_OR_RETURN(shdr, prior_shdr, st_rps_bits);
+    EQ_OR_RETURN(shdr, prior_shdr, lt_rps_bits);
 
     // All the other fields we need to compare are contiguous, so compare them
     // as one memory range.
diff --git a/media/parsers/h265_parser_unittest.cc b/media/parsers/h265_parser_unittest.cc
index 702b134..bc9c91c 100644
--- a/media/parsers/h265_parser_unittest.cc
+++ b/media/parsers/h265_parser_unittest.cc
@@ -910,6 +910,70 @@
     ASSERT_EQ(parser_.ParseSliceHeader(nalu, first_shdr, nullptr),
               H265Parser::kOk);
   }
+
+  // Builds an SPS with two short-term reference picture sets:
+  // RPS 0: ΔPOC = -1 (explicit)
+  // RPS 1: ΔPOC = -201 (explicit)
+  void BuildSpsWithTwoShortTermRefPicSets(H26xAnnexBBitstreamBuilder& builder) {
+    builder.BeginNALU(H265NALU::SPS_NUT);
+    builder.AppendBits(4, 0);  // sps_video_parameter_set_id
+    builder.AppendBits(3, 0);  // sps_max_sub_layers_minus1
+    builder.AppendBits(1, 1);  // sps_temporal_id_nesting_flag
+
+    H265ProfileTierLevel ptl = {};
+    ptl.general_profile_idc = 1;
+    ptl.general_level_idc = 120;
+    BuildPackedH265ProfileTierLevel(builder, ptl, true, 0);
+
+    builder.AppendUE(0);       // sps_seq_parameter_set_id
+    builder.AppendUE(1);       // chroma_format_idc = 1 (4:2:0)
+    builder.AppendUE(32);      // pic_width_in_luma_samples
+    builder.AppendUE(16);      // pic_height_in_luma_samples
+    builder.AppendBits(1, 0);  // conformance_window_flag
+    builder.AppendUE(0);       // bit_depth_luma_minus8
+    builder.AppendUE(0);       // bit_depth_chroma_minus8
+    builder.AppendUE(4);       // log2_max_pic_order_cnt_lsb_minus4
+    builder.AppendBits(1, 0);  // sps_sub_layer_ordering_info_present_flag
+    builder.AppendUE(1);       // sps_max_dec_pic_buffering_minus1
+    builder.AppendUE(0);       // sps_max_num_reorder_pics
+    builder.AppendUE(0);       // sps_max_latency_increase_plus1
+    builder.AppendUE(0);       // log2_min_luma_coding_block_size_minus3
+    builder.AppendUE(1);       // log2_diff_max_min_luma_coding_block_size
+    builder.AppendUE(0);       // log2_min_transform_block_size_minus2
+    builder.AppendUE(0);       // log2_diff_max_min_transform_block_size
+    builder.AppendUE(0);       // max_transform_hierarchy_depth_inter
+    builder.AppendUE(0);       // max_transform_hierarchy_depth_intra
+    builder.AppendBits(1, 0);  // scaling_list_enabled_flag
+    builder.AppendBits(1, 0);  // amp_enabled_flag
+    builder.AppendBits(1, 0);  // sample_adaptive_offset_enabled_flag
+    builder.AppendBits(1, 0);  // pcm_enabled_flag
+
+    // num_short_term_ref_pic_sets = 2
+    builder.AppendUE(2);
+
+    // RPS 0: num_negative_pics = 1, num_positive_pics = 0, delta_poc_s0_minus1
+    // = 0 (ΔPOC = -1), used = 1
+    builder.AppendUE(1);
+    builder.AppendUE(0);
+    builder.AppendUE(0);
+    builder.AppendBool(true);
+
+    // RPS 1: inter_ref_pic_set_prediction_flag = 0
+    builder.AppendBool(false);
+    // num_negative_pics = 1, num_positive_pics = 0, delta_poc_s0_minus1 = 200
+    // (ΔPOC = -201), used = 1
+    builder.AppendUE(1);
+    builder.AppendUE(0);
+    builder.AppendUE(200);
+    builder.AppendBool(true);
+
+    builder.AppendBits(1, 0);  // long_term_ref_pics_present_flag
+    builder.AppendBits(1, 0);  // sps_temporal_mvp_enabled_flag
+    builder.AppendBits(1, 0);  // strong_intra_smoothing_enabled_flag
+    builder.AppendBits(1, 0);  // vui_parameters_present_flag
+    builder.AppendBits(1, 0);  // sps_extension_present_flag
+    builder.FinishNALU();
+  }
 };
 
 TEST_F(H265CrossSliceTest, RejectsMismatchedNalUnitType) {
@@ -992,6 +1056,72 @@
             H265Parser::kOk);
 }
 
+TEST_F(H265CrossSliceTest, RejectsMismatchedShortTermRefPicSetBitLength) {
+  H26xAnnexBBitstreamBuilder builder;
+  BuildSpsWithTwoShortTermRefPicSets(builder);
+
+  H265PPS pps = {};
+  pps.pps_pic_parameter_set_id = 0;
+  pps.pps_seq_parameter_set_id = 0;
+  BuildPackedH265PPS(builder, pps);
+
+  // Slice 1: references SPS RPS 0 via inter-prediction with delta_rps = -210.
+  // Derived RPS: ΔPOC = -1 + (-210) = -211. Encoded length: 23 bits.
+  builder.BeginNALU(H265NALU::TRAIL_R);
+  builder.AppendBool(true);  // first_slice_segment_in_pic_flag
+  builder.AppendUE(0);       // slice_pic_parameter_set_id
+  builder.AppendUE(H265SliceHeader::kSliceTypeI);
+  builder.AppendBits(8, 0);   // slice_pic_order_cnt_lsb
+  builder.AppendBool(false);  // short_term_ref_pic_set_sps_flag
+  // Inline st_ref_pic_set (st_rps_idx = 2):
+  builder.AppendBool(true);   // inter_ref_pic_set_prediction_flag
+  builder.AppendUE(1);        // delta_idx_minus1 = 1 -> ref_rps_idx = 0
+  builder.AppendBool(true);   // delta_rps_sign = 1 (negative)
+  builder.AppendUE(209);      // abs_delta_rps_minus1 = 209 -> delta_rps = -210
+  builder.AppendBool(true);   // used_by_curr_pic_flag[0]
+  builder.AppendBool(false);  // used_by_curr_pic_flag[1]
+  builder.AppendBool(false);  // use_delta_flag[1]
+  builder.AppendSE(0);        // slice_qp_delta
+  builder.FinishNALU();
+
+  // Slice 2: references SPS RPS 1 via inter-prediction with delta_rps = -10.
+  // Derived RPS: ΔPOC = -201 + (-10) = -211. Encoded length: 13 bits.
+  builder.BeginNALU(H265NALU::TRAIL_R);
+  builder.AppendBool(false);  // first_slice_segment_in_pic_flag
+  builder.AppendUE(0);        // slice_pic_parameter_set_id
+  builder.AppendBits(1, 1);   // slice_segment_address
+  builder.AppendUE(H265SliceHeader::kSliceTypeI);
+  builder.AppendBits(8, 0);   // slice_pic_order_cnt_lsb
+  builder.AppendBool(false);  // short_term_ref_pic_set_sps_flag
+  // Inline st_ref_pic_set (st_rps_idx = 2):
+  builder.AppendBool(true);   // inter_ref_pic_set_prediction_flag
+  builder.AppendUE(0);        // delta_idx_minus1 = 0 -> ref_rps_idx = 1
+  builder.AppendBool(true);   // delta_rps_sign = 1 (negative)
+  builder.AppendUE(9);        // abs_delta_rps_minus1 = 9 -> delta_rps = -10
+  builder.AppendBool(true);   // used_by_curr_pic_flag[0]
+  builder.AppendBool(false);  // used_by_curr_pic_flag[1]
+  builder.AppendBool(false);  // use_delta_flag[1]
+  builder.AppendSE(0);        // slice_qp_delta
+  builder.FinishNALU();
+
+  builder.Flush();
+  parser_.SetStream(builder.data());
+
+  H265SliceHeader first_shdr;
+  ParseUpToSecondSlice(&first_shdr);
+  EXPECT_EQ(first_shdr.st_rps_bits, 23);
+
+  H265NALU nalu;
+  ASSERT_EQ(parser_.AdvanceToNextNALU(&nalu), H265Parser::kOk);
+  ASSERT_EQ(nalu.nal_unit_type, H265NALU::TRAIL_R);
+  H265SliceHeader second_shdr;
+  // Slices 1 and 2 derive identical H265StRefPicSet values, but differ in
+  // encoded RPS bit length (23 vs 13 bits). The parser must reject this
+  // to avoid desyncing stateless hardware decoders.
+  EXPECT_EQ(parser_.ParseSliceHeader(nalu, &second_shdr, &first_shdr),
+            H265Parser::kInvalidStream);
+}
+
 TEST_F(H265CrossSliceTest, RejectsNonFirstSliceSegmentWithoutPriorSliceHeader) {
   H26xAnnexBBitstreamBuilder builder;
   BuildSpsAndPps(builder);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/media/parsers/h265_parser_unittest.cc b/media/parsers/h265_parser_unittest.cc
index 702b134..bc9c91c 100644
--- a/media/parsers/h265_parser_unittest.cc
+++ b/media/parsers/h265_parser_unittest.cc
@@ -910,6 +910,70 @@
     ASSERT_EQ(parser_.ParseSliceHeader(nalu, first_shdr, nullptr),
               H265Parser::kOk);
   }
+
+  // Builds an SPS with two short-term reference picture sets:
+  // RPS 0: ΔPOC = -1 (explicit)
+  // RPS 1: ΔPOC = -201 (explicit)
+  void BuildSpsWithTwoShortTermRefPicSets(H26xAnnexBBitstreamBuilder& builder) {
+    builder.BeginNALU(H265NALU::SPS_NUT);
+    builder.AppendBits(4, 0);  // sps_video_parameter_set_id
+    builder.AppendBits(3, 0);  // sps_max_sub_layers_minus1
+    builder.AppendBits(1, 1);  // sps_temporal_id_nesting_flag
+
+    H265ProfileTierLevel ptl = {};
+    ptl.general_profile_idc = 1;
+    ptl.general_level_idc = 120;
+    BuildPackedH265ProfileTierLevel(builder, ptl, true, 0);
+
+    builder.AppendUE(0);       // sps_seq_parameter_set_id
+    builder.AppendUE(1);       // chroma_format_idc = 1 (4:2:0)
+    builder.AppendUE(32);      // pic_width_in_luma_samples
+    builder.AppendUE(16);      // pic_height_in_luma_samples
+    builder.AppendBits(1, 0);  // conformance_window_flag
+    builder.AppendUE(0);       // bit_depth_luma_minus8
+    builder.AppendUE(0);       // bit_depth_chroma_minus8
+    builder.AppendUE(4);       // log2_max_pic_order_cnt_lsb_minus4
+    builder.AppendBits(1, 0);  // sps_sub_layer_ordering_info_present_flag
+    builder.AppendUE(1);       // sps_max_dec_pic_buffering_minus1
+    builder.AppendUE(0);       // sps_max_num_reorder_pics
+    builder.AppendUE(0);       // sps_max_latency_increase_plus1
+    builder.AppendUE(0);       // log2_min_luma_coding_block_size_minus3
+    builder.AppendUE(1);       // log2_diff_max_min_luma_coding_block_size
+    builder.AppendUE(0);       // log2_min_transform_block_size_minus2
+    builder.AppendUE(0);       // log2_diff_max_min_transform_block_size
+    builder.AppendUE(0);       // max_transform_hierarchy_depth_inter
+    builder.AppendUE(0);       // max_transform_hierarchy_depth_intra
+    builder.AppendBits(1, 0);  // scaling_list_enabled_flag
+    builder.AppendBits(1, 0);  // amp_enabled_flag
+    builder.AppendBits(1, 0);  // sample_adaptive_offset_enabled_flag
+    builder.AppendBits(1, 0);  // pcm_enabled_flag
+
+    // num_short_term_ref_pic_sets = 2
+    builder.AppendUE(2);
+
+    // RPS 0: num_negative_pics = 1, num_positive_pics = 0, delta_poc_s0_minus1
+    // = 0 (ΔPOC = -1), used = 1
+    builder.AppendUE(1);
+    builder.AppendUE(0);
+    builder.AppendUE(0);
+    builder.AppendBool(true);
+
+    // RPS 1: inter_ref_pic_set_prediction_flag = 0
+    builder.AppendBool(false);
+    // num_negative_pics = 1, num_positive_pics = 0, delta_poc_s0_minus1 = 200
+    // (ΔPOC = -201), used = 1
+    builder.AppendUE(1);
+    builder.AppendUE(0);
+    builder.AppendUE(200);
+    builder.AppendBool(true);
+
+    builder.AppendBits(1, 0);  // long_term_ref_pics_present_flag
+    builder.AppendBits(1, 0);  // sps_temporal_mvp_enabled_flag
+    builder.AppendBits(1, 0);  // strong_intra_smoothing_enabled_flag
+    builder.AppendBits(1, 0);  // vui_parameters_present_flag
+    builder.AppendBits(1, 0);  // sps_extension_present_flag
+    builder.FinishNALU();
+  }
 };
 
 TEST_F(H265CrossSliceTest, RejectsMismatchedNalUnitType) {
@@ -992,6 +1056,72 @@
             H265Parser::kOk);
 }
 
+TEST_F(H265CrossSliceTest, RejectsMismatchedShortTermRefPicSetBitLength) {
+  H26xAnnexBBitstreamBuilder builder;
+  BuildSpsWithTwoShortTermRefPicSets(builder);
+
+  H265PPS pps = {};
+  pps.pps_pic_parameter_set_id = 0;
+  pps.pps_seq_parameter_set_id = 0;
+  BuildPackedH265PPS(builder, pps);
+
+  // Slice 1: references SPS RPS 0 via inter-prediction with delta_rps = -210.
+  // Derived RPS: ΔPOC = -1 + (-210) = -211. Encoded length: 23 bits.
+  builder.BeginNALU(H265NALU::TRAIL_R);
+  builder.AppendBool(true);  // first_slice_segment_in_pic_flag
+  builder.AppendUE(0);       // slice_pic_parameter_set_id
+  builder.AppendUE(H265SliceHeader::kSliceTypeI);
+  builder.AppendBits(8, 0);   // slice_pic_order_cnt_lsb
+  builder.AppendBool(false);  // short_term_ref_pic_set_sps_flag
+  // Inline st_ref_pic_set (st_rps_idx = 2):
+  builder.AppendBool(true);   // inter_ref_pic_set_prediction_flag
+  builder.AppendUE(1);        // delta_idx_minus1 = 1 -> ref_rps_idx = 0
+  builder.AppendBool(true);   // delta_rps_sign = 1 (negative)
+  builder.AppendUE(209);      // abs_delta_rps_minus1 = 209 -> delta_rps = -210
+  builder.AppendBool(true);   // used_by_curr_pic_flag[0]
+  builder.AppendBool(false);  // used_by_curr_pic_flag[1]
+  builder.AppendBool(false);  // use_delta_flag[1]
+  builder.AppendSE(0);        // slice_qp_delta
+  builder.FinishNALU();
+
+  // Slice 2: references SPS RPS 1 via inter-prediction with delta_rps = -10.
+  // Derived RPS: ΔPOC = -201 + (-10) = -211. Encoded length: 13 bits.
+  builder.BeginNALU(H265NALU::TRAIL_R);
+  builder.AppendBool(false);  // first_slice_segment_in_pic_flag
+  builder.AppendUE(0);        // slice_pic_parameter_set_id
+  builder.AppendBits(1, 1);   // slice_segment_address
+  builder.AppendUE(H265SliceHeader::kSliceTypeI);
+  builder.AppendBits(8, 0);   // slice_pic_order_cnt_lsb
+  builder.AppendBool(false);  // short_term_ref_pic_set_sps_flag
+  // Inline st_ref_pic_set (st_rps_idx = 2):
+  builder.AppendBool(true);   // inter_ref_pic_set_prediction_flag
+  builder.AppendUE(0);        // delta_idx_minus1 = 0 -> ref_rps_idx = 1
+  builder.AppendBool(true);   // delta_rps_sign = 1 (negative)
+  builder.AppendUE(9);        // abs_delta_rps_minus1 = 9 -> delta_rps = -10
+  builder.AppendBool(true);   // used_by_curr_pic_flag[0]
+  builder.AppendBool(false);  // used_by_curr_pic_flag[1]
+  builder.AppendBool(false);  // use_delta_flag[1]
+  builder.AppendSE(0);        // slice_qp_delta
+  builder.FinishNALU();
+
+  builder.Flush();
+  parser_.SetStream(builder.data());
+
+  H265SliceHeader first_shdr;
+  ParseUpToSecondSlice(&first_shdr);
+  EXPECT_EQ(first_shdr.st_rps_bits, 23);
+
+  H265NALU nalu;
+  ASSERT_EQ(parser_.AdvanceToNextNALU(&nalu), H265Parser::kOk);
+  ASSERT_EQ(nalu.nal_unit_type, H265NALU::TRAIL_R);
+  H265SliceHeader second_shdr;
+  // Slices 1 and 2 derive identical H265StRefPicSet values, but differ in
+  // encoded RPS bit length (23 vs 13 bits). The parser must reject this
+  // to avoid desyncing stateless hardware decoders.
+  EXPECT_EQ(parser_.ParseSliceHeader(nalu, &second_shdr, &first_shdr),
+            H265Parser::kInvalidStream);
+}
+
 TEST_F(H265CrossSliceTest, RejectsNonFirstSliceSegmentWithoutPriorSliceHeader) {
   H26xAnnexBBitstreamBuilder builder;
   BuildSpsAndPps(builder);
Loading diff…

Original Bug Report

reported by [email protected]

Potential stateless HEVC hardware decoder desync via cross-slice RPS bit length divergence

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: Chromium’s H.265 parser enforces the HEVC 7.4.7.1 rule that all slices of a picture carry identical short-term RPS syntax by comparing only the derived RPS values, never the encoded syntax elements or their bit length. Every hardware backend forwards the first slice’s st_rps_bits once per picture; per the V4L2 stateless UAPI, skip-based hardware (Hantro G2 class) uses that single value to skip the RPS while re-parsing every slice header of the frame. A crafted picture whose slices encode the same derived RPS in different bit lengths (23 vs 13 bits) passes all software checks and desyncs the hardware’s parse of slices 2..N, so bounds-bearing header fields (num_entry_point_offsets, ref-idx overrides, QP deltas) are consumed from attacker-chosen bit positions Chromium never validated. Reachable from plain web content (<video>/MSE, kPlatformHEVCDecoderSupport enabled by default) with no user interaction.

Affected files:

Estimated timestamp from git blame: 2026-07-22

Summary

Two slice headers can encode byte-for-byte different st_ref_pic_set() syntax of different bit lengths that derive to identical H265StRefPicSet values. Both slices then pass the parser’s 7.4.7.1 consistency check (h265_parser.cc:1504), which compares only the derivation result, and the memcmp block (:1508-1516), which excludes st_rps_bits.

The V4L2 delegate forwards the first slice’s st_rps_bits as v4l2_ctrl_hevc_decode_params.short_term_ref_pic_set_size, a per-frame control. The kernel UAPI (Chromium sysroot, build/linux/debian_bullseye_amd64-sysroot/usr/include/linux/v4l2-controls.h:2397-2414) defines it as the RPS size “of the first slice”, used by stateless hardware to skip the DPB-dependent RPS while re-parsing slice headers — there is no per-slice params control to correct it.

This was demonstrated end-to-end at the decoder→accelerator boundary by driving the real H265Decoder/H265Parser with a crafted 2-slice picture (23-bit vs 13-bit RPS encodings, identical derived values):

  • The stream is accepted; every cross-slice check passes.
  • SubmitFrameMetadata is called once with st_rps_bits == 23 — the exact value cast into the kernel control — while slice 2’s actual RPS is 13 bits and its full NALU is submitted for the same frame.
  • Re-parsing the exact submitted slice-2 bytes with the UAPI-documented 23-bit skip yields num_entry_point_offsets = 80975 (software-validated range for this picture: exactly 0), a phantom num_ref_idx_active_override_flag = 1 (2 ref indices where software validated 1), and a flipped loop-filter flag.

The one step not executed on silicon is the driver/hardware consuming the wrong skip length; that behavior is documented by the UAPI contract but was emulated, not run on a Hantro-G2-class device (none available in the test environment). Drivers that instead re-parse the RPS via num_delta_pocs_of_ref_rps_idx receive that value from the same first-slice-only submission (v4l2_video_decoder_delegate_h265.cc:488-489) and can be desynchronized by an explicit-vs-inter-predicted encoding variant.


Root Cause Analysis

  1. Consistency check compares derivations, not syntax: media/parsers/h265_parser.cc:1490-1517 — for non-first independent slices, RPS equality is EQ_OR_RETURN(shdr, prior_shdr, st_ref_pic_set) over a struct holding only derived values (h265_parser.h:130-144: num_negative_pics, delta_poc_s0/s1, used_by_curr_pic_s0/s1, num_delta_pocs, rps_idx_num_delta_pocs), plus a memcmp over [short_term_ref_pic_set_idx, slice_sao_luma_flag):
    // [`media/parsers/h265_parser.cc:1501-1504`](https://source.chromium.org/chromium/chromium/src/+/main:media/parsers/h265_parser.cc;l=1501)
    // 7.4.7.1: all syntax elements for short-term reference picture set
    // derivation shall have the same values for all coded slice segment
    // NAL units of a codec picture.
    EQ_OR_RETURN(shdr, prior_shdr, st_ref_pic_set);  // <-- derived values only
    
  2. Encoding form is discarded: media/parsers/h265_parser.cc:1784-1799 — inter_ref_pic_set_prediction_flag, delta_idx_minus1, delta_rps_sign, abs_delta_rps_minus1 are locals in ParseStRefPicSet, never stored. The per-slice encoded bit length st_rps_bits (EPB-compensated, :1259-1261) lives in the “Calculated” tail (h265_parser.h:459), outside both compared regions, and is compared nowhere in the tree (its only readers are the three hardware sinks).
  3. Different encodings, identical derivation: with SPS num_short_term_ref_pic_sets = 2 (RPS[0] = {1 negative pic, ΔPOC −1, used}, RPS[1] = {ΔPOC −201, used}), slice 1 inter-predicts from RPS[0] with delta_rps = −210 (23 bits) and slice 2 from RPS[1] with delta_rps = −10 (13 bits). Equations 7-61/7-62 (h265_parser.cc:1819-1863) give both {delta_poc_s0[0] = −211, used = 1, num_delta_pocs = 1, rps_idx_num_delta_pocs = 1} — all checks pass, encoded lengths differ by 10 bits (scalable to ~42 bits within the :1799 range gate).
  4. First-slice-only forwarding: media/gpu/h265_decoder.cc:404-407 — non-first slices skip StartNewFrame (curr_pic_ exists), so SubmitFrameMetadata (:1104-1106) runs once per picture with slice 1’s header:
    // [`media/gpu/v4l2/v4l2_video_decoder_delegate_h265.cc:479-482`](https://source.chromium.org/chromium/chromium/src/+/main:media/gpu/v4l2/v4l2_video_decoder_delegate_h265.cc;l=479)
    struct v4l2_ctrl_hevc_decode_params v4l2_decode_param = {
        .pic_order_cnt_val = pic->pic_order_cnt_val_,
        .short_term_ref_pic_set_size = static_cast<__u16>(slice_hdr->st_rps_bits),  // <-- slice 1 only
        .long_term_ref_pic_set_size = static_cast<__u16>(slice_hdr->lt_rps_bits),
    
    All slice NALUs (headers included) are appended into one OUTPUT buffer (v4l2_video_decoder_backend_stateless.cc:294-320); the delegate sets no per-slice control.
  5. Hardware-side desync voids software validation: per the UAPI contract (v4l2-controls.h:2397-2414), skip-based stateless hardware applies the 23-bit skip to slice 2’s 13-bit RPS, shifting its parse of every post-RPS field by 10 bits. Chromium’s IN_RANGE_OR_RETURN sanitization (h265_parser.cc:1339-1350, 1409-1412, 1448-1481) validated only the aligned reads, so the values hardware consumes at the shifted positions — including the num_entry_point_offsets loop bound and ref-idx overrides — are attacker-chosen and unvalidated.

Reproduction

  1. Craft an HEVC stream: SPS with two explicit short-term RPS candidates (ΔPOC −1 and −201); PPS with WPP or tiles enabled (so num_entry_point_offsets is present); one IDR picture, then one picture split into two independent P-slice segments using inter-RPS prediction as in step 3 above (23-bit and 13-bit encodings, both deriving ΔPOC −211).
  2. Choose slice 2’s bits so the aligned parse passes all software range checks while the same region read 10 bits later decodes to out-of-range values (e.g. a huge ue(v) at the hardware’s num_entry_point_offsets position).
  3. Serve via <video>/MSE to a device using V4L2 stateless HEVC decoding (e.g. ARM ChromeOS with a Hantro-G2-class decoder).
  4. Expected: the parser rejects the picture, since the slices violate the 7.4.7.1 same-syntax requirement the check block claims to enforce (a conformant encoder always produces identical RPS encodings across a picture’s slices).

Observed (executed against the real H265Decoder/H265Parser): the stream is accepted — all cross-slice checks pass; SubmitFrameMetadata forwards st_rps_bits = 23 per-picture while slice 2’s submitted NALU carries a 13-bit RPS; re-parsing the submitted slice-2 bytes with the UAPI-documented 23-bit skip yields num_entry_point_offsets = 80975 (validated max 0) and a phantom ref-idx override. The final step — the driver/hardware itself consuming the shifted values — follows from the documented UAPI semantics but was emulated rather than run on hardware, as no stateless V4L2 HEVC device was available.

Severity

medium — The demonstrated capability is a validation bypass that delivers attacker-controlled, bounds-bearing slice-header metadata across the sandboxed-GPU/kernel V4L2 boundary, defeating all software sanitization of non-first-slice header fields; it is reachable from default-enabled web content with no user interaction, and the same first-slice-only value feeds VAAPI and D3D. It is not rated high because no memory corruption inside a Chromium process is demonstrated — the corruption consequence would materialize in kernel-driver/hardware/firmware state, and the decode path runs in the sandboxed GPU/OOP-VD process. The “delegated component” argument does not lower this further: the V4L2 stateless design places bitstream validation in userspace (hardware cannot re-derive the DPB-dependent RPS — that is why the skip-size control exists), and Chromium owns and partially implements exactly this check.

Suggested Fix Direction

In the h265_parser.cc:1490-1517 consistency block, additionally require shdr->st_rps_bits == prior_shdr->st_rps_bits and lt_rps_bits (or compare the raw encoded RPS syntax), returning kInvalidStream on mismatch — conformant encoders already satisfy this. Note that H265Decoder resets last_slice_hdr_ in SetStream (h265_decoder.cc:153-155), so a picture whose slices arrive in separate input buffers skips this block entirely; the fix should cover that path as well.


POC: V4L2 HEVC hardware desync — first-slice-only short_term_ref_pic_set_size with derived-value-only cross-slice RPS check lets different-length st_ref_pic_set encodings reach stateless HW decoders

Strategy: unit-test (crafted HEVC input driven through the real H265Decoder/H265Parser) Bug Type: CWE-20 — improper input validation (software/hardware slice-header parse desync; unvalidated bounds-bearing values forwarded across the kernel/hardware boundary)

Summary

Chromium’s HEVC cross-slice consistency check (media/parsers/h265_parser.cc:1490-1517) enforces HEVC 7.4.7.1 by comparing the derived short-term RPS values (H265StRefPicSet, compared with a defaulted operator== plus a memcmp block), not the encoded syntax. The encoding-form elements (inter_ref_pic_set_prediction_flag, delta_idx_minus1, delta_rps_sign, abs_delta_rps_minus1) are locals in ParseStRefPicSet and are discarded; the per-slice encoded bit length st_rps_bits is compared nowhere.

Meanwhile H265Decoder::StartNewFrame calls SubmitFrameMetadata once per picture with the first slice’s header, and every hardware backend forwards that single slice’s st_rps_bits as a per-picture value:

Per the V4L2 stateless UAPI (Chromium sysroot, build/linux/debian_bullseye_amd64-sysroot/usr/include/linux/v4l2-controls.h:2394-2421), short_term_ref_pic_set_size is the RPS size “of the first slice” and is used by frame-based stateless hardware (Hantro G2 class) to skip the DPB-dependent st_ref_pic_set() while re-parsing every slice header in the OUTPUT buffer (all slice NALUs of the frame are appended into one OUTPUT buffer by media/gpu/v4l2/v4l2_video_decoder_backend_stateless.cc:294-320; no per-slice params control exists).

This POC constructs one picture with two independent slice segments whose st_ref_pic_set() encodings are 23 vs 13 bits long but derive to identical values, so every software check passes, while the hardware is told to skip 23 bits over slice 2’s 13-bit RPS — a 10-bit desync that makes the hardware consume every post-RPS slice-header field (ref-idx overrides, QP delta, num_entry_point_offsets loop bound…) from attacker-chosen bit positions that Chromium’s IN_RANGE_OR_RETURN validation never saw.

Stream construction

SPS with num_short_term_ref_pic_sets = 2:

SPS RPS form derived
RPS[0] explicit, delta_poc_s0_minus1=0, used=1 {1 negative, ΔPOC −1, used}
RPS[1] explicit, delta_poc_s0_minus1=200, used=1 {1 negative, ΔPOC −201, used}

Picture 1: plain IDR (POC 0). Picture 2: TRAIL_R, POC 211, two P slices, both with short_term_ref_pic_set_sps_flag=0 and inter-RPS-predicted RPS:

slice A (first) slice B (second)
delta_idx_minus1 1 → refs RPS[0] (ue = 3 bits) 0 → refs RPS[1] (ue = 1 bit)
delta_rps −210 (abs_delta_rps_minus1=209, ue = 15 bits) −10 (abs_delta_rps_minus1=9, ue = 7 bits)
flags used=[1,0], use_delta[1]=0 (3 bits) identical (3 bits)
encoded size 23 bits 13 bits
derived H265StRefPicSet {ΔPOC −211, used, num_delta_pocs=1, rps_idx_num_delta_pocs=1} identical

−1 + −210 = −201 + −10 = −211; both slices reference the IDR at POC 211−211=0, so the stream is fully decodable. PPS enables WPP (entropy_coding_sync_enabled_flag=1) so num_entry_point_offsets is present in slice headers (software-validated range for this 128x64/CTB-64 picture: exactly 0). Neither slice NALU contains emulation-prevention bytes, so raw bit offsets equal RBSP offsets and st_rps_bits needs no EPB adjustment.

Files

  • make_poc_hevc.py — generator + independent verifier (mirrors the parser’s Exp-Golomb/derivation logic, prints the software-vs-hardware parse of slice B, emits the byte arrays used by the C++ test)
  • poc-frame0.hevc — access unit 1: SPS + PPS + IDR (Annex-B)
  • poc-frame1.hevc — access unit 2: TRAIL_R slice A + TRAIL_R slice B
  • poc.hevc — both access units concatenated
  • chromium-changes.patch — adds H265DecoderTest.RpsDesyncDifferentLengthStRefPicSetEncodings to media/gpu/h265_decoder_unittest.cc (stream bytes embedded; runs the real H265Decoder + H265Parser with the mock accelerator at the exact interface the V4L2/VAAPI/D3D delegates implement)
  • bandaid-fix.patch — parser fix (see below)
  • test-output-vulnerable.log — test run on the unpatched parser (PASSES: stream accepted, desync demonstrated)
  • test-output-fixed.log — same test with bandaid-fix.patch applied (the acceptance assertion fails because Decode() now returns kDecodeError: the stream is rejected)
  • test-output-fixed-regression.log — all 61 H265 tests with the fix: only the POC test “fails” (by design, it asserts the vulnerable acceptance); no regressions

Prerequisites

Chromium checkout with an ASAN (or any) build dir configured with proprietary_codecs=true (HEVC decoder compiled on Linux).

Reproduction

cd /chromium_src/src
git apply /poc-artifacts/chromium-changes.patch
autoninja -C out/asan media_unittests
out/asan/media_unittests \
  --gtest_filter='H265DecoderTest.RpsDesyncDifferentLengthStRefPicSetEncodings'

To regenerate/modify the bitstream: python3 make_poc_hevc.py (writes the .hevc files and prints the C++ arrays).

To verify the fix:

git apply /poc-artifacts/bandaid-fix.patch
autoninja -C out/asan media_unittests
out/asan/media_unittests --gtest_filter='H265DecoderTest.RpsDesync*'   # now rejected -> acceptance EXPECT fails
out/asan/media_unittests --gtest_filter='H265DecoderTest.*:H265*.*'    # all pre-existing tests still pass

Expected Result

On the unpatched tree the test PASSES, proving at the decoder→accelerator boundary (where the hardware delegates read their values):

  1. Both slice segments of picture POC 211 are accepted — the 7.4.7.1 consistency check passes because the derived H265StRefPicSet values are identical (slice_rps[1] == slice_rps[2]).
  2. SubmitFrameMetadata is called once for the picture with slice_hdr->st_rps_bits == 23 — the exact value V4L2 casts into v4l2_ctrl_hevc_decode_params.short_term_ref_pic_set_size.
  3. Slice B’s actual encoded RPS is 13 bits (slice_hdr->st_rps_bits == 13 at SubmitSlice), and its full NALU is submitted for the same frame — so the per-picture skip size hardware applies to slice B is wrong by 10 bits.
  4. Emulating the documented hardware skip on the exact submitted slice B bytes shows the post-RPS fields hardware consumes vs what Chromium validated:
=== slice B post-RPS fields: software-validated vs hardware view (skip=23) ===
  slice_sao_luma_flag: software=0  hardware=0
  slice_sao_chroma_flag: software=0  hardware=0
  num_ref_idx_active_override_flag: software=0  hardware=1   <-- DESYNC
  num_ref_idx_l0_active_minus1: software=0  hardware=1   <-- DESYNC
  five_minus_max_num_merge_cand: software=0  hardware=0
  slice_qp_delta: software=0  hardware=0
  slice_loop_filter_across_slices: software=1  hardware=0   <-- DESYNC
  num_entry_point_offsets: software=0  hardware=80975   <-- DESYNC

num_entry_point_offsets is a hardware-side loop/array bound; software validated it against [0, PicHeightInCtbsY-1] = [0, 0], while the value decoded at the hardware-shifted position is an attacker-chosen 80975. Hardware also sees a phantom reference-index override (2 ref indices where software validated 1).

Output

[ RUN      ] H265DecoderTest.RpsDesyncDifferentLengthStRefPicSetEncodings

=== slice B post-RPS fields: software-validated vs hardware view (skip=23) ===
  slice_sao_luma_flag: software=0  hardware=0
  slice_sao_chroma_flag: software=0  hardware=0
  num_ref_idx_active_override_flag: software=0  hardware=1   <-- DESYNC
  num_ref_idx_l0_active_minus1: software=0  hardware=1   <-- DESYNC
  five_minus_max_num_merge_cand: software=0  hardware=0
  slice_qp_delta: software=0  hardware=0
  slice_loop_filter_across_slices: software=1  hardware=0   <-- DESYNC
  num_entry_point_offsets: software=0  hardware=80975   <-- DESYNC
[       OK ] H265DecoderTest.RpsDesyncDifferentLengthStRefPicSetEncodings (1 ms)
[  PASSED  ] 1 test.

With bandaid-fix.patch applied, the second Decode() returns kDecodeError (0) instead of kRanOutOfStreamData (2) — the divergent stream no longer reaches SubmitSlice/hardware (see test-output-fixed.log), and all 60 pre-existing H265 tests still pass (test-output-fixed-regression.log).

Bandaid fix

bandaid-fix.patch adds to the existing consistency block in media/parsers/h265_parser.cc:

EQ_OR_RETURN(shdr, prior_shdr, st_rps_bits);
EQ_OR_RETURN(shdr, prior_shdr, lt_rps_bits);

i.e., non-first slice segments must match the first slice’s encoded RPS bit lengths (which is what conformant encoders produce, since 7.4.7.1 requires the same syntax elements), not merely the derived values. This closes the gap for all three hardware sinks. mojo::ReportBadMessage does not apply (this is bitstream validation, not IPC); rejecting the stream as kInvalidStream matches how every other slice-header inconsistency is handled.

Why this is a unit-test tier reproduction (environment limits)

  • The vulnerable check + st_rps_bits forwarding only execute inside the hardware-accelerated decode paths (media/gpu). This x86 sandbox has no V4L2 decode device (/dev/video* absent) and no DRI/VAAPI device, and Chromium ships no software HEVC fallback that exercises this parser, so no browser-level path on this host reaches the code. The leased ChromeOS VM fleet exposes only virtio/software GPU — no stateless V4L2 HEVC hardware either.
  • The unit test therefore drives the real H265Parser/H265Decoder (byte-identical code to what runs in the sandboxed GPU/OOP-VD process) and observes the real values at the H265Accelerator interface — the same values v4l2_video_decoder_delegate_h265.cc:481 casts verbatim into the kernel control struct. The only unexecuted step is the hardware itself consuming the wrong skip length, which requires an ARM ChromeOS device with a Hantro-G2-class stateless decoder (e.g. MT8183/RK3399 family). On such a device, the same two access units packaged as fMP4/MSE (HEVC playback is enabled by default: kPlatformHEVCDecoderSupport, media/base/media_switches.cc) deliver this stream from plain web content with no user interaction.
  • The hardware-view parse in the test emulates exactly the behavior the V4L2 UAPI documents for short_term_ref_pic_set_size (“size … of the first slice”, used to skip “the RPS data in slice headers”); drivers that instead re-derive the RPS via num_delta_pocs_of_ref_rps_idx receive that value from the same first-slice-only submission (rps_idx_num_delta_pocs, delegate line 488-489) and remain desynchronized by the explicit-vs-inter-pred encoding variant.

Notes

  • An adjacent observation (not exercised by this POC, which keeps both slices of the access unit in one DecoderBuffer as MSE/mp4 delivery does): H265Decoder::SetStream resets last_slice_hdr_, so slice segments of one picture split across two input buffers skip the 7.4.7.1 consistency check entirely. The bandaid’s EQ_OR_RETURNs inherit this pre-existing scope.
  • The severity ceiling here is bounded by the consequence materializing in kernel-driver/hardware/firmware state rather than a Chromium process; the demonstrated capability is the bypass of all software validation of non-first-slice header fields at the sandbox/kernel API boundary.

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
Links in the report