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

00 前言回顾

在笔者的上一篇,也是 AFL++ Fuzzing 系列的第二篇中,我们从“汇编指令”深入到“策略机制”,完成了对 AFL++ 核心能力的解构:

插桩底层:通过 IDA 反编译,直观观察了 LLVM SanitizerCoverage 的内联插桩实现,解析了 __afl_area_ptr 与 guard 变量如何协作,以及 NeverZero 机制如何防止计数溢出,从指令级掌握了覆盖率追踪的底层逻辑;

反馈机制:通过“阶梯式”与“平墙式”代码的对比实验,量化了覆盖率反馈对 fuzzing 效率的决定性影响,验证了“覆盖率引导是 AFL 灵魂”这一核心思想,揭示了为何 memcmp 类检查是 fuzzing 的噩梦;

崩溃分析:借助 GDB 与 xxd 工具,我们完成了“触发崩溃→定位根因→验证输入”的完整闭环,学会了如何区分 ud2 指令触发的逻辑崩溃与内存破坏导致的异常。

虽然我们已经掌握了发现和分析崩溃的方法,但在真实漏洞挖掘中,存在一个更棘手的问题:并非所有的内存错误都会立即崩溃。大量的堆溢出、Use-After-Free 往往会“沉默”地破坏内存,直到程序运行很久后才随机崩溃,这让 AFL++ 难以捕获到有效的反馈信号。

为了解决“漏洞隐形”的问题,本文将引入 Sanitizer(消毒器) 机制。我们将重点探索 ASAN(地址消毒器)如何通过影子内存将“隐形漏洞”转化为“显性崩溃”,并结合实战演示其在 AFL++ 中的集成方法与性能权衡,进一步提升漏洞挖掘的深度与广度。

01 ASAN 深度解析(核心重点)

1st. 为什么需要ASAN

要回答这个问题,我们首先得知道 ASAN 是什么:

ASAN 全名 AddressSanitizer(整合了 LeakSanitizer (LSAN)),是一个由 Google 开发的内存错误检测工具,能够精准捕捉堆溢出、栈溢出、Use-After-Free(UAF)等常见的内存安全问题。

那么,为什么我们的 Fuzzing 特别需要它?

1. 很多内存错误是“沉默”的 在上一篇文章中,我们遇到的崩溃都是“立即触发”的(如 __builtin_trap 或空指针解引用)。但在真实世界中,很多严重的漏洞并不会立即让程序崩溃:

  • 堆溢出:写越界了,但只是覆盖了相邻堆块的元数据,程序可能还能跑很久。
  • UAF(Use-After-Free):访问了已释放的内存,恰好该内存还没被覆盖,读出了“正常”的数据。

在这种情况下,程序没有发出 SIGSEGV 信号,AFL++ 就会认为这个输入是“无效”的,从而错过了漏洞。

2. ASAN 让“潜伏”的漏洞现形 ASAN 的核心作用就是将“隐形”的内存错误转化为“显性”的崩溃。

  • 没有 ASAN:程序静默执行,AFL 一无所知。
  • 有 ASAN:非法内存访问瞬间触发 SIGABRT,AFL 捕获到崩溃,漏洞被发现。

3. 相比其他工具的优越性 传统的内存检测工具(如 Valgrind)动辄带来 10x-20x 的性能损耗,这在 Fuzzing 这种强调“高频执行”的场景下是不可接受的。而 ASAN 仅有 2x 左右的性能损耗,使其成为 Fuzzing 领域事实上的标准配置。

用例 1:堆缓冲区溢出(ASAN 核心演示)

光说不练假把式,咱直接上代码测试案例

// heap_overflow.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {
    char *buf = malloc(10);
    FILE *f = fopen(argv[1], "r");
    if (!f) return 1;
    
    // 危险:读取 100 字节到 10 字节缓冲区
    fread(buf, 1, 100, f);
    
    // 如果溢出不严重,可能不崩溃
    if (buf[0] == 'A')
        printf("Got A\n");
    
    free(buf);
    fclose(f);
    return 0;
}

对比测试:

python3 -c "print('A'*100)" > overflow_input

# 无 ASAN,足够长
gcc -o heap_normal heap_overflow.c
./heap_normal overflow_input

image-20260310175346481

# 有 ASAN,足够长
afl-cc -o heap_asan heap_overflow.c -fsanitize=address
# 也可以在 afl-cc 编译前使用环境变量声明使用ASAN
AFL_USE_ASAN=1

./heap_asan overflow_input

image-20260310175408434

