我的AFL++fuzzing入门之路(2)

00 前篇回顾

在笔者的上一篇,也是 AFL++ Fuzzing 系列的第一篇中,我们完成了以下工作:

理论基础:建立了对 AFL++ 的基础认知,包括其作为覆盖率引导模糊测试框架的定位、核心漏洞挖掘能力,以及编译时插桩的工作原理;

环境搭建:在 Ubuntu 24.04 虚拟机中完成了 AFL++ 的源码编译安装,解决了 Ubuntu Apport 与 core_pattern 的系统冲突;

实战入门:构造并测试了首个漏洞程序,在 2.7 秒内成功触发崩溃,同时掌握了 AFL++ 状态界面 8 区域的核心指标解读方法。

基于昨日的实践基础,本文将推进三个层面的深入探索:

插桩汇编表现:通过 IDA 反编译观察 AFL++ 使用 LLVM 的 SanitizerCoverage 在二进制层面插入的 inline 代码,理解 AFL++ 如何记录边覆盖率,从汇编指令级掌握覆盖率追踪的底层机制;

难度进阶:通过修改触发条件(从 “AFL++” 阶梯式扩展至平墙式),测试 AFL++ 的变异深度与效率边界,观察 havoc 策略在长模式匹配场景下的表现;

崩溃分析:引入 GDB 调试器对发现的崩溃进行根因定位,掌握调用栈回溯、寄存器状态检查等漏洞分析基础技能,完成从"发现崩溃"到"理解崩溃"的闭环。

01 汇编插桩

为了简单,咱直接素材复用,上一篇中的test.c使用afl-cc编译的,现在再用gcc编译一遍,然后放到不同的工具下比对

❯ cat test.c
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
    char buf[100];
    FILE *f = fopen(argv[1], "r");
    if (!f) return 1;
    fread(buf, 1, sizeof(buf), f);
    fclose(f);
    
    // 漏洞触发点:输入 "AFL++" 会崩溃
    if (buf[0] == 'A' && buf[1] == 'F' && buf[2] == 'L')
        if (buf[3] == '+')
            if (buf[4] == '+')
                __builtin_trap();  // 触发崩溃(SIGTRAP)
    return 0;
}

afl-cc -o test test.c
gcc -o test-gcc test.c

这里就得到了两个不同的可执行程序版本:有afl插桩的test和gcc编译的无插桩的test-gcc

我们由易到难用,工具来比对这两个版本

1st. 编译对比

简单直接,插桩添加的代码指令会导致程序比正常编译的程序更大

❯ ls -la test test-gcc
-rwxr-xr-x 1 root root 119968 Mar  9 00:06 test
-rwxr-xr-x 1 root root  16096 Mar  9 12:43 test-gcc

快速定位插桩符号,可以看到多出了很多以**__afl_**为前缀的函数

❯ strings test | grep -i afl | head -20
__afl_manual_init
__afl_coverage_interesting
__afl_auto_second
__afl_already_initialized_shm
__afl_prev_caller
__afl_persistent_loop
__afl_selective_coverage_start_off
__afl_selective_coverage
__afl_dictionary_len
__afl_connected
__afl_coverage_skip
__afl_fuzz_len
__afl_coverage_off
__afl_auto_init
__afl_prev_ctx
__afl_map_addr
__afl_area_ptr
__afl_coverage_on
__afl_auto_early
__afl_final_loc

❯ strings test-gcc | grep -i afl

2nd. 反汇编对比(核心重点)

执行以下命令,将两个程序的汇编代码输出出来比对main函数:

objdump -d test > test.asm
objdump -d test-gcc > test-gcc.asm

或者直接用ida看,图形化更直观:

比如函数解析中,afl-cc版比gcc版多了很多系统函数

image-20260309134118691

image-20260309134145982

而这是gcc版的main函数开头,可以发现非常简洁

image-20260309133038312

而这是afl-cc版的main函数开头

image-20260309133211921

