从零基础到零日漏洞——快速且简易的模糊测试

241 阅读21分钟

我们到目前为止讨论的代码审计和逆向工程策略,都是为了深入理解程序,从而进行某种变体的污点分析。寻找易受攻击的汇点并将其与可达的源点关联,是一种经久不衰、永远适用的策略。然而,即使借助各种自动化框架和节省时间的方法来高效分析目标,随着目标的复杂度增加,发现漏洞也变得愈发困难。

既然你的目标是快速且高效地发现漏洞,有时候“快速行动,打破常规”的方式能通过纯粹的暴力破解,斩断复杂问题的戈尔迪之结。在开锁领域,有些锁不是通过小心翼翼地一根根拨动锁针破解的,而是通过猛烈且快速地拨动锁针来达成,我们也可以采取类似的策略,先对程序进行模糊测试,然后再投入更多高级技术。

本章将教你如何快速利用经典模糊测试工具如 boofuzz 和 radamsa,对消息队列遥测传输协议(MQTT)及开源项目 NanoMQ 和 libxls 进行模糊测试。你还将学会使用现有的二进制模板和 FormatFuzzer 工具,快速启动文件格式的模糊测试。过程中,你会用到 AddressSanitizer 来检测和分析内存破坏漏洞。

为什么模糊测试有效

模糊测试是指为程序生成大量不同的输入,然后用这些输入进行测试。这些输入可能触发程序的异常行为或崩溃,从而揭示漏洞的存在。从某种意义上说,它是污点分析这种细致严谨方法的混沌反面。

污点分析虽然是一种全面的漏洞发现策略,但它的弱点恰是模糊测试的优势。例如,污点分析非常依赖你能否准确识别易受攻击的汇点,但并非所有漏洞都源自 memcpy 这样的简单缓冲区溢出。在内存破坏,尤其是堆破坏中,漏洞可能只在特定事件序列之后才会发生。

此外,在复杂程序中,可能存在大量潜在的漏洞汇点,比如路径遍历漏洞或时序竞态漏洞(TOCTOU)。试图完全捕获所有这些漏洞点的范围几乎是不可能的;你很可能因范围太广,在关注某些点的同时忽略了其他漏洞点。

那么,能否自动化地探索这些路径,而不是费力地逐个从汇点往源点(或反向)追踪?回想第20页“汇点到源点分析”中的迷宫比喻,与其手动从中心向外寻找出口,不如让计算机瞬间暴力穷举所有可能路径。

对于人类难以完成的任务,计算机尤其擅长,尤其可以利用多台计算机并行处理。此外,计算机不会疲劳或疏忽。如果你合理设计输入参数,就可以相当有信心保证模糊测试覆盖所有可能的组合。你只需静待漏洞被触发。这就是模糊测试令人期待的美妙之处。

模糊测试的标准与方法

由于模糊测试涵盖范围广泛,学术界和业界开发了多种多样的模糊测试工具和方法。它们可以根据多个维度进行分类,这些维度包括对目标信息的依赖程度、生成输入的方法、针对的输入类型以及是否使用反馈机制。根据你的目标,某些维度可能比其他维度更重要(请注意,下述分类并非穷尽所有模糊测试细分类型,也未完全涵盖各种方法的细微差别)。

目标信息

模糊测试器可以利用目标程序的各种信息,如预期格式、代码覆盖率及实现细节(源代码)。这些信息帮助引导模糊测试器构造新输入,避免简单的随机比特翻转,提高触发新行为的概率。根据依赖目标信息的程度,模糊测试器分为三类:

  • 黑盒模糊测试(Black-box)
    生成输入时对程序实现或内部结构几乎无了解。
  • 灰盒模糊测试(Gray-box)
    对程序实现或内部结构有部分了解,比如通过动态二进制插桩获取基本块级别的代码覆盖率。
  • 白盒模糊测试(White-box)
    完全了解程序实现或内部结构(即拥有源代码)。

输入生成方式

模糊测试器生成测试输入可以基于已有的种子输入(seed)或从零开始生成。种子是你提供给模糊测试器用以变异生成新输入的初始输入集,对模糊测试结果影响很大。例如,若你给模糊测试器一个 PDF 文件作为种子,但用它产生的输入去测试一个图片查看器程序,无论运行多久效果都不会理想。一些模糊测试器完全不依赖种子,而是根据预定义模板生成输入。两类主要类型:

  • 基于变异的模糊测试(Mutation-based)
    通过变异一组有效输入种子来生成测试输入。
  • 基于生成的模糊测试(Generation-based)
    根据预定义的输入格式规范生成输入,例如基于语法的模糊测试器定义有效输入的语法规则,如合法符号和序列。

输入类型

模糊测试可应用于多种输入类型,模糊测试器通常针对特定目标进行优化,例如:

  • 文件模糊测试器
    针对文件格式,如二进制文件格式(JPEG)或文本文件格式(XML)。
  • 协议模糊测试器
    针对网络协议,如多步协议(FTP)。
  • API 模糊测试器
    通过修改 API 请求针对 Web API。

反馈循环

模糊测试器可利用前一次测试结果的数据来调整下一次输入的变异策略,依据某种探索策略决定优先变异哪些测试用例。例如,若模糊测试器目标是最大化代码覆盖率,它会优先变异那些曾触发新代码路径或新指令的测试用例。反馈机制与目标信息相关,通常只在灰盒或白盒模糊测试中可用。根据反馈机制,模糊测试器大致分为两类:

  • “愚笨型”
    仅使用简单反馈如崩溃或卡死来判断成功测试用例,但不会利用这些信息优先生成新的输入。例如大多数通用模糊测试器随机翻转比特或变异基础数据类型。
  • “智能型”
    利用启发式算法或覆盖率反馈,根据探索策略优化输入生成。例如,覆盖率引导的模糊测试器会优先考虑那些能增加程序覆盖率的种子输入。

持续发展

新的模糊测试策略不断被提出和完善,原因很简单:任何特定策略只会发现某一类漏洞。通过调整策略,比如使用不同的变异器或消毒器,可以发现不同的漏洞。

如前所述,各类模糊测试器之间并非互斥,且往往有重叠。例如,微软研究人员开发的白盒模糊测试器 SAGE 使用符号分析最大化程序覆盖率而无传统反馈环路;黑盒模糊测试器也可采用基于变异的方式,等等。此外,随着时间推移,对各种分类的定义也在演变,比如什么样的反馈才算“智能”。