python3 -c "print('A'*10)" > normal_input

# 无 ASAN,刚刚好
./heap_normal normal_input

image-20260310175806313

# 有 ASAN,刚刚好
./heap_asan normal_input

image-20260310175840663

对比结果:

测试场景编译方式程序输出/行为关键信息结论
长输入 (100字节)无ASAN (gcc)Got Adouble free or corruption (out)程序崩溃堆管理器检测到数据结构被破坏,但无法定位具体错误位置未有效检测:错误信息模糊,无法定位溢出点
长输入 (100字节)有ASAN (afl-cc -fsanitize=address)AddressSanitizer: heap-buffer-overflow详细错误报告- 错误类型:堆缓冲区溢出- 操作:100字节写入- 定位:fread函数- 越界距离:0字节超出10字节区域有效检测:精准定位溢出点,立即拦截
短输入 (10字节)无ASAN (gcc)Got A程序正常退出无错误,程序逻辑正常执行未检测:未触发溢出,程序正常运行
短输入 (10字节)有ASAN (afl-cc -fsanitize=address)AddressSanitizer: heap-buffer-overflow详细错误报告- 错误类型:堆缓冲区溢出- 操作:11字节写入- 定位:fread函数- 越界距离:0字节超出10字节区域有效检测:即使轻微溢出也能立即拦截

那么是什么赋予了 ASAN 这样一种功能呢,接着往下看

2nd. ASAN 核心机制简述

从上面的堆溢出演示可以看到: 同样的写,在没有 ASAN 时“安安静静”,加上 ASAN 后立马被揪出来。 那么,ASAN 到底做了什么?

一句话概括:

ASAN 在程序每次访问内存前,先“查表”判断这个地址是不是被“毒化(poisoned)”,如果是就立刻报错。

这里的“表”,就是 影子内存(shadow memory); “毒化”,就是给这块内存打上“不可访问”的标记。

下面我们用一个简化版,把三件事讲清楚:影子内存、红区、毒化。

1. 影子内存:给每 8 字节“配一个影子”

ASAN 把进程的虚拟地址空间分成两部分:

  • 主应用内存(Mem):你的堆、栈、全局变量、代码等都在这里。
  • 影子内存(Shadow):专门用来记录“对应的主内存是否可访问”。

映射规则很简单(64 位为例):

Shadow = (Mem >> 3) + 0x7fff8000;

翻译一下:

  • 应用内存地址 Mem 右移 3 位,相当于除以 8;
  • 再加一个固定偏移,就得到对应的影子内存地址。

也就是说:每 8 字节的应用内存,对应 1 字节的影子内存。

影子内存里存的值,就是这 8 字节的状态:

  • 0:这 8 字节全部可访问(正常)。
  • 负数:这 8 字节全部“毒化”(不可访问,比如红区、已释放堆块)。
  • k (1~7):这 8 字节中,前 k 字节可访问,后面 8-k 字节不可访问(用于 malloc(13) 这种非 8 倍数的情况)。

2. 红区:在“合法内存”周围埋地雷

有了影子内存,ASAN 做的第二件事是: 在堆、栈、全局变量周围,插入额外的“陷阱”内存,叫做红区(redzone)。

  • 堆:malloc 返回给你一块内存,ASAN 在这块内存的前后各加一段红区;
  • 栈:局部数组周围,编译器会自动插入红区;
  • 全局变量:同样可以在周围布置红区。

RedZone

如图所示,ASAN 会在每个变量(var1-var4)的周围插入红色的"RedZone"区域。这些红区在物理内存上真实存在,但在影子内存中被标记为"毒化"(不可访问)。

这些红区有一个共同特点:

在影子内存里,它们被标记为“毒化”(不可访问)。

所以,一旦你的代码有:

  • 堆溢出,写到了后面的红区;
  • 栈溢出,写到了前后的红区;
  • 访问已释放堆块(UAF),该堆块已经被 free + 毒化;

都会命中影子内存里的“毒化”标记,触发 ASAN 报错。

3. 毒化:释放就“下毒”,访问前“验毒”

ASAN 对“释放内存”做了两件事:

  1. 把 free 掉的堆块放进 隔离区(quarantine),暂时不再分配给别人;
  2. 在影子内存中,把这整块堆区标记为“毒化”。

这样,一旦有 Use-After-Free,再去访问这块内存时:

  • 编译器插桩的检查会先查影子内存;
  • 发现影子值是“负数”,立即触发ReportError,打印类似:
    • AddressSanitizer: heap-use-after-free
    • 访问地址、是读还是写、调用栈等。

