☰
Fuzz工具实战指南:从Web接口到二进制程序的漏洞挖掘
2026/9/26 20:43:14 网站建设 项目流程

提到“Fuzz工具”,很多人的第一反应是“往程序里乱塞数据”,第二反应是“那是安全研究员才干的事”,第三反应是“我是不是得先有一台高配服务器”。这三个印象,前两个不算全错,第三个基本和实战无关。我用了几年Fuzz工具,从Web接口的目录爆破、参数枚举,到本地程序的崩溃挖掘,这套方法的产出效率比很多人想象得高得多,而且门槛没有想象中那么高。这篇文章就围绕Fuzz工具使用本身,把什么是Fuzz、怎么选工具、实际怎么跑、结果怎么用讲透,尽量让一个从没接触过的人也能拿着命令直接上手,同时让有经验的人能对照着补上一些细节。

1. 从“乱塞数据”说起:Fuzz测试到底在测什么

1.1 一段必须拆开的“玩笑定义”

Fuzz测试的通俗定义是“向目标程序输入大量随机或变异数据,观察程序是否崩溃、断言失败或出现异常行为”。这个定义本身没毛病,但它把“随机”两个字放大得太响了,好像Fuzz就是靠手气。你真的去跑一次就知道了:纯随机在大多数场景下,半天跑不出一个有价值的崩溃。现代Fuzz的核心,更准确地说,是“基于一定规则地变异输入,以触发未被预期处理的代码路径”。

理解这一点很关键。你给Web接口Fuzz的时候,不是随便扔几十万个字符串,而是围绕参数名、参数值、请求头、Cookie、文件上传字段去做替换和组合。你做文件格式Fuzz的时候,也不是把一个文件从第一个字节到最后一个字节全改成随机字符,而是把种子文件中的特定区块做位翻转、整数字段替换、长度字段修改。所谓的“乱塞”,是在语法合法的前提下,塞出边界和反逻辑。

1.2 无脑输入背后的两个核心逻辑:崩溃找洞与逻辑找错

第一个目的,是通过崩溃定位内存破坏类漏洞。这类问题在C/C++、Rust的不安全代码、老旧的C库中比较多见。一个经典的例子是解析器处理畸形输入时,发生缓冲区越界读写,最终导致Segmentation Fault或者被AddressSanitizer抓到堆溢出。

第二个目的,是通过响应差异发现业务逻辑问题。这在Web Fuzz里更常见。比如一个文件下载接口,参数里填正常文件名时返回200,填../../etc/passwd时返回200,那说明路径穿越可能就存在了;再比如一个搜索接口,加一个单引号返回数据库错误页,那SQL注入的嫌疑就很大。这种“逻辑上的不对”不会让程序崩溃,但它在业务上就是问题。

所以,当我跟别人说“Fuzz测试不是碰运气”的时候,指的就是这两层逻辑:要么去撞代码边界,要么去撞业务边界。前者看崩溃信号,后者看状态码、响应长度和内容差异。

1.3 现在主流的Fuzz装备是什么形态

工具形态大致可以分成三代。

第一代是纯黑盒随机,比如古老的zzuf这类工具,或者网上流传的各种“字典爆破脚本”,只管往输入里塞数据,不看代码内部状态。

第二代是基于覆盖率引导的灰盒Fuzz,代表是AFL/AFL++和libFuzzer。它们会在每次运行后查看程序的代码覆盖率,保留那些“发现了新代码路径”的输入,并基于这些输入做进一步变异。这样做的好处非常直接:变异方向始终朝着未覆盖的分支走,花的时间更少,发现的路径更多。

第三代是Web Fuzz里常见的“半自动化枚举”,代表是ffuf、wfuzz这类工具。它们不是去引导覆盖率,而是依靠高质量的字典和灵活的过滤规则,在HTTP请求的各个字段上做盲枚举。这套逻辑虽然简单,但在目标明确的时候,效率反而很高,因为Web应用的大多数漏洞都来自业务逻辑缺陷和配置疏漏,而这恰恰不是纯随机能轻易发现的。

2. 先分清阵营:通用型Fuzz和Web型Fuzz不能混着选

2.1 通用型模糊测试的代表选手

