覆盖率

代码覆盖率,是一种通过计算测试过程中被执行的源代码占全部源代码的比例,进而间接度量软件质量的方法。其计量方式很多,但无论是 GCC 的 GCOV 还是 LLVM 的 SanitizerCoverage,都提供函数(function)、基本块(basic-block)、边界(edge)三种级别的覆盖率检测。

函数(Fuction-Level)

函数就是代码执行时调用到哪些函数,但是函数里面的具体代码行却不作统计,相对比较粗糙但高效的统计方式。所以,通常的统计方式是用基本块,简称BB。

基本块(BasicBlock-Level)

image-20210413173114591

IDA中每一块代码就代表着一个基本块,就是以指令跳转为作划分界限的。

边界(Edge-Level)

edge本身就涵盖了基本块部分,唯一的差别是edge多记录了一些执行边界的信息。

我们可以将程序看成一个控制流图(CFG),图的每个节点表示一个基本块,而edge就被用来表示在基本块之间的转跳。知道了每个基本块和跳转的执行次数,就可以知道程序中的每个语句和分支的执行次数,从而获得比记录BB更细粒度的覆盖率信息。

具体到AFL的实现中,使用二元组(branch_src, branch_dst)来记录当前基本块 + 前一基本块 的信息,从而获取目标的执行流程和代码覆盖情况,下文会详细介绍。

代码插桩

afl插桩的代码写在afl-gcc.c里面,afl-gcc 是 gcc 的一个封装(wrapper)。主要实现的下述的三个功能。

1
2
3
find_as(argv[0]);                         //找到gcc/clang/llvm编译器
edit_params(argc, argv); //设置cc的参数
execvp(cc_params[0], (char**)cc_params); //执行

我们在 execvp之前,加入一段代码打印出cc_params的参数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
$:	./afl-gcc demo.c -o test                                                                                                    
afl-cc 2.52b by <lcamtuf@google.com>
arg0: gcc
arg1: demo.c
arg2: -o
arg3: test
arg4: -B
arg5: .
arg6: -g
arg7: -O3
arg8: -funroll-loops
arg9: -D__AFL_COMPILER=1
arg10: -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1

我们知道一个二进制文件完整的流程是:预处理 -> 编译 -> 汇编 -> 链接。而将汇编代码编译成为二进制的工具,即为汇编器assembler。Linux系统下的常用汇编器是as。编译完成AFL后,在其目录下也会存在一个as文件,并作为符号链接指向afl-as。所以,如果通过-B选项为gcc设置了搜索路径(根据gcc –help可知),那么afl-as便会作为汇编器,执行实际的汇编操作。

1
2
3
4
-funroll-loops     :执行循环强度消除并消除在循环内部使用的变量。这是用简单而快速的操作(如加法和减法)替代耗时操作(如乘法和除法)的过程
-B < > :设置编译器的搜索路径。
ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION /* a flag also shared with libfuzzer) or */
ifdef __AFL_COMPILER /* (this one is just for AFL). */

反汇编我们刚刚所编译出来的 test 文件可以发现其中多了一些汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.text:0000000000400860
.text:0000000000400860 argc = rdi ; int
.text:0000000000400860 argv = rsi ; char **
.text:0000000000400860 ; __unwind {
.text:0000000000400860 lea rsp, [rsp-98h]
.text:0000000000400868 mov [rsp+98h+var_98], rdx
.text:000000000040086C mov [rsp+98h+var_90], rcx
.text:0000000000400871 mov [rsp+98h+var_88], rax
.text:0000000000400876 mov rcx, 459Eh
.text:000000000040087D call __afl_maybe_log
.text:0000000000400882 mov rax, [rsp+98h+var_88]
.text:0000000000400887 mov rcx, [rsp+98h+var_90]
.text:000000000040088C mov rdx, [rsp+98h+var_98]
.text:0000000000400890 lea rsp, [rsp+98h]

阅读afl-as.c发现插桩完成在 add_instrumentation函数内部

fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));