4. 编译器插桩:每次访多加一次“安检”

在编译阶段,ASAN 会对程序中的内存访问指令进行插桩,大致逻辑如下:

访问前:

*addr = value;  // 写内存
// 或
x = *addr;      // 读内存

插桩后:

shadow_addr = MemToShadow(addr);
if (ShadowIsPoisoned(shadow_addr)) {
    ReportError(addr, size, is_write);
}
*addr = value;  // 原操作

也就是说:

  • 每次内存访问前,先算出影子内存地址;
  • 如果影子值显示“已毒化”,就报错;
  • 否则继续正常访问。

关键点: 这个“安检”非常快,只是多一次内存访问和条件判断,因此 ASAN 的整体开销大概只有 2x 左右——这是它能成为 Fuzzing 标准配置的重要原因。

5. 回到我们的堆溢出示例

回到前面的 heap_overflow.c:

  • malloc(10) 在堆上分配 10 字节;
  • ASAN 在这 10 字节前后各插入一段红区,并在影子内存里把红区标记为“毒化”;
  • fread(buf, 1, 100, f) 写了 100 字节,远远超出 10 字节;
  • 写到红区时,命中影子内存的“毒化”标记,ASAN 报告:
  AddressSanitizer: heap-buffer-overflow

而 没有 ASAN 时,越界写可能只是写到别的堆块或未使用区域,程序不崩溃,AFL 也就看不到任何异常。

tips1:ASAN+LSAN

有一个比较有趣的点是,如果我们直接将 ‘AAA’ 作为参数输入,而不是用文件输入的话:

image-20260310222505569

我们可以看到,gcc编译的版本是会因为没有读取到作为参数的文件而直接 return 1 的,

而afl-cc编译的版本则是报了一个**LeakSanitizer**的错。

这又是因为什么呢?

其实是现在的 ASAN 整合了 LSAN(即 LeakSanitizer),还记的用例的代码吗:

int main(int argc, char *argv[]) {
    char *buf = malloc(10);
    FILE *f = fopen(argv[1], "r");
    if (!f) return 1;
    
	// ...
    
    free(buf);
    fclose(f);
    return 0;
}

当程序因为没有读到文件参数而 return 1 时,最开始 malloc 的 buf 也执行不到 free 那一步;

所以 LSAN 告诉我们,检测到内存泄漏( detected memory leaks),

并且报错的最后一行进行了摘要说明:地址消毒器:在1次分配中泄漏了10字节。

总结一下:

  • ASAN 的泄露检测是“程序退出时”的检查,而非“运行时”;
  • 提前退出(如文件不存在)会导致泄露报错,这是 ASAN 的正常行为;
  • 真正的溢出错误(如输入 100 字节)会立即崩溃,LSAN 不会参与(因为程序未正常退出)。

这个细节正好体现了 ASAN 的设计哲学:既能在运行时拦截严重错误,也能在退出时捕获静默问题,全面覆盖内存安全问题。

02 MSAN简析:检测未初始化内存的“幽灵数据”

在 AFL++ 中,除了 ASAN,还有三个常用的 Sanitizer 工具:MSAN(MemorySanitizer)、TSAN(ThreadSanitizer) 和 UBSAN(UndefinedBehaviorSanitizer)。它们分别针对不同的内存问题,与 ASAN 形成互补,进一步提升 Fuzzing 的漏洞挖掘能力。

作用:MSAN 用于检测程序中使用了未初始化的内存(即“幽灵数据”)。未初始化的内存可能包含随机值,导致程序行为不可预测(如逻辑错误、信息泄露)。 核心机制:MSAN 会在内存分配时将内存初始化为特定值(如 0xAA),并在访问时检查是否被初始化。如果访问未初始化的内存,MSAN 会立即报错。

测试用例:

// msan_test.c
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *arr = malloc(10 * sizeof(int)); // 分配10个int,未初始化
    printf("arr[0] = %d\n", arr[0]);     // 访问未初始化的内存
    free(arr);
    return 0;
}

测试结果:

afl-cc -o msan_test msan_test.c -fsanitize=memory
./msan_test

image-20260310225935531

在 Fuzzing 中,未初始化的内存可能导致程序逻辑错误(如误判条件),MSAN 能提前捕获这类问题。

03 TSAN:捕获线程竞争的“隐形杀手”

作用:TSAN 用于检测多线程程序中的数据竞争(Data Race)。数据竞争是指多个线程同时访问同一内存,且至少有一个线程在写,但没有同步机制(如互斥锁)。 核心机制:TSAN 会在运行时监控线程的内存访问,检测是否存在未同步的竞争,并报告具体的线程和访问位置。