如果你要测的是本地二进制程序、图像解析库、音视频解码器、数据库协议实现,或者任何对文件、网络包、复杂数据结构做解析的组件,那需要的是通用型Fuzz工具。几个主流选择:

  • AFL/AFL++:使用最广、社区最活跃。它通过源码插桩记录覆盖率,也支持QEMU模式对黑盒二进制做无源码Fuzz。项目里的afl-fuzz、afl-cmin、afl-tmin这套组合非常完整。
  • libFuzzer:Clang自带的内存安全Fuzz引擎,写起来几乎就是一个LLVMFuzzerTestOneInput函数,直接和Sanitizer(ASan、UBSan、MSan)集成,对开发者自测特别友好。
  • honggfuzz:同样基于覆盖率引导,特点是支持硬件性能计数器(Intel PT),在反馈精度和变异策略上有自己的优势。

选通用型工具,首先要确认自己的目标程序能不能插桩。源码在自己手上,直接选AFL++或libFuzzer;手上只有一个黑盒二进制,不想重新编译,就选QEMU模式的AFL++,或者用frida动态插桩方案。

2.2 Web模糊测试的代表选手

Web方向,我常用的工具和它们的定位是这样:

  • ffuf:速度很快,语法简洁,过滤逻辑很直接。它最大的优点是“一个命令管所有”,目录、参数、子域、Header、Cookie都能Fuzz,而且性能和并发控制做得很好。
  • wfuzz:老牌工具,功能全面,支持从Burp请求中导出勘误格式,也可以直接整合字典、迭代器、代理、认证。语法比ffuf复杂一点,但灵活性更高,适合复杂的HTTP字段组合测试。
  • dirsearch:专注目录和文件发现,字典质量高,内置了很多常见备份文件、日志文件、版本控制目录的专项字典,跑起来省心。
  • Burp Intruder:图形化,适合“小规模精准测试”。它的优点是灵活定位参数、多种Payload类型、可视化比较响应,缺点是速度比命令行工具慢,不适合大字典全量跑。

2.3 工具选型的三个关键判断标准

标准一,看你的目标是“协议里的计算逻辑”还是“应用里的业务逻辑”。前者选通用Fuzz,后者选Web Fuzz。这个一点都不难判断,就看你的样例输入长什么样子。如果输入是一个PNG文件、一个MODBUS报文,那就是协议/文件解析方向;如果输入是一段GET请求里的Query参数,那就是Web方向。

标准二,看你能不能获得覆盖率信息。可以给代码插桩,绝不浪费这个优势,覆盖率引导Fuzz的威力远大于盲枚举。不能插桩,但在授权范围内可以多次请求目标服务,那Web Fuzz的响应反馈就已经是足够强的信号了。

标准三,看你要跑多大规模。日常小范围安全测试,ffuf足够;如果要持续集成到DevOps管线里做回归检查,那libFuzzer这类能嵌入CI的工具会更合适。规模上来之后,还要考虑分布式任务分配,虽然这属于进阶话题,但选型时尽量挑有“多核/分布式扩展能力”的,能省不少事。

3. Web Fuzz实战:用字典替我做“不可能完成的探测”

3.1 目录发现入门:ffuf的一条命令到底发生了什么

目录发现的本质是“猜路径”。一个Web应用总有一些隐藏的管理入口、备份文件、未加权限控制的接口,靠人肉访问永远测不完,Fuzz工具就是把这些请求批量自动化。

看这条最基础的ffuf命令:

ffuf -u https://target.com/FUZZ -w /path/to/dict.txt -mc 200,301,302 -ac

拆开看:

  • -u指定请求目标,FUZZ是占位符,工具会把它替换成字典中的每一行。
  • -w指定字典文件。
  • -mc是匹配HTTP状态码条件,这里只保留200、301、302。
  • -ac是自动校准,工具会先请求几个不存在的路径,学习那些“默认404页面”的响应特征,然后自动把长得像404的响应过滤掉。

这一步里的细节很多人会忽略。你以为只要看200就行,但不少管理系统会把登录页面、静态资源、接口路径都返回200,反而把真正有价值的301重定向(比如/admin/重定向到/admin/login.php)漏掉了。所以多数情况下,我会先-mc all跑一遍小字典,把返回结果全看一遍,再逐步过滤。

