根据官方定义:libFuzzer
is in-process, coverage-guided, evolutionary fuzzing engine.
其本身是llvm
项目的一部分,和clang
是亲兄弟,二者项目源码分别可见于https://github.com/llvm/llvm-project/tree/master/compiler-rt/lib/fuzzer和https://github.com/llvm/llvm-project/tree/master/clang,就在同一个仓库里面。
现在稍微新的版本的clang都已经内置libFuzzer了,所以Linux下直接apt install clang
就安装完事了。
1. Fuzz Target
libFuzzer要求实现一个fuzz target
作为被测对象的接口。
官方文档中的代码示例如下:
// fuzz_target.cc |
给的是个C++的源码,但实际上却通过extern "C"
指定采用C风格的编译约定。所以实际上fuzz target的C语言原型如下:
int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size); |
名称参数返回值类型都不能动,并且注意参数中传来的字节数组Data是通过底层const修饰了的,也就是不允许修改其中数据。
fuzzer target(即LLVMFuzzerTestOneInput
函数)目的是作为被测对象与libFuzzer库之间的一个中转接口,其作用在于接受libFuzzer提供的输入数据Data
字节串,(可能还需要进行数据格式转换,)然后传递给实际的被测函数(如上述示例中的DoSomethingInterestingWithMyAPI
)。
官方文档中对其有如下要求:
The fuzzing engine will execute the fuzz target many times with different inputs in the same process.
函数会在同一进程中多次执行,即被循环调用。
It must tolerate any kind of input (empty, huge, malformed, etc).
必须接受所有格式的输入。
It must not exit() on any input.
不允许主动exit,前面说了是循环调用,你exit了还怎么循环。
It may use threads but ideally all threads should be joined at the end of the function.
可以开线程,但返回之前必须结束之,原因还是那个——循环调用,自己的线程自己关。
It must be as deterministic as possible. Non-determinism (e.g. random decisions not based on the input bytes) will make fuzzing inefficient.
其执行必须结果必须是具有确定性的,两次的Data如果一致,则两次执行的结果也必须一致。
It must be fast. Try avoiding cubic or greater complexity, logging, or excessive memory consumption.
速度,速度!毕竟模糊测试需要进行大量数据的测试。
Ideally, it should not modify any global state (although that’s not strict).
不允许修改全局变量,因为在同一个进程里,修改全局变量会导致下一次运行时读取的是修改后的结果,可能会违反前面说的确定性原则。
Usually, the narrower the target the better. E.g. if your target can parse several data formats, split it into several targets, one per format.
尽量窄范围测试,如果测试处理多种数据格式的目标,还是分割成多个子目标为好。这既是处于速度考量,也是出于模糊测试数据变异的效果考量。
2. 编译链接
文档中给出的编译链接命令为:
clang -g -O1 -fsanitize=fuzzer mytarget.c |
-g
和-O1
是gcc/clang的通用选项,前者保留调试信息,后者指定优化等级为1(保守地少量优化),但这两个选项不是必须的。-fsanitize=fuzzer
才是关键,通过这个选项启用libFuzzer。- 除了
fuzzer
外,还可以附加其他sanitize选项也可以加进来,如-fsanitize=fuzzer,address
同时启用了地址检查。 - 如果是仅编译,选项中可以用
fuzzer-no-link
替换fuzzer
,但我实际实验发现二者在编译期的功能完全一致,只不过fuzzer-no-link强制在链接阶段不生效,而后者在链接阶段也生效。所以简单一致起见,可以统一使用fuzzer。
接下来依次按照预处理、编译、汇编、链接四个阶段,看看libFuzzer是怎么添加到被测对象中的。
2.1. 准备
测试代码源文件fuzz_target.c
内容如下:
|
代码比较简单,只有一个LLVMFuzzerTestOneInput
函数,没有进一步调用其他函数。在该函数中,首先判断输入数据的长度是否为16字节,是的话视为两个8字节整型,然后比较二者是否相等。因此有三个分支,给result
变量分别赋予了三个不同的值。
因为代码太简单了,所以编译的时候连使用O1
优化选项都会被优化掉,所以要关闭优化。
同时,现在只是想知道libFuzzer是怎么加到程序里去的,调试信息暂且无用,所以也不添加-g
选项。
2.2. 预处理
gcc/clang的预处理选项是-E
,指定该选项会将整个编译链接过程直接终止于预处理阶段,并将预处理后的结果输出到stdout
。
因此,执行以下两条命令可以分别输出启用/关闭libFuzzer时预处理结果:
clang -E fuzz_target.c |
或者直接通过diff
比较二者的差异:
diff <(clang -E fuzz_target.c) <(clang -fsanitize=fuzzer -E fuzz_target.c) |
<
用于输入重定向,将两次预处理的stdout
输出重定向交由diff
执行比较。
结果是空的,也就是两次结果一模一样。
结论,
libFuzzer不在预处理阶段产生任何作用。
2.3. 编译
-S
选项可以生成源文件的汇编文件,-o
选项指定输出文件名。
clang -S -o without_fuzzer.s fuzz_target.c |
这次diff以下可以看到结果已经产生了很大差异,说明libFuzzer对编译阶段是产生了影响的。
看一下without_fuzzer.s
的内容:
.text |
汇编代码也比较简单,两个comq
指令分别对应C代码中的两条if命令,而三条movq
1997/06/23的指令对应了三条赋值语句,一切都很正常。
但如果对比启用libFuzzer之后的结果呢?
--- a/without_fuzzer.s |
很明显可以看出一些端倪:
两条(也就是全部)
comq
指令全部被用一长串指令替代了,并其中分别调用了__sanitizer_cov_trace_const_cmp8
和__sanitizer_cov_trace_cmp8
函数,这是两个外部符号(没有定义在本文件中),结合函数名,libFuzzer应该是在这些函数中实现了对程序中所有比较语句的覆盖率监控,且从名称推测这二者分别处理了变量-常数比较和变量-变量比较。一条干路(if语句之前)和三条支路(三条赋值1997/06/23的指令)上都添加了如下的指令:
movb .L__sancov_gen_, %dl # 或者是.L__sancov_gen_+1/2/3
addb $1, %dl
movb %dl, .L__sancov_gen_这三条指令的作用是在对
.L__sancov_gen_+0/1/2/3
处的数据进行+1操作。而.L__sancov_gen_
处则定义了一个初始填充为0、长度为4的单字节数组,这么看来其中的每一个数组元素记录了每个支路(包括干路)上的执行次数。.L__sancov_gen_:
.zero 4
.size .L__sancov_gen_, 4
.type .L__sancov_gen_.1,@object # @__sancov_gen_.1
.section __sancov_pcs,"a",@progbits
.p2align 3不过有个想法,这里用1个字节记录次数,不怕循环次数超过255然后溢出吗?还是说libFuzzer会在合适的时机将里面的内容置空?这个问题有待验证。
额外定义了一个
sancov.module_ctor
函数,但没有使用.globl
指令修饰,所以是模块内的局部函数(类似C语言中被static修饰的函数(不是C++的类静态成员函数!)),从名称中的module这个词也可见一二。但目前尚看不出来它是如何工作的。
2.4. 汇编
使用-c
选项生成汇编后的.o文件:
clang -c -o without_fuzzer.o without_fuzzer.s |
使用sha1sum
命令确认with_fuzzer_2.o
和with_fuzzer.o
两个文件是否一致。结果发现相同,也就是在汇编阶段,-fsanitize=fuzzer
选项不起作用,一切按正常规则将汇编指令转为机器指令。
使用nm
命令打印.o文件中的符号:
nm without_fuzzer.o with_fuzzer.o |
without_fuzzer.o:
0000000000000000 T LLVMFuzzerTestOneInput
with_fuzzer.o:
0000000000000000 T LLVMFuzzerTestOneInput
0000000000000000 t sancov.module_ctor
U __sanitizer_cov_8bit_counters_init
U __sanitizer_cov_pcs_init
U __sanitizer_cov_trace_cmp8
U __sanitizer_cov_trace_const_cmp8
U __start___sancov_cntrs
U __start___sancov_pcs
U __stop___sancov_cntrs
U __stop___sancov_pcs
除了LLVMFuzzerTestOneInput这个由我们自己编写的fuzz target,启用libFuzzer后可以看得出来多了一些符号:
sancov.module_ctor
就是刚才说到的多出来的函数,类型是t
,表示局部属性。- 一大堆类型是
U
的符号,表示undefined,也就是外部符号,显然他们将由libFuzzer提供,在链接阶段会被添加。
2.5. 链接
clang -o without_fuzzer without_fuzzer.o |
别多想,第一条命令肯定会出错的,without_fuzzer.o
连main函数都没提供,肯定链接不通过。
第二条命令当然也会出错,除了main函数,还有上面提到的一堆undefined symbols呢。
第三条命令正常执行,说明在链接阶段-fsanitize=fuzzer
选项会在链接阶段启用libFuzzer所需的相关外部符号,包括一个main函数。这也就是为什么带-fsanitize=fuzzer
选项编译出来的二进制可以解析命令行参数并开始测试了。
如打印选项帮助:
./with_fuzzer -help=1 |
2.6. 小节
通过clang的-fsanitize=fuzzer
选项可以启用libFuzzer,这个选项在编译和链接过程中生效,实现了条件判断语句和分支执行的记录,并且辅以libFuzzer中的库函数,通过生成不同的测试样例然后能够获得代码的覆盖率情况,最终实现所谓的fuzz testing。
3. 简单体验
mkdir output |
不提供初始的corpus直接运行,并且限定运行两秒钟。
-seed=1833035选项用于指定随机种子,便于实验复现。
两秒钟内,libFuzzer就已经在output文件夹内生成了两个文件:
-rw-r--r-- 1 zq zq 16 8月 31 19:40 0c158325e41dc9bee15282e85cb1752cb9322e9d
-rw-r--r-- 1 zq zq 16 8月 31 19:40 e129f27c5103bc5cc44bcdf0a15e160d445066ff
文件大小16字节,文件名为对应文件内容的sha1
散列值。
用xxd
查看其内容,发现其内容分别为:
0000 0000 0000 0000 0000 0000 0000 000a
以及,
0000 0000 0000 0000 0000 0000 0000 0000
这就是我们的代码中Size == 16
时对应的*args == *(args + 1)
的true-false两个分支的输入情况!
初步体验能感觉得到libFuzzer的效率可以说是很高了,但是美中不足地是其没有覆盖到第三类情况,也就是输入数据长度不等于16的情况,即使延长时间限制依然如此。但是如果进行如下操作:
mkdir more_output |
上述命令指定以output中的数据为初始corpus生成新的测试数据到more_output文件夹中,结果以很快就能在more_output文件夹中生成一个长度为1的数据,也就是覆盖了第三种情形。
产生这种现象的原因我尚未能找到,有待进一步探究。