测试用例:

// tsan_test.c
#include <stdio.h>
#include <pthread.h>

int counter = 0;

void *thread_func(void *arg) {
    for (int i = 0; i < 1000; i++) {
        counter++; // 线程1和线程2同时递增counter,无锁
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread_func, NULL);
    pthread_create(&t2, NULL, thread_func, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Counter: %d\n", counter); // 预期2000,但可能不是
    return 0;
}

测试结果:

afl-cc -o tsan_test tsan_test.c -fsanitize=thread -lpthread
./tsan_test

image-20260310230252329

多线程程序中的数据竞争可能导致程序崩溃或逻辑错误,TSAN 能帮助发现这类并发漏洞。

04 UBSAN:拦截未定义行为的“安全网”

作用:UBSAN 用于检测程序中的未定义行为(Undefined Behavior, UB),如整数溢出、除以零、数组越界(全局)、无效的指针转换等。这些行为在 C/C++ 标准中是未定义的,可能导致程序崩溃或安全漏洞。

核心机制:UBSAN 会在编译时插入检查代码,在运行时检测这些未定义行为,并立即报告。 测试用例:

// ubsan_test.c
#include <stdio.h>
#include <limits.h>

int main() {
    int a = INT_MAX; // INT_MAX是2147483647
    int b = a + 1;   // 整数溢出(未定义行为)
    printf("b = %d\n", b);
    return 0;
}

测试结果:

afl-cc -o ubsan_test ubsan_test.c -fsanitize=undefined

# 非常奇怪的,我使用afl-cc编译会告诉我没有可插桩对象,可能是代码太简单了,直接用clang了
clang -o ubsan_test ubsan_test.c -fsanitize=undefined

./ubsan_test

image-20260310231511515

注意1:在编译UBSAN测试用例时,afl-cc 可能因代码过于简单(如仅包含整数溢出)而未检测到足够的插桩点,导致提示 No instrumentation targets found。这是因为UBSAN的插桩需要更明确的未定义行为(如除以零、数组越界等)。此时可使用 clang 直接编译验证,或扩展代码以触发更多UBSAN检查。

注意2:UBSAN 默认检测到未定义行为时会插入 ud2 指令(非法硬件指令)立即终止程序, 导致看不到详细的错误信息。如需查看完整报告,请添加 -fno-sanitize-trap 参数:

未定义行为是许多漏洞的根源(如整数溢出导致缓冲区溢出),UBSAN 能提前拦截这类问题,防止漏洞被利用。

tips2:环境变量快速启动Sanitizer

AFL++集成技巧: 除了ASAN,其他Sanitizer也可通过环境变量快速启用:

  • MSAN:AFL_USE_MSAN=1
  • TSAN:AFL_USE_TSAN=1
  • UBSAN:AFL_USE_UBSAN=1

05 性能权衡与实战建议

Sanitizer性能开销适用场景注意事项
ASAN~2x堆/栈溢出、UAF必须设置 -m none
MSAN~3x未初始化内存需要全程序编译,不能链接外部库
TSAN~5-15x线程竞争仅用于多线程程序
UBSAN~1.1x未定义行为建议始终开启,开销最小

实战建议:

  1. 初始阶段:先用普通模式快速积累覆盖率
  2. 深度挖掘:切换 ASAN 模式,捕获隐藏漏洞
  3. CI/CD:始终开启 UBSAN,防止回归

总结

  • MSAN:检测未初始化内存,防止“幽灵数据”导致逻辑错误;
  • TSAN:检测线程竞争,解决并发程序的安全问题;
  • UBSAN:拦截未定义行为,防止整数溢出、除以零等漏洞。

这些 Sanitizer 与 ASAN 形成互补,覆盖了内存安全的多个维度。在 AFL++ 中,可以通过 afl-cc 一键集成这些工具,提升 Fuzzing 的漏洞挖掘深度和广度。

文章核心:

ASAN不是让fuzzing“更快”,而是让原本不可见的漏洞变得可见。在真实漏洞挖掘中,大量堆溢出、UAF不会立即导致崩溃,而是静默破坏内存。ASAN通过影子内存和红区机制,将这些“潜伏漏洞”转化为AFL可感知的确定性崩溃,大幅提升漏洞发现率。结合AFL++的覆盖率反馈,ASAN能帮助fuzzer更高效地探索程序状态,实现“覆盖率+内存安全”的双重优化。