超越持续模糊测试:剖析存活多年的关键漏洞

本文探讨了即使项目长期参与 OSS-Fuzz 进行持续模糊测试,仍可能遗漏严重漏洞的原因。通过 GStreamer、Poppler 和 Exiv2 三个实际案例,分析了代码覆盖率低、外部依赖未覆盖、编码逻辑被忽视等关键问题,并提出了一套包含代码准备、覆盖率提升、上下文感知、数值覆盖和分类处理的五步工作流,以帮助安全研究人员发现更隐蔽的缺陷。

在持续模糊测试的炙烤下存活的漏洞

即使一个项目已经被高强度地模糊测试了多年,漏洞仍然可以存活下来。

OSS-Fuzz 是开源领域最具影响力的安全举措之一。在与 OpenSSF 基金会的合作下,它已经帮助发现了开源软件中的数千个漏洞。如今,OSS-Fuzz 免费为超过 1300 个开源项目进行模糊测试。然而,持续模糊测试并非万能药。即使是已经加入多年的成熟项目,仍然可能包含未被发现的严重漏洞。过去一年中,作为我在 GitHub 安全实验室工作的一部分,我审计了一些流行项目,并发现了一些有趣的漏洞。

下面,我将展示三个在 OSS-Fuzz 中注册了很长时间,但关键漏洞仍存活多年的开源项目。它们共同说明了为什么模糊测试仍然需要积极的人工监督,以及为什么仅仅提高覆盖率通常是不够的。

什么是模糊测试?

模糊测试(或 fuzzing)是一种自动化软件测试技术,它通过向程序输入随机或变异的值,并监控程序是否有异常或崩溃。

如果你想了解更多关于模糊测试的知识,请查看我们的 模糊测试 101 课程

Gstreamer

GStreamer 是 GNOME 桌面环境的默认多媒体框架。在 Ubuntu 上,每次你用 Totem 打开多媒体文件、访问多媒体文件的元数据,甚至每次打开文件夹为多媒体文件生成缩略图时,都会用到它。在 2024 年 12 月,我发现了 29 个新的漏洞,其中包括几个高风险问题。

为了理解在一个已经被持续模糊测试七年的软件中如何还能发现 29 个新漏洞,让我们看一下这里可用的公共 OSS-Fuzz 统计数据。如果我们查看 GStreamer 的统计数据,可以看到它只有两个活跃的模糊测试器,代码覆盖率大约为 19%。相比之下,像 OpenSSL 这样被深入研究过的项目有 139 个模糊测试器(是的,139 个不同的模糊测试器,这不是打字错误)。

而流行的压缩库 bzip2 报告的代码覆盖率为 93.03%,这个数字几乎是 GStreamer 覆盖率的五倍。

即使不是模糊测试专家,我们也能猜到 GStreamer 的这些数字一点也不理想。

这引出了我们的第一个原因:OSS-Fuzz 仍然需要人工监督来监控项目覆盖率,并为未覆盖的代码编写新的模糊测试器。我们寄希望于 AI 智能体很快能帮助我们填补这个空白,但在那之前,人类仍然需要手动完成这项工作。

OSS-Fuzz 的另一个问题不是技术性的,而是源于其用户以及他们在注册项目后产生的错误安全感。许多开发人员并非安全专家,因此对他们来说,模糊测试只是他们安全待办事项清单上的又一个勾选框。一旦他们的项目“正在被模糊测试”,他们可能会觉得项目“受到了 Google 的保护”而忘记它。即使项目实际上在构建阶段失败而根本没有被模糊测试(这在 OSS-Fuzz 中不止一个项目发生过)。

这表明,仍然需要人类安全专业知识来维护和支持每个注册项目的模糊测试,而随着 OSS-Fuzz 的成功,这很难进行规模化扩展!

Poppler

Poppler 是 Ubuntu 中默认的 PDF 解析库。当你使用 Evince(Ubuntu 25.04 之前版本的默认文档查看器)或 Papers(GNOME 桌面的默认文档查看器以及较新 Ubuntu 版本的默认文档查看器)打开 PDF 时,使用的就是这个库。

如果我们查看 Poppler 在 OSS-Fuzz 中的统计数据,可以看到它总共包含 16 个模糊测试器,代码覆盖率大约为 60%。这些是相当可靠的数字;也许算不上优秀,但肯定高于平均水平。