模糊测试框架允许研究人员根据目标构建不同的模糊测试工作流程。这些框架不仅生成输入,还能监控执行、记录崩溃并进行崩溃分类,支持跨程序和多输入类型的模糊测试扩展。

本书涉及多个漏洞研究领域,而非仅限模糊测试。如需深入,可参考 Andreas Zeller、Rahul Gopinath、Marcel Böhme、Gordon Fraser 和 Christian Holler 合著的《The Fuzzing Book》(www.fuzzingbook.org),或适合初学者的 h0mbre 博客系列“Fuzzing Like a Caveman”(h0mbre.github.io/Fuzzing-Lik…),后者从基础概念讲起,涵盖变异、崩溃监控、覆盖率引导等关键技术,并提供实践性强的逐步进阶教程。

黑盒模糊测试与 boofuzz

正如我的一位模糊测试导师所说:“有模糊测试总比没有好。”在研究特定格式或协议时,通常最好先从快速且简易的“笨拙型”或黑盒模糊测试开始,这类测试能快速应用于广泛的目标。

例如,当我初次接触 dBase 数据库文件格式时,使用 Peach Fuzzer(现已开源为 GitLab Protocol Fuzzer Community Edition)快速对各种 DBF 解析器进行模糊测试。这帮助我识别了低挂果实和许多小型、维护不善程序在 DBF 解析上的常见错误。

简单的模糊测试还能为后续更深入的“智能型”模糊测试指明方向,帮助确定程序中需要重点关注的重要部分。许多漏洞研究团队都采用类似的工作流程,比如 Claroty 的 Team82 团队,在他们的研究方法论博客中对此有详细描述(claroty.com/team82/rese…)。简言之,快速推进、敢于打破常规可以最大化时间和资源的利用。刚开始模糊测试时不必过于追求完美的测试环境,随着对瓶颈和数据的了解逐渐完善即可。

因此,采用简单模糊测试器且配置简单的策略并不令人意外。本章示例将展示相对较老、复杂度较低的模糊测试器在发现即使是像 libxls 这样已经广泛测试的项目漏洞时,仍表现出惊人效果。尽管许多开源项目测试时采用现代模糊测试器,这些工具往往聚焦于代码特定部分或者运行时间不够长,从而错过简单模糊测试器能轻易捕获的低挂果实。接下来,我们从构建 boofuzz 的协议模糊测试模板开始,并在简单目标上测试它。

boofuzz 简介

Boofuzz 是“怪兽公司”系列模糊测试器的一员,这个系列最早由 Sulley 开创。Sulley 是一个基于 Python 的开源网络协议模糊测试框架,其名字来源于电影中那只高大、蓝色、毛茸茸的角色。尽管 Sulley 早已停止维护,boofuzz 继承了它的地位。研究人员还开发了多个分支版本,如 Fuzzowski 和 OPCUA Network Fuzzer,专门针对工业控制系统的网络协议。

相较于现代模糊测试器,boofuzz 并不算特别复杂,但它提供了简单易学且可定制的脚本 API。这在针对多分支路径和必须按序执行(如握手流程)的网络协议时尤其有用。

boofuzz 属于基于生成的模糊测试器,需要你定义要测试协议的规格。虽然这需要一定准备工作,但与基于变异的模糊测试器(如 radamsa)相比,准备负担不一定更重。因为变异型模糊测试器同样需要有效的初始输入集(如抓包文件)进行分析和变异。

一般来说,简单且文档齐全的格式和协议更适合使用基于生成的模糊测试器,而复杂或专有格式更适合变异型模糊测试器。这个示例中,你将使用 boofuzz 针对 MQTT 协议进行模糊测试,MQTT 是物联网设备中常用的一种轻量级发布/订阅通信协议。

探索 MQTT 协议

编写基于生成的模糊测试规格时,你需要了解协议使用的关键数据结构和消息类型。首要参考应是该协议的 RFC 或等效文档。

MQTT 是一项官方标准,文档地址为 mqtt.org/mqtt-specif…。根据 MQTT 控制包章节(docs.oasis-open.org/mqtt/mqtt/v…),MQTT TCP 数据包由以下部分组成:

  • 固定头(Fixed header)
    每个 MQTT 包必须包含的头部,包含 4 位控制包类型(无符号)、4 位标志位以及 1 到 4 字节的可变字节整数,表示当前控制包中剩余字节数。
  • 可变头(Variable header)
    可选头部,内容随控制包类型变化,如 2 字节的包标识符和额外属性。
  • 载荷(Payload)
    包载荷,内容依控制包类型而不同。

规格中还详细定义了各控制包类型特定的结构。例如,PUBLISH 包的可变头包括主题名、包标识符和可选属性。规格中规定了若干顺序和包含关系的要求,如:

  • 客户端与服务器建立网络连接后,客户端发送的第一个包必须是 CONNECT 包 [MQTT-3.1.0-1]。
  • 当客户端或服务器尝试重新发送 PUBLISH 包时,DUP 标志必须设为 1 [MQTT-3.3.1-1];所有 QoS 0 消息的 DUP 标志必须为 0 [MQTT-3.3.1-2]。

鉴于漏洞往往发生在边界情况,比如缺失验证,你不必在模糊逻辑中严格遵循所有要求。但你也要避免简单错误导致的无谓失败,比如未按预期握手启动会话。

一般来说,应尽量遵循序列相关的要求(如第一条条件:首包为 CONNECT 包),而可对基于值的要求(如第二条 DUP 标志设置)进行更大幅度的扰动。同时,要密切监控被测目标的输出,确保你的输入确实触达程序关键部分,而非因检查失败而被丢弃。

MQTT 控制包类型众多,全面理解并覆盖所有类型的特定要求相当复杂。你可以聚焦于一两种包类型及其最小必要的序列。本练习中,将重点关注 PUBLISH 包类型,该包可在 CONNECT 包之后发送。

模糊测试 MQTT 协议

在了解了该协议之后,你现在可以将各种数据结构和消息类型用模糊测试模板或初始输入集的形式表示出来,供模糊测试器生成新的输入。对于 boofuzz,你将编写一个 Python 脚本,利用 boofuzz 的 API 来定义该协议。

请注意 boofuzz 中以下几个关键类和概念:

  • Session
    模糊测试会话的主接口,表现为包含多个请求节点的图结构。
  • Target
    模糊测试目标的主接口。
  • Connection
    与目标的连接,支持多种协议,如 TCP、UDP,甚至二层和三层的原始套接字。
  • Request、block 和 primitives
    协议规格的核心内容,定义请求包应包含的内容。原语(primitives)范围从 s_string 到 s_byte,且可根据大小端、默认值、是否有符号等进行修改。你甚至可以指定某个原语是否需要被模糊测试。