这里 afl 通过调用 fprintf 将 trampoline_fmt_64 或者 trampoline_fmt_32 插入目标的代码段,以完成插桩来计算代码覆盖率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//afl-as.h
static const u8* trampoline_fmt_32 =

"\n"
"/* --- AFL TRAMPOLINE (32-BIT) --- */\n"
"\n"
".align 4\n"
"\n"
"leal -16(%%esp), %%esp\n"
"movl %%edi, 0(%%esp)\n"
"movl %%edx, 4(%%esp)\n"
"movl %%ecx, 8(%%esp)\n"
"movl %%eax, 12(%%esp)\n"
"movl $0x%08x, %%ecx\n"
"call __afl_maybe_log\n"
"movl 12(%%esp), %%eax\n"
"movl 8(%%esp), %%ecx\n"
"movl 4(%%esp), %%edx\n"
"movl 0(%%esp), %%edi\n"
"leal 16(%%esp), %%esp\n"
"\n"
"/* --- END --- */\n"
"\n";

static const u8* trampoline_fmt_64 =

"\n"
"/* --- AFL TRAMPOLINE (64-BIT) --- */\n"
"\n"
".align 4\n"
"\n"
"leaq -(128+24)(%%rsp), %%rsp\n"
"movq %%rdx, 0(%%rsp)\n"
"movq %%rcx, 8(%%rsp)\n"
"movq %%rax, 16(%%rsp)\n"
"movq $0x%08x, %%rcx\n"
"call __afl_maybe_log\n"
"movq 16(%%rsp), %%rax\n"
"movq 8(%%rsp), %%rcx\n"
"movq 0(%%rsp), %%rdx\n"
"leaq (128+24)(%%rsp), %%rsp\n"
"\n"
"/* --- END --- */\n"
"\n";

可以看到插桩主要完成了(x64):

  • 保存rax rcx rdx 等寄存器的值
  • ecx的值设置为随机数
  • 调用__afl_maybe_log
  • 恢复原寄存器的数据

关于"movq $0x%08x, %%rcx\n"这条汇编代码其对应fprintf中的参数为R(MAP_SIZE),根据定义,宏MAP_SIZE为64K;R(x)的定义是(random() % (x)),所以R(MAP_SIZE)即为0到MAP_SIZE之间的一个随机数。这里的R(x)实际上是用来区分每个代码块的,也就是是一个标识。

关于__afl_maybe_log()的详细实现会在下文提及。

fork server

afl 的流程大致是:对输入的样本文件不断地变异,并将这些 mutated input 喂给 loader 执行,检查是否会造成崩溃。因此,fuzzing 涉及到大量的 fork 和执行 loader 的过程。但是对于简单的库,我们会花费大量时间去等待execve(),载入目标文件和库、解析符号地址等,为了避免这种情况,AFL实现了一套 fork server 机制。其基本思路是:启动target进程后,target会运行一个fork server;fuzzer并不负责fork子进程,而是与这个fork server通信,并由fork server来完成fork及继续执行目标的操作。

init fork server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//afl-fuzz.c
EXP_ST void init_forkserver(char** argv) {
static struct itimerval it;
int st_pipe[2], ctl_pipe[2]; //建立管道st_pipe和ctl_pipe,在父子进程之间,是通过管道进行通信,一个用于传递状态,另一个用于传递命令。
...

forksrv_pid = fork(); //fork 出来一个子进程,父子进程之间通过管道进行通信。
...

if (!forksrv_pid) {
...
/* Set up control and status pipes, close the unneeded original fds. */
if (dup2(ctl_pipe[0], FORKSRV_FD) < 0) PFATAL("dup2() failed"); //对于子进程(fork server),会进行一系列设置,其中包括将上述两个管道分配到预先指定的fd,并最终执行target:
if (dup2(st_pipe[1], FORKSRV_FD + 1) < 0) PFATAL("dup2() failed");
...
execv(target_path, argv);
/* Use a distinctive bitmap signature to tell the parent about execv() falling through. */
*(u32*)trace_bits = EXEC_FAIL_SIG;
exit(0);
}
//对于父进程(fuzzer),则会读取状态管道的信息,如果一切正常,则说明fork server创建完成。
close(ctl_pipe[0]);
close(st_pipe[1]);

fsrv_ctl_fd = ctl_pipe[1];
fsrv_st_fd = st_pipe[0];
...

rlen = read(fsrv_st_fd, &status, 4); //从管道中读取4个字节
...
/* If we have a four-byte "hello" message from the server, we're all set.
Otherwise, try to figure out what went wrong. */
if (rlen == 4) {
OKF("All right - fork server is up.");
return;
}
...

FATAL("Fork server handshake failed");
}