然而,几个月前,我的同事 Kevin Backhouse 发布了一个影响 Ubuntu 中 Evince 的“一键式”远程代码执行漏洞。受害者只需打开一个恶意文件,其机器就可能被入侵。像这样的漏洞没有被 OSS-Fuzz 发现的原因是不同的:外部依赖。

Poppler 依赖于相当多的外部依赖项:freetype、cairo、libpng… 根据 Fuzz Introspector 数据库中为这些依赖项报告的低覆盖率,我们可以肯定地说它们没有被 libFuzzer 进行插桩。因此,模糊测试器无法收到这些库的反馈,这意味着许多执行路径从未被测试过。

但情况甚至更糟:Evince 的一些默认依赖项根本没有包含在 OSS-Fuzz 的构建中。DjVuLibre 库就是这种情况,我在其中发现了 Kevin 后来利用的关键漏洞。

DjVuLibre 是一个实现了 DjVu 文档格式支持的库,DjVu 是 20 世纪 90 年代末和 21 世纪初流行的 PDF 开源替代方案,用于压缩扫描文档。自 2008 年 PDF 格式标准化以来,其使用已大大减少。

令人惊讶的是,虽然这个依赖项没有被包含在 OSS-Fuzz 覆盖的库中,但它却是 Evince 和 Papers 默认附带的。因此,这些程序依赖着一个“未经过模糊测试”的依赖项,同时该依赖项又默认安装在数百万系统上。

这是软件安全性仅相当于其依赖关系图中最弱环节的一个明显例子。

Exiv2

Exiv2 是一个 C++ 库,用于读取、写入、删除和修改图像中的 Exif、IPTC、XMP 和 ICC 元数据。它被许多主流项目使用,如 GIMP 和 LibreOffice 等。

早在 2021 年,我的队友 Kevin Backhouse 帮助改善了 Exiv2 项目的安全性。该工作的一部分包括将 Exiv2 注册到 OSS-Fuzz 进行持续模糊测试,这发现了多个漏洞,如 CVE-2024-39695、CVE-2024-24826 和 CVE-2023-44398。

尽管 Exiv2 已经在 OSS-Fuzz 中注册了三年多,但其他漏洞研究人员仍然报告了新的漏洞,包括 CVE-2025-26623 和 CVE-2025-54080。

在这种情况下,原因是在模糊测试媒体格式时一个非常常见的场景:研究人员总是倾向于关注解码部分,因为那是最明显可被利用的攻击面,而编码端受到的关注较少。因此,编码逻辑中的漏洞可能多年未被发现。

从普通用户的角度来看,编码函数中的漏洞可能看起来并不特别危险。然而,这些库通常用于许多后台工作流(如缩略图生成、文件转换、云处理管道或自动化媒体处理),在这些场景下,编码漏洞可能更为关键。

五步模糊测试工作流

至此,很明显模糊测试并非保护你免受一切的魔法解决方案。为了确保最低质量,我们需要遵循一些标准。

在本节中,你将找到我在过去一年中使用并取得很好效果的模糊测试工作流:五步模糊测试工作流(准备 – 覆盖率 – 上下文 – 数值 – 分类处理)。

步骤 1:代码准备

此步骤涉及对目标代码进行所有必要的更改,以优化模糊测试结果。这些更改包括,但不限于:

  • 移除校验和
  • 减少随机性
  • 丢弃不必要的延迟
  • 信号处理

如果你想了解更多关于此步骤的信息,请查看这篇博客文章

步骤 2:提高代码覆盖率

从前面的例子可以清楚地看出,如果我们想改善模糊测试结果,首先需要尽可能提高代码覆盖率。

在我的案例中,工作流通常是一个迭代过程,如下所示:

运行模糊测试器 > 检查覆盖率 > 提高覆盖率 > 运行模糊测试器 > 检查覆盖率 > 提高覆盖率 > …

“检查覆盖率”阶段是一个手动步骤,我在其中查看 LCOV 报告中未覆盖的代码区域。“提高覆盖率”阶段通常是以下之一:

  • 编写新的模糊测试套件来命中原本不可能命中的新代码
  • 创建新的输入用例来触发边界情况

关于自动化的、AI 驱动的提高代码覆盖率的方法,我邀请你查看我的 FRFuzz 框架中的 Plunger 模块。FRFuzz 是我正在进行的一个项目,旨在解决模糊测试工作流中的一些缺陷。我将在未来的博客文章中提供更多关于 FRFuzz 的详细信息。

