☰
AFL++在Kali Linux上的模糊测试实战:从安装到参数调优与崩溃分析
2026/10/20 17:28:50 网站建设 项目流程

1. 项目概述与核心价值拆解

1.1 用一句话说清这个工具的作用

今天这篇要聊的是我把开源模糊测试工具 AFL++ 实际部署在 Kali Linux 上的一整套操作记录。它不是一个复杂到只能躺在论文里的框架,而是一个拿到生产环境里也能独立翻出问题的工具链。很多人一听到模糊测试,第一反应是“那是漏洞挖掘专家才需要碰的东西”。其实做开发、做测试、做系统适配的人,迟早都会撞上一个随机出现的崩溃:同样的输入换台机器就复现不了,或者数据稍微变长一点就内存越界。AFL++ 这类工具,解决的就是这种“不可预测行为”的问题。

AFL++ 的工作方式可以通俗理解为:它把你的程序当成一个黑盒,不断生成随机但持续进化的输入,喂给目标程序执行。一旦某个输入触发了崩溃、挂死或者超时,它就把这个输入单独保留下来。这里的“进化”由覆盖率引导,它不关心你的业务逻辑长什么样,只关心这次输入有没有让程序走到新的代码分支。相比之下,绝大多数手工构造的测试用例只能覆盖正常路径,根本无法覆盖那些藏在边界条件背后的逻辑死角。

1.2 适合谁看,以及它有什么不可替代的地方

如果你是安全测试人员、服务端开发、测试开发,哪怕只是偶尔做做 CTF,这篇都值得看完。教程默认你已经装好了 Kali Linux,但不要求你提前会用 AFL++,我会从安装开始一路讲到底层参数改造、结果分析以及我的排错经验。

市面上模糊测试工具并不少,比如走协议生成的、走符号执行的。AFL++ 的优势在于它不需要你写复杂的约束求解,也不需要你理解目标系统的内部结构。只要程序能从文件或 stdin 读取输入,你就能在十几分钟内把它跑起来。除了命令行参数覆盖面广以外,它还提供了编译期插桩、QEMU 模式、并行同步、字典变异这些“别人有我也更完善”的能力。所以不管你是刚开始接触 fuzz,还是已经跑过几轮但总被参数劝退,这一篇应该都能让你少踩点坑。

2. 环境准备:在 Kali Linux 里把 AFL++ 装到顺手

2.1 直接安装还是从头编译:两套路线各有取舍

在 Kali Linux 上有一个比较省心的安装方式,就是直接用 apt 装:

sudo apt update sudo apt install afl++

装完之后输入afl-fuzz -h能调出帮助,接着就能用。这个方案适合急着开始、不想折腾编译依赖的人。代价是软件仓库里的版本可能不是最新的。模糊测试工具属于那种越新越能应对新变异策略的工具,所以我个人更推荐在正式项目里使用源码编译,尤其是当你需要开启 LLVM 插桩、QEMU 模式、Frida 模式这些进阶特性时,源码装起来更干净。

我推荐的源码安装流程如下:

sudo apt install -y git build-essential clang llvm git clone <AFLplusplus 官方仓库地址> cd AFLplusplus make distrib sudo make install

make distrib和普通的make不太一样,它会尽量把所有辅助模块都编译出来,比如插桩编译器、QEMU 支持、字典工具、并行分析脚本。整个过程大概需要几分钟,具体取决于机器性能。如果中途报缺少某个头文件,多半是 clang 或 llvm 相关依赖没装齐,用 apt 补齐即可。这里想提醒一句:不要觉得“反正我用不上 QEMU 就可以不装”,因为后续跑无源码目标时你会后悔。

2.2 三个初始设置,别等报错再回来补

很多新手在 Kali 上第一次运行 AFL++ 时会卡在一些和工具本身无关的系统设置上,我建议提前处理。

第一,核心转储通知。现代 Linux 发行版默认把 core dump 交给一个系统服务处理,AFL++ 发现后可能直接拒绝启动,报类似“core dump notifications to an external utility”的错误。解决办法是把它临时改回普通模式:

sudo sysctl -w kernel.core_pattern=core

第二,CPU 频率调节。如果 CPU 处于 powersave 之类的节能模式,AFL++ 会认为频率波动影响执行速度统计,并提示你调成 performance。可以这样设置:

sudo apt install linux-cpupower sudo cpupower frequency-set -g performance

