AFL 源码阅读——part 1 插桩
总览
相关的资料有:
- 首先是源码 american fuzzy lop
- 官方的技术细节 technical_details.txt 可以去了解一些原理内容
- AFL 源码分析博客 AFL源码阅读(一):启程 作者记录了详细的分析思路
整个 AFL 项目的主要部分在 afl-fuzz.c
文件里面,直接看这个文件非常庞大,不太易于阅读,所以可以从其他部分来看,先去了解其 API 的使用方式。
先问 ai 总结了一下功能相关的文件:
功能 | 主要文件 |
---|---|
GCC 插桩 | afl-gcc.c , afl-as.c , afl-as.h |
Clang/LLVM 插桩 | llvm_mode/afl-clang-fast.c , llvm_mode/afl-llvm-pass.so.cc |
Fuzz 变异策略 | afl-fuzz.c (bitflip 、havoc 等函数) |
输入传递 | afl-fuzz.c (write_to_testcase() ) |
覆盖度收集 | 插桩代码(如 afl-as.h 模板)、afl-fuzz.c 的覆盖率分析逻辑 |
QEMU 模式 | qemu_mode/afl-qemu-cpu-inl.h 、QEMU 修改的 cpu-exec.c 等 |
参考了 AFL源码阅读(一):启程 的阅读顺序:
阅读源码的主要目标应该是:
- 理清静态插桩过程(gcc、clang、llvm mode)
- 理清 fuzz 过程:如何变异、如何将 input 传递给程序、如何收集覆盖度信息
- 理清 qemu mode 的插桩和执行过程
因此,我们决定阅读顺序:
- 阅读
afl-gcc.c
和afl-as.c
,即静态插桩相关代码- 阅读
afl-tmin.c
,这个工具的用途是「将一个 input case 缩小,但与原 input 拥有相同的覆盖度」。它会完整地演示如何收集程序的覆盖度信息,而不涉及afl-fuzz.c
中的其他流程。这将给我们提供一个绝佳的切面,以研究 AFL 收集覆盖度的方法- 阅读
afl-fuzz.c
AFL-gcc 如何插桩
afl-gcc 文件开始
根据这个文件的注释说明,afl-gcc 是 gcc 或者 clang 的简易包装。在这个文件的 main 函数中,除去检查参数的部分,主要就是 3 行代码,用于找到 afl-as (这个是插桩的工具),编辑传递给 gcc 的参数,使用 execvp 来启动 gcc 进程:
find_as(argv[0]);
edit_params(argc, argv);
execvp(cc_params[0], (char**)cc_params);
AFL-as 会在汇编代码生成后、生成机器码(二进制)前,向汇编代码中插入特定的插桩代码,用于记录程序执行时的分支(branch)和基本块(basic block)覆盖率。流程是这样的:
afl-gcc → gcc → 生成汇编代码 → afl-as 插桩 → 汇编器(as) → 链接器(ld) → 可执行文件
核心的部分是去构造 gcc 的参数来实现插桩,我们就集中看 edit_params 的部分。在 edit_params
函数中除了将用户给 afl-gcc 的命令再传递给 gcc,设置调试和一些优化选项,以及 ASAN 之外,afl-gcc 也增加一些自己的特定的参数比如:
- 添加
-B
参数,指定 AFL 的汇编器路径(as_path
)。 - 如果是
clang_mode
,添加-no-integrated-as
参数。
cc_params[cc_par_cnt++] = "-B";
cc_params[cc_par_cnt++] = as_path;
if (clang_mode)
cc_params[cc_par_cnt++] = "-no-integrated-as";
所以这里的意思就是 AFL-gcc 指定使用自己的汇编器,在汇编的代码里面插桩。
afl-gcc 包装了 gcc/clang 等编译器,通过参数去指定了编译过程使用的汇编器。
afl-as 分析一下汇编器
把在 afl-as.c
文件的 main 函数,除开了参数解析的部分的主要部分摘出来了,主要做了这些事情:生成一个随机数,编辑了一下 as 的调用参数,add_instrumentation
去增加一些跟插桩相关的指令,最后使用 fork 函数来启动 as。
srandom(rand_seed);
edit_params(argc, argv);
if (!just_version) add_instrumentation();
if (!(pid = fork())) {
execvp(as_params[0], (char**)as_params);
FATAL("Oops, failed to execute '%s' - check your PATH", as_params[0]);
}
为什么这里会有一个随机数呢,这是用来做什么的?这个随机数和一个全局变量 inst_ratio 共同决定是否要对一个分支插桩,比如说- 如果 inst_ratio = 50
,表示只有 50% 的符合条件的代码分支会被插桩,如果产生的随机数小于这个设定的 inst_ratio 那么就进行插桩。下面这一段代码的指令就是,识别跳转的指令进行插桩。
if (line[0] == '\t') {
if (line[1] == 'j' && line[2] != 'm' && R(100) < inst_ratio) {
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));
ins_lines++;
}
continue;
}
frpintf 函数就插入了一段插桩识别的代码,trampoline_fmt_64
就对应 64 位的环境:
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";
以下是 trampoline_fmt_64
的逐行解析:
调整栈指针,分配 128+24 字节的栈空间,用于保存寄存器值。将 %rdx
、%rcx
和 %rax
的值保存到栈中。
leaq -(128+24)(%%rsp), %%rsp
movq %%rdx, 0(%%rsp)
movq %%rcx, 8(%%rsp)
movq %%rax, 16(%%rsp)
将一个随机值(由 AFL 生成)加载到 %rcx
,作为当前代码位置的标识。调用 AFL 的核心函数 __afl_maybe_log
,记录当前代码位置的执行信息。
movq $0x%08x, %%rcx
call __afl_maybe_log
从栈中恢复之前保存的 %rax
、%rcx
和 %rdx
的值,最后恢复栈指针到原始位置。
movq 16(%%rsp), %%rax
movq 8(%%rsp), %%rcx
movq 0(%%rsp), %%rdx
leaq (128+24)(%%rsp), %%rsp
可以发现,主要的逻辑是生成一个随机数后调用 __afl_maybe_log
,那么为什么这个随机数可以完成 log 功能来记录分支的执行信息呢?这就与 AFL 是的覆盖率测量机制有关系了~
AFL 如何测量覆盖率
AFL 将从一个代码块转移到另一个代码块称为 branch pair。比如说状态从 A 转移到 B,代码块 B 的标志是 cur_location (也就是上文放入 rcx 的随机数),代码块 A 的标志是 prev_location。
那么 A->B 这样的转移就会被记录到 shared_mem 中(这是一个 bitmap)。这个 shared_mem 的每一个字节就对应了一个分支对。字节值记录分支命中次数,但仅区分三种状态:0
(未覆盖)、1
(覆盖一次)、>1
(多次覆盖)。每次运行输入后,AFL 比较当前位图与历史记录的差异:
- 如果发现新分支 → 标记输入为“有趣”(interesting),加入队列用于变异。
- 如果是已知路径 → 根据执行速度、代码复杂度等启发式规则决定是否保留。
cur_location = <随机化的当前代码位置ID>
shared_mem[cur_location ^ prev_location]++;
prev_location = cur_location >> 1;
在技术细节中提到了一些细节,比如说 shared_mem 的大小设计是有讲究的,刚好可以适配 L2 缓存让接收端快速处理。cur_location >> 1
的唯一操作是为了区分分支的方向性,比如说 A->B 还是 B->A。命中计数器自增的时候,偶尔可能因溢出归零,但是概率小,就不用理会了。
__afl_maybe_log
了解了技术细节之后,就可以推测 __afl_maybe_log
函数大概做了些什么了:计算了 brach pair 在 bit map 里面的 idx 对应的技术器然后自增。主要就对应了函数里面的 __afl_store
标签
__afl_store:
/* Calculate and store hit for the code location specified in rcx. */
xorq __afl_prev_loc(%rip), %rcx
xorq %rcx, __afl_prev_loc(%rip)
shrq $1, __afl_prev_loc(%rip)
incb (%rdx, %rcx, 1)
总结
以上就分析了插桩的 client 部分逻辑,之后打算去分析 server 端是如何统计这些覆盖率信息的。