execv(target_path, argv)带参数执行target,这个函数除非出错不然不会返回。

  • execv会替换掉原有的进程空间为target_path代表的程序,所以相当于后续就是去执行target_path,这个程序结束的话,子进程就结束。
  • 此时由于我们的目标程序的 main 函数已经被插桩,程序的控制流会交到_afl_maybe_log手中。所以关于 fork server 的其余工作都在_afl_maybe_log中完成。而在这里非常特殊,第一个target会进入__afl_maybe_log里的__afl_fork_wait_loop,并充当fork server,在整个Fuzz的过程中,它都不会结束,每次要Fuzz一次target,都会从这个fork server fork出来一个子进程去fuzz。

__afl_maybe_log()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
char __usercall _afl_maybe_log@<al>(char a1@<of>, __int64 a2@<rcx>, __int64 a3@<xmm0>, __int64 a4@<xmm1>, __int64 a5@<xmm2>, __int64 a6@<xmm3>, __int64 a7@<xmm4>, __int64 a8@<xmm5>, __int64 a9@<xmm6>, __int64 a10@<xmm7>, __int64 a11@<xmm8>, __int64 a12@<xmm9>, __int64 a13@<xmm10>, __int64 a14@<xmm11>, __int64 a15@<xmm12>, __int64 a16@<xmm13>, __int64 a17@<xmm14>, __int64 a18@<xmm15>)
{
...

v19 = _afl_area_ptr;
if ( !_afl_area_ptr )
{
if ( _afl_setup_failure )
return v18 + 127;
v19 = _afl_global_area_ptr;
if ( _afl_global_area_ptr )
{
_afl_area_ptr = _afl_global_area_ptr;
}
else
{
...
v22 = getenv("__AFL_SHM_ID");
if ( !v22 || (v23 = atoi(v22), v24 = shmat(v23, 0LL, 0), v24 == (void *)-1LL) )
{
++_afl_setup_failure;
v18 = v29;
return v18 + 127;
}
_afl_area_ptr = (__int64)v24;
_afl_global_area_ptr = v24;
v28 = (__int64)v24;
if ( write(199, &_afl_temp, 4uLL) == 4 )
{
while ( 1 )
{
v25 = 198;
if ( read(198, &_afl_temp, 4uLL) != 4 )
break;
LODWORD(v26) = fork();
if ( v26 < 0 )
break;
if ( !v26 )
goto __afl_fork_resume;
_afl_fork_pid = v26;
write(199, &_afl_fork_pid, 4uLL);
v25 = _afl_fork_pid;
LODWORD(v27) = waitpid(_afl_fork_pid, &_afl_temp, 0);
if ( v27 <= 0 )
break;
write(199, &_afl_temp, 4uLL);
}
_exit(v25);
}
__afl_fork_resume:
close(198);
close(199);
v19 = v28;
v18 = v29;
a2 = v30;
}
}
v20 = _afl_prev_loc ^ a2;
_afl_prev_loc ^= v20;
_afl_prev_loc = (unsigned __int64)_afl_prev_loc >> 1;
++*(_BYTE *)(v19 + v20);
return v18 + 127;
}

