Effective Fuzzing: A Dav1d Case Study
原文链接:https://googleprojectzero.blogspot.com/
本文提及的“我”都不是我,而是原文作者Nick Galloway,本文仅翻译(英语也不好)和夹杂一些理解
有效的模糊测试:Dav1d 案例研究
2023 年底,在与 Project Zero 一起从事 20% 的项目时,我在 dav1d AV1 视频解码器中发现了一个整数溢出。该整数溢出会导致越界写入内存。Dav1d 1.4.0 修补了此问题,并为其分配了 CVE-2024-1580。在披露之后,我收到了一些关于这个问题是如何被发现的,因为 dav1d 已经被至少 oss-fuzz 模糊了。这篇博文解释了发生了什么。这是一个有用的案例研究,介绍如何构建模糊测试程序以执行尽可能多的代码。但首先,我们来了解一些背景…
背景
Dav1d
Dav1d 是一款高度优化的 AV1 解码器。AV1 是由开放媒体联盟开发的一种免版税视频编码格式,与旧格式相比,它实现了更好的数据压缩。AV1 受到 Web 浏览器的广泛支持,AV1 解码器中的重大解析漏洞可能被用作攻击的一部分,以获得远程代码执行。在正确的上下文中,如果 AV1 在接收到的消息中被解析,则可能允许 0 点击漏洞。通过发送 AV1 视频和 AVIF 图像(使用 AV1 编解码器)来测试一些流行的消息传递客户端,结果如下:
- AVIF 图像显示在 iMessage 中
- 作为MMS发送时,AVIF 图像不会显示在 Android 消息中
- AVIF 图像显示在 Google Chat 中
- AV1 视频不会立即显示在 Google Chat 中,但可以由接收者下载,最终可以在downscale
后播放
Dav1d 主要用 C 语言编写,值得注意的是,对于不同的架构,它具有不同的代码路径。存储库中有 x86、x86-64、ppc、riscv、arm32 和 arm64 代码路径,其中大多数包含优化的汇编。正如他们的路线图中所指出的,对其中一些的支持正在进行中,但至少 ARMv7、ARMv8 和 x86-64 已经在现场进行了全面测试。基于这是一个用 C 语言和汇编语言编写的库,以及 dav1d 在 Web 浏览器中的普遍支持,我可能认为它已经从多个来源获得了出色的模糊测试覆盖率。
整数溢出
完整的细节包括两个可用于复现漏洞的概念验证(POC),可从Project zero bug tracker。简短的解释是,当使用多个解码线程来计算要放入图块(tile)起始偏移(tile_start_off)数组的值时,可能会发生有符号的 32 位整数溢出。在下面的代码片中造成加法溢出:
f->frame_thread.tile_start_off[tile_idx++] = row_off + b_diff *
f->frame_hdr->tiling.col_start_sb[tile_col] * f->sb_step * 4;
然后,这些在tile_start_off中溢出的值将传递给setup_tile():
setup_tile(&f->ts[j], f, data, tile_sz, tile_row, tile_col++,
c->n_fc > 1 ? f->frame_thread.tile_start_off[j] : 0);
setup_tile()的tile_start_off参数来自上面的f->frame_thread.tile_start_off[j],用于计算多个指针的值。(注意,pal_idx,cbi和cf是frame_thread结构体中的指针,参阅internal.h。
static void setup_tile(Dav1dTileState *const ts,
const Dav1dFrameContext *const f,
const uint8_t *const data, const size_t sz,
const int tile_row, const int tile_col,
const int tile_start_off)
...
ts->frame_thread[p].pal_idx = f->frame_thread.pal_idx ?
&f->frame_thread.pal_idx[(size_t)tile_start_off * size_mul[1] / 8] :
NULL;
ts->frame_thread[p].cbi = f->frame_thread.cbi ?
&f->frame_thread.cbi[(size_t)tile_start_off * size_mul[0] / 64] :
NULL;
ts->frame_thread[p].cf = f->frame_thread.cf ?
(uint8_t*)f->frame_thread.cf +
(((size_t)tile_start_off * size_mul[0]) >> !f->seq_hdr->hbd) :
NULL;
这些指针稍后被写入,从而导致越界写入内存。该bug提供了两个测试用例,其中第一个 (poc1.obu) 将导致地址超出有效地址范围,因此可能无法利用。另一个测试用例 (poc2.obu) 启用high bit depth mode(高位深度模式?),因此具有更高的内存要求,但会导致指针在正常的地址范围内,因此在漏洞利用中更有可能有用。
模糊测试空间定义
一个fuzzer的成功通常通过“覆盖率”来衡量,即通过跟踪目标的执行情况以检查已覆盖的汇编代码行。当我谈论“模糊测试空间”时,我特指数学空间意义上的空间,其中由给定的测试用例集执行的代码行是我们希望最大化的。换句话说,一个好的模糊测试程序将使用尽可能小的测试用例集执行尽可能多的代码行。为了完全定义空间,我们还将考虑生成测试用例的模糊测试引擎、初始种子语料库以及要模糊测试的代码支持的各种配置和架构。
修改后的Dav1d Fuzzer
在我查看 dav1d 时,oss-fuzz 中的 dav1d 模糊测试程序在 GitHub 上可见。这包含构建说明和用于 oss-fuzz 大规模运行此操作的 dockerfile。fuzzer 实现位于 dav1d 源存储库中。meson.build 文件显示了几个配置,一个用于构建 dav1d_fuzzer,另一个用于构建 dav1d_fuzzer_mt,后者还定义了 DAV1D_MT_FUZZING。
模糊测试代码是用 C 语言编写的,可以在 dav1d_fuzzer.c 中找到。fuzzer 实现 LLVMFuzzerTestOneInput,这是使用 libfuzzer 的标准方式。模糊测试程序执行的第一件事是任何 C 函数中的常用变量声明,包括将 Dav1dSettings 结构实例化为全零。稍后,模糊测试器使用一个函数来初始化具有默认值的 settings 结构:
dav1d_default_settings(&settings);
#ifdef DAV1D_MT_FUZZING
settings.max_frame_delay = settings.n_threads = 4;
#elif defined(DAV1D_ALLOC_FAIL)
settings.max_frame_delay = max_frame_delay;
settings.n_threads = n_threads;
dav1d_setup_alloc_fail(seed, probability);
#else
settings.max_frame_delay = settings.n_threads = 1;
#endif
#if defined(DAV1D_FUZZ_MAX_SIZE)
settings.frame_size_limit = DAV1D_FUZZ_MAX_SIZE;
#endif
根据配置,fuzzer会创建一个或四个线程,但如果仅在有 3 个线程或 19 个线程时存在漏洞,则使用此模糊测试程序将无法检测到这些漏洞。也就是说,由于线程选项(threaded option)的代码路径(code paths)似乎仅根据线程数是1还是其他数量而有所不同,因此这似乎不太可能。
dav1d 库的用户可能会以不同的方式配置其他一些配置项。例如,此模糊测试程序中的output_invisible_frames始终为0。如果漏洞仅在 该项为非零时存在,则fuzzer不会在线程或多线程模糊测试配置中捕获此漏洞。
dav1d fuzzer 中另一个未经测试的覆盖率示例是DAV1D_FUZZ_MAX_SIZE的使用,对我来说最有趣的一个,因为它导致了整数溢出漏洞的发现。
#define DAV1D_FUZZ_MAX_SIZE 4096 * 4096
…
#if defined(DAV1D_FUZZ_MAX_SIZE)
settings.frame_size_limit = DAV1D_FUZZ_MAX_SIZE;
#endif
此最大帧大小并非存在于 dav1d 用户使用的所有配置中,尽管 32 位平台具有内部应用的限制,但在默认情况下64 位平台没有限制。删除此行(并在启用 ubsan 的情况下使用多线程fuzzer)足以触发整数溢出。为了允许模糊测试程序探索更多的配置空间,我添加了对fuzzer的支持以便对许多可以传递给 dav1d 的配置设置进行模糊测试。此 “配置模糊”(“configuration fuzzing”)的代码显示在下面的代码片中。它包含了一些只是为了避免触发断言(assert语句)的范围限制。这可能是将来要探索的领域,以防这些asserts中的任何一个未出现在生产系统中,并且配置可能会受到攻击者的影响。
struct SettingsFuzz {
int n_threads;
int max_frame_delay;
int apply_grain;
int operating_point;
int all_layers;
unsigned frame_size_limit;
int strict_std_compliance;
int output_invisible_frames;
int inloop_filters;
int decode_frame_type;
};
Dav1dSettings newSettings(struct SettingsFuzz sf) {
Dav1dSettings settings = {0};
dav1d_default_settings(&settings);
// Some of these trigger an assert if they're out of range
if (sf.n_threads < 0 || sf.n_threads > DAV1D_MAX_THREADS) {
sf.n_threads = DAV1D_MAX_THREADS / 2;
}
settings.n_threads = sf.n_threads;
if (sf.max_frame_delay < 0 || sf.max_frame_delay > DAV1D_MAX_FRAME_DELAY) {
sf.max_frame_delay = DAV1D_MAX_FRAME_DELAY;
}
settings.max_frame_delay = sf.max_frame_delay;
settings.apply_grain = sf.apply_grain;
if (sf.operating_point < 0 || sf.operating_point > 31) {
sf.operating_point = 0;
}
settings.operating_point = sf.operating_point;
settings.all_layers = sf.all_layers;
settings.frame_size_limit = sf.frame_size_limit;
settings.strict_std_compliance = sf.strict_std_compliance;
settings.output_invisible_frames = sf.output_invisible_frames;
settings.inloop_filters = (enum Dav1dInloopFilterType)sf.inloop_filters;
if (sf.decode_frame_type < 0 ||
sf.decode_frame_type > (int)DAV1D_DECODEFRAMETYPE_KEY) {
sf.decode_frame_type = 0;
}
settings.decode_frame_type = (enum Dav1dDecodeFrameType)sf.decode_frame_type;
return settings;
}
与其对模糊测试程序施加限制,不如将这些限制放在代码本身中。采用最大帧大小,在 32 位系统上,无论frame_size_limit配置如何,dav1d 都会将帧大小限制为 8192*8192。(参阅下方代码片)在没有限制的64位系统上,可以有非常大的 50,000x50,000 大小的帧。
/* On 32-bit systems extremely large frame sizes can cause overflows in
* dav1d_decode_frame() malloc size calculations. Prevent that from occuring
* by enforcing a maximum frame size limit, chosen to roughly correspond to
* the largest size possible to decode without exhausting virtual memory. */
if (sizeof(size_t) < 8 && s->frame_size_limit - 1 >= 8192 * 8192) {
c->frame_size_limit = 8192 * 8192;
if (s->frame_size_limit)
dav1d_log(c, "Frame size limit reduced from %u to %u.\n",
s->frame_size_limit, c->frame_size_limit);
}
人们总希望fuzzer在触发大量分配时避免报错。如果可能,可以使用 配置为 使用比通常可用的内存量大得多的分片 来探索模糊测试空间的这一部分。
我还测试了许多其他没有发现漏洞的途径。一个例子是 ARM 上的模糊测试,我曾预计这可能会导致漏洞,因为它没有被 OSS-Fuzz 覆盖。尽管这没有发现任何内容,但我仍然认为,如果可能的话,在其他架构上运行模糊测试是值得的,特别是当目标具有不同的代码路径和针对不同架构优化的程序集时,就像 dav1d 一样。
结论
我从中得到的最终教训是,寻找漏洞的一个富有成效的领域是模糊测试程序中的人为限制。通过设置相对较小的frame_size_limit,dav1d 模糊测试程序错过了整数溢出。这个限制是有充分理由的,那就是 oss-fuzz 仅支持 2.5GB 的 RAM。这突出了模糊测试程序的权衡。通过限制 RAM 的数量,我们可以希望通过在我们拥有的机器中安装更多的模糊测试来增加整体覆盖率。遗憾的是,这意味着需要更多内存的模糊测试空间部分的覆盖范围有限。
在内存安全解析器可用并得到广泛使用之前,内存损坏问题将继续对用户构成严重威胁。现在,也许我们可以创建模糊测试程序,这些模糊测试程序配置为偶尔探索模糊测试空间中需要更多内存的部分。