首先,实现客户端必须在发送任何其他数据包之前发送的 CONNECT 包的请求。清单 7-1 展示了一个最简 CONNECT 包的模糊测试脚本(代码也收录在本书代码仓库中)。

from boofuzz import *

session = Session(
    target=Target(connection=TCPSocketConnection(host="localhost", port=1883))
)

s_initialize("Connect")
with s_block("FixedHeader"):
    s_bit_field(
        value=0b00010000,
        width=8,
        fuzzable=False,
        name="ControlPacketTypeAndFlags"
    )
    s_size(
        block_name="Remaining",
        fuzzable=False,
        length=1,
        endian=BIG_ENDIAN,
        name="RemainingLength",
    )
    with s_block("Remaining"):
        with s_block("VariableHeader"):
            s_size(
                block_name="ProtocolName",
                fuzzable=False,
                length=2,
                endian=BIG_ENDIAN,
                name="ProtocolNameLength",
            )
            with s_block("ProtocolName"):
                s_string(value="MQTT", fuzzable=False)
            s_byte(value=5, fuzzable=False, name="ProtocolVersion")
            s_byte(value=2, fuzzable=False, name="ConnectFlags")
            s_word(
                value=60,
                fuzzable=False,
                name="KeepAlive",
                endian=BIG_ENDIAN
            )
            with s_block("Properties"):
                s_byte(value=0, fuzzable=False, name="PropertiesLength")
        with s_block("Payload"):
            s_size(
                block_name="ClientID",
                fuzzable=False,
                length=2,
                endian=BIG_ENDIAN,
                name="ClientIDLength",
            )
            with s_block("ClientID"):
                s_string(fuzzable=True, value="Client1")

session.connect(s_get("Connect"))

session.fuzz()

清单 7-1:用于 MQTT CONNECT 包的 boofuzz 脚本

当你识别出 boofuzz 脚本中的主要原语后,可以将它们映射回 MQTT 规格中的组成部分。为保持代码整洁易调试,应当经常使用 name 参数来标识各原语,比如固定头第一个字节中的 MQTT 控制包类型和标志位 ControlPacketTypeAndFlags ➊。

虽然 boofuzz 支持 width 参数,s_bit_field 最终会被填充到最近的字节边界(8 位),这可能导致包格式错误。你可以查看 boofuzz 中 _render_int 函数的源码 来理解具体细节。在这里,默认的位字段值 0b00010000 会被解析为包类型 1(CONNECT)且标志位为 0。

一个值得关注的原语是 s_size ➋,它用于计算由 block_name 指定的块的大小。在这里用于表示剩余长度字段。根据 MQTT 规格,该字段是一个 1 到 4 字节的可变字节整数,其中每个字节的最高位指示是否有后续字节。规格中还明确指出:“编码的值必须使用表示该值所需的最小字节数。”

由于用现有原语准确捕获此特性较难,这里先简单将其硬编码为 1 字节整数。考虑到 Remaining ➌ 块中的内容不太可能超过 1 字节可变字节整数所能表示的最大值 127,因此不会产生解析问题。

另一个值得注意的原语是 s_word ➍,它表示一个 2 字节的值,对应 Keep Alive 字段。MQTT 规格指出,所有 2 字节和 4 字节整数均为大端格式,且 boofuzz 原语默认是小端,因此必须相应地设置 endian 参数。

由于准确映射 boofuzz 原语到规格比较复杂,初次尝试难免不会完美。理想情况下,你希望 boofuzz 发送的输入格式足够规范,能被目标正确解析且不会因验证失败而被拒绝。毕竟,你要找的正是因缺失验证导致格式错误从而引发异常行为的情况。

你可以通过将所有原语(除了某个可接受任意值如字符串或字节的原语)设置为 fuzzable=False 来检验脚本的正确性。接着启动脚本并使用 Wireshark 等抓包工具捕获生成的包。随后,利用 Wireshark 的协议解析器、测试服务器的调试日志或你自己的手工分析,确认包中固定部分是否格式良好。

你可以将字符串原语 ➎ 设置为 fuzzable=True,并启动本地 Python HTTP 服务器。尽管这并非真正的 MQTT 服务器,且会返回无效响应,但为了完成 fuzzer 与目标的 TCP 握手并开始发送数据包,这是必须的。然后,启动 Wireshark,开始在环回接口(Loopback: lo)捕获数据包:

$ python -m http.server 1883 &
$ pip install boofuzz
$ python fuzz_mqtt.py

你应该能在 Wireshark 捕获中看到数据包,其中协议列显示 MQTT。点击第一个 MQTT 包,即可看到其各字段的详细解析,如图 7-1 所示。

image.png

唯一的不同是 Client ID 字段,因为你允许 boofuzz 对其进行模糊测试。包中的该字段不是默认值 Client1,而是一串特殊字符。在确认 CONNECT 包格式正确后,你可以将该字段的 fuzzable 参数恢复为 False。

构建 CONNECT 包的请求展示了使用 boofuzz 原语表示协议规范的一些技巧。特别要注意字节序(endianness)、预期字节数和特殊数据类型的区别。例如,在 MQTT 规范中,像 ClientID 这样的 UTF-8 编码字符串必须以 2 字节整数长度字段作为前缀。

模糊测试 MQTT PUBLISH 包

接下来,你需要构建 PUBLISH 包。清单 7-2 展示了一个可用的请求规格示例。

s_initialize("Publish")
with s_block("FixedHeader"):
    s_bit_field(
        value=0b00110000,  # ➊
        width=8,
        fuzzable=False,
        name="ControlPacketTypeAndFlags"
    )
    s_size(
        block_name="Remaining",
        fuzzable=False,
        length=1,
        endian=BIG_ENDIAN,
        name="RemainingLength",
    )
    with s_block("Remaining"):
        with s_block("VariableHeader"):
            s_size(
                block_name="TopicName",
                fuzzable=False,
                length=2,
                endian=BIG_ENDIAN,
                name="TopicNameLength",
            )
            with s_block("TopicName"):  # ➋
                s_string(value="test/fuzzme", fuzzable=False)
            with s_block("Properties"):  # ➌
                s_byte(value=0, fuzzable=False, name="PropertiesLength")
        with s_block("Payload"):
            s_bytes(
                fuzzable=True,
                value=b"testfuzz",
                name="ApplicationMessage"
            )  # ➍