可以发现多了__afl_area_ptr 和 __start___sancov_guards这两个东西

  • __afl_area_ptr:共享内存 bitmap 基址,是 AFL 覆盖率地图的核心。
  • __start___sancov_guards:LLVM SanitizerCoverage 的 guard 数组起始地址,用来给每条边编号。

guard 变量本身只是 uint32_t,真正编号是在模块构造函数 __sanitizer_cov_trace_pc_guard_init 里完成的:该函数遍历 __start___sancov_guards 到 __stop___sancov_guards,把每个 guard 赋值为 1、2、3…,用来标识不同的边

它们的作用以及触发条件:

  • __afl_area_ptr:运行时会被 AFL++ 的 afl-fuzz 通过共享内存映射覆盖,指向 fuzzer 侧维护的 64KB bitmap。
  • __start___sancov_guards 里的 guard 变量:在模块初始化函数中被编号为 1、2、3…,用来标识不同的边。
  • 插桩代码以 guard 值 为下标,访问 __afl_area_ptr[offset],实现“边覆盖计数”。

3rd. 汇编代码解析

那么它们是怎么来的呢?

这是LLVM 模式的内联优化:与传统 AFL 的运行时调用 __afl_maybe_log 不同,LLVM 模式利用 SanitizerCoverage 直接在编译期将探针内联到代码中。观察汇编可见,覆盖率更新被优化为单条 inc 指令操作共享内存位图,消除了函数调用开销,同时保留了 NeverZero 溢出保护机制。

仔细看汇编后可以发现afl-cc版有一个比较明显的结构:

2428:	lea    0x8d59(%rip),%rax        # b188 <__afl_area_ptr>
242f:	mov    (%rax),%r14              # r14 = 位图基地址
2439:	movzbl (%r14,%rax,1),%ecx       # ecx = 当前边的计数
243e:	inc    %cl                      # 计数+1 (核心操作!)
2440:	movzbl %cl,%ecx
2443:	cmp    $0x2,%cl                 # 检查是否溢出
2446:	mov    $0x1,%ebp
244b:	cmovb  %ebp,%ecx                # 溢出保护 (NeverZero)
244e:	mov    %cl,(%r14,%rax,1)        # 写回位图
  • NeverZero 的核心目标是:如果计数器 inc 之后会变为0( 即255 溢出时),把结果变成 1,避免 fuzzer 把这条边当成“没执行过。
  • 对应到指令,就是你看到的 cmp $0x2 + cmovb:如果结果是 0,就改成 1。
  • 即,如果某个边的执行次数超过 255 次,也不会因为偶然溢出到 0 而被 fuzzer 忽略,从而提高路径发现的稳定性

02 插桩原理:SanitizerCoverage

1st.什么是插桩

插桩(instrumentation):在保证原程序逻辑完整性的情况下,在程序中插入一些代码来采集运行期间的执行状态。

最简单的插桩如下所示,可能很多师傅在写程序的时候用过:

不确定这个段代码会不会执行,print一下,

看看某个变量是不是达到了自己的预期值,print一下……

int test_var = 0;

// original (1)
void b() { ...; }
void a() { ...; }

// instrumented (2)
void b() { printf("test_var: %d\n", test_var); ...; }
void a() { printf("test_var: %d\n", test_var); ...; }

插桩的特点︰

  1. 插桩的对象通常都具有相同的属性或类别涉及所有的功能、所有的基本块,比较少针对单一目标。

  2. 插桩的程序代码通常只有几行汇编代码,并且不会做太复杂的操作。

  3. 在模糊器中,插桩被用来进行覆盖,那么记录多少程序码被执行到。

2nd.LLVM 模式 vs 传统 AFL

而至于AFL++的LLVM模式:

AFL++ 的 LLVM 模式并非摒弃插桩,而是采用了更先进的编译器级插桩方案。通过 LLVM 的 SanitizerCoverage 框架,在 IR 层插入覆盖率探针,并借助编译器优化内联为直接内存操作,消除了传统汇编插桩的函数调用开销。