如果你在虚拟机里跑这套环境,内核可能根本不提供可调的性能模式,那么可以设置环境变量跳过检查。这个我们后面聊环境变量时再展开。

第三,确认当前用户对/proc/sys/kernel/core_pattern有写权限,或者干脆用 sudo 执行 AFL++。虽然不强制,但后续处理崩溃文件时少点权限问题,整体体验会顺利很多。

2.3 安装完成后如何验证可用性

装好之后不要把帮助页翻一遍就完事,我建议先做一次快速自检。运行:

afl-fuzz -h

你能看到主程序版本号和核心启动参数。再看一下插桩编译器的名字是否齐全,这里建议至少确认有afl-clang-fast、afl-clang-fast++或者afl-gcc-fast。如果只看到afl-gcc而没看到带 fast 的版本,说明你的 clang/llvm 环境可能不完整,后续插桩效率会受影响。

还有一种更直观的验证方式,用afl-showmap去跑一个简单的命令,看它能不能识别插桩。我习惯写一个很小的 C 程序,用afl-clang-fast编译,再手动运行一次。这个过程同时验证了“编译器 wrapper 是否生效”和“运行时能否正确加载插桩”。不用追求花哨,五秒钟就能判断整个工具链是否健康。

3. 参数全解:真正影响测试结果的开关都在这里

3.1 一条基础命令:-i、-o、-t、-m 与 @@

AFL++ 最常用的启动参数组合是这一套:

afl-fuzz -i seeds -o out -t 1000 -m none -- ./target_program @@

我来逐个拆解它们的实际含义,以及选错会有什么后果。

-i指定输入种子目录,也就是你准备放初始样本的地方。种子文件的质量比数量重要。一个能走入正确分支的最小合法输入,远胜过几百份格式混乱的数据。

-o指定输出目录。AFL++ 会把所有测试队列、崩溃文件、挂起样本、统计信息写到这个目录。注意,这个目录不能和输入目录重叠,这是它默认的安全保护机制。

-t表示单次执行的时间上限,单位是毫秒。很多熟人默认给-t 1000,也就是一秒。但如果目标程序里面有比较耗时的初始化逻辑,或者跑在插桩环境里本身就更慢,则要调大一些,比如2000+。这里的加号表示“超时不算致命错误,继续跑”。不加加号时,遇到慢目标可能直接判为 hang,影响统计准确性。

-m表示内存限制,单位是 MB。如果你用了 AddressSanitizer 这类检测工具,目标程序的虚拟内存占用会非常高,这个时候要写成-m none,也就是不限制内存访问。不带这个参数直接跑 ASAN 程序,十有八九会得到一堆“内存超限”的报错。

@@是一个很有象征意义的符号:它告诉 AFL++“把生成的输入写到临时文件,并把文件路径替换到 @@ 位置”。如果目标程序直接从标准输入读取,而不是读取文件,那么就不需要写@@,AFL++ 会用管道把输入喂进去。很多人在这一步犹豫不决,其实记住一个原则即可:程序打开argv[1]就用@@,程序直接read(stdin)就不写。

3.2 变异深度与字典参数:-d、-p、-x、-E

模糊测试的核心过程是“变异”,也就是对种子值进行位翻转、增减字节、替换关键字符串等操作。AFL++ 默认会先做确定性阶段,再做 havoc 阶段。这带来了一个问题:对许多大体积种子来说,确定性阶段极其耗时。

-d参数用来跳过确定性阶段。如果你确认自己的种子多大且目标很大,直接加-d进入快速变异阶段,可以让你开头几分钟就看到更多路径。但它不是万能加速器,如果目标很小、种子很短,确定性阶段反而能把许多边界条件找得更全。

-p参数负责选择 power schedule,决定不同那条 yin 和量级权重。默认值是fast,适合大多数场景。你可以切成explore获得更平均的路径探测,或者exploit偏向在当前追寻更深的分支。我实际跑下来比较喜欢-p rare,它会给“罕边”更高权重,对复杂协议更容易发现冷门分支,但具体效果得随机种本身的分布来定。

-x指定单词字典文件。AFL++ 不仅随机改字节,还会利用字典提供的高价值 token,比如 HTML 标签、JSON 关键字、文件头魔数等。对解析型目标来说,没有字典和用字典,覆盖率差距非常明显。字典文件每行一个 token,像这样:

HOST= Content-Type: <REQ>

-E则可以指定一个包含多个字典文件的目录,一次性让 fuzzer 从这个目录中加载多个字典。这个在测试多协议目标时比较方便,不用每次启动前手动合并文件。除非你的场景对内存很敏感,否则我建议直接用-E。

3.3 多核并行:-M、-S 与共享输出目录

单个核跑 fuzz 通常不够用。AFL++ 内置主从节点模式,可以让你在三五分钟里把多核 CPU 全部用满。先启动主节点:

afl-fuzz -M main -i seeds -o sync -- ./target @@

再启动从节点,注意主从节点必须使用同一个输出目录sync:

afl-fuzz -S slave1 -i seeds -o sync -p rare -- ./target @@ afl-fuzz -S slave2 -i seeds -o sync -p explore -- ./target @@

这里有个细节值得说明:-M和-S不能同时用,一个节点只能承担一种身份。主节点负责组织队列,从节点可以配置不同的调度策略、字典甚至超时参数。所有节点通过sync目录相互同步各自发现的测试脚本,这样某一台 node 如果找到新的路径,其他节点马上能看到。

信息同步还能写出一个很有意思的用法:把同一个目标的插桩版本和非插桩版本混在多个节点里跑。只要种子目录和输出目录规划好,整体效率会非常可期。但如果是自己造实验,不建议一开始就上太多从节点,我建议先单核跑十分钟,确认没报错,再扩到全核心。

3.4 定时退出与自动化控制:-V 和无 UI 模式

长期跑 fuzz 时,最常见的问题是“我不知道到底要跑多久”,以及“没有显示器时的界面输出飘了”。-V参数可以强制限制运行时间,单位是秒。例如:

afl-fuzz -V 1800 -i seeds -o out -- ./target @@

这意味着 fuzzer 会在 30 分钟后自动退出,非常适合 CI/CD 场景,也适合做对照组实验时保证每组测试时长一致。

如果你想彻底去掉终端 UI,可以用环境变量AFL_NO_UI=1。这个在某些发布的自动化环境里很实用,不会在终端里生成动态刷新屏幕的字符。加上AFL_EXIT_WHEN_DONE=1之后,fuzzer 跑完一个完整循环且没有新发现时会主动退出,进一步减少人工干预成本。

3.5 环境变量:隐藏的“参数库”

AFL++ 有很多控制行为的环境变量,它们不在命令行帮助里集中显示,却会在关键时刻决定成败。我把最常用的整理成表格:

环境变量作用典型使用场景
AFL_SKIP_CPUFREQ=1跳过 CPU 频率检查虚拟机、容器或不支持 performance 模式的机器
AFL_NO_UI=1关闭动态 UISSH 会话、无头服务器、自动化批处理
AFL_EXIT_WHEN_DONE=1跑完所有队列并稳定后自动退出想在无人看管时完成一轮完整 fuzz
AFL_USE_ASAN=1在编译插桩时启用 AddressSanitizer检测越界、释放后使用等内存错误
AFL_USE_UBSAN=1在编译插桩时启用 UndefinedBehaviorSanitizer检查位移溢出、非法整数运算等
AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1即便目标未插桩也继续工作应急排查时强制运行,但不推荐正式使用
AFL_SKIP_BIN_CHECK=1跳过二进制插桩检查某些特殊二进制无法自动判断插桩信息时
AFL_TMPDIR指定临时文件目录把临时文件放到内存盘,提升写入速度

举个例子,如果源码编译时想直接让 fuzzer 自带 ASAN,我只需要在前面追加环境变量:

AFL_USE_ASAN=1 afl-clang-fast -o demo demo.c

之后再启动afl-fuzz时,需要加-m none,因为 ASAN 内存占用远高于默认限制。这看起来像一个细节,但几乎所有人第一次把 ASAN 和 AFL++ 放在一起都会卡在这个环节。

4. 实操:跑通一次插桩模糊测试并分析结果

4.1 用插桩编译一个演示目标

为了不让示例变成一个空洞的命令列表,我先准备一个很小的“命令行文本解析器”演示目标。源码不要太复杂,但必须能被 fuzzer 快速找到崩溃点。