再强调一个关键点:字典的质量直接决定覆盖率。默认字典只有几万条常用路径,但对一个定制化系统,路径往往是业务相关的,比如/api/v1/exportReport、/console/doAction。这种路径从通用字典里猜不出来,需要你先抓一些真实请求,提取里面的路径结构,再合成自定义字典。

3.2 参数与值Fuzz:关注“非200”的响应

目录发现的思路是“找存在的路径”,而参数Fuzz更接近“偷逻辑”。一个正常的Web接口,服务端可能隐藏着一些未公开的参数,比如?debug=1、?admin=true、?test_mode=on。这些参数不在前端页面上出现,但后端代码却读了它,这往往就是配置绕过或功能溢出的起点。

参数名枚举的ffuf写法:

ffuf -u https://target.com/api/getUser?FUZZ=1 -w /tmp/params.txt -fs 4096

这里-fs 4096是过滤响应长度,意思是“去掉所有响应体大小为4096字节的结果”。为什么这么干?很多接口在参数不认识时,会返回一个统一的错误JSON,这个JSON大小固定;一旦某个参数被后端识别并参与了逻辑处理,返回内容就会出现长度变化。

参数值层面的Fuzz也是类似思路。我经常对某个参数做“类型翻转”,比如正常传id=1,Fuzz时换成id=abc、id=-1、id=1.5、id[]=1,看响应里有没有异常堆栈、数据库报错或状态码突变。这一招虽然老,但至今仍然有效,因为很多开发者在后端只做了“非空校验”,没做“类型校验”。

3.3 请求头与Cookie Fuzz:那些经常被忽略的“隐藏开关”

有种场景挺常见的:接口本身做了权限校验,返回403,但你把请求头里加上X-Forwarded-For: 127.0.0.1,返回码就变成200了。原因可能是后端反代在做IP白名单校验时,直接信任了这个头。这就是请求头Fuzz的价值。

常用请求头字典不长,但值得认真跑:

X-Forwarded-For X-Real-IP X-Originating-IP X-Remote-IP X-Client-IP X-Forwarded-Host X-Custom-IP-Authorization X-Original-URL X-Rewrite-URL

wfuzz在Header Fuzz场景下更好用,因为它可以精确控制要替换的位置:

wfuzz -H "X-Forwarded-For: FUZZ" -z list,127.0.0.1 https://target.com/admin -t 20

Cookie的Fuzz逻辑也类似。有些系统会判断admin、isLogin这类Cookie的取值,Fuzz时把这些Cookie值从0改成1、从false改成true,看接口有没有反应。这个行为在授权测试范围外会有法律风险,所以我只会在自己项目或者拿到授权的前提下跑,正式报告里也会把这个行为标注清楚。

3.4 结果去重:状态码、字数和Length的配合

Fuzz跑完之后的去重是个体力活,但也最能体现经验。

我按优先级排序,先把“看着无害但值得确认”的结果筛出来:

  • 状态码200但响应长度和其他200差异很大的,优先看。
  • 状态码302但Location头指向登录页的,属于正常业务,可以跳过。
  • 状态码500的,优先复现,因为服务端异常往往意味着未处理异常,可能直接暴露堆栈信息。
  • 状态码403的,别马上跳过,搭配不同的Header和请求方法再跑一遍,有时只是方法限制而非路径不存在。

自动化去重时,ffuf的-o参数可以输出JSON结果,配合jq做二次过滤很方便。比如我只关心响应长度大于2000的200响应:

ffuf -u https://target.com/FUZZ -w dict.txt -mc 200 -o result.json jq '.results[] | select(.length > 2000)' result.json

4. 文件与协议Fuzz实战:从AFL开始给原生代码做体检

4.1 AFL接入C程序的最小步骤

如果说Web Fuzz是“表面侦察”,那文件/协议Fuzz就是“深度体检”。以AFL++为例,假设我有一个简单的C程序parser.c,它从文件里读取数据并做解析:

#include <stdio.h> #include <string.h> #include <stdlib.h> void parse_data(const char *data, size_t len) { char buffer[64]; if (len > 100) return; memcpy(buffer, data, len); buffer[len] = '\0'; if (strcmp(buffer, "flag") == 0) { printf("ok"); } } int main(int argc, char **argv) { if (argc < 2) return 1; FILE *f = fopen(argv[1], "rb"); if (!f) return 1; fseek(f, 0, SEEK_END); long size = ftell(f); fseek(f, 0, SEEK_SET); char *buf = malloc(size); fread(buf, 1, size, f); fclose(f); parse_data(buf, size); free(buf); return 0; }