通过阅读伪代码或者汇编,可以总结其工作流程如下:

  1. 先判断是否设置了共享内存,如果没设置则判断_afl_setup_failure是否为真,如果为真,则代表setup失败,直接返回。也就是说只有第一次执行__afl_maybe_log()的时候,才会进入该 if 语句。

    • 如果初始化失败则直接返回
    • 初始化成功后,读取_afl_global_area_ptr的值,不为 0 ,则赋值给 _afl_area_ptr
    • _afl_global_area_ptr为 0, 则把共享内存连接到当前进程的地址空间,将得到的地址,保存到_afl_area_ptr_afl_global_area_ptr中。
    • write(199, &_afl_temp, 4uLL) == 4写4个字节到状态管道st_pipe[0],forkserver 告诉 fuzzer 自己准备好了,而这正好是rlen = read(fsrv_st_fd, &status, 4);中等待的信息。
      • read(198, &_afl_temp, 4uLL) != 4forkserver 再从管道中读取 4 个字节,这时候表示 fuzzer 也准备好了。这时候 fork 出一个新的子进程,用来跑 target,而原本的父进程则用来通信。
      • write(199, &_afl_fork_pid, 4uLL);将子进程的 pid 写进管道,以为fuzzer的监控。
      • 然后父进程即fork server等待子进程结束,并保存其执行结果到_afl_temp中,然后将子进程的执行结果,从_afl_temp写入到状态管道,告知fuzz。
      • 父进程不断执行__afl_fork_wait_loop循环,不断从控制管道读取,直到fuzz端命令fork server进行新一轮测试。
  2. 如果共享内存已经被设置,则直接进入__afl_store逻辑,看伪代码可以知道:就是将上一个桩点的值(prev_location)和当前桩点的值(R(MAP_SIZE))异或,取值后,使得共享内存里对应的槽的值加一,然后将prev_location设置为cur_location >> 1;。其余内容我们在下文分析。

在fork server执行完毕后,当我们运行target的时候,fuzzer会调用run_target(),在此方法中,便是通过命令管道,通知fork server准备fork;并通过状态管道,获取子进程pid:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//afl-fuzz.c
s32 res;

/* In non-dumb mode, we have the fork server up and running, so simply tell it to have at it, and then read back PID. */

