ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AFL模糊测试实战:覆盖率引导的漏洞挖掘利器

AFL模糊测试实战:覆盖率引导的漏洞挖掘利器 1. 先聊聊AFL为什么能在安全圈封神做安全测试和漏洞挖掘的人几乎没人不知道AFL。这个全称American Fuzzy Lop的开源模糊测试工具在GitHub上的星标数常年霸榜Google的Michał Zalewski大家习惯叫lcamtuf在2013年把它丢出来之后直接改变了整个漏洞挖掘行业的玩法。到现在十多年过去了AFL依然是安全研究者、CTF选手、软件测试工程师手里最趁手的那把刀。AFL能做什么它的核心任务就是给你的程序灌入大量经过遗传算法优化的畸形输入然后盯着程序的反应。如果程序崩了说明找到了一个触发bug的样本如果内存报错、断言失败、陷入死循环同样也是一条有效发现。说白了它就是个自动化找茬机器人专门黑进你程序的输入处理逻辑里翻箱倒柜。它适合谁用一类是安全研究员和白帽黑客拿它挖CVE、做漏洞研究另一类是开发者和测试工程师想在发布前自测一下程序健壮性还有一类是CTF玩家很多pwn题的初始利用思路就是从AFL崩溃样本里剥出来的。覆盖面极广学习曲线却很平缓这也是它这么多年依然是入门首选的原因。我第一次用AFL的时候心里想的是“这玩意儿真有那么神”结果在一个自己写的解析器上跑了十分钟就收获了一个segment fault。那一刻我才明白它厉害不在于算法多高深而是把“覆盖率引导”这个思路用工程化手段做到了极致。2. AFL的核心设计思路和三个关键支柱2.1 传统模糊测试的痛点和AFL的破局点在AFL出来之前模糊测试大致分两大流派。一派是纯黑盒随机比如早期的一些工具就是往程序里扔随机字节运气好碰到崩溃就赚了运气不好跑上几天毫无收获。问题很明显随机数据很难穿透深层代码路径大多数程序的前几层解析逻辑就把垃圾输入挡在门外了。另一派是白盒符号执行比如KLEE那类工具通过约束求解生成能覆盖特定路径的输入覆盖面确实精准但开销大到离谱约束求解器经常卡死只适合小规模程序。AFL选了一条中间路线。它不追求精确的符号约束而是通过在编译期插桩以极轻量的方式拿到程序的执行路径反馈。每当输入让程序走到一条新的边edgeAFL就认为这个输入“有趣”把它保存下来作为后续变异的种子。这套“覆盖率引导”机制让AFL能用很低的成本不断逼近程序的最深层逻辑。它的聪明之处在于不直接求解输入而是用一种类似遗传进化的方式让一批有价值的种子自己“杂交”和“变异”一代一代繁衍出更能深入代码的输入。程序员看到这个思路的第一反应往往是这不就是基因算法吗确实AFL自己也承认它借鉴了遗传算法里的变异和选择思想但实现上做了大量工程优化让整个流程在开销、效率和稳定性之间取得了当时最佳的平衡点。2.2 三个设计支柱插桩、覆盖率、变异进化支柱之一是插桩。AFL通过修改编译流程把探针代码注入到每个基本块的分支处。插入的不是重量级监控代码而是用一个共享内存区域做标记每次执行到某个分支就快速写入一个字节。在性能表现上插桩带来的额外开销通常控制在5%-10%左右比大多数动态二进制插桩工具要轻快得多。支柱之二是覆盖率反馈。AFL并不关心你覆盖了多少行代码它关心的是边覆盖率也就是两个基本块之间的跳转是否被执行过。这一设计能捕捉到更细粒度的执行流信息比传统的行覆盖率更能反映程序的真实走向。简单说行覆盖率告诉你“这一行有没有跑过”边覆盖率告诉你“从这个块跳转到那个块的路径有没有走通”后者对模糊测试的指导意义明显更大。支柱之三是变异进化。AFL维护一个种子队列每一轮从队列里取一个种子应用一组变异策略生成大量新用例然后插桩执行、收集覆盖率。如果某个新用例让程序走向了从未到达的边它就会被加入队列成为下一代变异的父本。这个循环往复的过程本质上是一个不断“扩疆拓土”的探索过程与随机测试完全不是一个量级。这三根支柱咬合得非常紧密没有插桩就没有覆盖率数据没有覆盖率数据就无法指导变异方向没有变异进化就谈不上路径探索。丢掉任何一环AFL的效果都会断崖式下跌。理解了这套底层骨架你才能看懂后面所有参数设置和优化手段的逻辑。3. 核心机制拆解覆盖率反馈和变异策略到底在做什么3.1 编译期插桩与QEMU模式的取舍AFL支持两种插桩方式。第一种是源码级插桩。编译时用afl-gcc、afl-clang-fast这类包装工具代替普通编译器编译器会在每个分支点插入探针代码。这种方式性能最好、覆盖率数据最准在多数场景下是首选。我强烈建议优先用afl-clang-fast而不是afl-gcc。afl-clang-fast基于LLVM的pass机制插入探针能利用LLVM的优化能力覆盖率反馈的精度和运行速度都优于老旧的afl-gcc方式。LLVM模式下检测不了的地方更少而且支持Sancov风格的覆盖率插桩整体体验好很多。如果你的项目能正常用Clang编译就果断选afl-clang-fast。第二种是无源码场景下的QEMU模式。AFL自带了定制的QEMU通过动态二进制翻译来实时记录分支覆盖情况。因为无需重新编译程序所有黑盒二进制都能拿来直接测特别适合分析第三方商业软件或自己没源码的老程序。但代价也很明显QEMU模式慢。动态翻译的开销加上覆盖率记录执行速度通常只有源码插桩的1/3到1/5。这意味着同等时间内你能尝试的用例数量大幅缩水整体挖掘效率也会随之下降。我的建议是能拿到源码的绝对优先用源码插桩QEMU模式只做保底方案或者用来快速验证某个二进制是否值得深入。3.2 覆盖率数据是怎么算出来的AFL通过一个固定大小的共享内存位图记录分支信息。默认大小是64KB可以配置为更大每次执行时插桩代码会在位图的某个位置做一次计数累加。关键点在于这个位置是通过当前基本块和前一个基本块的信息哈希计算出来的这样做记录的是“边”而非“块”。用公式可以近似表示为cur_location 当前基本块ID shared_mem[cur_location ^ prev_location] prev_location cur_location 1这里的右移操作是为了避免来回两条相同边被记录为同一条。AFL真正关心的是位图中有哪些位置被访问过以及访问频率。如果两次执行产生的位图指纹不同意味着程序的执行路径产生了差异AFL就会将新用例保留下来。这个位图设计还有一层精妙之处它天然具备模糊匹配能力。两个输入如果覆盖的位图几乎一致但有个别差异AFL能感知到“细微路径变化”。有些模糊测试工具会把覆盖率数据做得特别精细但AFL选择的是“足够区分路径且足够省内存”的平衡点。在数十万乃至上百万次执行中这个32KB到64KB的位图在工作集大小、写入性能和区分度之间取得了良好的平衡。一个常规经验是随着模糊测试进行位图几乎会被“染色”到接近满的状态。这时候AFL判断新路径是否有趣的阈值也会提高新种子入队的概率降低进度放缓。如果跑了一整天几乎没有新路径产生通常说明该切换策略、补充字典或者扩展语料了。3.3 变异策略的细节和循环逻辑AFL默认包含大量变异方式bit翻转、字节翻转、算术加减、特殊值替换、块删除、块复制、字典关键字插入等等。每一轮测试AFL会随机挑选若干种变异组合作用于种子然后执行、观察覆盖率。整个流程可以简化为1. 从种子队列中选一个种子 2. 对该种子执行一系列变异生成新测试用例 3. 将新用例喂给目标程序执行 4. 如果发生崩溃保存为crash样本 5. 如果覆盖了新路径将该用例加入种子队列 6. 回到第1步继续循环运行初期AFL会倾向于使用确定性变异deterministic fuzzing也就是系统性地做bit翻转和小的算术变化。这样做的好处是能在早期快速覆盖格式解析中的常见边界场景。等确定性变异跑完一轮AFL会切换到havoc模式执行更激进、更随机的组合突变这时的探索速度会大幅提升。很多新手会困惑为什么AFL开始跑得很慢过了一段时间越来越快这就是确定性变异到havoc模式的切换过程。确定性阶段每个位都要跑一遍速度慢是正常的havoc阶段单位时间内生成并执行的用例数量多速度自然快。如果你看到界面上的 execs/sec 在走高说明已经进入全力冲刺模式了。这里还给个实用建议如果目标程序有明确的关键词比如命令名、协议字段名、文件头魔数提前用-x参数提供一个字典文件。AFL在变异时会额外尝试把字典里的词插入种子这会大幅提升对结构化输入格式的挖掘效率。比如测试一个HTTP解析器时字典里放GET、POST、Host、Content-Type这类词效果立竿见影。4. 说干就干用AFL测试一个真实程序的完整流程4.1 环境准备与编译插桩我在本地以Ubuntu 22.04为演示环境其他Linux发行版步骤也大同小异。AFL并不依赖复杂的外部库安装本身非常简单。git clone https://github.com/google/AFL.git cd AFL make sudo make install这里我要多提一句AFL对内核的某些特性有依赖比如需要/proc下的状态信息来检测子进程状态。在普通Linux服务器、Docker容器里跑通常没问题。但macOS和Windows的原生支持有限建议统一用Linux虚拟机或Docker环境跑省得在兼容性上浪费生命。装好之后编译一个最简单的测试目标。我这边写了一个模拟解析器的C程序喂给它字节流如果前几个字节匹配特定格式就会触发一段有缺陷的逻辑。include stdio.h include stdlib.h include string.h int main(int argc, char **argv) { FILE *f fopen(argv[1], rb); if (!f) return 0; char buf[32]; int n fread(buf, 1, sizeof(buf) - 1, f); buf[n] \0; fclose(f); if (n 6 buf[0] A buf[1] B buf[2] C) { if (buf[3] T buf[4] E buf[5] S) { char *p buf; // 模拟一个明显的越界访问 char crash p[100]; printf(%c\n, crash); } } return 0; }这个程序问题很明显访问了p[100]在32字节缓冲区里越界了。但关键是只有前6个字节匹配特定内容时才会走到崩溃分支。普通随机测试几乎测不出来AFL却能通过覆盖率引导快速找到这条路。编译命令如下afl-clang-fast -o target target.c编译完成后在终端执行一下./target检查是否能正常运行。确保目标程序是能接收外部文件输入的单文件程序这是AFL最常见的接入方式。4.2 初始语料库的准备技巧AFL对初始种子非常敏感。一个高质量的种子集能让程序早早覆盖主执行路径为后续变异提供丰富的素材。相反种子质量差跑上几小时可能还在外层徘徊。我常用的方法是先准备一个包含基础合法输入的文件。对上面的程序来说初始种子可以是一个包含普通文本的文件也可以是一个以ABC开头的文件。哪怕只有一个种子AFL也能启动只是前期探索时间会稍长。种子目录规范如下mkdir -p input output echo hello world input/seed.txt echo ABCTESthis_is_a_long_string_0123456789 input/seed2.txt这里有个细节值得注意种子文件不要太大。AFL默认对超过1MB的种子会进行跳过或截断处理大种子会显著拖慢每一轮变异和执行的速度。初始种子控制在几百字节以内是最佳实践。如果手头只有大文件建议先用工具裁剪出关键片段或者准备多个不同的小文件。另外建议在种子集里放入多种风格的内容。比如文本、二进制片段、空文件、超长单行等。多样性越强AFL在早期阶段能探索到的执行路径就越多。很多经验丰富的人会把现有开源测试语料库比如AFL自带的testcases目录作为起点再结合目标程序的具体格式做精简。4.3 启动fuzzer和参数调优万事俱备启动命令afl-fuzz -i input -o output -m none -- ./target -m none表示不限制目标进程的内存因为ASAN插桩后的程序内存占用会明显提升不关闭限制容易出现误报。是占位符AFL会把生成的文件路径替换到这个位置程序从该文件读取数据。启动后你会立刻进入AFL的交互式UI界面。里面有几个核心指标需要读懂execs/sec表示每秒执行次数代表fuzz速度paths表示当前发现的路径总数是覆盖率探索的核心指标crashes是发现的崩溃数量达到非零值就意味着有戏hangs表示超时的用例数可能对应死循环或资源耗尽。有一次我在测试一个JSON解析器时crashes从0跳到1的瞬间整个房间都安静了那个1意味着一个可复现的崩溃。如果启动时提示CPU频率异常或core_pattern配置有问题按提示做相应处理即可。比如设置性能模式echo performance /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor echo core | sudo tee /proc/sys/kernel/core_pattern跑起来之后初期阶段我的建议是不要频繁干预让它先跑15到30分钟。这段时间AFL会在确定性变异阶段对种子做细致的位级处理速度不快但路径增长明显。等路径增长慢下来可以按CtrlC进入交互菜单选择调整性能设置或直接继续跑。5. 结果解读、崩溃复现与整条链路的进阶技巧5.1 为什么需要自定义字典、并运行多实例并行AFL默认的变异方法是通用的但真实世界的输入格式通常有明确的结构。HTTP请求有头字段PNG文件有magic numberSQL语句有关键词。面对这类场景把关键字符放字典里AFL变异时就能主动去尝试而不是靠随机拼接。创建一个字典文件key.dict内容类似keyword_one GET keyword_two Host keyword_three \r\n然后启动AFL时加上-x key.dict。实测效果在测试一个内部配置解析器的项目中我用字典后路径数量在相同时间内的增长比无字典模式快了约60%崩溃发现速度也肉眼可见地提前了。关于多实例并行AFL提供了-M主实例和-S从实例参数。典型做法是这样主实例做全局的确定性探索从实例之间通过不同的随机种子做不同方向的随机变异所有实例共享同步目录。afl-fuzz -i input -o output -M master -- ./target afl-fuzz -i input -o output -S slave1 -- ./target afl-fuzz -i input -o output -S slave2 -- ./target 并行实例之间通过output目录下的sync机制交换发现的种子。我见过有人在16核服务器上起8个实例同时跑第二天的路径覆盖率远高于单实例。机器资源允许的情况下并行是把时间成本打下来的最直接手段。核心思路就是不要让所有核心做相同的工作。主实例做系统化细致探索从实例用不同随机种子和变异策略乱拳打死老师傅组合起来效果最佳。5.2 崩溃样本的复现与去重当UI界面上crashes不再是0时所有产出文件都在output目录下的crashes子目录中。第一时间要做的不是急着统计CVE而是逐个复现确认崩溃不是环境因素导致的。复现方式./target output/crashes/id:000000,sig:11,src:000123,op:havoc,rep:4如果程序崩了接下来一步是拿gdb定位崩溃点gdb --batch -ex run -ex bt --args ./target output/crashes/id:000000,sig:11,src:000123,op:havoc,rep:4崩溃样本里经常有互相同源的重复样本。比较快速的去重方式是配合ASAN把所有崩溃样本逐个用ASAN版本的目标程序跑一遍过滤掉相同报错位置和相同调用栈的样本。有人说调试崩溃样本是查内存问题的艺术但AFL已经把人工筛查的工作量压缩到很小的范围剩下的就是基本功。如果能把崩溃输入整理到最小化复现集每次精简一个字节看是否能继续崩溃再交个开发修复效率会高很多。AFL自带的afl-tmin工具就是干这个的afl-tmin -i crash_file -o minimized_file -- ./target 5.3 死锁、超时和其他坑AFL默认超时设置是每个用例1秒左右。如果目标程序在处理某些输入时会进入明显的时间开销增长AFL会标记为hang并保存。hang不一定是死循环可能是解析算法复杂度异常导致的超时。检查方法很简单用timeout命令手动执行挂起样本看它是否真的无法结束。如果确认是死循环但程序对正常输入响应很快推荐的做法是用AddressSanitizer重新编译并配合AFL的-t参数扩大超时阈值。也可以考虑用LibFuzzer那一套超时检测机制做参考但AFL的情境下先调大超时确认问题不是误报。另一个高频坑是共享内存不足。在QEMU模式或某些复杂插桩环境下AFL会提示共享内存初始化失败。解决方法通常是调大内核允许的共享内存上限sudo sysctl -w kernel.shmmax67108864还有一类问题来自目标程序本身。有些程序会检查当前运行环境比如要求特定环境变量或特定工作目录。AFL提供的-e参数可以设置环境变量。遇到程序启动即退出的情况先手动在命令行执行一遍目标程序确认交互行为是否符合预期。这里有个容易被忽略的点如果目标程序是从stdin读取而不是从文件读取AFL也能支持只需去掉并把输入方式设为stdin即可AFL默认会用管道把生成的数据送进程序。但在实际工程中文件输入的目标更容易处理崩溃样本和追踪所以我都会优先改造目标程序为文件读取模式。我踩过最深的坑是一次在QEMU模式下测试一个大型GUI程序的辅助模块QEMU模式跑起来又慢又容易误报后来改成提取核心解析逻辑重编测试目标效率翻了十倍。对大型程序与其整体扔给AFL不如先“庖丁解牛”把解析核心拆出来单独测。这种“拆分重组”的思路在复杂目标面前往往比单纯堆算力更有效。6. 不同场景下的实战调整与AFL的家族扩展6.1 网络协议与复杂对象怎么测AFL最初面向文件输入设计但网络协议类目标也能测。常见做法是利用LD_PRELOAD把一个自定义的socket库塞进目标程序通过共享内存方式把AFL生成的测试数据“伪装”成网络包输入。这种方式在测试服务端解析逻辑时相当有效GitHub上有大量针对HTTP、DNS、TLS等协议的AFL接入框架可以借鉴。针对复杂结构化对象比如数据库的WAL格式、图片解码器、压缩器初始语料库是重中之重。建议直接抓一批真实场景使用的样本作为种子而不是自己手工构造。真实样本的边界往往比手工生成的更丰富AFL在此基础上变异得到的路径也更有现实意义。我自己在测图像解码器时习惯先准备一组不同尺寸、不同格式的小图再手动修改文件头字段生成几个变体。这套做法效果稳定路径数增长前快后慢的曲线非常典型。6.2 AFL与AFL家族的其他分支如果你还在用传统AFL有一个升级选项值得考虑AFL。它是社区在lcamtuf原版基础上持续迭代的维护分支支持更多编译器和平台修复了大量bug还引入了laf-intel、cmplog、红queen等新特性。AFL的插桩精度和整体fuzz效率对比原版AFL提升明显社区活跃度也是目前最高。实际项目中AFL通常能做到“拿过来就用”的无感替换。命令行参数和输出格式与原版基本兼容。如果你刚接触AFL甚至可以直接从AFL入手省去后续切换的麻烦。从原版AFL出发还可以延伸出各种思路和AddressSanitizer配合做内存安全检测与libdislocator配合探测堆利用补上插桩手段做持久化模式。粗略算下来AFL家族目前已有几十个衍生项目。但无论如何变化核心思想始终是那套“覆盖率引导 变异进化”的组合。6.3 跑一周不出崩溃问题出在哪模糊测试不是保证成功但长时间零崩溃并不等于你的程序安全。先看路径数。如果路径数长时间不动可能是探索到了某个瓶颈。对策是补充新种子、换一个变异策略、增加字典关键词或者干脆扩大多实例的随机性。如果整个输入空间很小运行十几分钟就已经把所有可达路径都覆盖完了那就没必要继续挂着。还有个常见原因程序崩溃后自动恢复了或者AFL认为崩溃样本不可复现。检查crashes目录里有没有非空文件手动跑一下看是否真的能复现。有些程序注册了信号处理器拦截异常导致崩溃被吞掉AFL检测不到。这种情况需要在编译阶段处理或者用代码审计方式补充排查。最容易被忽略的是目标程序本身的初始化开销。如果每次执行都要加载几十兆的模型文件单次执行时间过长会严重拖慢execs/sec。这类问题通常需要通过持久化模式persistent mode来缓解AFL支持的持久化循环能极大减少进程重启开销让执行速度提升数倍。7. 写在最后的几点个人心得根据我自己的使用经验AFL这类覆盖率引导模糊测试工具真正厉害的地方在于它把“输入变异的搜索空间”和“程序执行路径的反馈”这两个核心要素扣在了一起。理论上任何输入处理型的程序都能纳入它的范围实践上你能不能发挥出它的威力取决于对目标程序的理解程度和对工具的调优熟练度。给第一次接触AFL的同学一句掏心窝的建议不要一上来就把所有高级参数都加上。先用最简单的命令跑通一个demo搞清楚界面上的每个指标是什么意思再逐步做字典、并行、插桩优化。我刚上手那会儿总想把所有flag一次加齐结果出问题根本分不清是哪个参数导致的。后来学乖了一次只加一个变量定位问题的速度快了十倍。最后再分享一个实用的现场小技巧每次启动大规模fuzz前写个脚本把以下信息记录下来——AFL版本、编译参数、目标版本、种子文件哈希、运行时间、机器配置。这串信息看似繁琐但几个月后你回看某个漏洞是怎么挖出来的全靠它复原现场。Fuzzing是件看运气的事但好的流程会让运气倾向于你。
返回列表