这个程序有一个明显的栈溢出漏洞:buffer只有64字节,但memcpy的长度是len,而且len接近100时就能撑爆它。用AFL++来挖,流程是这样的:

# 安装AFL++之后,用它的编译器插桩 afl-clang-fast -g -fsanitize=address parser.c -o parser_afl # 创建输入输出目录 mkdir -p in out echo "hello" > in/seed.txt # 开始Fuzz afl-fuzz -i in -o out -- ./parser_afl @@

@@表示AFL会把当前生成的测试文件路径作为参数传给程序。测试文件名是随机生成的,程序从文件里读内容,所以AFL每次运行都会给一个不同的输入。跑几十秒后,out目录下就会出现crashes目录,里面是触发崩溃的文件。

4.2 种子语料库:给Fuzz的“第一版菜单”

种子文件的质量,在AFL流程里起的作用比大多数人想的更大。原因在于覆盖率引导Fuzz虽然会变异,但变异方向依赖初始输入的状态。如果初始种子文件是一个完全空白的GBK编码文件,那它离触达解析器核心逻辑可能要走特别长的路;如果初始种子是一份合法且结构完整的样例文件,解析器会先正常走完整个解析流程,AFL再在这个“完整路径”的基础上做局部破坏,效果会高效很多。

所以我的做法是:

  • 从官方文档或测试套件里收集最小合法样例,比如一张1x1像素的PNG、一个最小合法XML。
  • 不要把整个大文件丢进去,那会让初始运行时间变长,反而拖慢变异效率。
  • 多放几个“成员不同”的种子,不要放十几个几乎一样的种子。afl-cmin这个工具就是专门做这个的,它能把一堆种子精简到“能覆盖的路径集合不再变化”的最小规模。

4.3 代码覆盖率视角:为什么有些崩溃你永远发现不了

AFL跑得再久,只能发现它能“看得见”的路径。这个“看得见”的范围,取决于插桩位置和编译选项。

最典型的例子是,如果你没有开启Sanitizer,很多内存错误发生后程序并不会立即崩溃,而是继续运行,直到某个随机时刻才炸掉。这样AFL就找不到崩溃点,或者即使找到了,也说不清到底是哪一行代码出了问题。所以我强烈建议编译时加上-fsanitize=address,undefined,把“潜在的错误”变成“确定的崩溃”。

另外一点,如果目标程序有复杂的校验逻辑,比如CRC校验、魔数校验、签名校验,AFL默认的变异方式很难通过这层关卡。这时候要么做“定向Fuzz”,让代码在绕过校验函数之后再开始读取,要么在种子文件里直接修改校验值字段。不少项目会用后一种方式,通过在内存里挂钩函数来跳过校验,但这已经属于更复杂的Fuzz工程化范畴了。

4.4 崩溃用例的复现、精简与分类

找到崩溃文件只是第一步,后面还有一堆事。

首先用afl-tmin对崩溃用例做最小化。它会把一个上百KB的崩溃输入一点点删减,直到删除任何一个字节都无法让程序崩溃为止。精简后的输入,给研发看的时候会清楚得多。

afl-tmin -i out/crashes/id:0000000 -o crash_min.bin -- ./parser_afl @@

接着用AddressSanitizer重新跑一遍复现程序,拿到带堆栈的回溯。这一步虽然和Fuzz本身无关,但它是让研发快速认可问题的关键。把ASan输出的堆栈贴到报告中,比你写十行“这里可能会溢出”都有用。

分类的时候,我会看崩溃类型:SEGV常见于非法内存访问,ABRT可能是断言失败或abort()调用,BUS可能是对齐问题。把同一类崩溃合并成一个根因,然后优先推进能稳定复现、堆栈清晰的那一批。

5. 经验总结:Fuzz项目中决定产出质量的7个细节

5.1 字典的选择和自建

通用字典可以解决“跑一遍求心安”的需求,但真正高质量的结果必须结合业务自定义字典。Web方向,我会从已有的接口文档、前端JS文件、历史流量包里提取路径、参数名、文件后缀,合成一个几十条甚至几百条的高命中字典。命中率比几万条通用字典高出不少,跑起来也快。

