VLC 3.0.23 VP9 分辨率切换越界写崩溃 PoC 深度解析:64x64 → 64x8192 帧高突变触发陈旧 slice-thread entries 分配
VLC 3.0.23 VP9 分辨率切换越界写崩溃 PoC 深度解析:64x64 → 64x8192 帧高突变触发陈旧 slice-thread entries 分配
本篇技术指南围绕仓库内 vlc-vp9-reschange-crash-poc 这一紧凑型崩溃复现器展开:它用纯标准库 Python 生成一个仅 405 字节的 VP9 IVF 媒体样本,在 VLC 3.0.23(Windows x64)内置的 FFmpeg VP9 解码器中稳定触发 slice-thread 进度数组的越界零写。读完本文,你将掌握该崩溃条件的完整成因链条(sb_rows 计算、陈旧分配与重置循环)、样本的二进制结构与校验方式,以及如何在本地用 VLC 二进制安全重放并解读崩溃码。
概述:一个 405 字节的崩溃复现器
该 PoC 的核心是一份嵌入 Python 脚本中的 VP9 IVF 样本,包含两帧关键数据:
- 第 1 帧:
64x64 - 第 2 帧:
64x8192
两帧保持相同的 VP9 tile-column 布局(样本文件名中的 tc0 即 tile columns = 0),但第二帧的帧高从 64 突变到 8192。正是"帧高变化而 tile-column 数量不变"这一组合,使 VLC 3.0.23 附带的 FFmpeg VP9 解码器在解码第二帧时命中了陈旧(stale)的 slice-thread 进度分配。
复现器本身极小:poc.py 依赖 Python 标准库(argparse、base64、hashlib、json、subprocess、pathlib),无需任何第三方包,生成样本后以 JSON 输出路径、SHA256 哈希与大小,并可选地拉起本地 VLC 进程进行重放。需要强调的是,README 明确标注其研究状态为"不完整、持续进行中"(Research status: incomplete and continuing),它定位为崩溃复现器而非完整利用链。
漏洞机理:slice-thread 进度数组的陈旧分配
entries 数组按当前帧的超级块行数分配
VP9 解码器以切片(slice)并行方式解码,需要一个逐行记录各切片解码进度的数组,README 中称为 entries。关键点在于:该数组的分配大小由当前帧的超级块(superblock)行数决定,而超级块是固定 64x64 像素的块。
对于 64x64 的首帧:
sb_rows = (64 + 63) >> 6 = 1
entries allocation = 1 * sizeof(atomic_int) = 4 bytes
即首帧只需要 1 个 atomic_int(4 字节)的进度槽。对于后续的 64x8192 帧:
sb_rows = (8192 + 63) >> 6 = 128
新帧需要 128 个进度槽。
分辨率改变为何不触发重新分配
正常情况下,分辨率变化会触发解码器内部状态的重置与重新分配;但该路径的特殊之处在于:当 tile-column 数量不发生变化时,entries 数组不会被重新分配,仍然保留首帧按 1 行分配的 4 字节大小,形成陈旧分配。这解释了"必须同时满足帧高变化 + tile-column 稳定"才能复现的条件——若 tile-column 数量变化,分配路径会被刷新,越界条件也就不存在。
重置循环:对陈旧分配的固定模式零写
解码新帧时,VP9 的 slice-thread 重置循环按新帧的 sb_rows 逐行清零:
for (i = 0; i < s->sb_rows; i++)
atomic_store(&s->entries[i], 0);
新帧 sb_rows = 128,而 entries 仍是首帧的 4 字节分配,于是第二帧的解码过程变成一串 4 字节零写,持续越过原本的 4 字节分配边界。在 Windows 上的 VLC 3.0.23 进程中,具体结果取决于堆布局与运行时状态,观测到的表现包括:堆损坏终止(0xC0000374)与访问违规(0xC0000005)。
从样本到崩溃:IVF 容器与 VP9 帧结构
生成后的样本可在本地直接按 IVF 容器格式解析验证(解析逻辑与 poc.py 中内嵌样本的字节布局一致):
| 字段 | 值 | 说明 |
|---|---|---|
| 文件总大小 | 405 字节 | 与 README 声明一致 |
| 文件魔数 | DKIF |
IVF 容器标识 |
| 版本 / 头长 | 0 / 32 字节 | 标准 IVF 头 |
| FourCC | VP90 |
VP9 编码 |
| 容器宽 / 高 | 64 / 64 | IVF 头保留首帧尺寸 |
| 帧率 | rate=1, scale=1 | 时间戳单位为 1s |
| 帧数 | 2 | 第 0、1 帧 |
两帧在文件中的布局(偏移从 IVF 头 32 字节后开始):
| 帧 | 帧载荷大小 | 时间戳 | 数据偏移 |
|---|---|---|---|
| 第 0 帧(64x64) | 94 字节 | 0 | 32 |
| 第 1 帧(64x8192) | 255 字节 | 1 | 138 |
一个值得注意的细节:IVF 容器头部仍记录 64x64(容器头不随帧更新),真正的分辨率变化是编码在 VP9 帧的比特流内部的——两帧的未压缩头前缀(0x82 0x49 0x83 0x42 ...)一致,差异体现在后续的帧尺寸与压缩头区域,这与 README"第二帧改变帧高、保持 tile-column 布局稳定"的描述吻合。
使用方式:生成与校验样本
生成默认的 IVF 样本(输出 vp9_reschange_64x64_to_64x8192_tc0.ivf):
python poc.py
生成到自定义路径:
python poc.py -o sample.ivf
可选地拉起本地 VLC 重放:
python poc.py --vlc "C:\Path\To\VLC\vlc.exe"
脚本在写出样本前会先做内嵌数据的完整性校验:对 base64 解码后的字节计算 SHA256,若与 EXPECTED_SHA256 不符则直接抛出 RuntimeError("embedded sample hash mismatch"),防止样本被篡改。预期哈希为:
F26BDEFBDFD0B44359E314E0BFDE7AEA979D29F80F598749DCCA68AB34F54649
脚本输出结构化的 JSON:
{
"sample": "/abs/path/vp9_reschange_64x64_to_64x8192_tc0.ivf",
"sha256": "F26BDEFBDFD0B44359E314E0BFDE7AEA979D29F80F598749DCCA68AB34F54649",
"size": 405,
"vlc": { "...": "仅指定 --vlc 时出现" }
}
CLI 参数一览(见 poc.py 的 argparse 定义):
| 参数 | 默认值 | 说明 |
|---|---|---|
-o, --output |
vp9_reschange_64x64_to_64x8192_tc0.ivf |
样本输出路径 |
--vlc |
无 | 可选,vlc.exe 绝对路径,用于本地重放 |
--timeout |
8(秒) | VLC 子进程超时,超时判定为 timeout |
VLC 重放:命令行参数逐一解析
当传入 --vlc 时,poc.py 的 run_vlc() 会构造一套适合无人值守重放的参数集,并在 VLC 所在目录(cwd=str(vlc.parent))启动子进程:
vlc.exe -I dummy --dummy-quiet --ignore-config --no-media-library
--play-and-exit --run-time 2 --no-one-instance
--no-qt-privacy-ask --no-qt-error-dialogs --no-crashdump
--no-audio --vout dummy sample.ivf vlc://quit
各参数作用:
| 参数 | 用途 |
|---|---|
-I dummy |
使用无界面 dummy 交互模块,便于自动化 |
--dummy-quiet |
抑制 dummy 模块输出 |
--ignore-config |
忽略用户配置,保证复现环境一致 |
--no-media-library |
禁用媒体库,避免干扰 |
--play-and-exit --run-time 2 |
播放后退出,最多运行 2 秒 |
--no-one-instance |
允许多实例,不与既有 VLC 冲突 |
--no-qt-privacy-ask / --no-qt-error-dialogs |
关闭隐私询问与错误弹窗,防止进程挂起等待交互 |
--no-crashdump |
关闭崩溃转储弹窗 |
--no-audio / --vout dummy |
禁用音频、使用 dummy 视频输出,聚焦解码路径 |
vlc://quit |
播放结束后主动退出 |
子进程结束后,结果中会包含 status(clean / crash:<类型> / nonzero / timeout)、returncode 与十六进制形式 returncode_hex、elapsed 耗时,以及 stdout_tail / stderr_tail(各取末尾 2000 字符)。
崩溃观测与分类
poc.py 内置了一张 Windows 崩溃码表,用于把子进程返回码映射为可读类型:
| 十六进制返回码 | 含义 | 分类结果 |
|---|---|---|
0xC0000005 |
访问违规(access violation) | crash:access_violation |
0xC0000374 |
堆损坏(heap corruption) | crash:heap_corruption |
0xC0000409 |
栈缓冲区溢出(stack buffer overrun) | crash:stack_buffer_overrun |
返回码通过 code32() 做 32 位掩码归一化后参与分类;返回码为 0 判定 clean,超时判定 timeout,其余返回码归为 nonzero。
README 还记录了本地插桩观测到的具体证据:在 plugins/codec/libavcodec_plugin.dll 中,陈旧重置循环位于 RVA 0x698a5c,以固定零写模式对陈旧 entries 分配执行写入。针对 64x64 -> 64x8192 样本的直接存储追踪显示:
- 共
129次entries存储; - 其中
127次越过请求的 4 字节分配; - 其中
114次越过分配器原始可用块(raw usable block)。
可见越界写入规模远超分配本身,且已穿透到堆块的可用区域之外。README 同时提醒:在 Windows VLC 3.0.23 上,进程的具体表现(堆损坏终止或访问违规)取决于堆布局与运行时状态,并非每次重放都呈现同一崩溃码。
测试目标、适用前提与研究边界
README 声明该复现器针对以下环境验证:
- VLC media player 3.0.23 for Windows x64
- 解码模块:
plugins/codec/libavcodec_plugin.dll - VP9 解码器源码谱系:FFmpeg 4.4.x 的 VP9 解码器
关键解码器行为是"分辨率变化但不改变 tile-column 数量时 entries 分配保持陈旧"。因此复现的有效性前提是:目标 VLC 附带的 libavcodec 模块保持上述分配/重置逻辑,且运行在 Windows 平台(堆损坏/访问违规的观测结果依赖平台堆行为)。
仓库同时明确界定:这是一个紧凑的崩溃复现器,关于该原语(primitive)完整可利用性的研究尚不完整、仍在继续。请在自有的本地测试环境中运行——生成媒体文件的唯一用途是复现并研究解码器故障路径,不应在生产环境或他人设备上使用。
延伸阅读
- 复现器本体:poc.py
- 原始研究说明:vlc-vp9-reschange-crash-poc/README.md