#include <stdio.h> #include <stdlib.h> #include <string.h> int main(int argc, char **argv) { if (argc < 2) { return 1; } FILE *fp = fopen(argv[1], "rb"); if (!fp) { return 1; } unsigned char buf[64]; size_t n = fread(buf, 1, sizeof(buf), fp); fclose(fp); if (n < 5) { return 0; } if (memcmp(buf, "MAGIC", 5) == 0) { if (n > 5 && buf[5] == 'E') { abort(); } } return 0; }

这段代码里有一个明显的崩溃触发点:当输入前五个字节是MAGIC,且第六个字节是E时程序会直接abort()。这模拟了常见产品里“魔数判断后内部状态不一致”的错误。

编译的方式很关键,我们要用带插桩的编译器,而不是系统默认的 gcc:

afl-clang-fast -o demo demo.c

如果没有afl-clang-fast,说明安装时 LLVM 部分没到位,用afl-gcc也能跑,只是插桩效率略有差别。编译成功后,我建议手动跑一次正常输入:

echo -n "ABCD" > test.txt ./demo test.txt echo $?

看到返回 0 说明程序表现正常,没有在任何容器里崩溃。

4.2 准备种子并第一次运行

种子目录里放什么、不放什么,直接会影响 fuzzer 的起始路线。我们这里放两个很小的文件:

mkdir -p seeds echo -n "MAGIC" > seeds/seed1 echo -n "AB" > seeds/seed2

注意不要放一个进去就直接崩溃的种子,否则 fuzzer 可能会在启动阶段就检测到崩溃,并影响后续判断。种子越接近“合法但简单”越好。

然后启动:

afl-fuzz -i seeds -o out -m none -t 1000 -- ./demo @@

启动后终端会出现一个动态统计界面。前半部分显示执行速度、总的执行次数,这是判断 fuzzer “活不活跃”的关键指标。后半部分会显示已发现路径数量、崩溃数量、覆盖边数。刚开始听到一两秒出现一个红字崩溃,我们不需要立刻停止,因为崩溃里可能夹杂着大量同一个根因触发的重复样本。

跑完一轮之后按Ctrl-C退出,输出目录里会自动生成out/crashes、out/queue、out/hangs这些子目录。看到crashes目录里的文件名带sig:前缀,比如sig:06,说明它确实对应了进程异常终止。

4.3 复现崩溃并做最小化

找到崩溃文件之后,第一件事不是直接打开看内容,而是先复现。复现命令很简单:

./demo out/crashes/id:000000,sig:06,src:000001,time:3,execs:35,op:havoc,rep:4 CAREFUL

如果这个命令卡死无输出,说明崩溃存在。接下来我们需要减小崩溃样本的体积,因为 fuzzer 找到的崩溃几乎都带一大堆无关字节,直接交给研发去修,对方会很痛苦。

AFL++ 自带一个afl-tmin工具,用来单独最小化某一个崩溃样本:

afl-tmin -i out/crashes/id:000000,sig:06 -o minimized_crash -- ./demo @@

afl-tmin会不断尝试删除或修改输入中的字节,直到不能再进一步缩减,同时还要保持程序仍能崩溃。对上面这个演示,最终你很容易得到一个只有六个字节的崩溃输入,核心触发条件一目了然。

如果需要清理一批种子,比如从旧项目中挑出能够增加覆盖率的输入子集,那就用afl-cmin:

afl-cmin -i seeds -o clean_seeds -- ./demo @@

它会基于覆盖率做集合覆盖,把互相重复的输入尽量去掉。这样清洗后的种子,喂给 fuzzer 的启动效率会高很多。

4.4 把前面参数组合成一条真实运行命令

学参数不能只学孤立的选项,组合起来才有实战意义。我给一个比较贴近真实项目用法的主节点启动命令:

AFL_SKIP_CPUFREQ=1 AFL_NO_UI=1 \ afl-fuzz -M main -i clean_seeds -o sync_out \ -m none -t 2000+ -p explore \ -E dicts -- ./demo @@

再开两个从节点:

AFL_SKIP_CPUFREQ=1 AFL_NO_UI=1 \ afl-fuzz -S second1 -i clean_seeds -o sync_out \ -m none -t 2000+ -p rare -x json.dict -- ./demo @@

这里我用到-E dicts加载一个目录里的多个字典,其中一个节点再用-x json.dict单独强化 JSON 关键字。这样做的意义在于,同一个目标从不同角度逼近,有的节点偏重路径探测,有的节点偏重特定 token 变异,最后通过同步目录把所有发现汇总到主节点。等跑完,用afl-whatsup -s sync_out就能看到每个节点各自发现的崩溃数、路径数和执行速度。