另一个我们可以问自己的问题是:“我们何时可以停止提高代码覆盖率?” 换句话说,我们何时可以说覆盖率足够好,可以进入下一步?

根据我对许多不同项目进行模糊测试的经验,这个数字应该是 >90%。实际上,在尝试其他策略之前,甚至在启用像 ASAN 或 UBSAN 这样的检测工具之前,我总是试图达到这个覆盖水平。

要达到这个覆盖水平,你不仅需要对最明显的攻击向量(如解码/解复用函数、套接字接收器或文件读取例程)进行模糊测试,还需要对那些不太明显的攻击向量(如编码器/复用器、套接字发送器和文件写入函数)进行模糊测试。

你还需要使用先进的模糊测试技术,例如:

  • 故障注入:一种我们故意引入意外条件(损坏的数据、缺失的资源或失败的系统调用)来观察程序行为的技术。因此,与其等待真正的故障,我们可以在模糊测试期间模拟这些故障。这有助于我们发现那些很少执行的执行路径中的错误,例如:
    • 失败的内存分配(malloc 返回 NULL)
    • 中断的或部分的读/写操作
    • 缺失的文件或不可用的设备
    • 超时或中止的网络连接
  • 快照模糊测试:快照模糊测试可以在任何感兴趣的状态下对程序进行快照,这样模糊测试器就可以在每个测试用例之前恢复这个快照。这对于有状态程序(操作系统、网络服务或虚拟机)特别有用。示例包括 AFL++ 的 QEMU 模式和 AFL++ 的 Nyx 模式。

步骤 3:提高上下文敏感的覆盖率

默认情况下,最常见的模糊测试器(例如 AFL++、libfuzzer 和 honggfuzz)在边缘级别跟踪代码覆盖率。我们可以将“边缘”定义为控制流图中两个基本块之间的转换。因此,如果执行从块 A 到块 B,模糊测试器将边缘 A → B 记录为“已覆盖”。对于模糊测试器运行的每个输入,它会更新一个位图结构,标记哪些边缘被执行过,用 0 或 1 值表示(目前在大多数模糊测试器中实现为一个字节)。

在下面的例子中,你可以在左边看到一个代码片段,右边是其对应的控制流图:

边缘覆盖率 = { (0,1), (0,2), (1,2), (2,3), (2,4), (3,6), (4,5), (4,6), (5,4) }

每个编号的圆圈对应一个基本块,图展示了这些块如何连接以及根据输入可能采取哪些分支。这种代码覆盖率方法以其简单和高效被证明非常强大。

然而,边缘覆盖率有一个很大的局限性:它不跟踪块被执行的顺序。

所以想象一下,你正在对一个围绕插件流水线构建的程序进行模糊测试,其中每个插件读取并修改一些全局变量。不同的执行顺序可能导致非常不同的程序状态,而边缘覆盖率看起来仍然相同。由于模糊测试器认为它已经探索了所有路径,覆盖率引导的反馈将不再继续引导它,发现新漏洞的机会就会下降。

为了解决这个问题,我们可以利用上下文敏感的覆盖率。上下文敏感的覆盖率不仅跟踪哪些边缘被执行,还跟踪在当前边缘之前刚刚执行了什么代码。

例如,AFL++ 为上下文敏感的覆盖率实现了两种不同的选项:

  • 上下文敏感的分支覆盖率:在这种方法中,每个函数都有自己的唯一 ID。当执行一个边缘时,模糊测试器从当前的调用栈中获取 ID,将它们与边缘的标识符一起哈希,并记录组合后的值。
  • N-Gram 分支覆盖率:在这种技术中,模糊测试器将当前位置与前 N 个位置结合起来,创建一个上下文增强的覆盖率条目。例如:
    • 1-Gram 覆盖率:只查看前一个位置
    • 2-Gram 覆盖率:考虑前两个位置
    • 4-Gram 覆盖率:考虑前四个位置

与边缘覆盖率相比,在使用上下文敏感覆盖率时,追求 >90% 的覆盖率是不现实的。最终的数字将取决于项目的架构以及我们决定跟踪调用栈的深度。但根据我的经验,任何高于 60% 的上下文敏感覆盖率都可以被认为是非常好的结果。

步骤 4:提高数值覆盖率

为了解释这一部分,我将从一个例子开始。看看下面的 Web 服务器代码片段:

1
2
3
4
5
uint32_t webserver::unicode_frame_size(const HttpRequest& r) {
    //A Unicode character requires two bytes
    uint32_t size = r.content_length / (FRAME_SIZE * 2 - r.padding);
    return size;
}

这里我们可以看到,函数 unicode_frame_size 已经执行了 1910 次。经过这么多次执行,模糊测试器没有发现任何漏洞。看起来非常安全,对吧?

然而,当 r.padding == FRAME_SIZE * 2 时,存在一个明显的除零漏洞:

1
2
3
4
5
uint32_t webserver::unicode_frame_size(const HttpRequest& r) {
    //A Unicode character requires two bytes
    uint32_t size = r.content_length / (FRAME_SIZE * 2 - r.padding); // 如果 r.padding == FRAME_SIZE * 2,则除数为零
    return size;
}

由于填充是一个客户端控制的字段,攻击者可以通过发送一个填充大小恰好为 2156 * 2 = 4312 字节的请求来触发 Web 服务器的拒绝服务攻击。经过 1910 次迭代后模糊测试器没有发现这个漏洞,这很烦人,你不觉得吗?

现在我们可以得出结论,即使拥有 100% 的代码覆盖率也不足以保证一段代码片段没有漏洞。那么我们如何发现这些类型的漏洞呢?我的答案是:数值覆盖率。

我们可以将数值覆盖率定义为一个变量可以取的值的覆盖范围。换句话说,模糊测试器现在将由变量值范围引导,而不仅仅是由控制流路径引导。

在我们前面的例子中,如果模糊测试器对变量 r.padding 进行了数值覆盖,它可能已经达到了值 4312,从而检测到了这个除零漏洞。

那么,我们如何让模糊测试器在不同的执行路径中转换变量值呢?我想到的第一个简单的实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
inline uint32_t value_coverage(uint32_t num) {
   uint32_t no_optimize = 0;
   if (num < UINT_MAX / 2) {
       no_optimize += 1;
       if(num < UINT_MAX / 4){
           no_optimize += 2;
           //...
       }else{
           no_optimize += 3;
           //...
       }
   }else{
       no_optimize += 4;
       if(num < (UINT_MAX / 4) * 3){
           no_optimize += 5;
           //...
       }else{
           no_optimize += 6;
           //...
       }
   }
   return no_optimize;
}

在这段代码中,我实现了一个函数,它将变量 num 的不同值映射到不同的执行路径。注意 no_optimize 变量是为了避免编译器优化掉函数的某些执行路径。

之后,我们只需要为我们想要进行数值覆盖的变量调用该函数,像这样:

1
2
3
4
5
6
7
8
static volatile uint32_t vc_noopt;

uint32_t webserver::unicode_frame_size(const HttpRequest& r) {
   //A Unicode character requires two bytes
   vc_noopt = value_coverage(r.padding); // 数值覆盖
   uint32_t size = r.content_length / (FRAME_SIZE * 2 - r.padding);
   return size;
}

考虑到这可能产生的巨大执行路径数量,你应该只将其应用于我们认为“战略性”的某些变量。所谓战略性,我指的是那些可以直接由输入控制并涉及关键操作的变量。正如你可以想象的,选择正确的变量并不容易,这主要取决于开发人员和研究的经验。

我们拥有的另一种减少总执行路径数量的选项是使用“桶”的概念:与其测试一个 32 位整数的所有 2^32 个可能值,我们可以将这些值分组到桶中,每个桶对应一个单一的执行路径。通过这种策略,我们不需要测试每一个单独的值,仍然可以获得良好的结果。

这些桶也不需要在整个范围内对称分布。我们可以通过创建更小的桶来强调某些子范围,或者对我们不太感兴趣的范围创建更大的桶。

现在我已经解释了这个策略,让我们看看在模糊测试器中获得数值覆盖有哪些现实世界的选项:

  • AFL++ CmpLog / Clang trace-cmp:这些专注于追踪比较值(在调用 ==、memcmp 等时使用的值)。它们不会帮助我们找到除零漏洞,因为它们只追踪用于比较指令的值。
  • Clang trace-div + libFuzzer -use_value_profile=1:这个在我们的例子中会起作用,因为它追踪涉及除法的值。但它不提供变量级别的粒度,所以我们只能通过源文件或函数来限制其范围,而不能针对特定的变量。这限制了我们仅针对“战略性”变量的能力。