关键优化:

  • 无函数调用:直接 inc 内存,比 call __afl_maybe_log 快
  • NeverZero 补丁:cmp $0x2 + cmovb 确保计数不会归零溢出
  • 内联实现:LLVM 在编译期直接生成这些指令

SanitizerCoverage 作为 LLVM 的编译器级插桩框架,是 AFL++ 实现覆盖率追踪的核心机制。通过 IDA 反编译观察 AFL++ 在二进制层面内联插入的汇编代码(lea→mov→inc→cmovb 序列),理解其如何通过直接内存操作更新边覆盖率位图,从指令级掌握覆盖率追踪的底层实现——包括共享内存映射、NeverZero 溢出保护等关键技术细节。

这里主要对比的是 SanitizerCoverage 的 inline-8bit-counters 模式,它会在每个边缘插入内联的计数器增量,如下:

概念传统 AFLSanitizerCoverage
代码形态Trampoline(跳板函数调用)Inline(内联展开)
指令特征call __afl_maybe_loginc byte ptr [r14+rax]
性能有 call/ret 开销无函数调用开销,仅有少量内存访问指令

补充1:传统的AFL插桩function add_instrumentation() 中对会对每个基本块做插桩,主要使用跳板函数__afl_maybe_log() ,这一块我就不多介绍,有兴趣的师傅可以搜搜相关的文章了解一下。

补充2:不是所有 LLVM 模式都一定有 __sancov_guards,LLVM模式还有很多种,只不过刚好SanitizerCoverage被AFL++借来用了

至此,我们简单的完成了对 AFL++ 插桩机制的底层观察。接下来通过增加触发难度,测试其变异策略的实际表现。

03 难度进阶:反馈机制的重要性

1.st 实验源码

咱把之前的test.c修改一下:

cat > test_step.c << 'EOF'
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
    char buf[100];
    FILE *f = fopen(argv[1], "r");
    if (!f) return 1;
    fread(buf, 1, sizeof(buf), f);
    fclose(f);
	
    // 阶梯式
    if (buf[0] == 'A')
        if (buf[1] == 'F')
            if (buf[2] == 'L')
                if (buf[3] == '+')
                    if (buf[4] == '+')
                        __builtin_trap();
    
    return 0;
}
EOF

再添加一个比对的:

cat > test_wall.c << 'EOF'
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) {
    char buf[100];
    FILE *f = fopen(argv[1], "r");
    if (!f) return 1;
    fread(buf, 1, sizeof(buf), f);
    fclose(f);

    // 平墙式
    if (memcmp(buf, "AFL++", 5) == 0)
        __builtin_trap();
    return 0;
}
EOF

我们来看看AFL++在这两个程序上的表现

afl-cc -o test_step test_step.c
afl-cc -o test_wall test_wall.c

# 接着用无意义的垃圾数据做种子
❯ mkdir -p step_out wall_out
echo "test" > in/seed.txt

afl-fuzz -i in -o out -- ./test_step @@
afl-fuzz -i in -o out -- ./test_wall @@

2nd. fuzzing结果

先看阶梯式的test_step的fuzzing结果,可以发现5s钟就获取crashes

image-20260309182514541

那么我们看看平墙式test_wall的fuzzing结果,等了半个小时,探索进度始终卡在20%/20%,什么都么有发现

image-20260309191347992

3rd. 阶梯式 vs 平墙式

为什么会出现这种情况?

阶梯式(逐步反馈)

if (buf[0] == 'A')
    if (buf[1] == 'F')
        if (buf[2] == 'L')
            if (buf[3] == '+')
                if (buf[4] == '+')
                    __builtin_trap();

特点:

  • 每匹配一个字符,就进入一个 新的基本块(新边)
  • AFL 能感知 部分进展,逐步保留接近目标的变异
  • 有覆盖率反馈

平墙式(一棒子打死)

if (memcmp(buf, "AFL++", 5) == 0)
    __builtin_trap();

特点:

  • 只有 完全匹配 5 字节才触发崩溃
  • 匹配 0~4 字节时,没有任何新边产生
  • AFL 无法区分 “接近目标” 和 “完全无关” 的输入
  • 无覆盖率反馈,纯靠随机碰撞
