从零基础到零日漏洞——模糊测试全覆盖

0 阅读23分钟

请考虑当今你可能遇到的各种漏洞研究目标:Golang 网络协议服务器、Electron 桌面客户端、Kotlin 安卓应用等等。虽然传统的针对编译后的二进制文件进行白盒模糊测试仍有其用武之地,但你很可能无法总是拥有源代码的访问权限。然而,模糊测试生成意外输入以触发漏洞的核心思想,即使在黑盒场景下依然适用。通过将测试目标的结果范围扩展到不仅仅是程序崩溃或卡死,你还能实现其他目标,比如绕过安全检测工具,或者找到 SQL 注入的实例。

本章中,你将学习如何对三类目标进行模糊测试。首先,你将使用 AFL++ 的 Frida 模式,从闭源的角度动态插装并模糊测试 LibreDWG。接着,你将利用 Jazzer 针对 Java 的管理内存二进制文件,以及 Golang 内置的模糊测试功能,发现除常见内存破坏漏洞之外的其他漏洞。最后,你将使用字典、语法规则和中间表示方法,对非二进制文件格式进行模糊测试,寻找语法和语义解析中的漏洞。这些案例通常不属于传统的白盒编译机器码模糊测试目标,但近年来它们开始受到模糊测试开发者更多的关注。

到本章结束时,你将构建一套全面的工具包,能够对多种编程语言和格式下的广泛目标进行模糊测试。

闭源二进制文件

在处理专有软件时,你很可能无法获得其源代码。具有讽刺意味的是,闭源目标(其源代码未公开)往往可能比开源代码的目标包含更多漏洞,因为它们不太可能被其他研究人员充分测试。这导致了“因不透明而不安全”(这是对被广泛批评且有缺陷的“通过不透明获得安全”网络安全原则的一种戏谑),因为缺乏可视性让漏洞代码得以在软件中持续存在。虽然部分闭源目标经过了合理加固,且有大量不安全、维护不善的开源项目,但“因不透明而不安全”这一规律通常是成立的。

许多针对闭源二进制文件的模糊测试工具采用动态插装技术来实现覆盖引导的模糊测试。但这也带来一些妥协,比如速度的下降。你可以在 AFL++ 的多种仅二进制模糊测试模式中研究这些权衡。默认情况下,如果你按照 github.com/AFLplusplus… 中的标准安装说明操作,应已构建并安装了支持 QEMU 和 Frida 模式的 AFL++ 版本。

QEMU 模式

AFL++ 的主要仅二进制模糊测试模式是 QEMU 模式。它使用 QEMU 用户态模拟器,不是模拟完整系统,而是翻译系统调用和指令来运行为其他处理器编译的单个二进制文件。QEMU 通常包含在你的 AFL++ 初始安装中,如果没有,可以参考 github.com/AFLplusplus… 进行安装配置。

练习使用 QEMU 模式模糊测试时,可以对 NConvert 进行测试,这是一个用于解析和转换图片的命令行批处理工具。NConvert 是免费软件但非开源,因此只能采用仅二进制方式。NConvert 7.136 版本存在多个已披露的内存破坏漏洞(包括 CVE-2023-43250、CVE-2023-43251 和 CVE-2023-43252),尤其是在转换 TIFF 文件时,这暗示其在该功能开发上存在薄弱环节。

若想在更新版本的 NConvert 中发现其他 TIFF 解析漏洞,可以从 download.xnview.com/old_version… 下载并解压 7.155 版本。开始模糊测试前,你需要准备一个 TIFF 文件的种子语料库,可从 LibTIFF 开源项目的测试文件中获取。示例操作如下:

$ wget https://download.xnview.com/old_versions/NConvert/NConvert-7.155-linux64.tgz
$ tar -zxf NConvert-linux64.tgz
$ git clone https://github.com/libsdl-org/libtiff
$ mkdir NConvert/fuzz-in
$ cp libtiff/test/images/*.tiff NConvert/fuzz-in/
$ cd NConvert
$ afl-fuzz ➊ -c nconvert -Q -i fuzz-in -o fuzz-out -- ./nconvert -out tiff @@

其中,-Q 选项启用 QEMU 模式,-c 选项启用 CMPLOG 模式 ➊。顾名思义,CMPLOG 模式记录 CMP 指令,识别魔术字节检查并尝试通过它们,显著提升对二进制文件格式的模糊测试效果,减少模糊测试阻碍。

不出所料,AFL++ 可能会报告执行速度较慢。虽然稳定性不完美,但只要高于 80%,仍可成功发现漏洞。对闭源二进制启用覆盖引导模糊测试,相较“盲测”是整体效果上的重大进步。长时间模糊测试 NConvert,你将发现由缓冲区溢出引发的新崩溃。

Frida 模式

虽然 AFL++ 推荐 QEMU 模式作为针对仅二进制目标的“原生”解决方案,Frida 模式是较新的替代方案,具备更多功能,如脚本支持。它还能在支持 Frida 的其他环境中运行,如 Android 设备,从而实现比模拟环境更真实的模糊测试。

快速测试 Frida 模式,可克隆 LibreDWG 并构建无插装版本:

$ git clone https://github.com/LibreDWG/libredwg.git
$ cd libredwg
$ git checkout 77a8562
$ sh ./autogen.sh
$ ./configure --disable-bindings --disable-dxf --disable-json --disable-shared
$ make -C src && make -C programs dwgread

拷贝第 8 章使用的 fuzz-in 输入语料目录。若像往常那样直接运行 AFL++,会报错:

[-] PROGRAM ABORT : No instrumentation detected
         Location : check_binary(), src/afl-fuzz-init.c:2948

错误提示表明目标二进制未编译插装,故 AFL++ 失败。此时需用 Frida 模式启动 AFL++,加上 -O 选项:

$ afl-fuzz -O -i fuzz-in -o fuzz-out -- programs/dwgread @@

如日志所示,AFL++ 加载 afl-frida-trace.so 共享库,借助 Frida Stalker 运行时插装目标程序,注入汇编指令以跟踪、收集并报告覆盖率数据。

尽管 Frida 模式比前章使用的插装模式更慢,但仍足够重新发现 dwgreadbit_calc_CRC 函数的内存破坏漏洞。用 GDB 调试崩溃时,信息较前章少:

$ gdb --args ./programs/dwgread fuzz-out/default/crashes/id:000000,sig:11,src:000030,time:30220088,execs:157722,op:havoc,rep:2
(gdb) r
Starting program: ./programs/dwgread fuzz-out/default/
crashes/id:000000,sig:11,src:000030,time:30220088,execs:157722,op:havoc,rep:2
Program received signal SIGSEGV, Segmentation fault.
bit_calc_CRC (seed=seed@entry=49345, addr=0x55556bd010e6 <error: Cannot access memory at address 0x55556bd010e6>, len=<optimized out>) at bits.c:3456
3456          dx = ((dx >> 8) & 0xFF) ^ crctable[al]; ➊
(gdb) backtrace
#0  bit_calc_CRC (seed=49345, addr=0x55556bd010e6 <error: Cannot access memory at address 0x55556bd010e6>, len=<optimized out>) at bits.c:3456
#1  0x00005555559fa33b in decode_preR13_auxheader (dat=0x7fffffffc7a0, dwg=0x7fffffffc8c0) at decode.c:6278
#2  0x0000555555a1f3ce in decode_preR13 (dat=0x7fffffffc7a0, dwg=0x7fffffffc8c0) at decode_r11.c:786
#3  0x00005555559ecd9b in dwg_decode (dat=0x7fffffffc7a0, dwg=0x7fffffffc8c0) at decode.c:217
#4  0x00005555555ae157 in dwg_read_file (filename="fuzz-out/default/crashes/id:000000,sig:11,src:000030,time:30220088,execs:157722,op:havoc,rep:2", dwg=0x7fffffffc8c0) at dwg.c:261
#5  0x00005555555ad6fa in main (argc=<optimized out>, argv=0x7fffffffddb8) at dwgread.c:256

虽然指令 ➊ 不再映射回源代码,但导出的符号依然使函数名 ➋ 能准确反映在回溯中。

利用这些导出符号,可以用 Frida 强大的脚本功能动态修补有问题的函数。例如,bit_check_CRC 函数因检查失败形成模糊测试阻碍。以前你有源代码时,可直接修改函数并重新编译目标。现在假设无法访问源代码,也不能这样做,而是编写如下脚本(示例见书籍代码仓库 chapter-09/frida-mode/patch.js):

const bit_check_CRC = DebugSymbol.fromName('bit_check_CRC').address;
Afl.print(`bit_check_CRC: ${bit_check_CRC}`);

const bit_check_CRC_replacement = new NativeCallback(
    (dat, start_address, seed) => {
        Afl.print('intercepted bit_check_CRC');
        Afl.print(`seed: ${seed}`);
        return 1;  // ➊
    },
    'int',
    ['pointer', 'ulong', 'uint16']
);

Interceptor.replace(bit_check_CRC, bit_check_CRC_replacement);

Afl.done();

此替换函数跳过所有 CRC 计算步骤,直接返回 1 ➊。将该脚本放入当前工作目录,设置环境变量 AFL_FRIDA_JS_SCRIPT 为脚本文件名,AFL++ 会自动加载脚本并用你的替代函数 ➋ 替换所有 bit_check_CRC 调用。

可用 AFL_DEBUG=1 运行模糊测试,观察拦截时由 Afl.print 输出的信息:

$ AFL_FRIDA_JS_SCRIPT=patch.js AFL_DEBUG=1 afl-fuzz -O -i fuzz-in/ -o fuzz-out-2 --programs/dwgread @@
intercepted bit_check_CRC
seed: 49345
...

当然,实际闭源场景中,你需要先逆向识别出模糊测试阻碍函数,再逆向该函数以编写合适的替代逻辑。

借助 AFL++ 的 Frida 模式相关 API,你还能做更多脚本操作。例如,在无符号信息的剥离二进制文件中通过指定目标镜像偏移,设定持久化模糊测试起始地址。假设 dwgread 是剥离二进制,且你发现打开并解析输入文件的函数位于偏移 0x059fe0,可使用如下脚本(示例见 Listing 9-2):

const module = Process.getModuleByName('dwgread');
const dwg_read_file = module.base.add(0x059fe0);
Afl.setPersistentAddress(dwg_read_file);
Afl.done();

设置持久化地址 ➊ 会让 AFL++ 在子进程执行到 dwg_read_file 时保存状态,并在该函数的第一个 ret 指令处重置,从而显著加快模糊测试速度。更多脚本示例请参考 github.com/AFLplusplus…

处理闭源二进制并不意味着只能退回到黑盒模糊测试。借助动态插装工具如 Frida,你仍能利用覆盖引导模糊测试和高级功能(如持久化模式)。你可以根据目标选择使用 Frida 或 QEMU 模式。例如,若想直接在 Android 设备上模糊测试二进制文件,确保执行环境尽可能真实,Frida 的多环境支持就非常有用。此外,Frida 的脚本配置支持也可能比环境变量设置更方便。但相较 QEMU 模式,Frida 缺少 AFL++ 许多功能和处理器支持,比如持久化模式仅支持 x86、x64 和 ARM64 架构。

托管内存二进制文件

本节将介绍用 Java 和 Golang 编写的托管内存二进制文件。模糊测试虽然非常擅长发现内存破坏类漏洞,但对于路径遍历或命令注入等其他类型漏洞的发现能力较弱。这是因为程序崩溃相对容易被检测到,且编译时的 Sanitizer(如 ASan)为模糊测试提供了辅助。因此,可能有人认为模糊测试只适用于没有内存管理的编程语言,但事实并非如此。

对于像 Golang 和 Java 这样自带垃圾回收机制,不要求开发者自行分配和管理内存的语言,内存破坏漏洞较为少见。相反,模糊测试可以结合额外的 Sanitizer 来检测并在触发其他类型漏洞时抛出错误。本节将通过 Jazzer 和 Golang 内置模糊测试功能来实践这一点。

Jazzer

Jazzer 是针对 JVM 平台的覆盖引导模糊测试工具。由于其工作在字节码层面,你无需源代码即可针对已编译的 Java 类文件和 JAR 包进行测试。这使得 Jazzer 非常适合多种基于 JVM 的目标,从 Android 应用到用 Scala、Kotlin 编写的程序。

此外,Jazzer 具有自动模糊(autofuzz)模式,能自动填充并变异结构感知的公共方法参数,因此手工搭建测试环境是可选的(但我们仍会手动定制测试环境以便更灵活)。下面以一个简单的 Java Web 应用示例说明,该示例包含一个 SsrfExample 类(见清单 9-3),存在服务器端请求伪造(SSRF)漏洞,允许攻击者向受控地址发起请求。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

public class SsrfExample {
    public static void getRequest(String dest) {
        try {
            if (!dest.contains("/safepath")) {  // ➊
                System.out.println("path must be safe!");
                return;
            }

            URL url = new URL("https://example.com" + dest);  // ➋
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();  // ➌
            connection.setRequestMethod("GET");

            BufferedReader reader = new BufferedReader(
                new InputStreamReader(connection.getInputStream())
            );
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
            reader.close();
        } catch (IOException e) {
            System.err.println("An error occurred: " + e.getMessage());
        }
    }
}

清单 9-3:存在服务器端请求伪造漏洞的 Java 类示例

每当 Web 应用调用 getRequest 方法时,会检查传入字符串参数是否包含 /safepath 子串(➊),然后附加到 https://example.com 后(➋),并尝试打开对应 URL 的网络连接(➌)。

具备 Web 渗透测试经验的人会立刻识别 SSRF 漏洞:该检查只验证是否包含 /safepath,而非是否以其开头,因此可以绕过。攻击者传入 .evil.com/safepath,则请求将被发送至 https://example.com.evil.com/safepath,可导致访问内部敏感网络。

Jazzer 如何检测此漏洞?在 github.com/CodeIntelli… 的 “Sanitizers” 目录下有一系列 Sanitizer,这些 Sanitizer 会 hook 低层 Java API,帮助 Jazzer 判断漏洞是否触发。下面为 SSRF Sanitizer 片段(清单 9-4):

public class ServerSideRequestForgery {
    // --snip--
    @MethodHook(
        type = HookType.BEFORE,
        targetClassName = "java.net.SocketImpl",
        targetMethod = "connect",
        additionalClassesToHook = {
            "java.net.Socket",
            "java.net.SocksSocketImpl",
        }
    )
    // --snip--
    private static void checkSsrf(Object[] arguments) {
        if (arguments.length == 0) {
            return;
        }

        String host;
        int port;
        if (arguments[0] instanceof InetSocketAddress) {
            InetSocketAddress address = (InetSocketAddress) arguments[0];
            host = address.getHostName();
            port = address.getPort();
        } else if (arguments.length >= 2 && arguments[1] instanceof Integer) {
            if (arguments[0] instanceof InetAddress) {
                host = ((InetAddress) arguments[0]).getHostName();
            } else if (arguments[0] instanceof String) {
                host = (String) arguments[0];
            } else {
                return;
            }
            port = (int) arguments[1];
        } else {
            return;
        }

        if (port < 0 || port > 65535) {
            return;
        }

        if (!connectionPermitted.get().test(host, port)) {
            Jazzer.reportFindingFromHook(
                new FuzzerSecurityIssueMedium(
                    String.format(
                        "Server Side Request Forgery (SSRF)\n" +
                        "Attempted connection to: %s:%d\n",
                        host, port
                    )
                )
            );
        }
    }
}

清单 9-4:Jazzer 的服务器端请求伪造 Sanitizer 片段

该 Sanitizer 使用 Jazzer 的 @MethodHook 注解(➊)Hook 了 Java 标准库类 java.net.SocketImpl(➋)的 connect 方法(➌)。该类被多个高级类和 API 调用用于建立网络连接,例如后续会看到的 HttpsURLConnection。方法执行前,Jazzer 会执行 checkSsrfSocket,传入的参数会被 checkSsrf 提取连接地址(➍)并校验是否允许连接。若不允许,则触发 Jazzer 报告(➎)。

基于此,你可以测试 Jazzer 覆盖引导模糊测试是否能触发 SSRF 漏洞。确认已安装 Java 开发工具包后,编译 SsrfExample.java

$ sudo apt install default-jdk
$ javac SsrfExample.java

编译生成 SsrfExample.class。然后从 github.com/CodeIntelli… 下载并解压 Jazzer 最新版本,运行自动模糊测试,确保 classpath 指向当前工作目录:

$ ./jazzer --cp=./ssrf-example/ --autofuzz=SsrfExample::getRequest

日志显示加载了 SSRF Sanitizer hook(➊),但崩溃输入却是一个 null 指针异常(➋),导致未处理异常。虽然导致 Web 应用崩溃仍值得关注,但攻击者很难利用。幸好 Jazzer 允许用 --autofuzz_ignore 标志忽略这类错误,重新运行:

$ ./jazzer --cp=./ssrf-example/ --autofuzz=SsrfExample::getRequest --autofuzz_ignore=java.lang.NullPointerException

测试继续,Jazzer 通过变异输入绕过路径检查(➊),最终触发 SSRF Sanitizer hook(➋)。触发漏洞的输入(➌)包含特殊字符和 /safepath 字符串,说明 Jazzer 成功识别并通过了检测。

当然,若该函数在 Web 应用的正常业务流程中被调用,则是预期行为,不宜每次请求都报漏洞。正如前述,你可以编写自定义测试环境以调整 Jazzer 行为。示例如下(清单 9-5),允许特定主机连接,如 example.com,以精确测试验证和过滤逻辑:

import com.code_intelligence.jazzer.api.FuzzedDataProvider;

public class SsrfFuzzer {
    public static void fuzzerTestOneInput(FuzzedDataProvider data) {
        SsrfExample ssrfExample = new SsrfExample();
        com.code_intelligence.jazzer.api.BugDetectors
            .allowNetworkConnections(
                (String h, Integer p) -> h.equals("example.com")
            );
        ssrfExample.getRequest(data.consumeRemainingAsAsciiString());
    }
}

清单 9-5:自定义 Jazzer 测试环境,允许特定 SSRF 服务器端请求主机

该方法命名规则沿用 libFuzzer,Jazzer 会自动检测并执行此方法。测试时只允许连接 example.com(➊),并以变异的 ASCII 字符串作为参数调用目标方法(➋)。为简便起见,Jazzer 只使用 ASCII 字符串避免非 ASCII 字节。

SsrfFuzzer.java 与已编译的 SsrfExample.class 及 Jazzer 发布包中的 jazzer_standalone.jar 放在同目录,后者用于导入 Jazzer 及目标类。然后编译测试环境并使用 --target_class 运行 Jazzer,替代自动模糊模式:

$ javac -cp "jazzer_standalone.jar:." SsrfFuzzer.java
$ cd ..
$ ./jazzer --cp=./ssrf-example/ --target_class=SsrfFuzzer

Jazzer 会在检测到非白名单域 example.comq 请求时触发 SSRF 漏洞报告(➊),提示可能导致敏感数据泄露或内网暴露。

此示例表明,你无需通过逆向 Java 字节码或暴力枚举复杂特殊字符列表,即可借助覆盖引导模糊测试高效发现绕过手段。

Jazzer 漏洞发现能力受限于 Sanitizer 数量。2021 年 Log4Shell 漏洞披露后,许多人质疑为何自动分析工具未能发现该广泛使用库中的关键漏洞,原因之一是未检测到罕见但致命的远程 Java Naming and Directory Interface (JNDI) 查找入口。对此,OSS-Fuzz 联合 Jazzer 添加了 NamingContext Lookup Sanitizer。

Jazzer 的扩展性为你提供了全新漏洞研究机会。毕竟,使用相同的模糊测试工具和配置,很难发现新漏洞。如果能识别一般研究者忽视的潜在危险“入口”,就可编写自定义 Sanitizer,实现大规模模糊测试发现。相比静态代码自动分析,模糊测试的优势在于其在执行时发现漏洞,并能基于运行时值(如 SSRF 示例中的非白名单域)进行过滤。

Go 语言模糊测试

自 1.18 版本起,Go 语言内置支持模糊测试。开发者可以将模糊测试集成到测试套件中,帮助发现单元测试中未覆盖的边界情况。Go 的模糊测试主要用于发现预定义的失败案例(称为“crashers”)和默认错误,而非依赖 Sanitizer。

回顾前面的 Java 示例,其中因 URL 域名校验失败导致 SSRF 漏洞。假设开发者编写了一个 URL 域名验证函数,如清单 9-6 所示:

package main

import (
    "fmt"
    "regexp"
)

// 验证 inputURL 是否为 expectedDomain 的域名或子域名
func ValidateURLDomain(inputURL string, expectedDomain string) bool {
    // 转义 expectedDomain 中的特殊字符
    expectedDomain = regexp.QuoteMeta(expectedDomain)

    regexPattern := `^https?://(?:[A-Za-z0-9-]+.)*` + expectedDomain + `($|/|?)`

    regex, err := regexp.Compile(regexPattern)
    if err != nil {
        return false
    }

    return regex.MatchString(inputURL)
}

func main() {
    fmt.Println(ValidateURLDomain("https://example.com", "example.com"))        // true
    fmt.Println(ValidateURLDomain("https://sub.example.com", "example.com"))    // true
    fmt.Println(ValidateURLDomain("https://evil.com", "example.com"))           // false
}

清单 9-6:域名验证函数示例

该函数接收输入 URL 和预期域名(➊),使用正则表达式确保 URL 中的域名符合预期(➋)。但代码存在缺陷,导致部分输入可绕过校验。你能发现漏洞吗?

运行前,请按照 go.dev/doc/install 下载并安装 Go,并配置环境变量:

$ wget https://go.dev/dl/go1.23.1.linux-amd64.tar.gz
$ tar -xvf go1.23.1.linux-amd64.tar.gz
$ sudo mv go /usr/local
$ echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.zshrc

main.go 放置在工作目录(或本书代码仓库 chapter-09/go-example),执行:

$ go mod init example/fuzz
$ go run .
true
true
false

假设目标中包含该验证函数,你可能会怀疑其存在漏洞,因为它未使用标准库的 URL 解析功能提取主机名。若有源代码,你可编写覆盖引导模糊测试,尝试绕过校验,示例如清单 9-7:

package main

import (
    "net/url"
    "strings"
    "testing"
)

func FuzzValidateURLDomain(f *testing.F) {
    f.Add("https://example.com")  // ➊
    domain := "example.com"
    f.Fuzz(func(t *testing.T, data string) {
        parsedURL, err := url.Parse(data)
        if err == nil {
            host := strings.ToLower(parsedURL.Host)
            if ValidateURLDomain(data, domain) && host != domain &&
                !strings.HasSuffix(host, "."+domain) {  // ➋
                t.Errorf("Incorrectly validated %q", data)
            }
        }
    })
}

清单 9-7:针对域名验证函数的自定义模糊测试器

该模糊测试器添加种子输入(➊),变异测试验证函数,检测是否存在“验证通过但实际域名与预期不符”的情况(➋)。将 fuzz_test.go 放在同目录并执行:

$ go test -fuzz=FuzzValidateURLDomain

模糊测试器会发现输入 http://00example.com 绕过校验。原因是清单 9-6 中的正则表达式未正确转义点号,导致点号被当作通配符。

模糊测试器会生成 testdata 目录。除使用 f.Add 添加种子外,还可将格式化的种子文件放入 testdata/fuzz/FuzzValidateURLDomain,格式类似 boofuzz 语法,如:

go test fuzz v1
string("http://00example.com")

对于更复杂文件,可用 file2fuzz 工具自动转换为此格式。

接下来可实践 Snappy Golang 库的模糊测试,该库提供 Snappy 压缩格式的编解码 API。下载并解压最新版本(示例为 v0.0.4):

$ wget https://github.com/golang/snappy/archive/refs/tags/v0.0.4.tar.gz
$ tar -xzvf v0.0.4.tar.gz
$ cd snappy-0.0.4

编写简单的模糊测试器调用解码函数,见清单 9-8,放于 snappy-0.0.4 目录:

package snappy

import (
    "testing"
)

func FuzzDecode(f *testing.F) {
    f.Fuzz(func(t *testing.T, data []byte) {
        var dst [1000000]byte
        decode(dst[:], data)
    })
}

清单 9-8:Snappy 解码函数的自定义模糊测试器

该模糊测试器仅模糊 decode 函数,无额外检测。源码已含 Snappy 格式测试文件,可用 file2fuzz 转换成模糊测试输入格式:

$ go install golang.org/x/tools/cmd/file2fuzz@latest
$ mkdir -p testdata/fuzz/FuzzDecode
$ file2fuzz -o testdata/fuzz/FuzzDecode testdata/Isaac.Newton-Opticks.txt.rawsnappy
$ go test -run=FuzzDecode -fuzz=FuzzDecode -tags=noasm -parallel=2

因无额外检测,模糊测试器仅在崩溃或卡死时停止。若内存不足,可能快速崩溃,错误类似:

--- FAIL: FuzzDecode (17.57s)
    fuzzing process hung or terminated unexpectedly: exit status 2
    Failing input written to testdata/fuzz/FuzzDecode/8e241dc44fa688fc
    To re-run:
    go test -run=FuzzDecode/8e241dc44fa688fc
FAIL
exit status 1

重新运行该测试时通常无异常。这是因为 decode 函数调用 make 分配堆内存存放解压数据,连续快速执行可能导致资源耗尽,称为内存泄漏。

减少并行子进程数(用 -parallel 参数)可延长模糊测试时间,避免资源耗尽:

$ go test -fuzz=FuzzDecode -tags=noasm -parallel=2 -run=FuzzDecode

输出显示执行次数显著增加,未触发崩溃。

虽然 Go 内置模糊测试功能方便,但仍需源码支持且配置复杂度高于 Jazzer。若无自定义检测器,难发现有趣漏洞。此外,Jazzer 的 Sanitizer hook 可复用多个 Java 目标的低层 API,而 Go 内置模糊测试不支持 hook。因此,该方案更适合针对特定目标进行深度挖掘,例如绕过 Go 验证包或自定义 Web 应用中的关键认证或校验函数。

语法和语义目标

在变异输入时,模糊测试似乎更适合二进制格式而非基于文本的格式。这使得在没有额外工具的情况下对有趣的文本格式进行模糊测试变得困难。然而,这些基于文本的格式依然广泛存在于许多关键软件中,值得关注。本节将探讨模糊测试器如何为像 HTML 这样复杂的文本格式生成有效的变异输入。

首先,考虑以下 HTML 文件:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>From Day Zero to Zero Day</title>
</head>
<body>
    <h1>Chapter 0: Day Zero</h1>
    <p>Hello World!</p>

    <h2>What Is a Vulnerability?</h2>

    <p>Visit <a href="https://spaceraccoon.dev">My Blog</a>.</p>
</body>
</html>

若不了解 HTML 格式,简单的模糊测试器可能从逐字节变异该文件开始,导致生成的 HTML 语法无效或完全无效。HTML 格式规范明确,大多数解析器处理的是比单个字符更高层次的标准 HTML 元素,如 <head><body>。这里涉及两种解析:语法解析和语义解析。

语法解析 关注数据的结构。例如,HTML 元素由标签界定,HTML 标准(html.spec.whatwg.org)规定标签必须以小于号< 开始,之后根据下一个字符,词法分析器会切换到以下状态之一:

  • U+0021 感叹号 !:切换到标记声明打开状态。
  • U+002F 斜杠 /:切换到结束标签打开状态。
  • ASCII 字母:创建新的开始标签令牌,标签名为空字符串,重新进入标签名状态。
  • U+003F 问号 ?:意外的问号而非标签名,创建空数据的注释令牌,重新进入伪注释状态。
  • 文件结束符(EOF):标签名前遇到 EOF 错误,发出小于号字符令牌和文件结束令牌。
  • 其他字符:标签名首字符无效错误,发出小于号字符令牌,重新进入数据状态。

令牌和状态机是描述语法的常见方式,在 RFC 和格式标准中经常见到。

语义解析 关注数据的含义。例如,HTML 标准指出:

HTML 中的元素、属性及属性值被定义为具有特定的语义。例如,ol 元素表示有序列表,lang 属性表示内容的语言。

若模糊测试器理解格式的语法和语义,就能针对更有趣的解析逻辑进行测试,而非盲目地逐字节变异。结合合适的输入语料,覆盖引导模糊测试仍能取得强大效果,但往往伴随大量无效测试。

可以想象一个只允许 90 度转弯的全黑迷宫,若你一开始就知道这一点,就不会盲目乱转,而是一直直行直到碰壁,再向左或向右转;若你不了解迷宫结构,可能会浪费许多时间尝试各种角度,直到逐渐摸索出规律。

同理,某个文本格式越复杂,越早理解其结构对模糊测试器越有帮助。

字典

一旦涉及语法和语义,你就开始深入学术研究和概念的“兔子洞”,如上下文无关文法、语法树等。虽然你可以自由钻研理论,但许多工具已将这些研究内容集成到可立即部署的功能中。AFL 和 AFL++ 支持字典,实质上就是格式中常用令牌的列表。使用字典,模糊测试器能识别种子输入中的令牌,并相应地修改或注入它们,而不是逐字节变异。

例如,AFL++ 源代码中 dictionaries/html_tags.dict 文件包含以下 HTML 字典令牌:

#
# AFL dictionary for HTML parsers (tags only)
# -------------------------------------------
#
# A basic collection of HTML tags likely to matter to HTML parsers. Does *not*
# include any attributes or attribute values.
#
# Created by Michal Zalewski
#

tag_a="<a>"
tag_abbr="<abbr>"
tag_acronym="<acronym>"
tag_address="<address>"
tag_annotation_xml="<annotation-xml>"
tag_applet="<applet>"
tag_area="<area>"

你可以在解析 HTML 文件的文本浏览器 w3m 上测试 AFL++ 对字典的多种使用方式。首先,下载并带插装编译 w3m:

$ sudo apt-get install -y libgc-dev libglib2.0-dev
$ git clone https://github.com/tats/w3m
$ cd w3m
$ CC=afl-clang-fast CXX=afl-clang-fast++ ./configure
$ make w3m

输入语料可用 tests 目录下的 HTML 文件。此时有以下字典使用选项:

  • 手动 使用 AFL++ 提供的手工制作的 HTML 标签字典。
  • 自动生成 使用 AFL++ 的自动字典功能,在编译时基于字符串比较生成字典(afl-clang-lto 插装默认开启)。
  • 无字典 仅依赖覆盖引导模糊测试。

可用如下命令测试这些选项:

$ mkdir fuzz-in
$ cp tests/*.html fuzz-in/
➊ $ afl-fuzz -i fuzz-in -o fuzz-out-dict -x /home/kali/Desktop/AFLplusplus/dictionaries/html_tags.dict -- ./w3m @@
➋ $ make clean
   $ AFL_LLVM_DICT2FILE=/home/kali/Desktop/auto.dict make w3m
   $ afl-fuzz -i fuzz-in -o fuzz-out-autodict -x /home/kali/Desktop/auto.dict -- ./w3m @@
➌ $ afl-fuzz -i fuzz-in -o fuzz-out -- ./w3m @@

其中,手动方式(➊)使用 AFL++ 内置 HTML 标签字典,自动生成方式(➋)将所有字符串比较值写入字典文件供 AFL++ 使用,无字典方式(➌)则完全不使用字典。

尽管 AFL++ 文档称自动字典功能统计上可提升 5% 到 10% 的覆盖率,但实际效果因目标和字典而异。例如,查看刚生成的自动字典内容,会发现许多令牌似乎与 HTML 格式无明显关联:

$ head -n 20 /home/kali/Desktop/auto.dict
"\xfd\xff\xff\x03"
"\xfe\xff\xff\x03"
"content-type"
"user-agent"
"Download List Panel"
"\xfd\xff\xff\x03"
"\xfc\xff\xff\x03"
"\xfd\xff\xff\x03"
"\xfc\xff\xff\x03"
"\xfd\xff\xff\x03"
"\xfc\xff\xff\x03"
"\xfd\xff\xff\x03"
"\xfc\xff\xff\x03"
"!CURRENT_URL!"
"map"
"none"
"\x00\x00\x00\x10"
"\x00\x00\x00\x10"
"\x00\x00\x00\x10"
"\x00\x00\x00\x10"

除了 “content-type” 和 “user-agent” 外,多数字符串与 HTML 无明显关联。

评估字典有效性的方式之一是跟踪覆盖率差异。AFL++ 的 afl-plot 工具利用模糊测试输出目录数据生成覆盖率随时间变化图表,有助于判断何时停止模糊测试。例如,生成使用手动字典时的覆盖率图表:

$ afl-plot ~/Desktop/w3m/fuzz-out-dict/default ~/Desktop/fuzz-out-dict-graph

这让你能比较不同字典选项的覆盖率进展。无字典时的覆盖率图见图 9-1。

image.png

注意到覆盖率在开始阶段相对平缓,直到接近 600 秒时才急剧上升。随后,在约 1,300 秒时又出现第二次陡增。最初的缓慢进展反映了没有字典时模糊测试的挑战,即使是覆盖引导的模糊测试器,也可能需要多次迭代才能生成有效输入。借用之前摸索黑暗迷宫的比喻,通常需要较长时间在黑暗中摸索,才能发现规律。一旦找到规律,进展就会快得多。

现在,将其与图 9-2 中使用自动生成字典的模糊测试会话图表进行比较。

image.png

这一次,覆盖率从远高于图9-1的基础开始。不过,在最初的增长之后,覆盖率长时间停滞不前,直到1500秒时出现了类似的跃升。自动生成的字典显然帮助提升了初始覆盖率,但随后模糊测试过程似乎陷入了局部最优。

回到迷宫的比喻,这就像带着一套关于迷宫结构的线索进入迷宫,但其中大约一半线索是错误的(类似于自动生成字典中的错误条目)。因此,虽然你可能最初能取得进展,但依赖某些错误线索会让你陷入循环,直到通过反复试错弄清楚哪些线索是错的。之后,你就能再次更快地穿越迷宫。从这个角度看,一个设计不佳的字典有时反而比没有字典更阻碍前进。

最后,让我们把前面两张图与图9-3中使用手工制作字典的模糊测试过程的图进行对比。

image.png

这次,覆盖率不仅从一个较高的基础开始,而且爬升得很快,在600秒时跃升至超过1300条边,比之前的两个案例都要早得多。这表明,使用一个精心设计的字典,模糊测试工具整体表现最佳。然而,注意到最终这三次模糊测试都会达到相似的覆盖水平,因为它们都会收敛到触发最多覆盖的共同变异和输入集合上。

总结来说,虽然字典能在模糊测试初期带来提升,但拥有一个好字典的长期收益取决于输入的复杂性和目标的不同。对于那些格式复杂、需要许多特定标记且顺序严格的格式,没有字典的模糊测试会花费更长时间才能生成有效输入。

语法(Grammars)

基于标记(token)的字典虽然能帮助解决一些基础语法问题,但它们不足以有效变异复杂输入,比如编程语言。在这种情况下,不仅标记的值重要,其顺序也同样关键。例如,JavaScript中的对象字面量必须以左大括号开始,包含由逗号分隔的键值对,最后以右大括号结束。表达这种结构的一种方式是使用语法规则(grammars)。

AFL++包含一个Grammar Mutator项目(github.com/AFLplusplus…),允许研究人员构建自定义的基于语法的变异器。这些语法由键值对组成,键表示语法标记,值则是字符串和其他语法标记的组合。例如,JavaScript语法中对象及其成员定义如下:

"<OBJECT>": [    [ "<IDENTIFIER>" ],
    [ "{", "<OBJMEMBER>", "}" ],
    [ "{}" ]
],
"<OBJMEMBER>": [    [ "<VAR>", ": ", "<LITERAL>", ", ", "<OBJMEMBER>" ],
    [ "<VAR>", ": ", "<LITERAL>" ]
]

花些时间分析这些语法文件,你会发现它们通过递归特性,可以简洁地表达复杂的语法。例如,考虑下面这个JSON文档:

{
  "foo": {
    "bar": {
      "baz": [ "qux", [], 4 ]
    },
    "xyzzy": [ 1, 2, 3 ]
  }
}

如何验证这样一段字符串是有效的JSON?格式的灵活性让简单的迭代式解析器难以校验每个顶层键值对的正确性。对象可以包含嵌套对象和数组。我们看看Grammar Mutator中针对JSON的语法定义:

{
    "<start>": [["<json>"]],
    "<json>": [["<element>"]],
    "<element>": [["<value>"]],
    "<value>": [["<object>"], ["<array>"], ["<string>"], ["<number>"],
                ["true"], ["false"],
                ["null"]],
    "<object>": [["{}"], ["{", "<members>", "}"]],
    "<members>": [["<member>", "<symbol-2>"]],
    "<member>": [["<string>", ":", "<element>"]],
    "<array>": [["[]"], ["[", "<elements>", "]"]],
    "<elements>": [["<element>", "<symbol-1-1>"]],
    "<string>": [[""", "<characters>", """]],
    --省略--
}

这里定义了json标记包含一个element,element等价于value标记。value可以是对象、数组、字符串、数字,或者JSON中其他三个有效值之一。对象token定义为一个空的大括号,或包含成员的对象。沿着这条路径继续解析,成员被定义为以冒号分隔的字符串键和element值,从而形成递归结构。仅用几行语法,就以声明式的方式表达了JSON值的广泛可能。

除了JSON,Grammar Mutator还附带了HTTP、JavaScript和Ruby的预写语法。这不奇怪,因为许多格式在其RFC和标准中已经定义了语法。例如,JSON的RFC(datatracker.ietf.org/doc/html/rf…)中指出:

一个对象结构表示为一对大括号,包围零个或多个名称/值对(或成员)。名称是字符串。每个名称后跟一个冒号,将名称与值分隔开。值与后续名称之间用逗号分隔。对象中的名称应当唯一。
object = begin-object [ member *( value-separator member ) ] end-object
member = string name-separator value

这些语法行用递归表示法表达了类似的对象结构。通过阅读格式文档并将其转换成Grammar Mutator或其他工具的语法,你可以快速利用基于语法的模糊测试高效生成有效输入。

正如Grammar Mutator文档所述,若要在AFL++中使用自定义变异器,你需要构建该变异器并通过环境变量替换AFL++默认变异器:

$ make GRAMMAR_FILE=grammars/ruby.json
$ export AFL_CUSTOM_MUTATOR_LIBRARY=./libgrammarmutator-ruby.so
$ export AFL_CUSTOM_MUTATOR_ONLY=1
$ afl-fuzz -m 128 -i seeds -o out -- /path/to/target @@

这使得用更有用的变异提高AFL++模糊测试变得简单。不过,为复杂编程语言编写正确语法仍然具有挑战性。要做到这一点,需要更深层次地表达代码结构。

中间表示(Intermediate Representations)

你可能还记得在第一部分中提到,代码可以用不同抽象层级来表示,比如抽象语法树(AST)。有些模糊测试器并不是直接使用语法规则,而是尝试构建并变异AST,然后将其转换为模糊测试输入。这种方式允许在更高层次进行模糊测试,使得每次变异具有更强的语义影响,而不仅仅是在字节层面进行随机修改。

一个最近扩展此思路的项目是Fuzzilli,它是一个面向动态语言解释器的覆盖引导模糊测试器。Fuzzilli使用一种称为FuzzIL的中间语言,而不是AST或固定语法。该中间语言针对模糊测试变异进行了优化,使Fuzzilli能够快速生成有趣的输入,这些输入理论上也能映射到其他编程语言,虽然Fuzzilli目前只针对JavaScript。想深入了解Fuzzilli的功能,可以访问:github.com/googleproje…

由于Fuzzilli要求目标JavaScript引擎运行在特定的读-求值-打印-重置(read–eval–print-reset)循环中,这也需要对目标引擎应用定制补丁。虽然这不如AFL++的工作流那样简单直接,但成果显而易见。Fuzzilli的漏洞收集包括Safari的JavaScriptCore、Firefox的SpiderMonkey和Chromium的V8引擎中的安全漏洞。你也可以尝试在其他贡献者集成的目标上运行Fuzzilli,比如Meta的Hermes JavaScript引擎。

总体而言,Fuzzilli的方法展示了为复杂格式(如编程语言)构建定制变异器的价值。虽然不总是能直接使用标准的现成工具,但你可以结合自己的定制来集成它们。实际上,这通常能取得更好的效果,因为别人很可能没有用同样方式对你的目标进行过模糊测试。

总结

模糊测试是发现目标漏洞的最强大手段之一。通过扩展你的模糊测试能力,你可以将模糊测试应用到远不止开源二进制文件的更广泛目标上。

现代工具允许你利用覆盖引导的模糊测试,而无需编译时插装。在本章中,你学习了如何使用这些工具对黑盒和内存管理二进制进行模糊测试。此外,你还编写了自定义的Sanitizer来检测特定目标的漏洞。你还比较了处理文本格式中更复杂语法和语义的多种方法。

虽然依赖代码审查、逆向工程,甚至黑盒动态测试(也就是“手工模糊测试”)这些可靠、成熟的测试手段很诱人,但投资构建健壮的模糊测试流水线从长远来看会带来回报。

少见的方法带来少见的结果;大胆地在无人涉足之处进行模糊测试,你将会在意想不到的地方发现新颖的漏洞。研究人员往往高估了关键目标被深入测试的程度。鉴于现代软件的复杂性以及它所依赖的庞大遗留代码体系,仍有无数漏洞等待被发现。所需的不过是用你在过去几章中学到的一系列工具和策略,配以坚持不懈的努力和一点创造力。

现在,去征服世界吧!