# 流水线式fuzzing ## 前言 过去一段时间,为了完成公司关于CVE、CNVD相关的KPI,刷了一些开源库的漏洞。时间久了发现翻来覆去就是那一套流程,机械重复、毫无新意,因此打算对这一套流程进行总结,记录一下一些好用的工具和有用的思路。 ## 目标选择 学习oss-fuzz[1] 中的project 是一个入门fuzz很好的方法,里面有大量的开源软件的编译脚本及harness,而且oss-fuzz中的project大多数是较为热门的软件库,如果发现严重漏洞会比较有价值。但oss-fuzz中的project已经经受了长时间的测试,代码比较健壮,如果只是直接使用oss-fuzz里面的harness,在不改进fuzz工具的前提下就会演变成和google比拼服务器资源性能的尴尬境地。但是oss-fuzz的project并非无懈可击,我们可以在不修改harness和改进fuzz工具的前提下,在oss-fuzz的project中“捡漏”,举两个例子: - oss-fuzz 会根据用户设置按一定时间间隔对项目代码进行重新编译[4],通常测试配置是单核内存不超过2.5GB,持续时长默认为10分钟。可见fuzzing的时长过短,我们可以根据oss-fuzz这一不足,集中自己的服务器资源对重点目标进行fuzz。 如何定义重点目标?其实可以有不同的方法,以ffmpeg为例,ffmpeg几乎涵盖所有音视频解码格式,在有限的资源内对所有格式fuzz是不现实的。我这里使用git effort统计进一个月内改动次数较多的代码文件,对相应格式进行fuzz。`git effort --above 5 -- --since='last month'`(该命令表示一个月内修改次数大于5的文件)。通过这种方法我找到了 TIFF格式和MOV格式中的两个漏洞[5] [6]。 当然也可以统计一段时间内哪个文件改动的代码行数较多,这个我并没有找到相关的工具或者命令,如果有知道的朋友请联系我。 - qt project中有很长一段时间被测试的代码分支都固定在5.15[2],我将分支改为6.0.0进行测试成功发现了qtsvg中的一个浮点型溢出导致的越界读漏洞[3]。 当然我们也可以关注很久未更新或者停止更新的库和软件。例如DjVuLibre,这是一个用来解析djvu格式的库,虽然更新维护并不频繁,但有很多软件使用它解析djvu文件,通过对其解析部分代码进行fuzz,成功发现5个漏洞并获得CVE。 ## 样本与字典 ### 样本收集 选择合适的样本在fuzz过程中是至关重要的,好的样本能在fuzz的初期提供更多的覆盖率,从而大大提升漏洞挖掘的效率。样本来源主要有以下几种: - 软件提供的测试样本:例如 ,https://samples.ffmpeg.org/ 是ffmpeg官方提供的多种格式的测试样本。这类样本的优点在于:样本比较容易获得、数量较多、格式较为标准;缺点在于:样本不够精简,有些音视频样本多达几十M,有大量冗余的结构存在,这在fuzz过程中是难以接受的。 - 研究人员提交的崩溃样本:可以在gihub、redhat、各软件的bugzilla中获取。这类样本的优点在于:由于大多样本是通过fuzz得到大部分比较精简、能覆盖到标准样本覆盖不到的分支路径,有时一个软件的崩溃样本甚至可以直接在另一软件中复现;缺点在于:这类样本较为分散、需要编写多个网站的爬虫脚本,有些通过邮件报告漏洞或在漏洞披露前删除样本,这些都使得收集工作变得非常困难。 ### 样本精简 样本精简有两个维度,一个是从样本数量上精简即在输入样本集中找到最小的文件子集,这些文件仍然会触发在起始语料库中看到的所有检测数据点;另一个维度是从单个样本的大小上精简即尽可能缩减样本文件大小同时保持文件产生一致的检测输出。 数量上精简可以用`afl-cmin` 这个没什么可说的:`afl-cmin -m none -i input_dir -o output_dir -- target @@` 关于单个样本精简,afl中提供了`afl-tmin` : `afl-tmin -m none -i input_file -o output_file -- target @@` 。但是这个工具精简单个样本的时间过长,有时一个样本精简需要花几天时间。如果是音视频文件可以用ffmpeg截取前面几帧或前几秒的内容: ```bash $ ffmpeg -ss 00:00:00 -t 00:00:5 -i input.tta out.tta $ ffmpeg -ss 20 -c copy -t 1 -i input.mp4 out.mp4 ``` 还有一个`afl-ddmin-mod`[7]是通过添加多线程支持以及可调节递归深度限制来进行优化,但效果一般,遇到大型文件依旧需要较长时间精简。 ### 字典生成 字典中包含目标文件结构的固定字段,关键字等,通过添加字典可以减少对这些固定字段无意义的测试,从而提高效率。在libfuzzer[8]和afl[9]源码目录下都可以找到一些常见格式的字典文件。 除了从网络上收集现有的字典文件外,还可以使用afl++的dict2file[10]从源码中查找固定字段,关键字自动生成字典。添加环境变量`AFL_LLVM_DICT2FILE=/absolute/path/file.txt`后用llvm mode 编译即可。用的时候可以使用一个小技巧,比如要 fuzz ffmpeg中mp4格式,先编译一遍,不开 dict2file,编译完成后,将mp4解析对应的 .o 文件删除,生成的 target 文件删除,开启 dict2file,重新编译一次,就得到了mp4 相关的字典了。 ## fuzz工具 在漏洞挖掘的过程中,我使用以AFL为基础的工具进行fuzz。相比libfuzzer、honggfuzz,AFL扩展性更好,有大量对AFL改进的相关研究进行参考。但我并不建议使用AFLplusplus进行fuzz,首先相比于一个高效率的fuzz工具我更认为AFLplusplus是一个AFL相关改进的集合。我曾经用了大约两个月的时间对AFLplusplus中的参数进行测试,尝试找到效率最高的参数组合。但在测试过程中我发现AFLplusplus并没有想象中的那么稳定,由于添加了过多的功能而没有足够的时间进行充分的测试;并且其git commit不够规范,甚至能看到"review done, pray"[12]这种commit信息,这令阅读AFLplusplus代码改动变得十分困难。 尽管AFLplusplus在google的fuzzbench测试项目中排名第一[13],但阅读其在fuzzbench中的配置选项[14]可以发现AFLplusplus对很多benchmark都开启了dict2file进行编译,因为有根据关键字自动生成的字典,所以它必然要比没有添加字典的其他fuzzer获得更多覆盖率从而获得更高的评分。但我认为这只能体现AFLplusplus的”功能“优秀,并不能说明它的”变异算法“同样比其他fuzzer优秀。 经过半年多的调研,最终我们选择以google维护的AFL为基础在进行充分测试保证稳定性的前提下从AFLplusplus中移植优秀的功能。这里举几个例子: - ASAN,可以有效检测 UAF、* buffer overflow、memory leak 等。缺点是比较消耗内存,会减慢程序运行速度约2倍。 - LAF-INTEL,可以在编译阶段将硬编码的多字节比较转化为多个单字节比较,这样可以解决覆盖率指导的fuzzer绕过多字节模数比较的难题,提高了fuzz的效率。 - AFLFast,通过一套独特的搜索策略给发现低频路径种子更高权重,强调低频路径以探索更多分支并发现更多错误。AFLFast提供了六种调度策略(schedule),根据论文中的总结部分: > AFLFast中实现的 exponential schedule( fast ) 优于所有其他schedule。 cut-off exponential schedule(coe)的效果仅比AFLFast稍差。 24小时后,这两个schedule(fast 和 coe)比其他三个schedule(linear, quad, and explore)发现的崩溃多50%。 有趣的是,exploration-based constant schedule(explore)起初发现 crash 次数超过其他任何schedule; 它 fuzz 每个输入快速地移至下一个。 但是,从长远来看,这种策略不会奏效。 24小时后,它的执行情况比其他任何schedule 都要差(AFL的exploitation-based constant schedule 除外)。 quadratic schedule (quad)开始时显示出与AFLFast相似的独特崩溃次数,但是在24小时预算结束时,它的执行效果与其他两次(linear and explore)相当。 因此在7X24小时的fuzz过程中选择 `fast` 或 `coe`策略较为合适。 - Radamsa,是一个用于鲁棒性测试的测试用例生成器,读取有效数据的样本文件并根据自己独特的变异算法从中生成有趣的样本进行测试。尽管因为其独特的实现语言没有拜读过源码,但有时候对特定格式使用还是有奇效。 在移植后最关键的步骤就是对其进行稳定性测试。可以通过fuzzbench测试改进前后代码覆盖率是否有明显的提高。如果认为fuzzbench24小时测试时间过长,可以使用AFLplusplus的测试程序[15],这套测试程序包含了一些对覆盖率指导的fuzzer在探索路径时比较难以绕过的问题点,通过和AFLplusplus对比完成这些挑战完成的时间来判断是否有显著的改进。 ## 源码编译与参数选择 ### 源码编译 把这块单独作为一个小结来阐述主要是因为不同软件编译方式及编译工具差异巨大,需要针对每个软件编写编译脚本,同时还要解决在这一过程中出现的一系列bug。这里举几个例子: - 在参考oss-fuzz中ffmpeg harness的编译脚本时,需要将libfuzzer插桩修改成AFL插桩。ffmpeg configure文件中有一个`--enable-ossfuzz`参数,使用后会添加libfuzzer插桩相关的参数[11]: ```shell enabled ossfuzz && ! echo $CFLAGS | grep -q -- "-fsanitize=" && ! echo $CFLAGS | grep -q -- "-fcoverage-mapping" &&{ add_cflags -fsanitize=address,undefined -fsanitize-coverage=trace-pc-guard,trace-cmp -fno-omit-frame-pointer add_ldflags -fsanitize=address,undefined -fsanitize-coverage=trace-pc-guard,trace-cmp } ``` 若使用 AFL 进行插桩则不需要添加`--enable-ossfuzz`,否则AFL和 trace-pc-guard会插桩两次;在不添加`--enable-ossfuzz`参数时,再编译过程中当编译到`target_dec_fuzzer`时又会报:`undefined reference to codec_list` ,原因是在ffmpeg项目的allcodecs.c文件中对`codec_list`按如下方式定义: ```shell #if CONFIG_OSSFUZZ AVCodec * codec_list[] = {NULL,NULL,NULL}; #else #include "libavcodec/codec_list.c" #endif ``` 若不加开启ossfuzz的configure参数会include codec_list.c,而该文件在gitignore中,所以导致报`codec_list`未定义。解决方法: 去除宏判断直接对 `codec_list`初始化即可。 - DjVuLibre在tools目录下提供了很多解析转换格式的程序,因此需要自己编写harness。这些程序中ddjvu是用来解析djvu格式,但直接编译出的ddjvu程序是一个shell脚本,该脚本功能是将libdjvu重链接到ddjvu二进制文件再执行。 ```shell $ file ddjvu ddjvu: Bourne-Again shell script, ASCII text executable, with very long lines ``` shell脚本显然是不能作为AFL的目标进行fuzz的,因此需要修改编译参数: ```bash export AFL_LLVM_LAF_ALL=1 export CC="/afl/afl-clang-fast" export CXX="/afl/afl-clang-fast++" export CFLAGS=" -pthread -Wl,--no-as-needed -Wl,-ldl -Wl,-lm -Wno-unused-command-line-argument -O3 " export CXXFLAGS=" -stdlib=libc++ -pthread -Wl,--no-as-needed -Wl,-ldl -Wl,-lm -Wno-unused-command-line-argument -O3" cd $SRC/djvulibre-ddjvu/ && ./autogen.sh --host="x86_64-pc-none" --build="x86_64-pc-linux-gnu" && make cp $SRC/djvulibre-ddjvu/tools/ddjvu $OUT/ make clean export AFL_USE_ASAN=1 cd $SRC/djvulibre-ddjvu/ && ./autogen.sh --host="x86_64-pc-none" --build="x86_64-pc-linux-gnu" && make cp $SRC/djvulibre-ddjvu/tools/ddjvu $OUT/ddjvu_asan ``` 这里编译了两个ddjvu程序,一个开启ASAN,一个不开启ASAN。编译后查看ddjvu程序,可见成功将libdjvu库静态链接到ddjvu程序中(我们的目标是djvu内部代码没有必要将所有链接库全部静态编译): ```shell $ ldd ddjvu linux-vdso.so.1 => (0x00007ffff7ffa000) libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007ffff7bd3000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007ffff78ca000) libjpeg.so.8 => /usr/lib/x86_64-linux-gnu/libjpeg.so.8 (0x00007ffff7671000) libtiff.so.5 => /usr/lib/x86_64-linux-gnu/libtiff.so.5 (0x00007ffff73fc000) libc++.so.1 => /usr/lib/x86_64-linux-gnu/libc++.so.1 (0x00007ffff7137000) ... ``` 源码编译一直是一个比较玄学的问题,不仅要应对不同编译工具,编译AFL的clang版本,是否对代码进行优化(-O0,-O3)都会对产出产生影响。 ### 参数选择 编译时,开启 LAF,编译两个目标程序一个开启ASAN,一个不开启ASAN。parallel fuzzing 时每个node都添加了字典,开启aflfast,只保证有一个开启ASAN即可,这样可以保证fuzzing的速度。我们采用类似clusterfuzz每次启动按概率选择不同的样本生成策略[17],如 radamsa,peach mutaion 等。如果发现某种变异策略发现的崩溃数目较多,或者发现的路径较多,可以提高该策略再下次启动时的概率。 ## 崩溃样本处理与报告生成 自动化处理崩溃样本是必要的,这里给出一种基于fuzzmanager的实现方法:通过监控样本生成目录(AFL中为queue/ hangs/ crashes/ );先后使用带ASAN,不带ASAN两个程序进行复现,这样做的原因是ASAN可以在离漏洞更近的位置产生崩溃(相对于gdb直接复现来说),这有效地避免了处理漏洞点相同但崩溃位置不同的样本,在ASAN复现无崩溃时再使用gdb复现,这是因为有研究表明[18] 虽然大部分漏洞都能被gdb,ASAN检测出来,但还各有百分之十左右是只能由ASAN或gdb检测出来,因此多种复现方式是有必要的;在第一次复现成功后,会生成该漏洞的signature,signature主要记录了该程序崩溃时的N层栈回溯,但如何选择 N 的值以提供具有最低误报/漏报的结果是一个困难的研究问题,尚未完全解决;最后,将signature信息,poc等上传到server生成漏洞报告。 在上传报告之前还可以对崩溃样本进行精简,这里使用halfempty[19] ,这个工具核心思想是使用二分法快速精简崩溃样本,减少调试成本。贴出处理vim崩溃样本的处理脚本: ```bash #! /bin/bash tempfile=`mktemp` && cat > ${tempfile} /mnt/disk/out/vim/vim -u NONE -X -Z -e -s -S ${tempfile} -c :qa! out=$? if [[ $out -ge 132 ]] && [[ $out -le 139 ]] ; then exit 0 else exit 1 fi ``` ## 覆盖率分析 在fuzz过程中覆盖率很长一段时间没有增长时说明测试过程遇到了瓶颈,这个时候需要进行覆盖率分析。结合源码的覆盖率可视化分析有两种方法: - 通过grcov获取覆盖率:fuzzmanager使用的就是这种方法[21]: 以 pdfresurrect 项目为例,若用clang编译需加 `--coverage` 参数 gcc 需加 `-ftest-coverage` 和 `-fprofile-arcs` 。下面为afl-clang 编译示例: ```shell #!/bin/bash -eu export LIB_FUZZING_ENGINE="./libAFLDriver.a" export CC="/home/fuzz/AFLplusplus/afl-clang-fast" export CXX="/home/fuzz/AFLplusplus/afl-clang-fast++" export CFLAGS="-pthread -Wl,--no-as-needed -Wl,-ldl -Wl,-lm -Wno-unused-command-line-argument -O3 --coverage" export CXXFLAGS="-stdlib=libc++ -pthread -Wl,--no-as-needed -Wl,-ldl -Wl,-lm -Wno-unused-command-line-argument -O3 --coverage" export WORK=out/ #export AFL_USE_ASAN=1 ./configure LDFLAGS="$CXXFLAGS" make -j$(nproc) ``` 编译完成后查看目录,若有 `.gcno` 为后缀文件说明覆盖率参数添加成功。 之后用编译完成的二进制文件读入pdf后,会生成 `.gcda`为后缀的文件。再用grcov 生成 json文件(grcov可下载release版本直接使用): ```shell ./grcov ../pdfresurrect/ -t coveralls+ --commit-sha $(cd ../pdfresurrect/ && git rev-parse HEAD) --token NONE > test.json ``` FuzzManager 这边需要先创建项目,`GITSourceCodeProvide`参数后是pdfresurrect的源码路径: ```shell python3 server/manage.py setup_repository pdfresurrect GITSourceCodeProvider /mnt/disk/out/pdfresurrect ``` 之后将生成的json文件提交即可: ```shell python3 -mCovReporter --repository pdfresurrect --description "Test1" --submit test.json --serverhost 127.0.0.1 --serverport 8000 --serverproto http --tool afl++ ``` - 通过llvm-cov 获取覆盖率[22] : 使用 clang 编译时加上 `-fprofile-instr-generate -fcoverage-mapping`这两个参数; 加入如下环境变量`export LLVM_PROFILE_FILE="/out/report/target.%4m.profraw"`之后,将所有样本跑完会在`/out/report` 目录下生成 .profraw 文件; 使用如下命令生成index profile : ```shell /path/to/clang/bin/llvm-profdata merge -j=1 -sparse -o out/report/coverage.profdata out/report/*.profraw ``` 最后生成html报告: ```shell /path/to/clang/bin/llvm-cov show -output-dir=out/report -format=html -Xdemangler c++filt -Xdemangler -n -instr-profile=/out/report/coverage.profdata -object=/out/coverage/target ``` ## 总结 我们通过整合现有的开源软件(AFL,clusterfuzz,fuzzmanager),未进行过多的开发就已经获得了不错的产出。针对开源软件的fuzz流程大概就这么多,不得不说近些年通过开源软件刷CVE越来越内卷了,曾经看到过两个国内安全公司对着一个不太知名的开源库轮番提交十几个issue,对于个人只能通过提升自身实力产出高质量的漏洞摆脱这种枯燥的劳动。说回这套fuzz流程,与源码审计相结合应用到DevSecOps建设中才是它最终的归宿。谷歌、腾讯[23]、阿里[24] 都已经有相关的实现,但国内大多数企业在这方面的建设还有很长的路要走。 ## 参考链接 [1] https://github.com/google/oss-fuzz/tree/master/projects [2] https://github.com/google/oss-fuzz/commit/008b9bcec624369f9acb8760182cc5460b3d0ed2#diff-13fb5bca4b050a3fb64254cf596f5a9857939f5418df5282d28e8f8a44166d63 [3] https://bugreports.qt.io/browse/QTBUG-91507 [4] https://github.com/google/oss-fuzz/blob/30f3a8f1c0f5b072e77d5bea82709db04c53453d/infra/build/functions/project_sync.py#L176 [5] https://github.com/FFmpeg/FFmpeg/commit/c94875471e3ba3dc396c6919ff3ec9b14539cd71 [6] https://github.com/FFmpeg/FFmpeg/commit/292e41ce650a7b5ca5de4ae87fff0d6a90d9fc97 [7] https://github.com/MarkusTeufelberger/afl-ddmin-mod [8] https://chromium.googlesource.com/chromium/src/+/master/testing/libfuzzer/fuzzers/dicts [9] https://github.com/google/AFL/tree/master/dictionaries [10] https://github.com/AFLplusplus/AFLplusplus/blob/48c878a76ddec2c133fd5708b185b2ac27740084/instrumentation/README.llvm.md [11] https://github.com/FFmpeg/FFmpeg/blob/62dc5df941f5e196164c151691e4274195523e95/configure [12] https://github.com/AFLplusplus/AFLplusplus/commit/220dc4a43d197f5ff451627a9923b874805c02aa [13] https://www.fuzzbench.com/reports/sample/index.html [14] https://github.com/google/fuzzbench/blob/78cc0ff279d60f1e53d210cd4e74bd990e72cfb0/fuzzers/aflplusplus_optimal/fuzzer.py [15] https://github.com/AFLplusplus/fuzzer-challenges [16] https://github.com/HexHive/magma [17] https://github.com/google/clusterfuzz/blob/481c78b28fb2d9a401a6f3fe302746a8f6110954/src/python/fuzzing/strategy.py [18] https://arxiv.org/pdf/2010.01785.pdf [19] https://github.com/googleprojectzero/halfempty [20] https://www.geeksforgeeks.org/exit-codes-in-c-c-with-examples/ [21] https://www.fuzzingbook.org/html/FuzzingInTheLarge.html#Collecting-Code-Coverage [22] https://www.usmacd.com/2020/03/19/llvm_coverage/ [23] https://riusksk.me/2020/03/01/%E6%8C%81%E7%BB%ADFuzzing%E5%9C%A8DevSecOps%E4%B8%AD%E7%9A%84%E5%BA%94%E7%94%A8/ [24] https://isc.360.com/2020/detail.html?id=18&vid=116