本文重点介绍如何使用 AFL (American Fuzzy Loop) 对二进制程序进行 Fuzzing(模糊测试),以发现内存型漏洞。
AFL 的出现是模糊测试领域的里程碑事件,其背后蕴含了大量的先进理念和快速、健壮的技术实现,今天看来依然不过时。本文无意详细探讨这些理念和技术,作为开篇,先整体介绍 AFL 的使用。主要关注 Fuzzing 流程的完整性,并尽可能详细的描述各个阶段。
Part1选择测试目标
首先,需选择合适的测试目标。一般来说,适合使用 AFL Fuzzing 的目标具有如下特点:
-
程序接收并处理不安全的外部输入(输入可控) -
非交互、无状态 -
不安全的内存模型(如:C/C++) -
老旧的、缺乏维护 -
单一进程
外部输入可控这点很好理解,想要挖掘到漏洞,就需要让程序执行尽可能多的代码分支,覆盖更多的代码(更准确的说法是覆盖更多非预期或发生状态转换的代码)。在 Fuzzing 过程中,会大量反复的执行目标程序,为了保证效率和结果准确性,就需要目标程序是非交互且无状态的。使用不安全的内存模型设计的语言,需要程序员写代码实现分配和释放等操作内存的逻辑,因而更容易写出包含内存型漏洞的代码,如:栈溢出、堆溢出和释放后重用等漏洞,这些正是 AFL 大显身手的地方。由于 AFL 使用的 forkserver 机制(为了减少大量 execve()、链接和 libc 初始化带来的开销),对多进程目标应用进行 Fuzzing,将产生不确定性的结果。
我们这里选择 expr 作为示例目标程序,该工具属于 GNU coreutils 工具集。
Part2分析目标程序
AFL 支持白盒插桩测试以及黑盒仿真测试(QEMU 模式),QEMU 模式会带来2~5倍的性能开销,但实验结果表明 QEMU 模式比其它同类工具 DynamoRIO 和 Pin 在性能表现上要好很多。因为 expr 是开源程序,所以我们可以直接分析其源码。其整体执行流程如下图:
进一步分析发现,目标程序只支持 argv 类型且为字符串类型的输入。AFL 不直接支持这种类型的输入,只支持本地文件输入和标准输入(stdin)。因此需要写一些 harness 代码,支持 stdin 类型的输入。此外,还要分析程序是否具有主入口点(main 函数)或者只是一个库,也要看看程序是否包含一些不影响漏洞挖掘但会拖慢程序执行的函数,如:socket, checksum, timer 和 random 等,如果有的话想办法去除这部分的影响。
如果需要进一步加快 Fuzzing 进程,AFL 在 forkserver 的基础上,又提供了 persistent 和 deferred 两种高级模式。
persistent 模式的设计初衷是为了减少 fork 函数调用的开销,实现一次 fork 处理多个输入文件。具体使用可参考 AFL 提供的示例代码(/experimental/persistent_demo/),其核心伪代码如下:
#ifdef __AFL_HAVE_MANUAL_CONTROL
while (__AFL_LOOP(1000)) {
/* Read input data. */
/* Call library code to be fuzzed. */
/* Reset state. */
}
/* Exit normally */
#endif
不同于常规的自动在主入口点开始处插桩的做法,deferred 模式 可以实现有选择的手动插桩。这样一来,就可以跳过那些耗时的初始化代码,这些代码往往不包含真正的功能逻辑,被 fork 出来的子进程直接就执行关键代码,达到加快 Fuzzing 进程的目的。使用起来也比较简单,只需在合适的位置添加如下代码即可:
#ifdef __AFL_HAVE_MANUAL_CONTROL
__AFL_INIT();
#endif
虽然这两种模式都非常不错,但需注意他们只支持 afl-clang-fast 编译器,不支持 afl-gcc 和 afl-clang 编译器(PS 这些编译器只是 AFL 实现的 GCC 和 Clang 的包装器,增加了一些特定的编译选项,最终真正执行编译工作的还是 GCC 和 Clang)。他们在插桩方式上,也是完全不同的。前者使用 Clang 编译器,结合后端 LLVM 的 Pass 框架实现了编译器级别的插桩,而后者只是通过对汇编代码进行动态改写实现的汇编器级别的插桩。不同的插桩方式,最终会影响程序的执行性能和平台兼容性。最后需特别注意的是,不当的使用会带来不确定性的结果,所以请谨慎使用。
Part3编写 harness
为了使 expr 支持 stdin 类型输入,需对目标程序进行适当改造。这里只是简单增加对 stdin 的支持,感兴趣的读者也可以尝试支持 persistent 和 deferred 模式。
int
main (int argc, char **argv)
{
/* harness for fuzzing */
if (argc == 1) {
int i = 1;
char input[1 << 16] = {0};
ssize_t length;
length = read(STDIN_FILENO, input, 1 << 16);
char *p = strtok (input, " ");
while (p != NULL)
{
argv[i++] = p;
p = strtok (NULL, " ");
}
argc = i;
}
VALUE *v;
......
接着使用 AFL 提供的 GCC 包装器 afl-gcc 编译目标程序,以注入插桩代码
$ CC=/usr/local/bin/afl-gcc ./configure
$ make -j $(nproc)
Part4构建测试用例集
让 AFL 跑起来需要为其提供测试用例集(初始语料集,至少1个非空文件)。测试用例不能太多,单个测试用例也不能太大,理想情况是包含目标程序全部执行路径的同时,测试用例数量最少且单个文件最小。为了完成这个工作,AFL 提供了 afl-cmin 和 afl-tmin 两个好用的工具。前者用来删减测试用例数量,后者用来精简测试用例大小。
工具比较简单,对照帮助文档(不带参数,直接运行命令)使用即可。需注意两点:
-
一般顺序是先删减数量,再精简大小。而且,这个操作可以多次执行; -
afl-tmin 一次只能对一个文件进行精简,可以写个脚本封装 afl-tmin 实现批处理。
import sys
from os import makedirs, listdir, system
from os.path import exists, isfile, join, getsize
import shutil
import time
# keep your test cases small
MIN_FILESIZE = 1024
MAX_FILESIZE = MIN_FILESIZE * 10
def make_min_dir(input_dir, min_type):
try:
min_dir = join(input_dir, min_type)
if not exists(min_dir):
makedirs(min_dir)
return min_dir
except OSError as e:
print(str(e))
exit(-1)
def cmin(input_dir, min_dir, target_app_cmdline):
cmd_tpl = "afl-cmin -i {origin_dir} -o {output_dir} -m 100 -t 2000 -- {target_app}"
system(cmd_tpl.format(origin_dir=input_dir, output_dir=min_dir, target_app=target_app_cmdline))
def tmin(input_dir, min_dir, target_app_cmdline):
# default is instrumented mode, if need use crash mode, add -x setting
cmd_tpl = "afl-tmin -i {origin_file} -o {tmin_file} -m 100 -t 2000 -- {target_app}"
seed_list = listdir(input_dir)
for seedfile in seed_list:
abs_seedfile = join(input_dir, seedfile)
if isfile(abs_seedfile):
in_file = abs_seedfile
out_file = join(min_dir, seedfile)
in_file_size = getsize(in_file)
if in_file_size > MIN_FILESIZE:
if in_file_size <= MAX_FILESIZE:
print("+++ run afl-tmin +++")
system(cmd_tpl.format(origin_file=in_file, tmin_file=out_file, target_app=target_app_cmdline))
else:
# afl-tmin process large file will very slow, so ignore it
print("+++ ignore large file: {}, the size is: {:.2f}KB +++".format(in_file, in_file_size/1024.0))
else:
print("+++ copy file: {} -> {} +++".format(in_file, out_file))
shutil.copyfile(in_file, out_file)
time.sleep(0.02)
if __name__ == "__main__":
if len(sys.argv) < 4:
print("Usgae: {} [cmin|tmin] input_dir target_app_cmdline".format(sys.argv[0]))
exit(-1)
min_type = sys.argv[1]
input_dir = sys.argv[2]
target_app_cmdline = sys.argv[3]
if min_type in ["cmin", "tmin"]:
min_dir = make_min_dir(input_dir, min_type)
if min_type == "cmin":
cmin(input_dir, min_dir, target_app_cmdline)
else:
tmin(input_dir, min_dir, target_app_cmdline)
else:
print("Only support cmin or tmin command")
exit(-1)
完成以上语料蒸馏工作(Corpus Distillation)后,应该能将测试用例集控制在一个较小的范围。
Part5开始 Fuzzing
有了插桩过的目标程序,也有了初始语料集,这时我们就可以使用 AFL 提供的 afl-fuzz 工具开始 Fuzzing 了。使用工具前,先直接运行以查看帮助文档。
为了实现启发式的变异,使得每次变异更加有效,AFL 支持自定义和自动发现 Token 字典。举个例子,IHDR 为 PNG 文件的头数据块,定义此 Token 后,AFL 会围绕 IHDR 去变异数据,这样更可能变异出有效的数据格式,进而覆盖更多的代码,进而提高发现漏洞的概率。对于 expr 程序,可定义如下 Token(真正使用时,在字典文件中逐行存放各个 Token):
"|", "&", "<", "<=", "=", "!=", ">=", ">", "+", "-", "*", "/", "%", " : ", "\", "index ", "substr ", "length "
AFL 有主/从两种类型,主 Fuzzer (-M) 会先执行确定性变异策略、后执行完全随机变异策略,从 Fuzzer(-S)则只执行完全随机变异策略。在变异策略方面,AFL 的优势主要体现在确定性变异策略这部分。主从 Fuzzer 也会互相同步其它 Fuzzer 变异出的有趣的(能够覆盖新的执行路径)测试用例。
$ afl-fuzz -i fuzzing/in -o fuzzing/out -m none -x fuzzing/expr.dict -M expr_main -- src/expr
$ AFL_IMPORT_FIRST=1 AFL_NO_UI=1 afl-fuzz -i fuzzing/in -o fuzzing/out -m none -x fuzzing/expr.dict -S expr_slave -- src/expr
这里 AFL 会通过 dup2
系统调用函数自动将测试用例的内容复制到标准输入。除非手动终止(Ctrl+C),AFL 将会一直运行下去。
Part6持续监控
当并行测试时,每个 AFL 实例都会有一个状态屏幕。为了方便查看汇总数据,AFL 内置了 afl-whatsup 工具。但该工具每次运行只能查看当前时刻的统计数据,使用起来不是很方便。我们可以写个脚本对工具做个封装,循环的去调用。
import sys
from os import system
from os.path import isdir
import time
if __name__ == "__main__":
if len(sys.argv) == 2 and isdir(sys.argv[1]):
output_dir = sys.argv[1]
while True:
try:
system("clear && afl-whatsup -s {}".format(output_dir))
time.sleep(10)
except KeyboardInterrupt:
print("Stop successful")
exit(0)
else:
print("Usgae: {} outputdir".format(sys.argv[0]))
exit(-1)

除了状态屏幕,AFL 还提供了更直观的图形化工具,这就是 afl-plot。该工具依赖系统工具 gnuplot,使用起来比较简单。
$ afl-plot fuzzing/out/expr_main/ fuzzing/out/expr_main/graph
第一个参数为 Fuzzing 实例的输出目录(注意和同步目录区分),该目录包含用于图形化展示的 plot-data 数据文件,第二个参数为输出图形化结果的目录。运行命令后,会在 graph 目录生成1个 html 文件和3张 png 图片:
既然 AFL 会一直运行,那应该何时手动停止呢?这个问题没有明确的答案,这里仅提供一些参考思路:
-
状态屏幕中的 last new path 或 last uniq crash 已过去较长时间。具体多长时间算长,因人而异、因目标程序而异; -
随着时间的推移,cycles done 的颜色会由最初的洋红色,逐步变为黄色、蓝色和绿色。当变为绿色时,是个不错的停止 Fuzzing 的时间点; -
pend fav 的数量变为零并且 total paths 数量基本上没有再增长。
AFL 是基于覆盖引导的 Fuzzer,使用代码覆盖率可以评估和改进测试过程,执行到的代码越多,找到漏洞的可能性就越大。无论是 GCC 的 gcov 还是 LLVM 的 llvm-cov,都提供函数(function)、基本块(basic-block)和边界(edge,也称为 branch)三种级别的覆盖率计算。AFL 主要使用的是 branch 覆盖,接下来介绍如何计算我们的测试用例对 expr 目标程序的代码覆盖率。
这里主要使用的是随 GCC 一起发布的 gcov 工具,该工具用来计算代码覆盖率。为了使结果更加直观,我们结合使用了 lcov 工具,用来生成可读性更好的 HTML 页面。
$ ./configure CC=/usr/bin/gcc CFLAGS="-fprofile-arcs -ftest-coverage" --prefix=/root/Sec/Fuzzing/projects/coreutils-9.1/build-cov
$ ./build-cov/bin/expr 1 + 3
$ gcov -b -c src/expr.c
$ lcov --directory ./src/ --capture --output-file expr.info --rc lcov_branch_coverage=1
$ genhtml expr.info --output-directory expr-cov --rc lcov_branch_coverage=1
首先,使用 GCC 结合特定编译选项进行配置,为了不覆盖先前 AFL 插桩的二进制文件,这里指定了 --prefix
选项。这时在源代码目录会生成一个 expr.gcno
文件。接着,使用 AFL 输出目录中 queue 目录下的测试用例执行目标程序,这时在源代码目录会生成一个 expr.gcda
文件。最后,使用 gcov 命令计算该测试用例的代码覆盖率,结果保存在 expr.c.gcov
文件。

如图所示,至此我们已经得到了本次测试用例执行的代码覆盖率。但结果展示不是太友好,我们可以接着使用 lcov 工具生成可读性更好的 HTML 文件。需注意的是,lcov 默认不生成分支覆盖率数据,需增加选项 --rc lcov_branch_coverage=1
。同样,使用 genhtml 工具生成 HTML 文件时也需增加该选项。

这样就完成了一个测试用例对目标程序代码覆盖率的计算。那如何计算本次 Fuzzing 总体的覆盖率呢?这里需要另外一个第三方开源工具 afl-cov,该工具通过遍历 AFL 输出目录,使用 gcov 和 lcov 工具依次对每个测试用例计算覆盖率,最后生成总体的覆盖率结果。
Part7分析 Crash
至此,我们可能通过 AFL 收获了一些 Crashes。接下来我们需要分析这些造成程序 Crash 的测试用例,这些测试用例可能包含无效或重复的样本,需要找出来并剔除它们,也需要进一步确认 Crash 是否可利用以及对漏洞分类。完全通过人工来完成这些操作会非常的低效,好在 AFL 的生态比较的成熟,已经有人为我们实现了自动化这些操作的工具。这类工具不只一种,这里推荐 afl-collect,该工具被包含在 AFL 第三方工具集 afl-utils 中。值得一提的是,该工具通过 GDB 的 exploitable 插件来判断 Crash 可利用性以及自动化漏洞分类。
也可以使用 Google 开源的 sanitizers 项目下的工具确认漏洞是否存在及分类。该项目包含 AddressSanitizer、MemorySanitizer 等工具,这些 AFL 都支持,而且使用起来比较简单,只需在编译时设置特定环境变量即可:
$ AFL_USE_ASAN=1/AFL_USE_MSAN=1 make
但需注意的是,使用 ASAN 会平均拖慢2倍左右的程序运行速度。而且在 64 位系统上其会请求大约 20TB 的虚拟内存空间,AFL 难以将其和糟糕的目标程序行为区分开。所以,一般建议只在 Fuzzing 结束后需确认漏洞是否真实存在时,才使用 ASAN。
本轮 Fuzzing,我们在两个半小时共跑出 3 个 Crashes。
使用 ASAN 进一步分析,发现其均属于非漏洞 Crash。
如果运气好发现了真实存在的漏洞,需要进一步分析和理解样本造成目标程序 Crash 的原因。如果 Crash 样本比较大,可以先通过 afl-tmin 工具精简样本,然后使用内置的 afl-analyze 工具具体分析样本文件的数据格式。
Part8总结
总的来说,上手 AFL 不是一件难事。但工具终究只是工具,如何最大化发挥工具的价值还是看人。那如何发挥 AFL 的最大价值呢?我认为最根本的还是要真正去理解它背后的设计理念和技术实现,而这些最后都会落在对漏洞的理解上(Know it, then hack it)。理解了漏洞,你甚至可以去打造自己的 Fuzzer。
参考链接
-
https://lcamtuf.coredump.cx/afl/ -
https://qiuhao.org/P_A_script_kiddie%E2%80%99s_fuzzing_trip.pdf -
https://mp.weixin.qq.com/s/G7l5wBB7oKjXCDGtjuxYTQ -
https://mp.weixin.qq.com/s/WMfCNN095-PpM0VB_pRESg
原文始发于微信公众号(洋洋自语):AFL Fuzzing 使用指南
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/272888.html