文件方向,字典的对应物是“结构定义”。如果一个协议有文档,把每一个字段的最小值、最大值、边界值、负数、超长值都列出来,生成样例文件作为种子。这比让AFL从头变异高效得多。

5.2 速率与干扰:别把被测服务“测挂”

Fuzz本质上是高并发请求或大量进程运行,对目标系统会产生真实压力。本地程序还好,崩溃了重启一下就行;Web服务就麻烦了,大量请求可能把测试环境打垮,甚至影响同机房的其它应用。

我把跑批资源分成三个档:

  • 小范围:并发线程10到20,适合参数精确Fuzz。
  • 中范围:并发50到100,适合目录和路径发现。
  • 大范围:并发200以上,只推荐在目标明确、资源充足的靶场或者完全隔离的测试环境中用。

实际执行时,ffuf的-rate参数可以限制每秒请求数,-t设置线程数。我一般会先小跑一轮,观察目标响应时间,再逐步调高。如果发现响应变慢,立刻降速。

5.3 误报与无效用例的过滤思路

Fuzz结果里最让人头疼的是误报。一个接口对不认识的参数返回错误码是正常的,如果你凭“响应长度变化”就误判成漏洞,报告发出去就会被研发怼回来。

我的过滤逻辑分三层:

第一层,自动校准:先用几个不存在路径/参数跑一遍,记录基线响应特征,后面所有结果都基于这个基线做对比。

第二层,结果二次验证:可疑的结果收到一个列表里,用curl单独复现三遍,确认不是偶发问题。

第三层,逻辑闭环:看这个“异常响应”能不能被解释成一个实际可利用的漏洞路径。比如能返回500堆栈,但堆栈里没有敏感路径信息,只能算低危信息泄露;但如果能配合参数注入改变业务数据,就要标记为高可疑。

5.4 Fuzz工具的版本与语法差异

ffuf、wfuzz、dirsearch这几种工具的语法差异很大,经常有人混用导致命令失效。比如:

  • ffuf的过滤参数是-fc/-fs/-fw,对应状态码、响应大小、单词数过滤。
  • wfuzz的过滤参数是--hc/--hs/--hw,而且多个值用逗号分隔,写法是--hc 401,403。
  • dirsearch的行为更偏向扫描器,配置文件和字典路径都是约定好的,不太适合做细粒度参数Fuzz。

我在项目里一般会固定一条工具链,比如“目录发现用dirsearch,参数枚举用ffuf,复杂修改用wfuzz”。把它们的输出都统一转成JSON或CSV,再汇总到同一个报告里,会省很多整理时间。

5.5 结合代码审计结果定向Fuzz

纯盲Fuzz的产出,往往跟时间投入不成正比。如果代码在手,先做快速审计,找出几个高风险函数,比如“没有边界检查的字符串拷贝”“直接把外部输入拼进SQL语句”的位置,然后做一个只针对这些函数入口的Fuzz配置,会比全量Fuzz更快看到结果。

定向Fuzz的另一个常见路线是,从崩溃栈回溯到具体的参数处理链路,然后回头构造一个更加贴近真实调用的输入文件,让AFL从这个文件开始继续跑。这比直接改参数值去碰运气要精准得多。

5.6 时间预算与多核分配

Fuzz是一个“跑得越久越有价值”的过程,但人的时间是有限的。我的经验是,给每个Fuzz任务设置一个“最小有产出时间”和“最大投入时间”。本地程序的覆盖率引导Fuzz,通常跑24到48小时能看到比较完整的崩溃集合;Web的字典枚举,大多数在几小时内就出结果了,再跑下去开始出现重复。

多核分配的技巧:把种子文件分成多个子集,每个子集喂给一个AFL实例,而不是用默认的单实例多核模式。这样每个实例的变异路径相对独立,整体跑出的路径更丰富。不过要注意,CPU核数越多,IO和内存压力也越大,别把机器拖死。

5.7 记录与回溯:让每次Fuzz都变成可复用的资产

很多人把Fuzz当一次性任务,跑完就删字典、删输出,下次遇到类似目标又从头再来。这其实很浪费。

我在项目目录里会固定保留:

