高效模糊测试:Dav1d案例研究

访问原始链接 Google 翻译

特邀作者:Nick Galloway,高级安全工程师,20%时间用于Project Zero项目

2023年底,在参与Project Zero的20%项目时,我在dav1d AV1视频解码器中发现了一个整数溢出漏洞。该整数溢出会导致内存越界写入。Dav1d 1.4.0版本已修复此问题,并分配了CVE-2024-1580。披露后,我收到了一些关于如何发现此问题的疑问,因为dav1d至少已经被oss-fuzz进行模糊测试。这篇博客文章将解释事情的经过。这是一个关于如何构建模糊测试器以尽可能多地覆盖代码的有用案例研究。但首先,一些背景信息……

背景

Dav1d

Dav1d是一个高度优化的AV1解码器。AV1是由开放媒体联盟(Alliance for Open Media)开发的一种免版税视频编码格式,与旧格式相比,它实现了更高的数据压缩率。AV1得到了网络浏览器的广泛支持,AV1解码器中严重的解析漏洞可能被用作攻击的一部分,以实现远程代码执行。在特定场景下,例如在接收到的消息中解析AV1,这可能允许实现0点击漏洞利用。通过发送AV1视频和AVIF图像(使用AV1编解码器)测试一些流行的即时通讯客户端,结果如下:

  • AVIF图像在iMessage中会显示
  • 通过彩信(MMS)发送时,AVIF图像在Android Messages中不会显示
  • AVIF图像在Google Chat中会显示
  • AV1视频在Google Chat中不会立即显示,但接收者可以下载,并在经过降尺度处理后最终可以播放

Dav1d主要用C语言编写,值得注意的是,它为不同的架构提供了不同的代码路径。代码库中包含x86、x86-64、ppc、riscv、arm32和arm64的代码路径,其中大多数包含优化的汇编代码。正如其路线图所述,对其中一些架构的支持仍在进行中,但至少ARMv7、ARMv8和x86-64已经在实际环境中经过了充分测试。鉴于这是一个用C和汇编编写的库,并且dav1d在网络浏览器中得到了普遍支持,我可能预期它已经通过多个来源获得了优秀的模糊测试覆盖率。

整数溢出

完整的细节,包括两个可用于复现漏洞的概念验证,可在Project Zero漏洞追踪器上找到。简而言之,当使用多个解码线程时,在计算要放入图块起始偏移数组的值时,可能会发生有符号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_idxcbicfframe_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;

这些指针随后会被写入,导致内存越界写入。漏洞报告中提供了两个测试用例,第一个(poc1.obu)将导致地址超出有效地址范围,因此可能无法被利用。另一个测试用例(poc2.obu)启用了高位深模式,因此内存需求更高,但产生的指针在正常地址范围内,因此在漏洞利用中可能更有用。

模糊测试空间定义

模糊测试器的成功通常通过“覆盖率”来衡量,即追踪模糊测试目标的执行情况,以检查哪些汇编代码行已被覆盖。当我谈到“模糊测试空间”时,我特指数学空间意义上的空间,我们希望最大化给定测试用例集所执行的代码行集合。换句话说,一个好的模糊测试器将用尽可能小的测试用例集执行尽可能多的代码行。为了完整定义这个空间,我们还需要考虑生成测试用例的模糊测试引擎、初始种子语料库,以及待测代码支持的各种配置和架构。

修改后的Dav1d模糊测试器

在我研究dav1d时,oss-fuzz中的dav1d模糊测试器可以在GitHub上查看。其中包含了构建说明和一个dockerfile,供oss-fuzz大规模运行此测试器。模糊测试器的实现在dav1d源代码仓库中。meson.build文件显示了几个配置,一个用于构建dav1d_fuzzer,另一个用于构建dav1d_fuzzer_mt,后者额外定义了DAV1D_MT_FUZZING

模糊测试代码用C语言编写,位于dav1d_fuzzer.c中。该测试器实现了LLVMFuzzerTestOneInput,这是使用libfuzzer的标准方式。模糊测试器做的第一件事是任何C函数中常见的变量声明,包括实例化一个全零的Dav1dSettings结构体。稍后,测试器使用一个函数用默认值初始化设置结构体:

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

模糊测试器会根据配置创建一个或四个线程,这很好,但如果漏洞只在有三个线程或19个线程时才存在,那么使用此模糊测试器的任何测试都无法检测到它们。话虽如此,由于线程选项的代码路径似乎主要只在线程数为1或其他数字时有所不同,所以这种情况似乎不太可能。

dav1d库的用户可能会以不同方式配置其他一些配置项。例如,在此模糊测试器中,output_invisible_frames始终为零。如果漏洞仅在此值为非零时才存在,那么模糊测试器在线程化或非线程化配置中都无法捕获它。

dav1d模糊测试器中另一个未覆盖的测试案例,也是对我来说最有趣的一个,因为它导致了整数溢出漏洞的发现,就是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运行多线程模糊测试器)就足以触发整数溢出。为了让模糊测试器探索更多的配置空间,我增加了支持,使模糊测试器也能对可以传递给dav1d的许多配置设置进行模糊测试。下面摘录的代码展示了这种“配置模糊测试”。它只包含一些范围限制,以避免触发断言。如果某些断言在生产系统中不会发生,并且攻击者可以影响配置,这可能是未来值得探索的一个领域。

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);
}

避免在模糊测试器触发巨大分配时报告错误是可以理解的。在可能的情况下,可以通过配置一个分片(shard)来探索这部分模糊测试空间,该分片运行时可用的内存量远大于通常可用量。

我还测试了许多其他途径,但并未发现漏洞。一个例子是在ARM上进行模糊测试,我曾预期由于OSS-Fuzz未覆盖ARM,可能会导致漏洞。尽管这没有发现任何问题,我仍然认为在可能的情况下在其他架构上运行模糊测试是值得的,特别是当目标像dav1d一样,针对不同架构有不同的代码路径和优化汇编时。

结论

我从这件事中得到的最终教训是,寻找漏洞的一个富有成效的领域是模糊测试器内部的人为限制。通过设置相对较小的frame_size_limit,dav1d模糊测试器错过了这个整数溢出。设置这个限制有充分的理由,即oss-fuzz只支持2.5GB的RAM。这凸显了模糊测试器的一个权衡。通过限制RAM使用量,我们可以希望通过在我们拥有的机器中容纳更多的模糊测试器来提高整体覆盖率。不幸的是,这意味着对需要更多内存的那部分模糊测试空间的覆盖率是有限的。

内存安全解析器可用并被广泛使用之前,内存损坏问题将继续对用户构成严重威胁。目前,也许我们可以创建一些模糊测试器,配置为偶尔探索需要更多内存的模糊测试空间部分。

附言:OSS-Fuzz漏洞猎人奖励计划

最后,我想提一下,截至撰写本文时,Google有一个漏洞猎人奖励计划,旨在提高关键OSS项目中的模糊测试覆盖率详见漏洞猎人网站获取更多详情。