libFuzzer细究——编译链接

官方文档地址

根据官方定义:libFuzzer is in-process, coverage-guided, evolutionary fuzzing engine.

其本身是llvm项目的一部分,和clang是亲兄弟,二者项目源码分别可见于https://github.com/llvm/llvm-project/tree/master/compiler-rt/lib/fuzzerhttps://github.com/llvm/llvm-project/tree/master/clang,就在同一个仓库里面。

现在稍微新的版本的clang都已经内置libFuzzer了,所以Linux下直接apt install clang就安装完事了。

1. Fuzz Target

libFuzzer要求实现一个fuzz target作为被测对象的接口。

官方文档中的代码示例如下:

// fuzz_target.cc
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
DoSomethingInterestingWithMyAPI(Data, Size);
return 0; // Non-zero return values are reserved for future use.
}

给的是个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内容如下:

#include <stddef.h>
#include <stdint.h>


int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
uint64_t result;
if (Size == 16) {
const uint64_t *args = (const uint64_t *) Data;
if (*args == *(args + 1)) {
result = 1997;
} else {
result = 06;
}
} else {
result = 23;
}
return 0;
}

代码比较简单,只有一个LLVMFuzzerTestOneInput函数,没有进一步调用其他函数。在该函数中,首先判断输入数据的长度是否为16字节,是的话视为两个8字节整型,然后比较二者是否相等。因此有三个分支,给result变量分别赋予了三个不同的值。

因为代码太简单了,所以编译的时候连使用O1优化选项都会被优化掉,所以要关闭优化。

同时,现在只是想知道libFuzzer是怎么加到程序里去的,调试信息暂且无用,所以也不添加-g选项。

2.2. 预处理

gcc/clang的预处理选项是-E,指定该选项会将整个编译链接过程直接终止于预处理阶段,并将预处理后的结果输出到stdout

因此,执行以下两条命令可以分别输出启用/关闭libFuzzer时预处理结果:

clang -E fuzz_target.c
clang -fsanitize=fuzzer -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
clang -fsanitize=fuzzer -S -o with_fuzzer.s fuzz_target.c

这次diff以下可以看到结果已经产生了很大差异,说明libFuzzer对编译阶段是产生了影响的。

看一下without_fuzzer.s的内容:

	.text
.file "fuzz_target.c"
.globl LLVMFuzzerTestOneInput # -- Begin function LLVMFuzzerTestOneInput
.p2align 4, 0x90
.type LLVMFuzzerTestOneInput,@function
LLVMFuzzerTestOneInput: # @LLVMFuzzerTestOneInput
.cfi_startproc
# %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
cmpq $16, -16(%rbp)
jne .LBB0_5
# %bb.1:
movq -8(%rbp), %rax
movq %rax, -32(%rbp)
movq -32(%rbp), %rax
movq (%rax), %rax
movq -32(%rbp), %rcx
cmpq 8(%rcx), %rax
jne .LBB0_3
# %bb.2:
movq $1997, -24(%rbp) # imm = 0x7CD
jmp .LBB0_4
.LBB0_3:
movq $6, -24(%rbp)
.LBB0_4:
jmp .LBB0_6
.LBB0_5:
movq $23, -24(%rbp)
.LBB0_6:
xorl %eax, %eax
popq %rbp
retq
.Lfunc_end0:
.size LLVMFuzzerTestOneInput, .Lfunc_end0-LLVMFuzzerTestOneInput
.cfi_endproc
# -- End function

.ident "clang version 6.0.0-1ubuntu2 (tags/RELEASE_600/final)"
.section ".note.GNU-stack","",@progbits

汇编代码也比较简单,两个comq指令分别对应C代码中的两条if命令,而三条movq1997/06/23的指令对应了三条赋值语句,一切都很正常。

但如果对比启用libFuzzer之后的结果呢?