session.connect(s_get("Connect"))
session.connect(s_get("Connect"), s_get("Publish"))  # ➎

session.fuzz()

清单 7-2:用于 MQTT PUBLISH 包的 boofuzz 脚本片段

PUBLISH 包的固定头支持 DUP、QoS(服务质量)和 RETAIN 选项标志,但根据规范,你可以将它们全部置为 0 ➋。唯一需要改动的是包类型,应该从 1(CONNECT)改为 3(PUBLISH)。

规范还给出了可变头的示例,包括 UTF-8 编码的主题名称 ➋、包标识符以及一个空的属性集 ➌。由于 QoS 标志设置为 0,包标识符可以省略。最后,负载包含 ApplicationMessage ➍,即 MQTT 协议携带的任意数据。

将这段代码添加到 fuzz_mqtt.py 中。注意,修改后的脚本指定 PUBLISH 包必须在 CONNECT 包之后发送 ➎。这一次,boofuzz 将发送格式正确且 ApplicationMessage 字段经过模糊测试的 PUBLISH 包,如图 7-2 所示。

image.png

这样,你就可以开始对 PUBLISH 包进行模糊测试了。一个挑战是决定哪些字段需要模糊测试。如果你有无限的时间和计算资源,自然可以全部字段都测试一遍。但如果想要优化测试效率,一个较好的经验法则是模糊测试类型-长度-值(type–length–value)字段。例如,RemainingLength、TopicNameLength、TopicName 区块中的字符串原语,以及 PropertiesLength 都是很好的测试候选。把它们的 fuzzable 参数设置为 True,同时把 ApplicationMessage 的 fuzzable 改为 False。

模糊测试 NanoMQ

现在是时候将你的模糊测试脚本应用于一个小型目标了。NanoMQ 是一个实现简单的开源 MQTT 代理。你可以从 github.com/emqx/nanomq… 下载 0.17.5 版本的源码。同时,从 github.com/nanomq/Nano… 下载所需依赖 NanoNNG 的 0.17.2 版本(务必使用这两个版本)。解压后,将 NanoNNG 文件放入 nanomq-0.15.0 目录下的 nng 文件夹,再构建 NanoMQ:

$ mv NanoNNG-0.17.2/* nanomq-0.17.5/nng
$ cd nanomq-0.17.5
$ mkdir build
$ cd build
$ cmake -DDEBUG=ON -DASAN=ON ..
$ make

你可能注意到多了两个 cmake 参数,它们分别是添加调试信息和 AddressSanitizer(ASan)内存错误检测器到编译后的二进制。ASan 是一种编译器的 Sanitizer,通过编译时加入额外的检测代码,在运行时捕获潜在的内存错误。

对模糊测试来说,编译时加入 ASan 很有用,因为不是所有内存破坏漏洞都会立即导致程序崩溃,否则你可能会漏掉一些漏洞。ASan 不仅能捕获崩溃,还能提供详细的溢出位置和性质信息。

ASan 通过编译时的检测模块和运行时库来拦截所有内存读写操作,并将其与“影子内存”区域对比,影子内存是对原始内存的映射。为检测越界访问,ASan 在分配内存周围设置“毒性红区”,检查是否有毒区域被读写。你可以在 github.com/google/sani… 了解更深入的原理。

开始模糊测试之前,先了解一下如果不遵守 MQTT 包的正确顺序会发生什么。修改脚本最后几行如下:

# session.connect(s_get("Connect"))
# session.connect(s_get("Connect"), s_get("Publish"))
➊ session.connect(s_get("Publish"))

session.fuzz()

这意味着 boofuzz 只会发送 PUBLISH 包 ➊,而不会先发送 CONNECT 包。接下来,启动编译好的目标程序和 boofuzz 脚本:

$ ./nanomq/nanomq start &
$ python fuzz_mqtt.py

模糊测试过程中,NanoMQ 会不断输出非法 CONNECT 包类型的错误信息。遇到该错误后,NanoMQ 会直接关闭连接,而不会解析后续包内容。

这反映了端到端“盲”模糊测试的一大挑战。因为执行的是整个程序,你必须处理各种校验,选择哪些校验通过,哪些校验进行模糊测试。此处如果不保证第一个包是 CONNECT,测试时会浪费大量时间只覆盖程序很小一部分。不过,这种方法的好处是,一旦遇到漏洞,说明它是通过程序正常使用路径可以利用的,并且自带现成的概念验证。

将模糊脚本恢复成原始包顺序并重启 NanoMQ。这次,当你启动 boofuzz,应该很快遇到 ASan 报告的崩溃,输出类似:

==43885==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x607000010053 at pc 
0x55e876d8b23f bp 0x7f35d2bf8510 sp 0x7f35d2bf8508
READ of size 1 at 0x607000010053 thread T7
--snip--
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/kali/Desktop/nanomq-0.17.5/nng/src/
supplemental/mqtt/mqtt_codec.c:2788 in read_byte
Shadow bytes around the buggy address: 
  0x0c0e7fff9fb0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fff9fc0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fff9fd0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fff9fe0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fff9ff0: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x0c0e7fffa000: fa fa 00 00 00 00 00 00 00 00[03]fa fa fa fa fa
  0x0c0e7fffa010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fffa020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fffa030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fffa040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0c0e7fffa050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa

ASan 输出很直观:堆缓冲区溢出发生在地址 0x607000010053 ➊,因为有越界读操作。影子字节如图 ➋ 所示,越界读发生在合法可寻址内存的右侧。

由于编译时加入了调试标志,ASan 还能准确定位溢出发生的代码行。本例中,崩溃发生在 nng/src/supplemental/mqtt/mqtt_codec.c 文件的 read_byte 函数:

int
read_byte(struct pos_buf *buf, uint8_t *val)
{
     if ((buf->endpos - buf->curpos) < 1) {
          return MQTT_ERR_NOMEM;
     }
     *val = *(buf->curpos++);  // 崩溃发生在这里

     return 0;
}

这个信息有限,所以继续向上查看调用了 read_byte 的 decode_buf_properties 函数:

/**
 * packet_len: remaining length
 * len: property length
 * */
