libFuzzer细究——测试执行

被测程序在启用libFuzzer并编译链接后,即成为了一个可接受用户参数的命令行程序,直接执行程序便是启动测试。

1. 命令统一格式

一般格式:

./fuzzer -flag1=val1 -flag2=val2 ... dir1 dir2 ...

flags代表各个控制测试过程的选项参数,可以提供零到任意个,但必须是严格的-flag=value形式,并不是很符合unix命令行习惯:

  • 选项前导用单横线,即使选项是一个词而非单个字符
  • 选项必须要提供对应的值,即使只是一个开关选项如-help,必须要写作-help=1,且选项与值中间只能用等号,不能用空格。

dirs表示语料库目录,它们的内容都会被读取作为初始语料库,但测试过程中生成的新输入只会被保存到第一个目录下。

常用有以下参数:

对于开关选项(如help),效用一列表示当参数启用时(-help=1)的效果

选项 默认 效用
verbosity 1 运行时输出详细日志
seed 0 随机种子。如果为0,则自动生成
runs -1 单个测试运行的次数(-1表示无限)
max_len 0 测试输入的最大长度。若为0,libFuzzer会自行猜测
cross_over 1 交叉输入
mutate_depth 5 每个输入连续突变的数量
shuffle 1 启动时打乱初始语料库
prefer_small 1 打乱语料库时将小输入置于优先位置
timeout 1200 若为正,表示单元运行最大秒数。超时会被提前中止
error_exitcode 77 libFuzzer本身出错时的退出码
timeout_exitcode 77 libFuzzer超时退出码
max_total_time 0 若为正,表示整个模糊测试运行最大秒数
help 0 打印帮助并退出
merge 0 不损失覆盖率前提下,将第2/3/4/…个语料库合并到第一个中去
merge_control_file 0 指定合并进程的控制文件,用于恢复合并状态
jobs 0 运行的作业数量。所有作业的输出会被重定向到fuzz-JOB.log。
workers 0 运行作业的并发进程数。若为0,实验CPU核心数一半
reload 1 每N秒载主语料库,以知悉其他进程发现的单元
only_ascii 0 仅生成ASCII(isprint + isspace)输入
print_final_stats 0 退出时打印统计信息
print_corpus_stats 0 退出时打印语料库元素统计信息
print_coverage 0 退出时打印覆盖率信息
close_fd_mask 0 为1则在关闭stdout,为2则关闭stderr,为3则关闭二者

重运行模式:

./fuzzer -flag1=val1 -flag2=val2 ... file1 file2 ...

与上面一样,但是选项后面接的是文件列表而非文件夹列表,这些输入样例将会重新读取并输入运行,不会产生新样例,在回归测试时十分有用。

2. 进程、作业、并发

测试代码:

// process.cpp
#include <iostream>

int global_count = 0;

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
if (global_count < 5) {
std::cout << "global count: " << ++global_count << std::endl;
}
return 0;
}

编译,

clang -fsanitize=fuzzer process.cpp -o fuzzer

当使用默认选项启动模糊测试程序时,

./fuzzer

只会有一个作业,当然也只有一个测试进程。

此时,libFuzzer的日志信息会输出到终端的stderr,程序本身的stdout会被输出到终端的stdout。

而其中所有测试样例其实都是运行在同一个进程内,也就是说函数LLVMFuzzerTestOneInput被循环调用了,因此全局变量将会在每个样例运行时共享。如上述示例程序的stdout为:

global count: 1
global count: 2
global count: 3
global count: 4
global count: 5

这也就是libFuzzer为什么建议不要去修改全局变量,因为如果此做,同一个测试样例在两次被修改得不同的全局变量环境下运行,可能会产生不一致的结果,这违反了输入-行为确定性的规则。

然后如果使用jobs选项指定作业数量,

./fuzzer -jobs=2

此时日志和程序输出不会输出到终端,而是重定向到fuzz-N.log文件,查看其内容,发现二中中打印的global_count都是1、2、3、4、5,这说明二者运行在不同的进程之中,全局变量也是独立的。

但是多任务也会随之引发协调问题,每个任务并不知道其他任务发现了哪些样例,这时候需要指定reload等选项应对这些问题。

然后是进程的并发数量问题,这个数量由workers参数指定。

当指定了多个任务时,程序启动时会先产生一个master进程,同时并发启动相应数量个worker进程,master会把jobs分配到workers上去执行,当某个任务结束后,相应进程终止,同时master会启动一个新的任务进程分配到对应的worker上,平均每个worker上会分配jobs/workers个任务。

./fuzzer -jobs=16

对于8核电脑,其进程树如下:

fuzzer -jobs=16
  ├─sh -c ./fuzzer >fuzz-0.log 2>&1
  │   └─fuzzer
  │       └─{fuzzer}
  ├─sh -c ./fuzzer >fuzz-1.log 2>&1
  │   └─fuzzer
  │       └─{fuzzer}
  ├─sh -c ./fuzzer >fuzz-2.log 2>&1
  │   └─fuzzer
  │       └─{fuzzer}
  ├─sh -c ./fuzzer >fuzz-3.log 2>&1
  │   └─fuzzer
  │       └─{fuzzer}
  └─5*[{fuzzer}]

如果电脑不准备干其他活,可以把workers提升到CPU核心数量,满载并行。但是再往上加worker数量大多就没什么用了,反正也达不到并行效果,总有些进程会被挂起。但是也是有例外的,比如如果程序里有一步需要网络IO,这时候CPU不能真正并行也不是什么问题。

额外说一下线程吧,libFuzzer不管理线程,但你可以在自己的逻辑代码中自己开线程,自己记得关上就行。