对比维度阶梯式 if 嵌套平墙式 memcmp
覆盖率反馈✅ 每步都有新边❌ 完全无反馈
变异方向性✅ 有明确引导❌ 纯随机碰撞
发现时间5秒>30分钟(未找到)
执行效率15次找到681万次无果
AFL 状态活跃探索绝望 finished

AFL变异策略的底层逻辑(为什么平墙式会“退化为随机测试”)

  • 阶梯式:每猜对一个字符,程序进入新基本块,AFL++通过“新边覆盖”奖励该输入,并基于此变异(如bitflip、arith等),形成“反馈循环”。
  • 平墙式:memcmp只有“全对/全错”两种结果,AFL++无法区分“接近目标”和“完全无关”的输入。此时,AFL++的变异策略(如havoc、splice)会变成“盲猜”,因为没有任何中间信号引导变异方向。

那这是否意味着本次实验失败了呢?

并非,恰恰证明了 覆盖率引导是 AFL 的灵魂。在当前的极端案例中程序设计为"平墙式"判断时,AFL 退化为纯粹的随机测试,效率足足暴跌了45万倍。

阶梯式通过‘新边覆盖’形成反馈循环,AFL++能基于历史输入变异(如bitflip、arith),逐步逼近目标;而平墙式因缺乏中间信号,AFL++的变异策略(如havoc、splice)退化为‘盲猜’。这解释了为什么真实漏洞中,memcmp等‘全有或全无’的检查是fuzzing的常见障碍——AFL++为此开发了CmpLog、Redqueen等技术,通过记录比较函数的输入/输出,生成更精准的种子,从而绕过这类障碍。

4th. 深入:为什么平墙式让 AFL “失明”?

AFL 的核心假设:变异输入如果触发了新代码路径,就保留下来继续变异。

平墙式 memcmp 打破了这一假设:

输入 “test” → memcmp 失败 → 路径 A → 无新边 → 丢弃

输入 “Axxxx” → memcmp 失败 → 路径 A → 无新边 → 丢弃

输入 “AFxxx” → memcmp 失败 → 路径 A → 无新边 → 丢弃

输入 “AFL++” → memcmp 成功 → 路径 B → 崩溃!

从 AFL 的视角,前三种输入完全无法区分——它们都走同一条路径,都被视为"无价值"。

概率计算:

  • 假设字符集为 62 个([a-zA-Z0-9])
  • 随机生成 "AFL++" 的概率:(1/62)^5 ≈ 1/9 亿
  • 以 3712 execs/sec 的速度,期望时间 ≈ 67 小时

而阶梯式通过中间反馈,将问题分解为 5 个独立子问题,每个只需 (1/62) 概率,整体复杂度从指数级降为线性级。

  • 本次实验使用的是“空种子”(echo "test" > in/seed.txt),若种子包含部分字符(如"A"、"AF"),平墙式的表现是否会改善?
  • AFL++的配置参数(如-t超时时间、-m内存限制)是否会影响结果?比如,延长超时时间是否会增加平墙式的发现概率?

为什么这个实验很重要?

真实软件中大量存在"平墙式"判断:

  • 文件头魔数检查:if (memcmp(header, "PNG", 3) != 0) return ERROR;
  • 协议版本校验:if (version != PROTO_VER) return FAIL;
  • 校验和验证:if (crc32(buf) != expected) return CORRUPT;

没有覆盖率反馈时,AFL 需要暴力枚举 2^32 种可能才能通过 CRC 校验。而阶梯式结构(如分步解析文件头)则能让 AFL 逐步深入。

那么面对平墙式 memcmp,AFL++ 就束手无策了吗?

并非。AFL++ 的 CmpLog 模式(AFL_LLVM_CMPLOG=1)专门解决这类问题:

  • 在编译期 hook 所有比较指令(memcmp、strcmp 等)
  • 运行时捕获比较操作数,智能变异输入以绕过检查

这将是我们后面的内容…(敬请期待~)