property *
➊ decode_buf_properties(uint8_t *packet, uint32_t packet_len, uint32_t *pos,
       uint32_t *len, bool copy_value)
{
    int       rv;
    uint8_t * msg_body    = packet;
    size_t    msg_len     = packet_len;
    uint32_t  prop_len    = 0;
    uint8_t   bytes       = 0;
    uint32_t  current_pos = *pos;
    property *list        = NULL;

    if (current_pos >= msg_len) {
        return NULL;
    }

 ➋ if ((rv = read_variable_int(msg_body + current_pos,
             msg_len - current_pos, &prop_len, &bytes)) != 0) {
        *len = 0;
        return NULL;
    }
    current_pos += bytes;
    if (prop_len == 0) {
        goto out;
    }
    struct pos_buf buf = {
        .curpos = &msg_body[current_pos],
     ➌ .endpos = &msg_body[current_pos + prop_len],
    };
    --snip--

    /* Check properties appearance time */
    // TODO

 ➍ while (buf.curpos < buf.endpos) {
        if (0 != read_byte(&buf, &prop_id)) {  // 崩溃发生在这里
            property_free(list);
            break;
        }

该函数接受一个指针参数 *pos ➊,后续会解引用为 current_pos,并用它作为偏移量读取可变字节整数 prop_len ➋。然后 prop_len 被用来确定包属性的结束位置 ➌。最后,当读取器的当前位置小于结束位置时,while 循环会逐字节读取包内容 ➍。

这表明包可能存在格式错误,导致属性长度值过大。虽然 PropertiesLength 允许模糊测试,但从 boofuzz 输出可以看到,导致崩溃的测试用例实际上是针对 RemainingLength 的:

Test Case: 49: Connect->Publish:[Publish.FixedHeader.RemainingLength:48]
Info: Type: Size
Info: Opening target connection (localhost:1883)...
Info: Cannot connect to target; retrying. Note: This likely indicates a
failure caused by the previous test case, or a target that's slow to
restart.

连续运行脚本的一个难点是复现性,因为损坏可能出现在前面的测试用例中,但崩溃只在最近的测试用例触发。你可能需要借助调试器或源代码分析来进一步排查崩溃原因。

另外,遇到崩溃就终止模糊测试并不现实。幸运的是,boofuzz 支持监控器,可以监控目标程序并在崩溃后自动重启。你可以下载并运行进程监控脚本:raw.githubusercontent.com/jtpereyda/b… ,然后修改模糊测试脚本开头,启用进程监控:

procmon = ProcessMonitor('localhost', 26002)
procmon.set_options(
    start_commands=[
        '/home/kali/Downloads/nanomq-0.17.5/build/nanomq/nanomq start'
    ]
)

session = Session(
    target=Target(
        connection=TCPSocketConnection(
            host="localhost",
            port=1883
        ),
        monitors=[procmon]
    )
)

启用进程监控脚本后,你的新模糊测试脚本就能持续运行,日志会保存到 boofuzz-results 目录。同时,boofuzz 提供了一个 Web 界面(http://localhost:26000)用来监控测试进程和查看导致崩溃的测试用例。通常很快就能遇到另一次崩溃:

[2023-08-19 15:23:12,964] Test Case: 142: Connect->Publish:[Publish.FixedHeader.Remaining
                                          .VariableHeader.TopicNameLength:3]
[2023-08-19 15:23:12,964]     Info: Type: Size
[2023-08-19 15:23:12,964]     Info: Opening target connection (localhost:1883)...
[2023-08-19 15:23:12,964]     Info: Connection opened.
--snip--
[2023-08-19 15:23:12,967] Test Step: Fuzzing Node 'Publish'
[2023-08-19 15:23:12,967]     Info: Sending 26 bytes...
[2023-08-19 15:23:12,967]     Info: Target connection reset.
[2023-08-19 15:23:12,967] Test Step: Contact target monitors
[2023-08-19 15:23:12,969]     Check Failed: ProcessMonitor#140535404135056
[localhost:26002] detected crash on test case #142: [03:23.12] Crash. Exit
code: 256. Reason - Exit with code - 1
[2023-08-19 15:23:19,972]     Info: Giving the process 3 seconds to settle in

这次崩溃是由 TopicNameLength 字段导致的。你可以尝试修改模糊测试脚本中的原语和参数,触发不同类型的崩溃。例如,如果你设置了固定头中的 QoS 标志,MQTT 规范规定 PUBLISH 包的可变头必须包含一个 2 字节的包标识符,这样会改变测试覆盖范围并测试程序的其他部分。

零配置基于变异的 Radamsa 模糊测试

有时候,你可能不想费心阅读协议规范或逆向二进制来定义模糊测试的协议或格式。像 Radamsa 这样的基于变异的黑盒模糊测试工具在这种情况下非常有用。Radamsa 是一个通用的基于变异的模糊测试工具,历史悠久。尽管它年代较久,但因其易用性和自称的“极度黑盒”方法,仍然非常受欢迎。

要尝试它,可以先构建并安装 Radamsa:

$ git clone https://gitlab.com/akihe/radamsa
$ cd radamsa
$ make
$ sudo make install

Radamsa 能对通过管道传入的输入或通过文件参数接收的输入进行变异。多次运行时,你可以观察到 Radamsa 根据推断的输入类型随机变异原始输入,变异操作包括位翻转、整数运算、换行符插入等:

$ echo '1337' | radamsa
25701550?337
$ echo '1337' | radamsa
133338????37???????
$ echo '1337' | radamsa
-0??-167296
$ echo '1337' > input.txt
$ radamsa input.txt
1??23298114366028620614915103
$ radamsa input.txt
0337
$ radamsa input.txt
337
1337

与 boofuzz 或其他模糊测试框架不同,Radamsa 仅对测试用例进行变异,这意味着你需要负责将测试用例传给被测目标程序并监控程序是否崩溃。幸运的是,这并不复杂,Radamsa 在其文档中提供了一个示例 bash 脚本,展示了如何用 Radamsa 测试 gzip 二进制,如下所示:

# 创建变异的种子输入
gzip -c /bin/bash > sample.gz
while true
do
    radamsa sample.gz > fuzzed.gz
    gzip -dc fuzzed.gz > /dev/null
    # 返回值大于127表示崩溃
    test $? -gt 127 && break
done

Listing 7-3:使用 Radamsa 模糊测试 gzip 的 bash 脚本

你可以很快将此脚本适配到任何接受简单命令行参数指定变异输入并最终退出的目标程序。当然,对于更复杂的软件,这种情况比较少见,但你后续会学习如何针对这些复杂目标进行模糊测试。

模糊测试 libxls

基于变异的模糊测试一个常见的用例是文件解析库和程序,因为处理畸形输入的能力对于这类软件的安全运行至关重要。你可以使用 Radamsa 来练习针对 libxls C 库的模糊测试,该库提供了解析 XLS 文件的 API。尽管它声称已经通过 libFuzzer 进行了大量模糊测试(后面会详细介绍),但 1.6.2 版本仍然存在内存破坏漏洞。

先下载并解压该版本,然后进行构建:

$ wget https://github.com/libxls/libxls/releases/download/v1.6.2/libxls-1.6.2.tar.gz
$ tar -zxf libxls-1.6.2.tar.gz
$ cd libxls-1.6.2
$ sed -i -e '39,41d' -e '43d' include/libxls/xlstypes.h ➊
$ ./configure
$ make

注意命令中包含了一个用于修复编译时缺失符号的补丁 ➊。

由于 libxls 是一个库,不能直接用 Radamsa 对其进行模糊测试。幸运的是,libxls 也会构建两个可执行文件 test_libxls 和 test2_libxl,并提供了一个测试用的 XLS 文件,作为模糊测试的种子输入。你可以使用它们作为模糊测试的目标。尝试时,可以使用书中代码仓库 chapter-07/radamsa-libxls/fuzz-libxls.sh 中的修改版模糊测试脚本(见 Listing 7-4)。

while true
do
    radamsa test/files/test2.xls > fuzzed.xls
    ./test2_libxls fuzzed.xls > /dev/null
    test $? -gt 127 && break
done

Listing 7-4:使用 Radamsa 对 libxls 进行模糊测试的 bash 脚本

将脚本放在 libxls 目录下,然后执行:

$ chmod +x fuzz-libxls.sh
$ ./fuzz-libxls.sh

运行脚本直到停止,表示发生了崩溃。如果你不重定向输出,重新运行该测试用例,会得到如下输出:

$ ./test2_libxls fuzzed.xls
ole2_open: fuzzed.xls
libxls : xls_open_ole
libxls : xls_parseWorkBook
--snip--
libxls : xls_getWorkSheet
zsh: segmentation fault  ./test2_libxls fuzzed.xls

只用几分钟的模糊测试,你就发现了一个导致崩溃的测试用例!这或许令人惊讶,因为 libxls 声称已经被“广泛模糊测试”过。用于 libFuzzer 的模糊测试代码在 fuzz/fuzz_xls.c 中,其中包括调用 xls_getWorkSheet,因此不太可能没有测试到该易受攻击的函数。实际上,有几个原因可能解释了为什么 Radamsa 找到了 libFuzzer 未发现的测试用例:

  • Radamsa 使用了 libFuzzer 没有的变异器。仅仅通过不同的变异方式,就能发现一整套崩溃的边缘情况。
  • 开发者可能没有对 libxls 进行充分的模糊测试。测试时间越长,发现更多漏洞的概率越大。
  • 开发者可能没有定期对 libxls 进行模糊测试。新功能和 API 可能没有被先前的测试覆盖。

让我们更详细地探讨第二点和第三点。为此,需要站在开发者的角度,了解他们如何在软件开发生命周期中使用模糊测试。

分析 OSS-Fuzz 的模糊测试覆盖率

libxls 项目使用了 Google 提供的 OSS-Fuzz 服务,这是一个面向开源项目的免费模糊测试服务。要使用该服务,开发者必须创建并提交一个模糊测试目标。你可以在 github.com/libxls/libx… 找到 libxls 的 OSS-Fuzz GitHub 工作流程。

默认情况下,OSS-Fuzz 使用 libFuzzer、AFL++、Honggfuzz 和 Centipede 等模糊测试引擎。利用该服务可以让开发者更快地将模糊测试纳入测试工具箱,减少了实现的工作量,因为他们不需要维护自己的模糊测试环境,可以依赖 Google 的基础设施来完成工作。但有时这也会导致模糊测试配置不完整或出现问题,原因我们稍后会探讨。

为了更好的兼容性,提交的模糊测试目标应该包含 LLVMFuzzerTestOneInput 函数,该函数定义在 libxls 项目的 fuzz/fuzz_xls.c 中:

#include "xls.h"

➊ int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
    ➋ xlsWorkBook *work_book = xls_open_buffer(Data, Size, NULL, NULL);
       if (work_book) {
           for (int i=0; i<work_book->sheets.count; i++) {
               xlsWorkSheet *work_sheet = xls_getWorkSheet(work_book, i);
            ➌ xls_parseWorkSheet(work_sheet);
            ➍ xls_close_WS(work_sheet);
           }
           xls_close_WB(work_book);
       }
       return 0;
   }

该标准函数接收两个参数,分别是来自模糊测试输入及其大小 ➊,然后将它们传递给目标库函数 ➋。开发者需要保证重要函数被模糊测试到 ➌,并在每次迭代后进行清理 ➍。

模糊测试任务通过 ClusterFuzz 进行分发,这是一个可扩展的模糊测试基础设施。如果 OSS-Fuzz 在测试过程中检测到崩溃或超时,会自动生成漏洞报告,并最终在 OSS-Fuzz 漏洞跟踪器上公开。你可以在 issues.oss-fuzz.com/issues?q=li… 查看 libxls 的报告。注意 2019 年到 2022 年之间存在较长时间的构建失败,这可能导致某些漏洞被遗漏。

虽然 OSS-Fuzz 可以帮助发现之前缺乏模糊测试项目中的大量低垂果实,但其效果取决于是否正确集成。例如,如果开发者提交的模糊测试目标覆盖率较低,OSS-Fuzz 就无法检测代码的所有部分漏洞。你可以通过 Fuzz Introspector 网页 oss-fuzz-introspector.storage.googleapis.com/index.html 查看 OSS-Fuzz 项目的公开覆盖率报告。令人惊讶的是,据 storage.googleapis.com/oss-fuzz-in… 报告显示,libxls 达到了大约 82% 的函数覆盖率,覆盖率虽好但并不完整。

另一个限制是模糊测试的运行时间。例如,libxls 通过 CIFuzz 集成 OSS-Fuzz,这是一个在每次 GitHub Pull Request 时运行的 GitHub Action。模糊测试默认运行 10 分钟,尽管最长可达 6 小时(GitHub Actions 任务最大运行时间),但大多数项目包括 libxls 都坚持默认时间。

所有这些因素可能导致 libxls 的模糊测试覆盖率存在空白。正如本例所示,即使一个项目已经进行了广泛的模糊测试,使用不同的模糊测试器或延长模糊测试时间,仍然可能发现新的漏洞。新的模糊测试策略也在不断发展,你可以将它们应用于已经被充分模糊测试的目标。即便是像 radamsa 这样的老牌模糊测试器,也能在经过现代覆盖率引导的模糊测试的目标中继续发现漏洞。

引导式模糊测试

在前面两个部分中,你分别从两个极端方法接近了“简单”模糊测试:一种是手动编码格式规范来生成测试用例,另一种是以最少的人工干预对种子输入进行变异。这两种方法各有优缺点,但通过使用已有的格式模板引导生成式模糊测试器,可能实现两者兼得的效果。

基于生成的模糊测试需要你严格定义规范,这可能会任意限制测试用例的范围。例如,如果模糊测试器只关注字符串相关的变异,可能会漏掉位翻转或其他可能触发漏洞的有趣变异。

另一方面,基于变异的模糊测试擅长快速生成大量测试用例,但其中许多用例会无效,无法通过基本的解析器检查,导致大量浪费的测试迭代。例如,考虑 PNG(便携式网络图形)文件格式,它为文件中的每个“数据块”包含循环冗余校验(CRC)校验和。CRC 是一种错误检测码,基于数据块的类型和数据字段用数学算法计算。如果其中任何一个字节发生变化,CRC 校验将无效;同理,如果 CRC 字节被修改,也会无效。

这对基于变异的模糊测试器来说是个问题,因为它们大多数生成的 PNG 测试用例可能都无法通过 CRC 校验,从而无法深入触及目标程序的 PNG 解析逻辑。即使是“智能”的覆盖引导模糊测试器,也需要研究者编写自定义变异器或者在目标源代码中禁用这些检查,才能高效进行模糊测试。

对于基于生成的模糊测试,编码完整的 PNG 规范(包括 CRC 计算)是极其繁重的工作。幸运的是,你不需要从零开始。已有多种声明式二进制结构模板格式被用于模糊测试和解析,比如 Kaitai Struct、010 Editor 二进制模板和 Peach Fuzzer 的 Peach Pit,这些都允许你重用别人写好的各种文件格式模板。

Kaitai Struct 是免费开源的,但通常用于格式解析和解码,而非样本生成。Binary Template 和 Peach Pit 格式起源于商业专有软件,虽然研究者已将其改造用于其他模糊测试项目,如 FormatFuzzer 和 AFLSmart。

举例来说,FormatFuzzer 的 PNG 二进制模板 png.bt 定义了一个通用的数据块结构:

typedef struct {
    // 数据字节数(不包括长度、类型或 crc)
    uint32  length<arraylength=true>;
    local int64 pos_start = FTell(); ➊
    CTYPE   type <fgcolor=cDkBlue>;        // 数据块类型
    if (type.cname == "IHDR") ➋
        PNG_CHUNK_IHDR    ihdr;
    else if (type.cname == "tEXt")
        PNG_CHUNK_TEXT    text;
    --省略--
    else if( length > 0 && type.cname != "IEND" )
        ubyte   data[length];       // 数据(或不存在)
    local int64 pos_end = FTell();
    local uint32 correct_length = pos_end - pos_start - 4;
    // 如有必要,修正长度
    if (length != correct_length) {
        FSeek(pos_start - 4);
        local int evil = SetEvilBit(false);
        uint32  length = { correct_length };
        SetEvilBit(evil);
        FSeek(pos_end);
    }
    local int64 data_size = pos_end - pos_start; ➌
    local uint32 crc_calc = Checksum(CHECKSUM_CRC32, pos_start, data_size); ➍
    // CRC(不包括长度和 crc)
    uint32  crc = { crc_calc } <format=hex, fgcolor=cDkPurple>;
    if (crc != crc_calc) {
        local string msg;
        SPrintf(msg, "*ERROR: CRC Mismatch @ chunk[%d]; in data: %08x; expected: %08x",
            CHUNK_CNT, crc, crc_calc);
        error_message( msg );
    }
    CHUNK_CNT++;
    if (type.cname == "eXIf")
        uint16 pad;
} PNG_CHUNK <read=readCHUNK>;

Binary Template 格式支持复杂逻辑,比如内置的 FTell 函数 ➊ 获取当前读取位置,条件判断 ➋,表达式计算 ➌ 和校验和计算 ➍。虽然这些功能在 010 Editor 中用于文件解析,但将它们适配到文件生成是完全不同的挑战。

FormatFuzzer 会将 Binary Template 转换成生成器和解析器的 C++ 代码,既可以用于基于生成的,也可以用于基于变异的模糊测试。例如,github.com/uds-se/Form… 中的 PNG Binary Template 会被转换为 png.cpp。

下面是 FormatFuzzer PNG Binary Template 里定义 PNG_INTERLACE_METHOD 单字节整型的两个可能枚举值的代码片段:

// 交错方法
➊ typedef enum <byte> pngInterlaceMethod {
       NoInterlace = 0,
       Adam7Interlace = 1
   } PNG_INTERLACE_METHOD;

(摘自 Listing 7-5)

所有关键信息都包含在类型定义中;这是一个单字节枚举,有固定可能的值范围 ➊。与之相比,Listing 7-6 展示了 C++ 代码中相应部分:

enum pngInterlaceMethod : byte { ➊
    NoInterlace = (byte) 0,
    Adam7Interlace = (byte) 1,
};
std::vector<byte> pngInterlaceMethod_values = { NoInterlace, Adam7Interlace };

typedef enum pngInterlaceMethod PNG_INTERLACE_METHOD;
std::vector<byte> PNG_INTERLACE_METHOD_values = { NoInterlace, Adam7Interlace };
--省略--
PNG_INTERLACE_METHOD PNG_INTERLACE_METHOD_generate() { ➋
     return (PNG_INTERLACE_METHOD) file_acc.file_integer(sizeof(byte), 0,
         PNG_INTERLACE_METHOD_values);
}

(摘自 Listing 7-6)

类型定义相同 ➊,但伴随一个生成器函数,调用 FormatFuzzer 内置 API 随机从枚举值范围中选取一个单字节 ➋,正确地构造了 PNG 交错方法字节的模糊测试器。

你可以在一个 DBF 格式玩具示例中练习使用 FormatFuzzer 和 Binary Templates。utdbf 是一个开源 DBF 解析器,包含多个内存破坏漏洞。克隆它:github.com/gwentruong/… 并编译时不加任何 sanitizer 或调试标志:

$ git clone https://github.com/gwentruong/utdbf
$ cd utdbf
$ make

从 GitHub 下载 FormatFuzzer v1.0 并安装依赖:

$ wget https://github.com/uds-se/FormatFuzzer/releases/download/v1.0/FormatFuzzer-v1.0.zip
$ unzip FormatFuzzer-v1.0.zip
$ sudo apt install -y git g++ make automake python3-pip zlib1g-dev libboost-dev
$ pip install py010parser six intervaltree

接下来,你需要生成 DBF 格式的模糊测试器。FormatFuzzer 默认没有 DBF Binary Template,但你可以基于 010 Editor 的 DBF.bt(www.sweetscape.com/010editor/r…)进行定制,并根据 FormatFuzzer 的 GitHub 文档对模板进行优化。

参考 Listing 7-7(书中代码库有完整文件),这是一个兼容 FormatFuzzer 的 DBF Binary Template 片段:

//------------------------------------------------
//--- 010 Editor v2.1.3 Binary Template
//
//      File: DBF.bt
//   Authors: A Norman
//   Version: 0.3
//   Purpose: Parses .dbf (database) format files.
//  Category: Database
// File Mask: *.dbf
//  ID Bytes:
//   History:
//   0.3   2023-08-01 spaceraccoon: Optimized for FormatFuzzer.
//   0.2   2016-01-29 SweetScape: Updated header for repository submission.
//   0.1   A Norman: Initial release.
//------------------------------------------------

string yearFrom1900 (char yy)
{
    string s;
    SPrintf(s, "%d", 1900 + yy);
    return s;
}

struct DBF {
    struct HEADER {
        char version;
        struct DATE_OF_LAST_UPDATE {
            char yy <read=yearFrom1900,format=decimal>;
            char mm <format=decimal>;
            char dd <format=decimal>;
        } DateOfLastUpdate;
        int numberOfRecords;
        short lengthOfHeaderStructure;
        short lengthOfEachRecord;
        char reserved[2];
        char incompleteTrasaction <format=decimal>;
        char encryptionFlag <format=decimal>;
        int freeRecordThread;
        int reserved1[2];
        char mdxFlag <format=decimal>;
        char languageDriver <format=decimal>;
        short reserved2;
    } header;
    struct FIELD {
        char fieldName[11];
     ➊ char fieldType = { 'C', 'D', 'F', 'L', 'M', 'N' };
        char fieldType;
        int fieldDataAddress;
        char fieldLength <format=decimal>;
        char decimalCount <format=decimal>;
        short reserved;
        char workAreaId <format=decimal>;
        short reserved1;
        char flags <format=hex>;
        char reserved2[7];
        char indexFieldFlag <format=decimal>;
 ➋ } field[(header.lengthOfHeaderStructure-33)/32];
    char Terminator <format=hex>;
    struct RECORD {
        char deletedFlag;
        char fields[header.lengthOfEachRecord-1];
    } record [ header.numberOfRecords ] <optimize=false>;
 ➌ char EndOfFile = { 0x1A } <format=hex>;
} dbf <optimize=false>;

(摘自 Listing 7-7)

该模板针对特定字段类型 ➊ 指定了已知的良好取值集合,针对 FormatFuzzer 解析错误,硬编码了字段长度 ➋,最后添加了原模板缺失的文件结束符字节 ➌。

完成模板后,将其移动到 FormatFuzzer 项目的 templates/dbf.bt,然后生成模糊测试器:

   $ cd FormatFuzzer-v1.0
   $ ./ffcompile templates/dbf.bt dbf.cpp
   Finished creating cpp generator.

➊ $ sed -i '21i #include <ctime>' fuzzer.cpp
   $ g++ -c -I . -std=c++17 -g -O3 -Wall fuzzer.cpp
   $ g++ -c -I . -std=c++17 -g -O3 -Wall dbf.cpp
   $ g++ -O3 dbf.o fuzzer.o -o dbf-fuzzer -lz

这修正了一个小的编译错误 ➊,并在当前目录生成了 dbf-fuzzer,可用它为 utdbf 生成测试输入。

你可以用类似 radamsa 的方式运行它,通过如下 bash 脚本(Listing 7-8):

#!/usr/bin/env bash

while true
do
    ./dbf-fuzzer fuzz test.dbf 2>/dev/null
    # 最多运行 1 秒钟测试 utdbf 并退出timeout 1 ./utdbf ./test.dbf <<< "0" >/dev/null
    test $? -gt 127 && break
done

(摘自 Listing 7-8)

为避免因程序挂起导致资源消耗,你可以杀死执行超过 1 秒的测试 ➊。同时需要向 utdbf 提供标准输入以正常退出。

该脚本假设位于 dbf-fuzzer 和 utdbf 同一目录,因此在执行前,请将刚编译好的 dbf-fuzzer 和该脚本移入 utdbf 目录。

希望几秒钟内你就能碰到崩溃用例:

 $ ./fuzz-utdbf.sh
free(): invalid next size (fast)
./fuzz-utdbf.sh: line 9: 325999 Aborted

同样,只用最少的准备和基础的模糊测试流程,你就快速生成了崩溃用例。通过借助现有模板引导,你可以执行基于生成的模糊测试,而无需费力地重新编写规范。这个过程非常适合大范围目标的模糊测试,因为你不必每次都构建自定义流程,可以专注于特定格式或协议。

总结

本章聚焦于“快速行动,勇于破坏”的策略,这种方法非常适合研究项目的早期阶段。它能迅速捕捉一些显而易见的漏洞,突出关键热点,便于后续进行源码审查或逆向工程。这样可以帮你节省大量手动枚举潜在漏洞点或逐步调试函数的时间。

此外,你还看到了“简单粗暴”的模糊测试在当今依然非常有效。它尤其适用于无源码的网络协议目标,因为在这些场景中,重现固定顺序的特定数据包并匹配目标的实际运行环境往往很困难。

本章中,你使用了基于生成和基于变异的模糊测试器,快速发现了开源项目中的崩溃输入。为了弥合两种方法的优缺点,你还通过已有文件格式模板引导生成式模糊测试。

即便目标之前已经被模糊测试过,用不同的模糊测试工具和配置再次测试往往仍然值得尝试。这种“快速且简陋”的策略通过减少前期投入时间,显著提升风险/收益比。记住,有些模糊测试总比没有好,快速且简陋的模糊测试能让你马上开始行动。

然而,正如前文所暗示,这种方法也有显著限制,比如需要目标较为简单,能够接受直接输入并最终退出;此外,你也未必总能拿到完整规范文档来编写生成式模糊测试模板。要有效地模糊测试复杂目标,接下来一章中介绍的工具和技术将帮助你拓展模糊测试手段。

询问 ChatGPT