尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Fuzz工具实战指南:从Web接口到二进制程序的漏洞挖掘

Fuzz工具实战指南:从Web接口到二进制程序的漏洞挖掘 提到“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这套组合非常完整。libFuzzerClang自带的内存安全Fuzz引擎写起来几乎就是一个LLVMFuzzerTestOneInput函数直接和SanitizerASan、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接口服务端可能隐藏着一些未公开的参数比如?debug1、?admintrue、?test_modeon。这些参数不在前端页面上出现但后端代码却读了它这往往就是配置绕过或功能溢出的起点。参数名枚举的ffuf写法ffuf -u https://target.com/api/getUser?FUZZ1 -w /tmp/params.txt -fs 4096这里-fs 4096是过滤响应长度意思是“去掉所有响应体大小为4096字节的结果”。为什么这么干很多接口在参数不认识时会返回一个统一的错误JSON这个JSON大小固定一旦某个参数被后端识别并参与了逻辑处理返回内容就会出现长度变化。参数值层面的Fuzz也是类似思路。我经常对某个参数做“类型翻转”比如正常传id1Fuzz时换成idabc、id-1、id1.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-URLwfuzz在Header Fuzz场景下更好用因为它可以精确控制要替换的位置wfuzz -H X-Forwarded-For: FUZZ -z list,127.0.0.1 https://target.com/admin -t 20Cookie的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.json4. 文件与协议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 -fsanitizeaddress 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就找不到崩溃点或者即使找到了也说不清到底是哪一行代码出了问题。所以我强烈建议编译时加上-fsanitizeaddress,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工具就是一个又狠又稳的测试利器。它不会替你思考但能在你思考完方向之后替你把验证的工作量消掉一大半。这就是我一直愿意花时间把这类工具用深的原因。
返回列表