AFL 源码阅读——part 1 插桩

相关的资料有:

整个 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.cbitfliphavoc 等函数)
输入传递 afl-fuzz.cwrite_to_testcase()
覆盖度收集 插桩代码(如 afl-as.h 模板)、afl-fuzz.c 的覆盖率分析逻辑
QEMU 模式 qemu_mode/afl-qemu-cpu-inl.h、QEMU 修改的 cpu-exec.c

参考了 AFL源码阅读(一):启程 的阅读顺序:

阅读源码的主要目标应该是:

  1. 理清静态插桩过程(gcc、clang、llvm mode)
  2. 理清 fuzz 过程:如何变异、如何将 input 传递给程序、如何收集覆盖度信息
  3. 理清 qemu mode 的插桩和执行过程

因此,我们决定阅读顺序:

  1. 阅读 afl-gcc.cafl-as.c,即静态插桩相关代码
  2. 阅读 afl-tmin.c ,这个工具的用途是「将一个 input case 缩小,但与原 input 拥有相同的覆盖度」。它会完整地演示如何收集程序的覆盖度信息,而不涉及 afl-fuzz.c 中的其他流程。这将给我们提供一个绝佳的切面,以研究 AFL 收集覆盖度的方法
  3. 阅读 afl-fuzz.c

根据这个文件的注释说明,afl-gcc 是 gcc 或者 clang 的简易包装。在这个文件的 main 函数中,除去检查参数的部分,主要就是 3 行代码,用于找到 afl-as (这个是插桩的工具),编辑传递给 gcc 的参数,使用 execvp 来启动 gcc 进程:

c

  find_as(argv[0]);
  edit_params(argc, argv);
  execvp(cc_params[0], (char**)cc_params);

AFL-as 会在汇编代码生成后、生成机器码(二进制)前,向汇编代码中插入特定的插桩代码,用于记录程序执行时的分支(branch)和基本块(basic block)覆盖率。流程是这样的:

text

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 参数。

c

  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.c 文件的 main 函数,除开了参数解析的部分的主要部分摘出来了,主要做了这些事情:生成一个随机数,编辑了一下 as 的调用参数,add_instrumentation 去增加一些跟插桩相关的指令,最后使用 fork 函数来启动 as。

c

  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 那么就进行插桩。下面这一段代码的指令就是,识别跳转的指令进行插桩。

c

    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 位的环境:

c

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 的值保存到栈中。

asm

leaq -(128+24)(%%rsp), %%rsp
movq %%rdx,  0(%%rsp)
movq %%rcx,  8(%%rsp)
movq %%rax, 16(%%rsp)

将一个随机值(由 AFL 生成)加载到 %rcx,作为当前代码位置的标识。调用 AFL 的核心函数 __afl_maybe_log,记录当前代码位置的执行信息。

asm

movq $0x%08x, %%rcx
call __afl_maybe_log

从栈中恢复之前保存的 %rax%rcx%rdx 的值,最后恢复栈指针到原始位置。

asm

movq 16(%%rsp), %%rax
movq  8(%%rsp), %%rcx
movq  0(%%rsp), %%rdx
leaq (128+24)(%%rsp), %%rsp

可以发现,主要的逻辑是生成一个随机数后调用 __afl_maybe_log ,那么为什么这个随机数可以完成 log 功能来记录分支的执行信息呢?这就与 AFL 是的覆盖率测量机制有关系了~

AFL 将从一个代码块转移到另一个代码块称为 branch pair。比如说状态从 A 转移到 B,代码块 B 的标志是 cur_location (也就是上文放入 rcx 的随机数),代码块 A 的标志是 prev_location。

那么 A->B 这样的转移就会被记录到 shared_mem 中(这是一个 bitmap)。这个 shared_mem 的每一个字节就对应了一个分支对。字节值记录分支命中次数,但仅区分三种状态:0(未覆盖)、1(覆盖一次)、>1(多次覆盖)。每次运行输入后,AFL 比较当前位图与历史记录的差异:

image-20250407113357549

  • 如果发现新分支 → 标记输入为“有趣”(interesting),加入队列用于变异。
  • 如果是已知路径 → 根据执行速度、代码复杂度等启发式规则决定是否保留。

text

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 函数大概做了些什么了:计算了 brach pair 在 bit map 里面的 idx 对应的技术器然后自增。主要就对应了函数里面的 __afl_store 标签

asm

__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 端是如何统计这些覆盖率信息的。