project/ dict/ params.txt paths.txt headers.txt seeds/ seed1.pcap seed1.png config/ ffuf_commands.sh wfuzz_commands.sh output/ raw_result.json crash_min/ final_report.md

命令脚本保留住,下次换一个目标域名,只改URL就能重跑。字典按照业务类型分类,比如电商、OA、API网关各自的字典放好,长期积累下来,命中率会越来越高。

6. 从发现漏洞到推动修复:Fuzz报告的“最后一公里”

6.1 一个合格的Fuzz报告包含什么

跑出几十个崩溃文件或者一堆可疑响应,这只是开始。真正让漏洞被认领、被修复,是报告的事。

我的Fuzz报告标配是这几个部分:

  • 漏洞名称与风险等级,比如“某接口存在SQL注入风险(高危)”。
  • 涉及URL、参数、请求方法,精确到具体取值。
  • 复现步骤,每一步尽量包含完整请求命令或请求包。
  • 预期响应与实际响应对比,让人一眼看出问题。
  • 修复建议,越具体越好,是“限制输入类型”还是“改用预编译语句”。

6.2 复现环境与触发条件怎么写

复现环境除了写清楚目标URL、测试账号、测试数据之外,还要写清前置条件。Web场景下,有时需要先登录、先创建一个资源、先设置某个Cookie,这些都必须交代清楚。不然研发照着报告跑一遍,发现404了,第一反应就是“你这不是bug,是操作不对”。

本地程序场景下,要提供最小化后的崩溃输入文件、目标程序版本、编译参数(尤其是插桩和Sanitizer选项)。没有这些信息,研发即使想复现,也得花大量时间搭建环境。

一个我踩过很多次的坑是,从AFL里直接导出的崩溃文件名很难读,比如id:000000,sig:11,src:000123,op:flip1,pos:42。不要直接把文件名丢给研发,而是用afl-tmin精简后,给文件起一个业务相关的名字,比如overflow_on_parse_len_trigger.bin,这样沟通效率高很多。

6.3 与研发沟通时的三个建议

第一,先讲结论,再讲过程。不要上来就发三百行ASan堆栈,先说“这个接口在传负id时会返回数据库错误,疑似注入”,然后再附堆栈或复现包。

第二,给一个“最小可复现范围”。如果影响面是一整个上传功能,而崩溃只发生在特定文件格式的特定字段长度区间,就把范围写精确。研发最怕看到“全站都可能受影响”这种无边界的描述,他们没法排期修复。

第三,主动提供修复后的验证方法。在报告里加一条“修复后,应该用以下命令再次Fuzz,确认无同类崩溃”。这能形成闭环,也显得你不是只会提问题。很多优秀的工程师就是靠这一步,把“发现问题的人”变成了“被信任的安全伙伴”。

7. 用我自己的环境跑一次完整流程,以及那些只有实测才知道的坑

最后聊一点实测感受。我自己在做一个内部小工具时,用AFL++测一个自研的配置文件解析器。第一次跑,我扔进去一个很大的合法配置,跑了几个小时,一个崩溃都没有。后来我认真做了两件事:一,从代码里找到一处“长度字段被直接用于堆分配”的逻辑;二,把种子文件改成“长度字段极短但后续数据极长”的结构。几分钟内,ASan就报了一个Heap-buffer-overflow。

这个例子说明,Fuzz工具的能力边界,既取决于工具本身的变异算法,更取决于使用者对目标系统的理解。工具能帮你把“几分钟的重复劳动”变成“自动化的持续劳动”,但方向感还是要靠人。

另一个实测坑是,不要在一个快照环境里跑Web Fuzz,尤其是带登录态的Fuzz。因为服务端session可能过期,导致后半段请求全部重定向到登录页,结果里会混入大量“假200”。我的做法是,先手动操作一遍完整流程,确认目标接口在Fuzz期间能保持稳定状态,再开跑;跑的过程中,每隔一段时间抽查一下返回的Content-Type和响应头,确认没有跳登录页,才继续。

把这些细节处理好,Fuzz工具就是一个又狠又稳的测试利器。它不会替你思考,但能在你思考完方向之后,替你把验证的工作量消掉一大半。这就是我一直愿意花时间把这类工具用深的原因。

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

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

立即咨询