5. 真实使用中的常见问题与排查技巧

5.1 高频报错速查表

我整理了一份在这类环境里出现频率最高的报错,配合具体的解决动作:

报错关键词根因解决方式
core dump notifications系统把 core dump 交给外部服务sudo sysctl -w kernel.core_pattern=core
not instrumented目标没有用插桩编译器构建改用afl-clang-fast重新编译,或启用-Q的 QEMU 模式
CPU governorCPU 频率不是 performancesudo cpupower frequency-set -g performance,或设AFL_SKIP_CPUFREQ=1
No instrumentation编译工具链和运行环境不匹配检查是否误用系统 gcc 编译,导致运行栈地址不对
crash didn't affect ...崩溃状态不稳定,可能与 ASAN 配置相关确认是否漏加-m none或目标是否处于可复现状态

从我的经验看,绝大多数“跑不起来”的问题,最终都会归结到项目本身“不够插桩”这一点上。遇到这类问题,先不要怀疑工具坏了,先确认目标是怎么编译出来的总没错。

5.2 覆盖率不涨了,可能不是目标没价值

很多人跑了一个小时之后发现覆盖率的曲线已经平了,就以为这个目标没有挖掘空间。我一直觉得,覆盖率停滞往往意味着你的参数结构有问题,而不是目标本身没问题。

第一,检查初始种子是否过于单一。如果所有种子走向的路径都很接近,fuzzer 相当于在一棵树上绕圈。用afl-cmin清一遍种子通常能改善。

第二,检查是否缺少字典。解析型程序对特定关键字非常敏感,比如文件头、分隔符、魔数。没有字典时,fuzzer 需要从随机字节里碰巧拼出MAGIC这类字符,概率极低。给字典之后,变异才能“精准发力”。

第三,看看超时和内存限制是不是太苛刻。如果目标偶尔有较长的 CPU 初始化,比如读取全局配置或者连接某个外部组件,执行时间波动大会影响稳定性,-t 2000+通常能缓解。

第四,我需要提醒一个不太显眼的点:如果目标程序本身就是多线程的,AFL++ 的 fork 模式可能会受到影响,执行速度会异常慢。这种情况下往往要考虑使用AFL_NO_FORKSRV=1或者换一种运行模式,不过这会涉及更细致的机制,普通项目里尽量先确认目标是不是单线程。

5.3 实战中帮我省时间的一些小习惯

我不是那种会把命令行打开就扔着不管的人。以下几条来自真实使用中的经验,能帮你少走弯路。

第一,长跑之前先做十分钟冒烟测试。只用一个主节点,不要堆从节点,也不要立刻加 QEMU 模拟。先在目标上跑十分钟,看它能不能稳定产生新的路径。如果十分钟之内执行速度忽上忽下,或者偶尔报超时,就先把参数调对再扩规模。冒烟测试里暴露的问题,长跑之后只会加倍。

第二,将崩溃文件的哈希记录下来。AFL++ 生成的那个目录里会有很多文件,同一个源码缺陷可能衍生成几十个触发样本。直接用sha256sum做一次去重,能帮你精确定位到真正不同的崩溃类型,不然你会被“同一根因大量样本”的假象误导。

第三,善用afl-showmap验证覆盖率。如果你不知道某个新目标到底有没有插桩成功,用afl-showmap -o map.out -- ./demo @@跑一下,看输出的map.out里面边数是否大于零。这一步只需要几秒,却能在启动 fuzzer 之前就发现“没插桩”这类致命问题。

第四,把每次实验的启动命令和种子版本写进注释文件。模糊测试是典型的长尾工作,今天跑的命令也许和三天前有细微差别,但你以为没差别。把这个命令存成文本,和输出目录放一起,后续分析结果时能省很多时间。

我在实际使用里的体会是:AFL++ 的真正门槛从来不是“安装”或“运行”,而是你能不能理解自己到底在测什么、想从哪个入口走进目标。把种子、字典、插桩方式、参数组合当成一个整体来对待,效果才会稳定。如果你只是在某台机器上跑一次就丢开,那就完全浪费了它帮你自动生成大量边界输入的能力。每次跑完,记得回到那个最小化崩溃样本上,把它变成一条可复现的用例,这才是模糊测试最终要交出的答卷。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询