前言
学习一下模糊测试,其中AFL是一个非常经典的Fuzzing工具,以此入门,希望对未来的学习有所帮助。
简介
AFL(American Fuzzy Lop)是由安全研究员Michał Zalewski(@lcamtuf)开发的一款基于覆盖引导(Coverage-guided)的模糊测试工具,它通过记录输入样本的代码覆盖率,从而调整输入样本以提高覆盖率,增加发现漏洞的概率。其工作流程大致如下:
- 从源码编译程序时进行插桩,以记录代码覆盖率(Code Coverage);
- 选择一些输入文件,作为初始测试集加入输入队列(queue);
- 将队列中的文件按一定的策略进行“突变”;
- 如果经过变异文件更新了覆盖范围,则将其保留添加到队列中;
- 上述过程会一直循环进行,期间触发了crash的文件会被记录下来。
Fuzzing处理
测试目标
模糊测试的目标通常是接受外部输入的程序或库,输入一般来自控制台或文件
目标范围
AFL主要用于C/C++程序的测试。
目标要求
可以编译源码时插装,也可以通过QEMU mode对二进制文件动态插桩,前者测试速度比后者快很多。
构建语料库
AFL需要一些初始输入数据(也叫种子文件)作为Fuzzing的起点,这些输入甚至可以是毫无意义的数据,AFL可以通过启发式算法自动确定文件格式结构。
尽管AFL如此强大,但如果要获得更快的Fuzzing速度,那么就有必要生成一个高质量的语料库
主要围绕三个问题
如何选择输入文件
- 有效的输入,覆盖更多路径。
- 尽量小的体积,减少测试和处理的时间。
从哪里寻找这些文件
使用项目自身提供的测试用例
目标程序bug提交页面
afl源码的testcases目录下提供了一些测试用例
其他开源的语料库
如何精简找到的文件三个问题
网上找到的一些大型语料库中往往包含大量的文件,这时就需要对其精简,这个工作有个术语叫做——语料库蒸馏(Corpus Distillation)。AFL提供了两个工具来帮助我们完成这部工作——
afl-cmin
和afl-tmin
。(1) 移除执行相同代码的输入文件——afl-cmin
afl-cmin
的核心思想是:尝试找到与语料库全集具有相同覆盖范围的最小子集。举个例子:假设有多个文件,都覆盖了相同的代码,那么就丢掉多余的文件。其使用方法如下:$ afl-cmin -i input_dir -o output_dir -- /path/to/tested/program [params]
更多的时候,我们需要从文件中获取输入,这时可以使用“@@”代替被测试程序命令行中输入文件名的位置。Fuzzer会将其替换为实际执行的文件:
$ afl-cmin -i input_dir -o output_dir -- /path/to/tested/program [params] @@
下面的例子中,我们将一个有1253个png文件的语料库,精简到只包含60个文件。
(2) 减小单个输入文件的大小——afl-tmin
整体的大小得到了改善,接下来还要对每个文件进行更细化的处理,afl-tmin缩减文件体积的方法如下。
afl-tmin
有两种工作模式,instrumented mode
和crash mode
。默认的工作方式是instrumented mode
,如下所示:$ afl-tmin -i input_file -o output_file -- /path/to/tested/program [params] @@
如果指定了参数
-x
,即crash mode
,会把导致程序非正常退出的文件直接剔除。$ afl-tmin -x -i input_file -o output_file -- /path/to/tested/program [params] @@
afl-tmin
接受单个文件输入,所以可以用一条简单的shell脚本批量处理。如果语料库中文件数量特别多,且体积特别大的情况下,这个过程可能花费几天甚至更长的时间!for i in *; do afl-tmin -i $i -o tmin-$i -- ~/path/to/tested/program [params] @@; done;
下图是经过两种模式的修剪后,语料库大小的变化:
这时还可以再次使用
afl-cmin
,发现又可以过滤掉一些文件了。
构建测试程序
AFL从源码编译程序时进行插桩,来记录代码覆盖率。
有两种模式
afl-gcc
通过GCC构建
afl-clang
通过LLVM构建 Fuzzing速度更快
开始Fuzzing
afl-fuzz是主要测试程序
对那些可以直接从stdin读取输入的目标程序来说,语法如下:./afl-fuzz -i testcase_dir -o findings_dir /path/to/program […params…]
对从文件读取输入的目标程序来说,要用“@@”,语法如下:./afl-fuzz -i testcase_dir -o findings_dir /path/to/program […params…] @@
白盒测试
编译完程序后,可以选择使用afl-showmap跟踪单个输入的执行路径,并打印执行的输出,捕获的元组tuple等。
tuple用于获取分支信息,从而计算程序覆盖率。
黑盒测试
针对二进制程序,需要增加-Q选项。
Fuzzing结果分析
Fuzzing之后我们还需要考虑以下问题
- 何时结束Fuzzing工作
- afl-fuzz生成了哪些文件
- 如何对产生的crash进行验证和分类
- 用什么来评估Fuzzing的结果
- 代码覆盖率及相关概念
- AFL是如何记录代码覆盖率的
Fuzzing工作状态
因为afl-fuzz永远不会停止,所以何时停止测试很多时候就是依靠afl-fuzz提供的状态来决定的。除了前面提到过的通过状态窗口、afl-whatsup查看afl-fuzz状态外,这里再补充几种方法。
afl-stat
afl-stat是afl-utils这套工具AFL辅助工具中的一个(这套工具中还有其他更好用的程序,后面用到时会做介绍),该工具类似于afl-whatsup的输出结果。
使用前需要一个配置文件,设置每个afl-fuzz实例的输出目录:
{ "fuzz_dirs": [ "/root/syncdir/SESSION000", "/root/syncdir/SESSION001", ... "/root/syncdir/SESSION00x" ] }
然后指定配置文件运行即可:
$ afl-stats -c afl-stats.conf [SESSION000 on fuzzer1] Alive: 1/1 Execs: 64 m Speed: 0.3 x/s Pend: 6588/249 Crashes: 101 [SESSION001 on fuzzer1] Alive: 1/1 Execs: 105 m Speed: 576.6 x/s Pend: 417/0 Crashes: 291 ...
afl-whatsup
afl-whatsup是依靠读afl-fuzz输出目录中的fuzzer_stats文件来显示状态的,每次查看都要需要手动执行,十分麻烦。因此可以对其进行修改,让其实时显示fuzzer的状态。方法也很简答,基本思路就是在所有代码外面加个循环就好,还可以根据自己的喜好做些调整:
afl-plot
可视化结果
pythia
一个拓展项目
结束测试
检查afl-fuzz工作状态的目的是为何时停止测试提供依据,通常来说符合下面几种情况时就可以停掉了。
(1)状态窗口中”cycles done”字段颜色变为绿色该字段的颜色可以作为何时停止测试的参考,随着周期数不断增大,其颜色也会由洋红色,逐步变为黄色、蓝色、绿色。当其变为绿色时,继续Fuzzing下去也很难有新的发现了,这时便可以通过Ctrl-C停止afl-fuzz。
(2)距上一次发现新路径(或者崩溃)已经过去很长时间了,至于具体多少时间还是需要自己把握,比如长达一个星期或者更久估计大家也都没啥耐心了吧。
(3)目标程序的代码几乎被测试用例完全覆盖,这种情况好像很少见,但是对于某些小型程序应该还是可能的,至于如何计算覆盖率将在下面介绍。
(4)上面提到的pythia提供的各种数据中,一旦path covera达到99%(通常来说不太可能),如果不期望再跑出更多crash的话就可以中止fuzz了,因为很多crash可能是因为相同的原因导致的;还有一点就是correctness的值达到1e-08,根据pythia开发者的说法,这时从上次发现path/uniq crash到下一次发现之间大约需要1亿次执行,这一点也可以作为衡量依据。
输出结果
afl-fuzz的输出目录中存在很多文件
- queue:存放所有具有独特执行路径的测试用例。
- crashes:导致目标接收致命signal而崩溃的独特测试用例。
- crashes/README.txt:保存了目标执行这些crash文件的命令行参数。
- hangs:导致目标超时的独特测试用例。
- fuzzer_stats:afl-fuzz的运行状态。
- plot_data:用于afl-plot绘图。
处理测试结果
我们跑出了一大堆的crashes,那么接下来的步骤,自然是确定造成这些crashes的bug是否可以利用,怎么利用?这是另一个重要方面。当然,个人觉得这比前面提到的内容都要困难得多,这需要对常见的二进制漏洞类型、操作系统的安全机制、代码审计和调试等内容都有一定深度的了解。但如果只是对crash做简单的分析和分类,那么下面介绍的几种方法都可以给我们提供一些帮助。
crash exploration mode
通过观察crash情况产生类似样例,将一个导致crash测试用例作为afl-fuzz的输入,使用-C选项开启crash exploration模式后,可以快速地产生很多和输入crash相关、但稍有些不同的crashes,从而判断能够控制某块内存地址的长度。
triage_crashes
AFL源码的experimental目录中有一个名为triage_crashes.sh的脚本,可以帮助我们触发收集到的crashes。例如下面的例子中,11代表了SIGSEGV信号,有可能是因为缓冲区溢出导致进程引用了无效的内存;06代表了SIGABRT信号,可能是执行了abort\assert函数或double free导致,这些结果可以作为简单的参考。
crashwalk
当然上面的两种方式都过于鸡肋了,如果你想得到更细致的crashes分类结果,以及导致crashes的具体原因,那么crashwalk就是不错的选择之一。这个工具基于gdb的exploitable插件,安装也相对简单,在ubuntu上,只需要如下几步即可:
$ apt-get install gdb golang $ mkdir tools $ cd tools $ git clone https://github.com/jfoote/exploitable.git $ mkdir go $ export GOPATH=~/tools/go $ export CW_EXPLOITABLE=~/tools/exploitable/exploitable/exploitable.py $ go get -u github.com/bnagy/crashwalk/cmd/...
crashwalk支持AFL/Manual两种模式。前者通过读取crashes/README.txt文件获得目标的执行命令(前面第三节中提到的),后者则可以手动指定一些参数。两种使用方式如下:
#Manual Mode $ ~/tools/go/bin/cwtriage -root syncdir/fuzzer1/crashes/ -match id -- ~/parse @@ #AFL Mode $ ~/tools/go/bin/cwtriage -root syncdir -afl
两种模式的输出结果都一样,如上图所示。这个工具比前面几种方法要详细多了,但当有大量crashes时结果显得还是十分混乱。
afl-collect
最后重磅推荐的工具便是afl-collect,它也是afl-utils套件中的一个工具,同样也是基于exploitable来检查crashes的可利用性。它可以自动删除无效的crash样本、删除重复样本以及自动化样本分类。使用起来命令稍微长一点,如下所示:
$ afl-collect -j 8 -d crashes.db -e gdb_script ./afl_sync_dir ./collection_dir -- /path/to/target --target-opts
但是结果就像下面这样非常直观:
代码覆盖率及相关概念
代码覆盖率是模糊测试中一个极其重要的概念,使用代码覆盖率可以评估和改进测试过程,执行到的代码越多,找到bug的可能性就越大,毕竟,在覆盖的代码中并不能100%发现bug,在未覆盖的代码中却是100%找不到任何bug的,所以本节中就将详细介绍代码覆盖率的相关概念。
代码覆盖率(Code Coverage)
是一种度量代码的覆盖程度的方式,也就是指源代码中的某行代码是否已执行;对二进制程序,还可将此概念理解为汇编代码中的某条指令是否已执行。其计量方式很多,但无论是GCC的GCOV还是LLVM的SanitizerCoverage,都提供函数(function)、基本块(basic-block)、边界(edge)三种级别的覆盖率检测,更具体的细节可以参考LLVM的官方文档。
基本块(Basic Block)
简称BB,指一组顺序执行的指令,BB中第一条指令被执行后,后续的指令也会被全部执行,每个BB中所有指令的执行次数是相同的。
边界(edge)
AFL的技术白皮书中提到fuzzer通过插桩代码捕获边(edge)覆盖率。那么什么是edge呢?我们可以将程序看成一个控制流图(CFG),图的每个节点表示一个基本块,而edge就被用来表示在基本块之间的转跳。知道了每个基本块和跳转的执行次数,就可以知道程序中的每个语句和分支的执行次数,从而获得比记录BB更细粒度的覆盖率信息。
元组(tuple)
AFL中用二元组记录
当前基本块+前一基本块
的信息。从而获取目标的执行流程和代码覆盖情况,伪代码如下:cur_location = <COMPILE_TIME_RANDOM>; //用一个随机数标记当前基本块 shared_mem[cur_location ^ prev_location]++; //将当前块和前一块异或保存到shared_mem[] prev_location = cur_location >> 1; //cur_location右移1位区分从当前块到当前块的转跳
实际插入的汇编代码,如下图所示,首先保存各种寄存器的值并设置ecx/rcx,然后调用
__afl_maybe_log
,这个方法的内容相当复杂,这里就不展开讲了,但其主要功能就和上面的伪代码相似,用于记录覆盖率,放入一块共享内存中。
计算代码覆盖率
这里需要用到的工具之一是GCOV,它随gcc一起发布,所以不需要再单独安装,和afl-gcc插桩编译的原理一样,gcc编译时生成插桩的程序,用于在执行时生成代码覆盖率信息。
另外一个工具是LCOV,它是GCOV的图形前端,可以收集多个源文件的gcov数据,并创建包含使用覆盖率信息注释的源代码HTML页面。
最后一个工具是afl-cov,可以快速帮助我们调用前面两个工具处理来自afl-fuzz测试用例的代码覆盖率结果。在ubuntu中可以使用apt-get install afl-cov
安装afl-cov,但这个版本似乎不支持分支覆盖率统计,所以还是从Github下载最新版本为好,下载完无需安装直接运行目录中的Python脚本即可使用:
$ apt-get install lcov
$ git clone https://github.com/mrash/afl-cov.git
$ ./afl-cov/afl-cov -V
afl-cov-0.6.2
第一步,使用gcov重新编译源码,在CFLAGS中添加"-fprofile-arcs"
和"-ftest-coverage"
选项,可以在--prefix
中重新指定一个新的目录以免覆盖之前alf插桩的二进制文件。
$ make clean
$ ./configure --prefix=/root/tiff-4.0.10/build-cov CC="gcc" CXX="g++" CFLAGS="-fprofile-arcs -ftest-coverage" --disable-shared
$ make
$ make install
第二步,执行afl-cov。其中-d选项指定afl-fuzz输出目录;—live用于处理一个还在实时更新的AFL目录,当afl-fuzz停止时,afl-cov将退出;–enable-branch-coverage用于开启边缘覆盖率(分支覆盖率)统计;-c用于指定源码目录;最后一个-e选项用来设置要执行的程序和参数,其中的AFL_FILE和afl中的”@@”类似,会被替换为测试用例,LD_LIBRARY_PATH则用来指定程序的库文件。
$ cd ~/tiff-4.0.10
$ afl-cov -d ~/syncdir --live --enable-branch-coverage -c . -e "cat AFL_FILE | LD_LIBRARY_PATH=./build-cov/lib ./build-cov/bin/tiff2pdf AFL_FILE"
成功执行的结果如下所示:
我们可以通过—live选择,在fuzzer运行的同时计算覆盖率,也可以在测试结束以后再进行计算,最后会得到一个像下面这样的html文件。它既提供了概述页面,显示各个目录的覆盖率;也可以在点击进入某个目录查看某个具体文件的覆盖率。
点击进入每个文件,还有更详细的数据。每行代码前的数字代表这行代码被执行的次数,没有执行过的代码会被红色标注出来。