为了克服数值覆盖的这些问题,我使用 LLVM FunctionPass 功能编写了自己的自定义实现。你可以通过查看 FRFuzz 代码 了解更多关于我的实现细节。

最后一英里:几乎无法检测的漏洞

即使你使用了所有最新的模糊测试资源,一些漏洞仍然可以在模糊测试阶段存活下来。下面两种情况尤其难以通过模糊测试来解决。

大型输入用例

这些是需要非常大的输入(数量级为兆字节甚至千兆字节)才能触发的漏洞。它们很难通过模糊测试发现主要有两个原因:

  1. 大多数模糊测试器限制最大输入大小(例如 AFL 中是 1 MB),因为更大的输入会导致更长的执行时间和更低的整体效率。
  2. 总的可能输入空间是指数级的:O(256ⁿ),其中 n 是输入数据的字节大小。即使覆盖率引导的模糊测试器使用启发式方法来处理这个问题,就输入大小而言,模糊测试仍然被认为是一个亚指数问题。因此,随着输入大小的增长,发现漏洞的概率迅速下降。

例如,CVE-2022-40303 是影响 libxml2 的一个整数溢出漏洞,需要大于 2GB 的输入才能触发。

需要“额外时间”才能触发的漏洞

这些是在模糊测试器通常使用的每次执行时间限制内无法触发的漏洞。请记住,模糊测试器旨在尽可能快,通常每秒执行数百或数千个测试用例。在实践中,这意味着每次执行的时间限制大约为 1–10 毫秒,这对于某些类型的漏洞来说太短了。

例如,我的同事 Kevin Backhouse 在 Poppler 代码中发现的一个漏洞很符合这个类别:这个漏洞是一个引用计数溢出,可能导致释放后使用漏洞。

引用计数是一种跟踪指针被引用次数的方式,有助于防止诸如释放后使用或双重释放之类的漏洞。你可以将其视为一种半手动的垃圾收集形式。

在这种情况下,问题是这些计数器被实现为 32 位整数。如果攻击者能将计数器递增 2^32 次,该值将回绕到 0,然后在代码中触发释放后使用。

Kevin 写了一个概念证明,演示了如何触发这个漏洞。唯一的问题是,事实证明它相当慢,使得利用变得不现实:PoC 需要 12 小时才能完成。

这是一个需要“额外时间”才能显现的漏洞的极端例子,但许多漏洞至少需要几秒钟的执行才能触发。即使是这已经超出了现有模糊测试器的典型限制,它们通常设置的每次执行超时时间远少于一秒。

这就是为什么对于模糊测试器来说,发现需要几秒钟才能触发的漏洞几乎是一种妄想。这实际上从模糊测试器可以找到的漏洞中排除了许多现实世界的利用场景。

需要注意的是,尽管模糊测试器超时经常被证明是误报,但检查它们仍然是一个好主意。偶尔它们会暴露真正的与性能相关的拒绝服务漏洞,例如二次循环。

在这些情况下如何继续?

我希望我能给你一个关于在这些情况下如何进行的操作指南。但现实是,我们目前还没有针对这些角落情况的有效模糊测试策略。

目前,主流的模糊测试器还无法捕捉这些类型的漏洞。为了找到它们,我们通常必须转向其他方法:静态分析、混合(符号 + 具体)测试,甚至是传统的(但仍然非常有效的)手动代码审查方法。

结论

尽管模糊测试是我们用于在复杂软件中发现漏洞的最强大工具之一,但它并非一劳永逸的解决方案。持续模糊测试可以发现漏洞,但也可能漏掉一些攻击向量。如果没有人工驱动的工作,整个类别的漏洞可以在流行且关键的项目中存活多年的持续模糊测试。这在上述三个 OSS-Fuzz 例子中显而易见。

我提出了一个五步模糊测试工作流,它超越了单纯的代码覆盖率,还涵盖了上下文敏感覆盖率和数值覆盖率。这个工作流旨在成为一个实用的路线图,以确保你的模糊测试工作超越基础,从而能够发现更难以捉摸的漏洞。

如果你刚开始接触开源模糊测试,我希望这篇博客文章能帮助你更好地理解当前模糊测试的空白以及如何改进你的模糊测试工作流。如果你已经熟悉模糊测试,我希望它能给你新的想法,推动你的研究更进一步,并发现传统方法容易遗漏的漏洞。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计