if ((res = write(fsrv_ctl_fd, &prev_timed_out, 4)) != 4) {
...
if ((res = read(fsrv_st_fd, &child_pid, 4)) != 4) {
...

if ((res = read(fsrv_st_fd, &status, 4)) != 4) {
...
/*fuzzer再次读取状态管道,获取子进程退出状态,并由此来判断子进程结束的原因,例如正常退出、超时、崩溃等,并进行相应的记录。. */

if (WIFSIGNALED(status) && !stop_soon) {

kill_signal = WTERMSIG(status);

if (child_timed_out && kill_signal == SIGKILL) return FAULT_TMOUT;

return FAULT_CRASH;

}

简介来说整个 server 流程如下图:

image-20210413092646310

afl 在初始化 forkserver 的时候会创建两个管道,fork 后通过 execve 去执行 target,因为目标程序的 main 函数已经被插桩,程序的控制流会交到_afl_maybe_log手中。如果 fuzz 实例是第一次运行,则此子进程则会充当 fuzz server,之后的程序都是由该 server fork出来的子进程。fuzz进行的时候,fuzz server会一直fork子进程,并且将子进程的结束状态通过pipe传递给afl-fuzz。

共享内存

我们知道AFL 是以无限 fork 的形式进行 fuzzing 的,那么可以了解到 fuzzer 和 target 直接信息是要共享的,比如:执行过程中的分支信息;随后,fuzzer便可以根据这些信息,判断这次执行的整体流程和代码覆盖情况。

AFL使用共享内存,来完成以上信息在fuzzer和target之间的传递。具体地,fuzzer在启动时,会执行setup_shm()方法进行配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//afl-fuzz.c
EXP_ST void setup_shm(void) {
u8* shm_str;
if (!in_bitmap) memset(virgin_bits, 255, MAP_SIZE);

memset(virgin_tmout, 255, MAP_SIZE);
memset(virgin_crash, 255, MAP_SIZE);

shm_id = shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);

if (shm_id < 0) PFATAL("shmget() failed");

atexit(remove_shm);

shm_str = alloc_printf("%d", shm_id);

/* If somebody is asking us to fuzz instrumented binaries in dumb mode,
we don't want them to detect instrumentation, since we won't be sending
fork server commands. This should be replaced with better auto-detection
later on, perhaps? */

if (!dumb_mode) setenv(SHM_ENV_VAR, shm_str, 1);

ck_free(shm_str);

trace_bits = shmat(shm_id, NULL, 0);

if (!trace_bits) PFATAL("shmat() failed");

}

shmget():用来创建共享内存

shmat() :第一次创建完共享内存时,它还不能被任何进程访问,shmat()函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间

  • 首先调用shemget()分配一块共享内存,大小MAP_SIZE为64K

  • 分配成功后,该共享内存的标志符会通过setenv设置到环境变量中,从而之后fork()得到的子进程可以通过该环境变量,得到这块共享内存的标志符

  • fuzzer 则会通过 trace_bits 来保存共享内存的地址

  • 每次 fuzzer 去运行 target 的时候都会初始化共享内存

    1
    2
    3
    4
    5
    6
    7
    //afl-fuzz.c
    static u8 run_target(char** argv, u32 timeout) {
    ...
    memset(trace_bits, 0, MAP_SIZE);
    MEM_BARRIER();
    ...
    }
  • 而在 forkserver 内部

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    char __usercall _afl_maybe_log@<al>(char a1@<of>, __int64 a2@<rcx>, __int64 a3@<xmm0>, __int64 a4@<xmm1>, __int64 a5@<xmm2>, __int64 a6@<xmm3>, __int64 a7@<xmm4>, __int64 a8@<xmm5>, __int64 a9@<xmm6>, __int64 a10@<xmm7>, __int64 a11@<xmm8>, __int64 a12@<xmm9>, __int64 a13@<xmm10>, __int64 a14@<xmm11>, __int64 a15@<xmm12>, __int64 a16@<xmm13>, __int64 a17@<xmm14>, __int64 a18@<xmm15>)
    {
    ...
    v22 = getenv("__AFL_SHM_ID");
    if ( !v22 || (v23 = atoi(v22), v24 = shmat(v23, 0LL, 0), v24 == (void *)-1LL) )
    {
    ++_afl_setup_failure;
    v18 = v29;
    return v18 + 127;
    }
    ...
    }

    则会先判断共享内存是否被设置,然后通过调用shmat(),target将这块共享内存也映射到了自己的内存空间中,并将其地址保存在__afl_area_ptredx中。由此,便完成了fuzzer与target之间共享内存的设置。

分支信息的记录

由官网文档可知,AFL 是根据二元 tuple (跳转的源地址和目标地址)来记录分支信息,从而获取 target 的执行流程和代码覆盖情况,其伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
.text:0000000000400CB0 __afl_store:                            ; CODE XREF: __afl_maybe_log+4F↓j
.text:0000000000400CB0 ; __afl_maybe_log+309↓j
.text:0000000000400CB0 xor rcx, cs:__afl_prev_loc
.text:0000000000400CB7 xor cs:__afl_prev_loc, rcx
.text:0000000000400CBE shr cs:__afl_prev_loc, 1
.text:0000000000400CC5 inc byte ptr [rdx+rcx]

v20 = _afl_prev_loc ^ a2;
_afl_prev_loc ^= v20;
_afl_prev_loc = (unsigned __int64)_afl_prev_loc >> 1;
++*(_BYTE *)(v19 + v20);

其中 a2 保存的寄存器 rcx 的值,跟踪可以发现,rcx 存贮的是随机数,那么简单来说上述流程就是:就是将上一个桩点的值(prev_location)和当前桩点的值(R(MAP_SIZE))异或,取值后,使得共享内存里对应的槽的值加一,然后将cur_location 的值右移一位然后得到新的prev_location

1
2
3
cur_location = <COMPILE_TIME_RANDOM>;
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;

因为,AFL在为每个代码块插桩的时候都会生成一个随机数,作为其”位置”的记录,然后对分支处的”源位置”和”目标位置”进行异或,讲其结果作为该分支的 key,并保存每个分支的执行次数。

用于保存执行次数的实际上是一个哈希表,大小为MAP_SIZE=64K,当然会存在碰撞的问题;但根据AFL文档中的介绍,对于不是很复杂的目标,碰撞概率还是可以接受的:

1
2
3
4
5
6
7
8
 Branch cnt | Colliding tuples | Example targets
------------+------------------+-----------------
1,000 | 0.75% | giflib, lzo
2,000 | 1.5% | zlib, tar, xz
5,000 | 3.5% | libpng, libwebp
10,000 | 7% | libxml
20,000 | 14% | sqlite
50,000 | 30% | -

如果一个目标过于复杂,那么AFL状态面板中的map_density信息就会有相应的提示:

1
2
3
4
┬─ map coverage ─┴───────────────────────┤
│ map density : 3.61% / 14.13% │
│ count coverage : 6.35 bits/tuple │
┼─ findings in depth ────────────────────┤

这里的map density,就是这张哈希表的密度。可以看到,上面示例中,该次执行的哈希表密度仅为3.61%,即整个哈希表差不多有95%的地方还是空的,所以碰撞的概率很小。不过,如果目标很复杂,map density很大,那么就需要考虑到碰撞的影响了。

另外,AFL需要将cur_location右移1位后,再保存到prev_location中。官方文档中解释了这样做的原因。假设target中存在A->AB->B这样两个跳转,如果不右移,那么这两个分支对应的异或后的key都是0,从而无法区分;另一个例子是A->BB->A,如果不右移,这两个分支对应的异或后的key也是相同的。

由上述分析可知,之前提到的共享内存,被用于保存一张哈希表,target在这张表中记录每个分支的执行数量。随后,当target执行结束后,fuzzer便开始对这张表进行分析,从而判断代码的执行情况。

分支信息的分析

首先,fuzzer通过调用 classify_countstrace_bits(共享内存)进行预处理

具体地,target 是将每个分支的执行次数用 1 个 byte 来储存,而 fuzzer 则进一步把这个执行次数归入以下的 buckets 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
static const u8 count_class_lookup8[256] = {

[0] = 0,
[1] = 1,
[2] = 2,
[3] = 4,
[4 ... 7] = 8,
[8 ... 15] = 16,
[16 ... 31] = 32,
[32 ... 127] = 64,
[128 ... 255] = 128

};

举个例子,如果某分支执行了 1 次,那么落入第 2 个 bucket,其计数 byte 仍为 1;如果某分支执行了 4 次,那么落入第 5 个 bucket,其计数 byte 将变为 8,等等。(执行了 4-7 次的其计数为 8)

这样处理之后,对分支执行次数就会有一个简单的归类。例如,如果对某个测试用例处理时,分支A执行了32次;对另外一个测试用例,分支A执行了33次,那么AFL就会认为这两次的代码覆盖是相同的。当然,这样的简单分类肯定不能区分所有的情况,不过在某种程度上,处理了一些因为循环次数的微小区别,而误判为不同执行结果的情况。

随后,对于某些mutated input来说,如果这次执行没有出现崩溃等异常输出,fuzzer还会检查其是否新增了执行路径。具体来说,是对trace_bits计算hash并来实现:

1
u32 cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);

通过比较hash值,就可以判断trace_bits是否发生了变化,从而判断此次mutated input是否带来了新路径,为之后的fuzzing提供参考信息。

参考

https://lcamtuf.blogspot.com/2014/10/fuzzing-binaries-without-execve.html

https://www.cnblogs.com/52php/p/5861372.html

http://rk700.github.io/2017/12/28/afl-internals/

https://xz.aliyun.com/t/4628#toc-10

https://eternalsakura13.com/2020/08/23/afl/

https://mp.weixin.qq.com/s?__biz=MzU0MzgzNTU0Mw==&mid=2247483746&idx=1&sn=9d0ff5e0bae65f9ca9dbea4cf74449c6&chksm=fb04159acc739c8c36e0785e2ce9c3a202066c6cc39a9ce32fd0fc9dfeb348e860a82e29c9d6&scene=178&cur_album_id=1363100069033066496#rd

https://paper.seebug.org/842/#1-code-coverage