--- a/without_fuzzer.s
+++ b/with_fuzzer.s
@@ -1,45 +1,128 @@
.text
.file "fuzz_target.c"
.globl LLVMFuzzerTestOneInput # -- Begin function LLVMFuzzerTestOneInput
.p2align 4, 0x90
.type LLVMFuzzerTestOneInput,@function
LLVMFuzzerTestOneInput: # @LLVMFuzzerTestOneInput
.cfi_startproc
# %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
+ subq $64, %rsp
+ movl $16, %eax
+ movl %eax, %ecx
+ movb .L__sancov_gen_, %dl
+ addb $1, %dl
+ movb %dl, .L__sancov_gen_
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
- cmpq $16, -16(%rbp)
+ movq -16(%rbp), %rsi
+ movq %rcx, %rdi
+ movq %rsi, -40(%rbp) # 8-byte Spill
+ callq __sanitizer_cov_trace_const_cmp8
+ movq -40(%rbp), %rcx # 8-byte Reload
+ cmpq $16, %rcx
jne .LBB0_5
# %bb.1:
movq -8(%rbp), %rax
movq %rax, -32(%rbp)
movq -32(%rbp), %rax
movq (%rax), %rax
movq -32(%rbp), %rcx
- cmpq 8(%rcx), %rax
+ movq 8(%rcx), %rcx
+ movq %rax, %rdi
+ movq %rcx, %rsi
+ movq %rax, -48(%rbp) # 8-byte Spill
+ movq %rcx, -56(%rbp) # 8-byte Spill
+ callq __sanitizer_cov_trace_cmp8
+ movq -48(%rbp), %rax # 8-byte Reload
+ movq -56(%rbp), %rcx # 8-byte Reload
+ cmpq %rcx, %rax
jne .LBB0_3
+.Ltmp0: # Block address taken
# %bb.2:
+ movb .L__sancov_gen_+1, %al
+ addb $1, %al
+ movb %al, .L__sancov_gen_+1
movq $1997, -24(%rbp) # imm = 0x7CD
jmp .LBB0_4
+.Ltmp1: # Block address taken
.LBB0_3:
+ movb .L__sancov_gen_+2, %al
+ addb $1, %al
+ movb %al, .L__sancov_gen_+2
movq $6, -24(%rbp)
.LBB0_4:
jmp .LBB0_6
+.Ltmp2: # Block address taken
.LBB0_5:
+ movb .L__sancov_gen_+3, %al
+ addb $1, %al
+ movb %al, .L__sancov_gen_+3
movq $23, -24(%rbp)
.LBB0_6:
xorl %eax, %eax
+ addq $64, %rsp
popq %rbp
retq
.Lfunc_end0:
.size LLVMFuzzerTestOneInput, .Lfunc_end0-LLVMFuzzerTestOneInput
.cfi_endproc
# -- End function
+ .section .text.sancov.module_ctor,"axG",@progbits,sancov.module_ctor,comdat
+ .p2align 4, 0x90 # -- Begin function sancov.module_ctor
+ .type sancov.module_ctor,@function
+sancov.module_ctor: # @sancov.module_ctor
+ .cfi_startproc
+# %bb.0:
+ pushq %rax
+ .cfi_def_cfa_offset 16
+ movabsq $__start___sancov_cntrs, %rax
+ movabsq $__stop___sancov_cntrs, %rcx
+ movq %rax, %rdi
+ movq %rcx, %rsi
+ callq __sanitizer_cov_8bit_counters_init
+ movabsq $__start___sancov_pcs, %rax
+ movabsq $__stop___sancov_pcs, %rcx
+ movq %rax, %rdi
+ movq %rcx, %rsi
+ callq __sanitizer_cov_pcs_init
+ popq %rax
+ retq
+.Lfunc_end1:
+ .size sancov.module_ctor, .Lfunc_end1-sancov.module_ctor
+ .cfi_endproc
+ # -- End function
+ .type .L__sancov_gen_,@object # @__sancov_gen_
+ .section __sancov_cntrs,"aw",@progbits
+.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
+.L__sancov_gen_.1:
+ .quad LLVMFuzzerTestOneInput
+ .quad 1
+ .quad .Ltmp0
+ .quad 0
+ .quad .Ltmp1
+ .quad 0
+ .quad .Ltmp2
+ .quad 0
+ .size .L__sancov_gen_.1, 64
+
+ .hidden __start___sancov_cntrs
+ .hidden __stop___sancov_cntrs
+ .section .init_array.2,"aGw",@init_array,sancov.module_ctor,comdat
+ .p2align 3
+ .quad sancov.module_ctor
+ .hidden __start___sancov_pcs
+ .hidden __stop___sancov_pcs

.ident "clang version 6.0.0-1ubuntu2 (tags/RELEASE_600/final)"
.section ".note.GNU-stack","",@progbits

很明显可以看出一些端倪:

  • 两条(也就是全部)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
clang -fsanitize=fuzzer -c -o with_fuzzer.o with_fuzzer.s
clang -c -o with_fuzzer_2.o with_fuzzer.s

使用sha1sum命令确认with_fuzzer_2.owith_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
clang -o without_fuzzer with_fuzzer.o
clang -fsanitize=fuzzer -o with_fuzzer with_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
./with_fuzzer -seed=1833035 -max_total_time=2 ./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
./with_fuzzer -seed=1833035 -max_total_time=2 ./more_output/ ./output

上述命令指定以output中的数据为初始corpus生成新的测试数据到more_output文件夹中,结果以很快就能在more_output文件夹中生成一个长度为1的数据,也就是覆盖了第三种情形。

产生这种现象的原因我尚未能找到,有待进一步探究。