04 崩溃分析

直接用刚刚test_step的崩溃文件

cd ~/afl_test

# 确保有崩溃文件
ls step_out/default/crashes/

启动gdb进行基础分析

# 启动 GDB
gdb ./test_step

# 运行到崩溃
(gdb) run step_out/default/crashes/id:000000*

# 查看崩溃信息
(gdb) info registers    # 寄存器状态
(gdb) bt                # 调用栈回溯
(gdb) x/10i $pc         # 崩溃处附近指令

显示结果(部分):

image-20260309201226660

可以看的最新的指令指向ud2触发的崩溃

ud2 是 x86 架构的故意未定义指令,专门用于触发崩溃。

特性说明
全称Undefined Instruction(未定义指令)
操作码0F 0B(2字节)
作用强制产生 #UD(Undefined Opcode)异常
结果SIGILL(Illegal Instruction)信号
用途编译器插入的"安全崩溃点"

GDB 分析显示崩溃点为 ud2 指令,这是 x86 的故意未定义指令,由编译器生成以触发 SIGILL。虽然源代码使用 __builtin_trap()(通常生成 int3),但优化器可能选择 ud2 作为更明确的"安全崩溃"标记。这验证了崩溃是程序设计的可控终止,而非内存破坏导致的非法执行。

以及,如果有师傅还记得第一篇文章中查看和验证崩溃的方式

❯ xxd step_out/default/crashes/id:000000*
00000000: 4146 4c2b 2b10 8060                      AFL++..`
❯ ./test_step step_out/default/crashes/id:000000*
[1]    808657 illegal hardware instruction  ./test_step 

那可能也会有一定的疑惑:

  1. 为什么xxd能查看崩溃时的输入信息?
  2. 为什么这样可以验证崩溃查看信息?

首先,xxd 是一个十六进制转储工具,可以把任意文件以十六进制和 ASCII 形式显示出来。

其次,AFL 保存的崩溃文件就是普通的二进制文件,本质上是触发崩溃的原始输入数据。

实际上:

./test_step crash_file
#     ↑程序        ↑输入数据(被 xxd 查看的就是这个)

相当于把crash_file作为参数输入给了程序,那程序当然会报和当时一样的错

总的来说,xxd 是十六进制转储工具,用于查看 AFL 保存的崩溃文件(原始输入数据)的字节内容,确认触发崩溃的具体输入是否为预期的 "AFL++" 或变异版本。

05 总结

本文从三个层面深入探索了 AFL++ 的核心机制,完成了从底层原理到实际应用的闭环学习:

插桩底层:解码覆盖率追踪的汇编实现
通过 IDA 反编译对比 afl-cc 与 gcc 编译的二进制,观察到 LLVM SanitizerCoverage 的内联插桩机制——__afl_area_ptr 指向共享内存位图,guard 变量标识代码边,配合 inc + cmovb 指令实现 NeverZero 溢出保护。这揭示了 AFL++ 如何在零函数调用开销下,完成边覆盖率的精确追踪。

反馈机制:验证覆盖率引导是 AFL 的灵魂
通过"阶梯式"与"平墙式"代码的对比实验,量化了覆盖率反馈对变异效率的决定性影响。阶梯式因每步产生新边、形成反馈循环,5 秒即发现崩溃;平墙式因 memcmp 全有或全无的特性,退化为随机盲猜,30 分钟 681 万次执行无果——效率差距达 45 万倍。这一结果解释了真实漏洞中魔数检查、CRC 校验等"平墙式"逻辑为何是 fuzzing 噩梦。

崩溃分析:建立从发现到理解的闭环
借助 GDB 定位崩溃点为 ud2 指令(SIGILL),区分了编译器生成的安全崩溃与内存错误导致的非法执行;通过 xxd 验证崩溃文件本质是原始输入,完成了"触发崩溃→根因定位→输入重现"的完整分析流程。

下一篇将探索 AFL++ 的 ASAN/MSAN/UBSAN 集成,理解内存错误检测与覆盖率引导的协同工作机制,敬请期待。