基于变异的黑盒模糊测试的最大优势之一是几乎不需要复杂的准备工作。收集好初始输入(种子语料库)后,你所要做的就是运行模糊测试工具,等待崩溃或异常行为的发生,这些往往暗示着潜在的漏洞,比如内存损坏漏洞。这相较于耗费数小时的繁琐逆向工程和源代码审查,无疑是极大的解脱。
然而,虽然这种方法在过去代码安全意识薄弱、漏洞多且容易找到的黄金时代效果显著,但随着软件的不断加固,黑盒模糊测试的效果逐渐减弱。大多数明显的内存损坏漏洞已经被发现并通过安全开发实践(包括模糊测试)修复。为了发现更深层次的漏洞,敢于探索无人涉足之地,现代模糊测试工具采用覆盖率引导模糊测试技术,利用先前输入产生的代码覆盖率数据指导后续变异,目标是最大化目标程序的代码覆盖率,从而触达之前未被模糊过的程序新区域。
本章你将学习覆盖率引导模糊测试的原理,使用 AFL++ 在 LibreDWG 项目中发现漏洞。你将编写并优化自定义测试挂载程序,移除阻碍模糊测试的因素。随后,借助 Fuzz Introspector 分析模糊测试的覆盖率,锁定优质的模糊测试目标。
覆盖率引导模糊测试的优势
在第7章,我提到了“像穴居人一样模糊测试”系列教程,讲述了从零构建模糊测试工具的过程。与直接执行目标程序的传统黑盒模糊测试不同,“像现代人一样模糊测试”是借助高度优化和带有插桩的测试挂载程序完成的。
测试挂载程序(Harness)是专门定制的程序,用于导入并执行目标库中的特定函数,或运行目标可执行文件的特定部分。通过作为目标程序的中介或包装器,它可以简化模糊测试,比如提供更便捷的输入接口,跳过不相关的程序部分,甚至支持并行执行等性能优化。
没有优化的测试挂载,模糊测试速度会非常慢。普通用户在普通电脑上打开一份微软Word文档已经够快,但若没有充足的计算资源,想达到每秒数千次迭代是不现实的。此外,很多程序不是简单的命令行工具,不能仅接收单个文件作为输入。通过测试挂载隔离特定函数或指令集合,可以跳过程序中不与模糊测试数据交互的部分,从而加快测试速度。
此外,没有插桩提供的反馈机制,测试用例的范围基本局限于手动定义的模板或种子语料库,限制了变异范围仅在特定模板字段或变量上。覆盖率引导模糊测试则会保存那些触达更多程序路径的变异输入,并用它们生成更多测试用例。
举例来说,假设有一个PNG文件格式解析程序。除了上一章提到的循环冗余校验(CRC),程序还应能处理多种“块”类型,如:
- 图像头块(IHDR)
- 调色板(PLTE)
- 图像数据(IDAT)
- 背景色(bKGD)
- 图像伽玛(gAMA)
- 文本数据(tEXt)
- 透明度(tRNS)
程序会基于不同块类型数据包含的内容,通过switch语句分支处理。例如,IHDR块含有一个单字节整数,表示数据的传输顺序:0表示无交错,1表示Adam7交错。因此,假设的PNG解析器伪代码类似:
def parse_png(data):
while data:
# ...省略...
if chunk_type == "IHDR":
# 处理IHDR块
interlace_type = read_byte(chunk_data)
elif chunk_type == "PLTE":
# 处理PLTE块
elif chunk_type == "IDAT":
# 处理IDAT块
if interlace_type == NO_INTERLACE:
# 正常处理扫描线
elif interlace_type == ADAM7_INTERLACE:
# 使用Adam7交错处理扫描线
黑盒模糊测试器无法知道切换交错类型(interlace_type)这一操作会在后续触发新的指令执行。虽然你可以手动在格式模板中指定固定值专门针对Adam7交错测试,但这依赖于你个人判断,可能遗漏其他情况。
覆盖率引导模糊测试的优势在于通过编译时和运行时插桩,模糊测试器能够跟踪每次变异输入的代码覆盖情况,优先迭代那些触发更多覆盖的输入,快速产生更多有意义的测试用例,挖掘出新的漏洞,且无需人工干预。
此外,覆盖率引导模糊测试还能帮助跳过诸如“魔术字节”等校验点。在Michal Zalewski的博客文章《afl-fuzz: Making Up Grammar with a Dictionary in Hand》(lcamtuf.blogspot.com/2015/01/afl…)中,他介绍了AFL如何利用覆盖率引导算法自动识别PNG文件中的关键标记(如块名“IHDR”):
PNG格式使用四字节、可读的魔术值表示段落开始,例如:
89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 | .PNG........IHDR
00 00 00 20 00 00 00 20 02 03 00 00 00 0e 14 92 | ................
该算法通过检测连续字节区段,判断翻转这些字节会触发与相邻区域不同的执行路径,从而识别“IHDR”等语法标记。
当然,覆盖率引导模糊测试难以处理诸如CRC校验等复杂逻辑。这类瓶颈需在模糊测试前通过格式规范研究发现,或在初轮模糊测试后通过分析覆盖率报告识别。最直接的解决方式是对源代码进行打补丁,但这可能导致因测试用例崩溃而产生的误报。
多数现代模糊测试器都采用覆盖率引导策略,因其显著优势。但这并不意味着“笨蛋”模糊测试器(dumb fuzzers)无用,它们有不同的用途。快速而简单的策略适合模糊测试流程的早期阶段,用来发现易得的漏洞或潜在问题区域。此外,“笨蛋”模糊测试器在目标难以插桩或缺少测试挂载时表现更佳。
使用 AFL++ 进行模糊测试
AFL++(American Fuzzy Lop plus plus)是社区中最活跃的模糊测试项目之一,它是已停更的 AFL 的继任者(也是“更优的分支”)。作为一个非常活跃的社区项目,AFL++ 不断新增功能,融合最新的模糊测试技术和研究成果。同时,它也解决了研究者常遇到的许多实际问题,比如针对仅有二进制的目标进行模糊测试。
虽然 AFL++ 提供了容器镜像,但我建议你自行编译安装,以避免资源消耗问题,并方便调试。请按照 github.com/AFLplusplus… 的说明操作(本例使用版本4.21c)。由于构建步骤较多,安装过程会比较耗时:
$ sudo apt-get update
$ sudo apt-get install -y build-essential python3-dev automake cmake git flex bison \
libglib2.0-dev libpixman-1-dev python3-setuptools cargo libgtk-3-dev
$ sudo apt-get install -y lld llvm llvm-dev clang
$ GCC_VER=$(gcc --version|head -n1|sed 's/..*//'|sed 's/.* //')
$ sudo apt-get install -y gcc-$GCC_VER-plugin-dev libstdc++-$GCC_VER-dev
$ sudo apt-get install -y ninja-build
$ wget https://github.com/AFLplusplus/AFLplusplus/archive/refs/tags/v4.21c.tar.gz
$ tar -zxf v4.21c.tar.gz
$ cd AFLplusplus-4.21c
$ make distrib
$ sudo make install
AFL++ 对带有源代码的目标支持最好,因为它可以在编译时添加优化过的插桩。因此,你将从一个已知存在漏洞的 LibreDWG 版本开始,这是一个开源的 C 库,用于读写 DWG(绘图)格式文件。
有趣的是,开发者似乎已经用原版 AFL 和 Honggfuzz 进行了部分模糊测试,这在项目的 HACKING 文件中有说明。你可以参照这些说明,使用 AFL++ 插桩编译 dwgread 程序:
$ sudo apt-get install -y autoconf automake libtool pkg-config m4
$ git clone https://github.com/LibreDWG/libredwg
$ cd libredwg
$ git checkout 77a8562
$ sh ./autogen.sh
$ CC=afl-clang-lto ./configure --disable-bindings --disable-dxf --disable-json --disable-shared
$ make -C src
$ make -C programs dwgread
暂时不必过于担心编译选项,AFL++ 中央编译器会自动选择合理的默认值。例如,它会排除原始 Makefile 中的 -fsanitize=address
选项,因为地址消毒器会占用大量内存和计算资源,一般建议先不启用消毒器进行模糊测试。你可以参考 afl-1.readthedocs.io/en/latest/n… 了解消毒器对模糊测试性能的影响。
此阶段的关键选择之一是决定使用哪种插桩模式。AFL++ 提供四种模式:
- 链接时优化(LTO) :使用定制的 AFL 链接器在链接阶段插桩,避免边缘碰撞(instrumented branches 被随机赋予相同哈希值导致覆盖率错误报告),同时提高运行时性能,但编译时间较长。
- GCC 插件:类似 LLVM Pass 框架,GNU 编译器集合支持插件添加新功能,AFL++ 包含自定义 GCC 插件以实现插桩。
- GCC/Clang 内置插桩:依赖原始编译器自带的未优化汇编级插桩机制。
- LLVM 插桩:基于 LLVM Pass 框架,在编译时插桩,仅支持 Clang 编译器,可实现更多优化。
只要系统中有 Clang 或 Clang++ 版本 11 及以上,AFL++ 官方文档推荐使用 LTO 模式。你已经在环境变量 CC
中设置了 afl-clang-lto
,否则默认使用 afl-clang-fast
。编译输出中也会体现此选择。由于链接时优化,编译时间会更长。
目标编译完成后,即可用单个种子文件开始模糊测试:
$ mkdir fuzz-in
$ cp test/test-data/example_2000.dwg fuzz-in/
$ afl-fuzz -i fuzz-in -o fuzz-out -- programs/dwgread @@
这里,@@
代表 AFL++ 自动替换的输入文件路径,用于执行目标程序。如果一切顺利,你的第一个 AFL++ 模糊测试会话就开始了!
AFL++ 的界面大致如图 8-1 所示,你应定期查看以确认模糊测试进展符合预期。
界面大部分内容比较直观,但你可以参考官方文档 aflplus.plus/docs/status… 了解更多细节。有几个关键指标需要重点关注:
- Last new find(最新发现)
跟踪新的崩溃和卡死,以及新的执行路径(即新的覆盖)。如果刚开始模糊测试时没有新增覆盖,说明输入可能没有正确触发程序行为。 - Map coverage(覆盖率地图)
代表 AFL++ 用来显示被测试程序代码覆盖情况的“模糊位图”。理想情况下,开始时地图密度不宜过高(超过70%),否则 AFL++ 较难识别代码覆盖的显著变化。 - Item geometry(路径深度)
显示模糊测试达到的路径深度,特别关注稳定性(stability),即对相同输入覆盖率的一致性。稳定性理想值是100%,否则你可能遇到难以复现的崩溃。 - Stage progress(阶段进度)
显示当前执行的模糊测试动作。执行速度依赖硬件和测试环境,目标是每秒约 500 次执行。
这些指标帮助你判断模糊测试是否正确设置,并迅速发现可能的瓶颈,比如模糊测试环境、输入语料或校验逻辑等。
模糊测试运行足够久后,会开始遇到崩溃和卡死。AFL++ 会将导致独特崩溃或卡死的输入保存到 fuzz-out/default/crashes/
目录。由于每次模糊测试都是随机的,可能几天都遇不到崩溃,本书代码仓库在 chapter-08/aflplusplus-libredwg/crash-1.dwg
提供了一个崩溃输入样本。
用 GDB 调试该崩溃输入,可获得如下信息:
$ gdb --args ./programs/dwgread crash-1.dwg
(gdb) r
Starting program: /home/kali/Desktop/libredwg/programs/dwgread crash-1.dwg
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Program received signal SIGSEGV, Segmentation fault.
0x00005555557e48f3 in bit_calc_CRC (seed=49345, addr=0x555555e625c0 <error: Cannot access memory at address 0x555555e625c0>, len=11518) at /home/kali/Desktop/libredwg/src/bits.c:3455
3455 al = (unsigned char)((*addr) ^ ((unsigned char)(dx & 0xFF)));
这里 bit_calc_CRC
函数发生了越界读取,addr
变量无法访问。得益于编译时加入的调试信息,GDB 能精确定位代码出错行。你也可以用 backtrace
命令查看崩溃时的调用栈:
(gdb) backtrace
#0 0x00005555557e48f3 in bit_calc_CRC (seed=49345, addr=0x555555e625c0 <error: Cannot access memory at address 0x555555e625c0>, len=11518) at /home/kali/Desktop/libredwg/src/bits.c:3455
#1 decode_preR13_auxheader (dat=0x7fffffffc870, dwg=0x7fffffffc8b0) at decode.c:6278
#2 0x00005555557ec800 in decode_preR13 (dat=0x7fffffffc870, dwg=0x7fffffffc8b0) at decode_r11.c:786
#3 0x00005555555d1893 in dwg_decode (dat=0x7fffffffc870, dwg=0x7fffffffc8b0) at decode.c:217
#4 0x00005555555be43d in dwg_read_file (filename=<optimized out>, dwg=0x7fffffffc8b0) at /home/kali/Desktop/libredwg/src/dwg.c:261
#5 0x00005555555be43d in main (argc=<optimized out>, argv=0x7fffffffdeb8)
调用栈较深,但令人担忧的是崩溃发生在 CRC 相关函数,表明模糊测试卡在了该校验环节。这与你在状态屏幕看到的数值对应,覆盖率最终趋于平稳,说明 AFL++ 仅在 CRC 校验代码区域做变异,无法进入程序其他部分。
尽管如此,这个简短测试展示了覆盖引导模糊测试的强大威力。即使使用未经优化的测试环境、极简输入、无消毒器、覆盖率较弱,AFL++ 仍能“智能”地探索程序,并最终触发崩溃。它唯一需要的就是时间。
模糊测试优化
“模糊后就忘”可以是一种有效策略。然而,虽然这对像 dwgread 这样简单的程序有效,但在复杂程序中难以扩展。为了提升模糊测试性能,可以尝试以下介绍的优化技巧。
绕过校验检查
虽然 bit_calc_CRC
函数中存在越界读取漏洞,但你还希望在 dwgread 的其他代码部分发现漏洞。为此,你需要通过 CRC 校验。
看看 bit_calc_CRC
的调用位置(见清单 8-1):
int decode_preR13_auxheader (Bit_Chain *restrict dat, Dwg_Data *restrict dwg) {
int error = 0;
BITCODE_RS crc, crcc;
Dwg_AuxHeader *_obj = &dwg->auxheader;
--省略--
crcc = bit_calc_CRC(
0xC0C1,
&dat->chain[_obj->auxheader_address + 16], // 哨兵之后(16字节)
_obj->auxheader_size - 2); // 减去 crc 长度(2字节)
crc = bit_read_RS(dat);
LOG_TRACE("crc: %04X [RSx] from 0x%x-0x%lx\n", crc,
_obj->auxheader_address + 16, dat->byte - 2);
if (crc != crcc) {
LOG_ERROR("AUX header CRC mismatch %04X <=> %04X", crc, crcc);
error |= DWG_ERR_WRONGCRC;
}
error |= decode_preR13_sentinel(DWG_SENTINEL_R11_AUX_HEADER_END,
"DWG_SENTINEL_R11_AUX_HEADER_END", dat, dwg);
LOG_TRACE("\n");
return error;
}
作为 DWG 格式解码的一部分,计算头部 CRC 校验码(➊),并与提供的 CRC 校验码(➋)比较。如果不匹配,则记录错误(➌),错误会被传递到调用栈上的 dwg_decode
函数,见清单 8-2:
EXPORT int dwg_decode (Bit_Chain *restrict dat, Dwg_Data *restrict dwg) {
--省略--
PRE (R_13b1) {
Dwg_Object *ctrl;
int error = decode_preR13(dat, dwg);
if (error <= DWG_ERR_CRITICAL) {
ctrl = &dwg->object[0];
dwg->block_control = *ctrl->tio.object->tio.BLOCK_CONTROL;
}
return error;
}
VERSIONS (R_13b1, R_2000) { return decode_R13_R2000(dat, dwg); }
VERSION (R_2004) { return decode_R2004(dat, dwg); }
VERSION (R_2007) { return decode_R2007(dat, dwg); }
SINCE (R_2010) {
read_r2007_init(dwg); // 仅设置日志等级
return decode_R2004(dat, dwg);
}
--省略--
}
decode_preR13
函数(➊)最终触发 CRC 校验,校验失败会导致函数提前返回(➋)。如果 CRC 校验通过,则根据 DWG 文件版本代码执行不同解码流程(➌)。
如第7章所述,CRC 校验是一种错误检测码,数据或校验码中任何一位的变化都会导致校验失败。这使得模糊测试在没有外部辅助的情况下很难通过校验。
不过,绕过该校验不太可能影响后续崩溃的可利用性,因为正确计算并替换崩溃输入中的 CRC 校验码相对容易。与其他格式特定的校验不同,CRC 校验可以轻松恢复而不会影响导致崩溃的实际字节。这使其成为修补的理想候选。
查阅 Open Design Alliance 的 DWG 规范(www.opendesign.com/files/guest…),你会发现 DWG 格式实际支持多个版本,差异显著。例如,CRC 校验码大小根据版本不同,可从 8 位到 64 位不等。另外,还有使用称为哨兵(sentinel)的一组魔术字节进行的数据完整性检查。
这使得 LibreDWG 中绕过 CRC 和哨兵校验比简单注释一行代码复杂。例如,bit_check_CRC
函数在部分代码中使用,而其他部分使用 bit_calc_CRC
计算并与头部读取的期望值比较。
因此,绕过 CRC 和哨兵校验需要做多个修改:
- 修改
bit_check_CRC
,即使校验失败也返回 1(成功标志)。 - 找到所有将
bit_calc_CRC
返回值与期望值比较并触发错误的地方,修改它们以避免触发错误。 - 找到所有将
dwg_sentinel
返回值与解析值比较并触发错误的地方,修改它们以避免触发错误。 - 找到所有抛出
DWG_ERR_WRONGCRC
错误的其他地方,修改以避免触发错误。
修改时注意不要误改无关代码。例如,在 bit_check_CRC
函数(见清单 8-3)中有两种失败情况:
int bit_check_CRC(Bit_Chain *dat, long unsigned int start_address, uint16_t seed) {
uint16_t calculated;
uint16_t read;
long size;
loglevel = dat->opts & DWG_OPTS_LOGLEVEL;
if (dat->bit > 0) {
dat->byte++;
dat->bit = 0;
}
if (start_address > dat->byte || dat->byte >= dat->size) {
loglevel = dat->opts & DWG_OPTS_LOGLEVEL;
LOG_ERROR("%s buffer overflow at pos %lu-%lu, size %lu",
__FUNCTION__, start_address, dat->byte, dat->size);
return 0; // ➊ 不要修改此处为总是返回1,否则模糊测试会出现误报
}
size = dat->byte - start_address;
calculated = bit_calc_CRC(seed, &dat->chain[start_address], size);
read = bit_read_RS(dat);
LOG_TRACE("crc: %04X [RSx]\n", read);
if (calculated == read) {
LOG_HANDLE(" check_CRC %lu-%lu = %ld: %04X == %04X\n",
start_address, dat->byte - 2, size, calculated, read);
return 1;
} else {
LOG_WARN("check_CRC mismatch %lu-%lu = %ld: %04X <=> %04X\n",
start_address, dat->byte - 2, size, calculated, read);
return 0; // ➋ 修改此处使其总返回1,绕过 CRC 校验
}
}
越界读取检查(➊)不应总是返回1,否则会导致模糊测试误报且无法在原始程序复现。相比之下,CRC 校验失败时返回0(➋)可以修改为总是返回1,这样只要纠正崩溃输入头部的 CRC 校验码,就能复现崩溃。
由于需要修改的地方较多,本书代码仓库 chapter-08/aflplusplus-libredwg
中提供了相应的 Git 补丁文件。进入 libredwg
目录,执行:
$ cp ~/Desktop/from-day-zero-to-zero-day/chapter-08/aflplusplus-libredwg/remove_crc_sentinel.patch .
$ git apply remove_crc_sentinel.patch
补丁应用后,重新编译程序,并清理之前模糊测试输出:
$ make clean
$ make -C src
$ make -C programs dwgread
$ mv fuzz-out fuzz-out-1
$ afl-fuzz -i fuzz-in -o fuzz-out -- programs/dwgread @@
这次,使用补丁后的二进制进行模糊测试会产生模糊的结果,如图 8-2 所示。
例如,在与之前未修补 CRC 校验时相似的运行时长(约 30 分钟)内,没有出现新的崩溃或卡死。然而,如果仔细观察统计数据,会发现“own finds”(独立发现)数量增加了约 20%。这是因为修补后的二进制文件在没有 CRC 瓶颈的情况下,能够触及程序中更多的部分。之前的模糊测试只能专注于 CRC 校验,因此只能触及 bit_calc_CRC
中深层次的漏洞,而新的模糊测试覆盖面更广。
虽然经过足够时间,你仍可能再次触达 bit_calc_CRC
中的漏洞,但程序中其他函数(如 decode_R13_R2000
、decode_R2004
和 decode_R2007
)中也可能存在漏洞,而现有的输入语料库可能无法发现它们。
精简种子语料库
你在最初模糊测试 dwgread
时,仅使用了单个输入文件作为语料库。虽然这足够启动测试,但对于具有多个版本变体的复杂文件格式(如 DWG)而言,这并不理想。不同版本间差异明显,覆盖导向的模糊测试器不太可能将一个 DWG 2000 文件变异为有效的 DWG 2007 文件,因此应使用更大的种子语料库。
然而,如果种子语料库过大且代码覆盖高度重叠,则会浪费模糊测试资源。例如,两个元数据差异较小的 DWG 2000 文件很可能覆盖相似的代码,而 DWG 2000 和 DWG 2007 文件的覆盖差异更大。你应选择一个最小的语料库集合,以获得最大的初始覆盖率。
幸运的是,AFL++ 自带一个语料库精简工具 afl-cmin
,它通过使用插桩后的二进制测量每个输入文件的覆盖率,并找出能提供最大覆盖的最小输入子集。
尝试使用 afl-cmin
精简 test/test-data/2007
目录下的一组 DWG 文件,然后用新的精简语料库继续进行模糊测试:
$ mv fuzz-out fuzz-out-2
$ afl-cmin -i test/test-data/2007 -o fuzz-in-cmin -- programs/dwgread @@
$ afl-fuzz -i fuzz-in-cmin -o fuzz-out -- programs/dwgread @@
这次你应该能比之前更快地触发崩溃。
在图 8-3 中,可以看到尽管运行时间相近,这次模糊测试的“levels”和“own finds”数远超之前的测试,这反映了新语料库带来的更广泛覆盖。
这个模糊测试会产出新的崩溃点,你应当用 GDB 对其进行分析。和之前一样,如果因为模糊测试的随机性没能触达该崩溃,可以使用书中示例仓库提供的 crash-2.dwg 文件:
$ gdb --args ./programs/dwgread crash-2.dwg
(gdb) r
Starting program: /home/kali/Desktop/libredwg/programs/dwgread crash-2.dwg
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Program received signal SIGSEGV, Segmentation fault.
0x0000555555810645 in ➊ read_data_section (sec_dat=0x7fffffffc1f0, dat=0x7fffffffc880,
sections_map=<optimized out>, pages_map=0x555555b0fd50, sec_type=<optimized out>) at decode_r2007.c:840
840 r2007_section_page *section_page = section->pages[i];
(gdb) backtrace
#0 0x0000555555810645 in read_data_section (sec_dat=0x7fffffffc1f0, dat=0x7fffffffc880,
sections_map=<optimized out>, pages_map=0x555555b0fd50,
sec_type=<optimized out>) at decode_r2007.c:840
#1 0x0000555555808d5c in read_2007_section_revhistory (dat=0x7fffffffc880, dwg=0x7fffffffc8c0,
sections_map=0x555555b0f410,
pages_map=0x555555b0fd50) at decode_r2007.c:2023
#2 read_r2007_meta_data (dat=0x7fffffffc880, hdl_dat=<optimized out>, dwg=0x7fffffffc8c0) at
decode_r2007.c:2466
#3 0x00005555555d5279 in decode_R2007 (dat=0x7fffffffc880, dwg=0x7fffffffc8c0) at
decode.c:3469
#4 dwg_decode (dat=0x7fffffffc880, dwg=0x7fffffffc8c0) at decode.c:227
#5 0x00005555555be42d in dwg_read_file (filename=<optimized out>, dwg=0x7fffffffc8c0) at
/home/kali/Desktop/libredwg/src/dwg.c:261
#6 0x00005555555be42d in main (argc=<optimized out>, argv=0x7fffffffdec8)
漏洞出现在 decode_r2007.c
文件中的 read_data_section
函数 ➊。显然,这是因为更换了测试语料库,原先单一的 DWG 2000 种子输入未包含此版本文件。
与之前在 bit_calc_CRC
中发现的漏洞不同,这个漏洞可在 LibreDWG 的发布版本中被利用。因为当以 --enable-release
配置构建时,LibreDWG 会排除对 2000 版本(归为“pre-R13”)的支持。你可以下载官方 0.12.5 版本并构建发布版本来验证:
$ tar -xzvf libredwg-0.12.5.tar.gz
$ cd libredwg-0.12.5
$ ./configure --enable-release
$ make
用崩溃文件运行发布版本会触发预期的段错误。请注意,加载 LibreDWG 共享库需要先设置环境变量:
$ LD_LIBRARY_PATH="./src/.libs:$LD_LIBRARY_PATH" gdb --args ./programs/.libs/dwgread /home/kali/Desktop/crash.dwg
(gdb) r
Starting program: /home/kali/Downloads/libredwg-0.12.5/programs/.libs/dwgread /home/kali/Desktop/crash.dwg
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
ERROR: Invalid num_pages 7274598, skip ➊
ERROR: Invalid section->pages[0] size
Warning: Failed to find section_info[1]
ERROR: Failed to read header section
Warning: Failed to find section_info[3]
ERROR: Failed to read class section
Warning: Failed to find section_info[7]
ERROR: Failed to read objects section
Warning: Failed to find section_info[2]
ERROR: Preview overflow 119 + 0 > 302223
Warning: thumbnail.size mismatch: 302223 != 0
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff728a5c4 in read_data_section (sec_dat=sec_dat@entry=0x7fffffffc850, dat=dat@entry=
0x7fffffffcb20, sections_map=sections_map@entry=0x55555555b410, pages_map=pages_map@entry=
0x55555555bd50, sec_type=sec_type@entry=SECTION_REVHISTORY) at decode_r2007.c:805
805 r2007_section_page *section_page = section->pages[i];
有趣的是,虽然崩溃文件中的多个区块因各种检查触发错误和警告 ➊,但这并不妨碍程序执行到漏洞处。无论如何,你应确保崩溃案例在发布版本中也能复现。
编写 Harness
正如本章开头提到,使用模糊测试 harness 的好处之一是能进行更高效的模糊测试。到目前为止,你仅针对 LibreDWG 库自带的示例程序 dwgread
进行模糊测试。虽然这适合快速入门,但不利于全面测试,因为它调用的仅是 LibreDWG 提供的部分 API。
要测试其他 API,你需要编写自己的 harness 来调用目标函数。实际上,LibreDWG 开发者写了几个专门用于模糊测试的示例程序,位于 examples/dwgfuzz.c
和 examples/llvmfuzz.c
。
这些 harness 可利用 AFL++ 的持久模式(persistent mode)。该模式下,AFL++ 不为每次测试创建新进程,而是在单一进程中完成初始化后,重复调用目标函数进行模糊测试,速度可提升十倍。
编写 harness 的标准模板是定义一个名为 LLVMFuzzerTestOneInput
的函数。此名称来源于使用 LLVM 插桩的 libFuzzer 模糊测试引擎,后来 AFL 和 AFL++ 也支持此模板。ClusterFuzz 的所有覆盖导向模糊引擎均兼容此模板,实现大规模模糊测试。
虽然 LibreDWG 开发者提供了 examples/llvmfuzz.c
,但代码较大且不便维护。你可以选择聚焦特定 API,练习编写针对 dwg_decode
函数的简化 harness。将 examples/llvmfuzz.c
简化为下述代码,或从书中代码仓库的 chapter-08/aflplusplus-libredwg/llvmfuzz.c
获取:
#include <dwg.h>
#include "bits.h"
#include "decode.h"
extern int LLVMFuzzerTestOneInput (const uint8_t *data, size_t size);
int LLVMFuzzerTestOneInput (
➊ const uint8_t *data, size_t size
) {
Dwg_Data dwg;
Bit_Chain dat = { NULL, 0, 0, 0, 0 };
➋ memset(&dwg, 0, sizeof (dwg));
dat.chain = (unsigned char *)data;
dat.size = size;
➌ dwg_decode(&dat, &dwg);
➍ dwg_free(&dwg);
return 0;
}
兼容的模糊器会自动调用 LLVMFuzzerTestOneInput
,将模糊输入放入 data
参数,输入大小放入 size
参数 ➊。你需要在 harness 中初始化数据结构 ➋,将模糊数据传给目标函数 ➌,调用后释放工作数据 ➍,以保证稳定性和模糊效率。
构建此精简版 llvmfuzz
之前,需修改 examples/Makefile.am
中的编译标志:
llvmfuzz_CFLAGS = $(CFLAGS) $(AM_CFLAGS) \
-fsanitize=fuzzer -fno-omit-frame-pointer
这是为了避免使用其他额外的 sanitizer,因为它们会显著增加资源消耗。确认修改后,继续构建并启动模糊测试。此时无需传入文件路径参数,AFL++ 会自动检测 LLVMFuzzerTestOneInput
函数:
$ mv fuzz-out fuzz-out-3
$ make clean
$ CC=afl-clang-lto ./configure --disable-bindings --disable-dxf --disable-json --disable-shared
$ make -C src
$ make -C examples llvmfuzz
$ afl-fuzz -i fuzz-in-cmin -o fuzz-out -- examples/llvmfuzz
你应该会看到如下初始化信息,确认持久模式被启用:
[+] Persistent mode binary detected.
[+] Deferred forkserver binary detected.
[*] Spinning up the fork server...
[+] All right - fork server is up.
[*] Using SHARED MEMORY FUZZING feature.
相比之前的数百次每秒执行速度,你现在大部分时间应能达到数千次每秒的执行速度,具体视当前输入而定。
并行模糊测试
如果你有足够的处理器或核心,可以同时运行多个模糊器,共享测试用例,同时针对使用不同 Sanitizer 编译的二进制文件进行测试。例如,你的主模糊器可以对未启用任何 Sanitizer 编译的目标进行模糊测试,而次级模糊器则对启用了 AddressSanitizer (ASan) 编译的目标进行模糊测试。
具体做法是,将原始编译好的目标重命名为 llvmfuzz-orig
。然后,修改 examples/Makefile.am
,为 llvmfuzz
增加 AddressSanitizer 支持:
llvmfuzz_CFLAGS = $(CFLAGS) $(AM_CFLAGS) \
-fsanitize=fuzzer,address -fno-omit-frame-pointer
重新编译后,将输出的二进制文件重命名为 llvmfuzz-asan
。
接下来,在不同终端分别启动两个模糊测试会话,命令如下:
$ mv fuzz-out fuzz-out-4
$ afl-fuzz -i fuzz-in-cmin -o fuzz-out -M orig -- examples/llvmfuzz-orig
$ afl-fuzz -i fuzz-in-cmin -o fuzz-out -S asan -- examples/llvmfuzz-asan
你可能会发现启用了 ASan 的 harness 运行速度比原始版本慢,但由于启用了持久模式,速度仍然能保持在合理范围内。这样可以帮助你捕获那些不会直接导致崩溃但仍可被利用的潜在内存损坏漏洞。
使用 afl-cov 测量模糊测试覆盖率
到目前为止,你主要通过在 harness 中使用各种优化来提升模糊测试的速度和效率。但是,如果你只是测试那些已经被充分模糊和加固的代码路径,或者仅仅覆盖了可用代码的一小部分,即使达到每秒数千次执行也是没有意义的。仅仅优化模糊测试而没有正确选择目标是一种糟糕的策略。相反,你应该收集数据来选择最有可能暴露漏洞的模糊测试目标。
评估模糊测试目标最直接的方法之一是测量覆盖率。你可以通过为目标编译时启用分析支持,来识别模糊测试器实际触及的代码,这样可以帮助你发现模糊测试中的潜在盲点。
修改不同项目的构建过程往往很复杂,且容易破坏工作流程。幸运的是,有一个名为 afl-cov 的实用工具,它提供了多种辅助脚本来简化这一过程。
下面是针对你修改后的 LibreDWG 和模糊测试 harness 使用 afl-cov 的示例步骤。首先,将其恢复到非 ASan 版本,并复制到一个新目录中,同时包含 fuzz-out 目录下的模糊测试会话工作数据,因为 afl-cov 会测量模糊测试队列中每个测试用例的覆盖率:
$ sudo apt-get install -y lcov libdatetime-perl
$ yes | sudo perl -MCPAN -e 'install Capture::Tiny'
$ git clone https://github.com/vanhauser-thc/afl-cov
$ cp -r libredwg libredwg-gcov
$ cd libredwg-gcov
$ make clean
$ /home/kali/Desktop/afl-cov/afl-cov-build.sh -c ./configure --disable-bindings --disable-dxf --disable-json --disable-shared
$ make -C src
$ make -C examples llvmfuzz
$ cp ../afl-cov/afl-clang-cov.sh .
$ /home/kali/Desktop/afl-cov/afl-cov.sh -v -c /home/kali/Desktop/libredwg-gcov/fuzz-out "/home/kali/Desktop/libredwg-gcov/examples/llvmfuzz @@"
$ sed -i 's/src/src/src/g' fuzz-out/default/cov/lcov/trace.lcov_info_final
$ genhtml --ignore-errors unmapped --output-directory fuzz-out/default/cov/web fuzz-out/default/cov/lcov/trace.lcov_info_final
上述命令还应用了一些 Bug 修复,以确保 afl-cov 能与你的模糊测试器正常配合。不幸的是,模糊测试相关的很多工具还较为实验性,维护不够完善,因此不处理类似 Clang 支持的边缘情况会导致问题。
生成报告后,你可以直接打开 libredwg-gcov/fuzz-out/default/cov/web
目录下生成的 index.html
文件查看报告,效果类似于图 8-4。
报告会告诉你模糊测试会话在目标程序的每个源代码文件和函数中实现了多少代码覆盖率。此外,如果你点击查看具体的源代码文件,还能看到哪些代码被测试用例覆盖到了。例如,来看一下 src/dwg.c 中的 dwg_paper_space_ref 函数:
/** 返回 DWG 的纸空间块对象。
*/
EXPORT Dwg_Object_Ref *
dwg_paper_space_ref (Dwg_Data *dwg)
{
if (dwg->header_vars.BLOCK_RECORD_PSPACE
&& dwg->header_vars.BLOCK_RECORD_PSPACE->obj)
return dwg->header_vars.BLOCK_RECORD_PSPACE; ➊
return dwg->block_control.paper_space && dwg->block_control.paper_space->obj
? dwg->block_control.paper_space
: NULL;
}
在这里,覆盖率报告指出模糊测试器从未触及第一个 return ➊。这说明在模糊测试过程中,无论是你的初始种子输入还是变异生成的测试用例,都没有满足触发这段代码的条件。因此,模糊测试器无法达到该代码路径下可能触发的后续代码。你可以考虑检查这些未覆盖的边缘情况,手动设计种子输入来针对这些代码路径进行模糊测试。
Fuzz Introspector
虽然 afl-cov 能提供模糊测试器盲区的一些初步见解,但它并不能真正告诉你应该优先模糊哪些目标。一个强大的工具是 Fuzz Introspector。Fuzz Introspector 是 OSS-Fuzz 的一个核心组件,用于测量和分析项目的模糊测试状态。
由于它高度集成于 OSS-Fuzz,通常在 OSS-Fuzz 框架内运行 Fuzz Introspector 更为方便,而不是单独运行。OSS-Fuzz 自带许多辅助脚本和 Docker 容器,可以在本地运行该工具。
此外,LibreDWG 已经集成了 OSS-Fuzz。项目与 OSS-Fuzz 的集成遵循相同的模式:
- project.yaml
项目的 OSS-Fuzz 集成元数据,指定使用哪些模糊测试引擎和 Sanitizer。OSS-Fuzz 会通过环境变量自动构建项目的不同版本。 - Dockerfile
基于 OSS-Fuzz 构建镜像的容器构建指令,负责下载并准备目标项目以供构建。 - build.sh
实际构建项目和模糊测试 harness 的命令。关键的环境变量是LIB_FUZZING_ENGINE
,允许 OSS-Fuzz 注入不同的编译配置。
你可以从 github.com/google/oss-… 克隆 OSS-Fuzz 项目,LibreDWG 的集成位于 projects/libredwg 目录。其 Dockerfile 如下所示(见清单 8-5):
FROM gcr.io/oss-fuzz-base/base-builder
RUN apt-get update && apt-get install -y autoconf libtool texinfo
RUN git clone https://github.com/LibreDWG/libredwg
WORKDIR $SRC
COPY build.sh $SRC/build.sh
COPY llvmfuzz.options $SRC/llvmfuzz.options
该容器构建指令克隆 LibreDWG 主分支源代码,并将构建脚本复制到源码目录中。基础 OSS-Fuzz 构建镜像会自动检测并运行该脚本。脚本构建标准的 LibreDWG 发布版本,并确保在模糊测试时 libFuzzer 不会检测到内存泄漏。
你使用的是已移除模糊测试阻碍(如 CRC 校验)的自定义代码库,因此应修改 Dockerfile,使用本地版本替代克隆 LibreDWG。将你修改过的 libredwg 复制到 projects/libredwg,并按清单 8-6 修改 Dockerfile:
FROM gcr.io/oss-fuzz-base/base-builder
RUN apt-get update && apt-get install -y autoconf libtool texinfo
WORKDIR $SRC
COPY libredwg $SRC/libredwg
COPY llvmfuzz_seed_corpus.zip $SRC/llvmfuzz_seed_corpus.zip
COPY build.sh $SRC/
COPY llvmfuzz.options $SRC/
除了将修改后的源码复制进容器镜像外,Dockerfile 还添加了种子语料库,以进一步提升模糊测试覆盖率。OSS-Fuzz 允许开发者通过特定命名的 ZIP 压缩包提供种子语料库。将之前准备好的种子语料库压缩为 ZIP 并放置于相同目录:
$ git clone https://github.com/google/oss-fuzz
$ cd oss-fuzz/projects/libredwg
$ cp -r /home/kali/Desktop/libredwg .
$ zip llvmfuzz_seed_corpus.zip libredwg/fuzz-in-cmin/*
由于种子语料库需要作为构建产物传递,还需修改构建指令(见清单 8-7):
cd libredwg
sh ./autogen.sh
# enable-release 跳过不稳定的 preR13,bindings 不参与模糊测试。
./configure --disable-shared --disable-bindings --enable-release
make -C src
$CC $CFLAGS src/.libs/libredwg.a -I./include -I./src -c examples/llvmfuzz.c
$CXX $CXXFLAGS $LIB_FUZZING_ENGINE llvmfuzz.o src/.libs/libredwg.a -o $OUT/llvmfuzz
cp $SRC/llvmfuzz.options $OUT/llvmfuzz.options
cp $SRC/llvmfuzz_seed_corpus.zip $OUT/llvmfuzz_seed_corpus.zip
你添加的指令会将种子语料库归档复制到存储构建产物的目录。
现在你已准备好通过 OSS-Fuzz 在本地运行 Fuzz Introspector。首先安装 Docker,并将当前用户添加到 Docker 用户组以无需提升权限即可使用:
$ sudo apt install -y docker.io
$ sudo usermod -aG docker $USER
$ su - $USER
$ cd /home/kali/Desktop/oss-fuzz
$ python infra/helper.py introspector libredwg --seconds=30
警告
这是一个内存消耗较大的操作,会创建并运行多个 Docker 容器。如果容器运行失败,可能需要调整 Docker 的资源使用设置,或者在宿主机直接运行 Fuzz Introspector。请留意调试和错误信息。如果你无法自行生成报告,书中示例仓库提供了预生成的 LibreDWG Fuzz Introspector 报告,路径为 chapter-08/introspector-report。
如果一切顺利,辅助脚本将生成 Fuzz Introspector 报告。你可以快速启动 Python Web 服务器来提供报告文件:
$ cd build/out/libredwg/introspector-report/inspector
$ python -m http.server 8080
通过浏览器访问 http://localhost:8080/fuzz_report.html。下一步就是分析报告,找出改进模糊测试会话的方法。
识别模糊测试阻碍点
Fuzz Introspector 报告的一个关键用途是识别阻碍模糊测试器深入更多代码行的“模糊阻碍点”。这与 afl-cov 类似,但它使用 Clang 的基于源代码的覆盖率功能,而非 lcov。
打开报告中 Fuzzer 详情部分的 “Fuzz Blockers” 表格。Fuzz Introspector 识别出的一个阻碍点位于 src/decode_r2007.c
文件中的 read_2007_section_header
函数,如清单 8-8 所示。
➊ if (bit_search_sentinel (&sec_dat,
dwg_sentinel (DWG_SENTINEL_VARIABLE_BEGIN)))
{
BITCODE_RL endbits = 160; // 起始位:16位哨兵 + 4位大小
dwg->header_vars.size = bit_read_RL (&sec_dat);
LOG_TRACE ("size: " FORMAT_RL "\n", dwg->header_vars.size);
*hdl_dat = sec_dat;
// 未使用:后续版本复用2004节格式
/*
if (dat->from_version >= R_2010 && dwg->header.maint_version > 3)
{
dwg->header_vars.bitsize_hi = bit_read_RL(&sec_dat);
LOG_TRACE("bitsize_hi: " FORMAT_RL " [RL]\n",
dwg->header_vars.bitsize_hi) endbits += 32;
}
*/
if (dat->from_version == R_2007) // 目前总是为真
{
dwg->header_vars.bitsize = bit_read_RL (&sec_dat);
LOG_TRACE ("bitsize: " FORMAT_RL " [RL]\n",
dwg->header_vars.bitsize);
endbits += dwg->header_vars.bitsize;
bit_set_position (hdl_dat, endbits);
section_string_stream (dwg, &sec_dat, dwg->header_vars.bitsize,
&str_dat);
}
dwg_decode_header_variables (&sec_dat, hdl_dat, &str_dat, dwg);
}
else
{
DEBUG_HERE;
error = DWG_ERR_SECTIONNOTFOUND;
}
清单 8-8:decode_r2007.c
中的模糊测试阻碍点
由于 Fuzz Introspector 检测到的哨兵检查 ➊(我们在之前修改代码时忽略了它),代码路径会默认返回错误,导致无法继续解析 DWG 数据。
针对这种情况,你应再次修改源码以通过该哨兵检查,如前面所述。
分析函数复杂度
Fuzz Introspector 提供了另一个有用的分析——函数复杂度,它可以突出那些覆盖大量代码的函数,这些函数通常是良好的模糊测试目标。函数越复杂,越有可能包含缺陷代码或不安全的功能。从开发者角度来看,测试和保障小而简单的函数比那些包含数百行代码和多个条件分支的函数要容易得多。
Fuzz Introspector 报告了几个听起来较复杂的指标。圈复杂度(Cyclomatic Complexity)从宏观上衡量每个函数中独立代码路径的数量。函数的累计复杂度表示该函数及其调用的函数的总复杂度。最后,未覆盖的复杂度指当前模糊测试未达到的代码路径。
如图 8-5 所示,如果你在项目函数概览表中按累计圈复杂度排序,会发现 dwg_write_file
和 dwg_encode
分别排名第一和第二。
虽然这似乎暗示你应该对 dwg_write_file
进行模糊测试,而不是 dwg_encode
,但请注意 dwg_write_file
的圈复杂度非常低。对一个涉及文件系统调用的函数进行模糊测试可能会更慢,这实际上使得 dwg_encode
成为更合适的模糊测试候选对象。此外,由于你自定义的模糊测试器只关注了 dwg_decode
,因此 dwg_encode
中仍然存在大量未被发现的复杂度也是合理的。
正如这个例子所示,应用这些数据有两种方式。首先,识别累计复杂度最高的函数。即使这些函数已经被他人加固和模糊测试过,你仍然可以利用代码覆盖率和模糊测试阻碍点数据来优化你的模糊测试器,从而触达新的代码路径。其次,识别未覆盖复杂度最高的函数(换句话说,就是那些尚未被深入模糊测试的函数),并编写新的模糊测试器针对这些函数。
相比 afl-cov,Fuzz Introspector 在原始数据(如代码覆盖率)基础上提供了更多高层次的分析。这些分析赋予数据更多意义,帮助开发者或研究人员解决最重要的问题:漏洞最可能出现在哪里?
在结束本节前,值得一提的是,Fuzz Introspector 有一个叫 Auto-Fuzz 的工具,可以基于覆盖率数据自动生成模糊测试框架。这个功能目前还处于实验阶段,承诺实现完全自动化的模糊测试。但正如你所见,总会有一些边缘情况需要人工介入处理。
总结
模糊测试(fuzzing)背后的复杂性常常使许多研究人员将其视为一种黑盒操作。他们通常搭建一个“足够好”的测试环境和测试用例库,然后“模糊测试后就不再管”。在本章中,你深入理解了 AFL++ 及其相关工具,从而能够更有效地使用它。你学会了如何移除阻碍模糊测试的因素,通过编写自定义测试环境和并行化大幅提升了模糊测试的速度。
当然,模糊测试不仅仅是一个粗暴地发现程序漏洞的工具(虽然它在这方面表现相当出色);结合覆盖率分析,它还能帮助你聚焦于程序中更关键的部分。本章中你使用了 afl-cov 和 Fuzz Introspector 来发现额外的模糊测试阻碍点,并识别出有价值的测试目标。
这里介绍的许多技术都需要源代码来正确调试和插装目标程序。虽然大量软件依赖开源代码,但对仅有二进制文件的目标或托管内存框架进行模糊测试则没有那么简单。下一章将补充这些不足,介绍如何在不依赖源代码或详细格式规范的情况下,开始对所有类型的目标进行模糊测试。