文章总结: 本文系统讲解从fuzz到POC的完整二进制漏洞挖掘流程,涵盖目标选择、攻击面梳理、harness编写、fuzzing运行、crash去重与根因分析、可利用性判断及报告披露,强调选择输入面清晰的小目标、控制入口而非整个程序,并给出GoogleProjectZero的90+30披露政策等实操建议。
综合评分: 88
文章分类: 漏洞分析,二进制安全,实战经验,安全工具
从 fuzz 到 PoC:一次二进制漏洞挖掘的完整流程
原创
Sink
Sink
船山信安
2026年9月22日 00:00
湖南
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
底层手艺 · BINARY
从 fuzz 到 PoC:一次二进制漏洞挖掘的完整流程
实战流程 · 写给学完基础、想挖真漏洞的人
你以为挖漏洞是从一个聪明的输入开始的。其实它从挑一个不折磨人的目标开始——选错了,你会在前两周就耗光耐心,然后告诉自己”这活不适合我”。这篇把从选目标到交出 PoC 的整条链路摊开讲,不省略任何一步,包括那些让人怀疑人生的步骤。
01目标怎么选
新手最喜欢一口吞下浏览器内核。那是最坏的起点:攻击面有几百万行,状态机深不见底,光把程序跑起来就要配一周环境。你还没见到第一个 crash,就已经在编译 V8 的报错里迷路了。
真正适合练手的是输入面清楚、反馈快的东西:文件解析器、编解码库、协议实现。给一段字节,它吐一个结果,崩不崩当场知道。这种目标的循环特别短,你一天能跑几千个变异,而不是一周才调通一个构建。
挑之前拿五个维度过一遍,别凭”听说这个项目有名”就上手:
| | | |
| — | — | — |
| 维度 | 看什么 | 新手该挑 |
| 是否开源 | 能不能读到源码、对得上崩溃栈 | 开源优先,闭源留给后面 |
| 有无历史 CVE | 说明这段代码有人踩过坑,patch 里藏着思路 | 有 CVE 更友好,不是更危险 |
| 是否已有 harness | 别人写好入口,你省掉最枯燥的胶水 | 有 harness 直接上手,没有就选简单的 |
| 攻击面是否清晰 | 入口是不是”读一段字节就干活” | 越窄越好,别挑状态机迷宫 |
| 维护活跃度 | 没人管的代码,你报了也白报 | 活跃项目,能真的走到披露 |
常见的第一印象是”越大的项目漏洞越多”。错的。大项目的维护者也被 fuzz 了很久,浅坑早填平了。挑一个被低估的、输入面清楚的小目标,比在巨无霸里刨食强十倍。
02攻击面梳理:别一上来就 fuzz 整个程序
很多人把”fuzz 一个程序”和”fuzz 一个入口”混为一谈。fuzz 整个程序,等于让模糊器自己去猜”先点哪个按钮、再发哪段报文”——它根本不知道。结果覆盖率永远卡在第一层解析,后面几百个函数它一辈子摸不到。
先把这个程序所有的输入口列出来,再挑一个最薄的往里钻:
| | | |
| — | — | — |
| 入口点 | 典型位置 | 常见漏洞类型 |
| 文件解析 | 解码器、文档/图片/视频库 | 越界读写、整数溢出 |
| 网络报文 | 协议栈、RPC、服务端监听 | 堆溢出、UAF、解析状态错乱 |
| 命令行参数 | CLI 工具、守护进程启动 | 栈溢出、格式化字符串 |
| IPC | 共享内存、管道、socket 本地 | 信任边界绕过、越界 |
| 环境变量 | 启动脚本、setuid 程序 | 缓冲区溢出、注入 |
| 反序列化接口 | 对象编解码、配置加载 | 类型混淆、UAF |
你会遇到的典型情况是:文档库有二十个解析函数,你只挑其中一个入口写 harness。把那一个入口打透,比把整个库模糊一遍有用。覆盖率反馈会告诉你——它只走过你喂进去的那条路,这才是正常的。
一句话记牢:fuzz 一个入口,是”我指定程序走哪条路”;fuzz 一个程序,是”让机器自己猜路”。前者你控得住,后者基本是在浪费 CPU。
03写 harness
harness 就是一段把”输入字节”喂给”你想测的那个函数”的胶水。libFuzzer 的用法里,核心就是一个函数:
include
include
include “codec.h”
extern “C” int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
Parser p;
p.reset(); // 清掉上次的状态
Result r = p.parse(data, size); // 只调这一个入口
(void)r;
return 0; // 0=没意见,非0=有情况
}
三件事写 harness 时最容易翻车。一是太大:顺手把整个 main 的逻辑抄进来,初始化、日志、配置文件全带上,结果崩溃栈里一半是你自己的胶水代码,真正出问题的那行反而被淹了。二是跑不独立:依赖某个全局单例,第一次能跑,第二次状态就脏了。三是留了全局状态:忘了 reset(),上一次解析的残渣混进下一次,崩溃时你分不清是谁的锅。
我踩过最蠢的一个坑:harness 里忘了关掉日志输出,变异一来就打一行,一秒两万行,磁盘先满了,fuzzer 自己先挂。那天的产出是 Zero,外加一个需要清的几十 G 日志目录。
覆盖率反馈的意义在这儿:libFuzzer / AFL++ 会在编译时插桩,记录”这次输入走到了哪些分支”。每发现一个没走过的新分支,就把触发它的输入存进语料库。你的 harness 越干净,覆盖率曲线越能反映真实代码的边界,而不是你胶水代码的边界。
04跑起来
编译插桩这一步决定你后面能不能拿到精准的崩溃栈。libFuzzer 把 fuzzer 引擎和 sanitizer 一起编进二进制;AFL++ 则靠环境变量控制插桩模式。常用命令长这样:
libFuzzer + ASan:覆盖率与内存错误一起抓
clang++ -g -O1 -fsanitize=fuzzer,address harness.cc -o fuzz_target
跑:语料库 + 字典 + 时间上限
./fuzz_target corpus/ -dict=proto.dict -max_total_time=86400
AFL++ 插桩编译(CLASSIC 模式 + 地址消毒 + 硬化)
AFL_USE_ASAN=1 AFL_LLVM_INSTRUMENT=CLASSIC make
主从并行:-M 主节点做确定性变异,-S 从节点随机
afl-fuzz -i seeds -o sync -M main — ./target @@
afl-fuzz -i seeds -o sync -S s1 — ./target @@
语料库(seed corpus)别空着。给几个真实有效的输入当起点,fuzzer 第一秒就过了”这是不是合法格式”那道门,变异直接往深处走。字典(dict)对文本协议、带魔数的二进制格式尤其值钱——把关键字、标签值写进去,它一步就能拼出多字节的魔数,而不是一个字节一个字节地蒙。
并行用 -M 加若干 -S,主节点做确定性变异、从节点做随机变异,二者共享语料目录。跑多久停?看两条线:覆盖率曲线变平了、且长时间没有新路径,说明这一轮到头了;或者你只是想挂一晚上,设个 -max_total_time 就行。别指望”再跑五分钟就有突破”——那种期待几乎每次都落空。
05Crash 不是漏洞
fuzzer 报 crash,你先别激动。AFL++ 一个晚上能攒几百个 crash 文件,里面九成是同一个 bug 的不同触发。三件事按顺序做:
去重。按栈回溯哈希分桶。同一个根因,无论输入长什么样,回溯栈是一样的。AFL++ 自己会做崩溃分类,你别把”id:000001″和”id:000237″当成两个洞——它们多半是同一个。
最小化。原始触发输入可能几 MB,大部分字节跟崩溃无关。用 afl-tmin 缩,用 llvm-symbolizer 看符号,再手动删字节——直到少一个字节就不崩。这一步不是洁癖,是给后面写报告的人(包括未来的你)留一条能读懂的路。
可复现。换台机器、换个编译选项、重跑十次,它还崩吗?很多”崩溃”只在某次内存布局下出现,是噪音。复现不了的,先丢一边,等它再来。
大部分 crash 是重复的。你真正要处理的那一个,往往藏在几百个文件里、看起来最不起眼的那条栈。去重做不好,你会把时间花在给同一个 bug 写第十遍报告上。
06根因分析
最小化之后,用 ASan / UBSan / Valgrind 重新编译复跑。sanitizer 会在坏内存访问发生的那一刻 abort,并吐一份报告。一份典型的 heap-buffer-overflow 报告长这样:
==4121==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000b1
READ of size 1 at 0x6020000000b1 thread T0
#0 in read_varint /src/codec/varint.c:41
#1 in decode_field /src/codec/decode.c:118
#2 in codec_decode /src/codec/module.c:72
0x6020000000b1 is located 0 bytes after 1-byte region
[0x6020000000b0,0x6020000000b1)
allocated by thread T0 here:
#0 in malloc
#1 in PyBytes_FromStringAndSize
SUMMARY: AddressSanitizer: heap-buffer-overflow /src/codec/varint.c:41
逐行读:
第一行,漏洞类型。heap-buffer-overflow 直接告诉你这是堆上的越界,不是栈也不是释放后重用。地址是出错那块内存的位置。
第二行,越界偏移。READ of size 1 说读了 1 字节;0 bytes after 1-byte region 说缓冲区只有 1 字节长,这次读正好踩在它右边一格。一个变长整数解码器,没检查输入是不是在半路就结束了。
第三到五行,访问栈。从 read_varint 一路到 codec_decode,告诉你”坏读发生在哪、是谁调进来的”。根因通常不在最底层那行,而在它的调用者——为什么给了它一个会越界的输入。
第七到九行,分配栈。这块内存是哪来的。配合访问栈,你能拼出”谁分配、谁越界、偏移多大”的完整故事。UBSan 则补另一类:有符号整数溢出、空指针、对齐错误——这些 ASan 看不见。
同一份报告里,漏洞类型 + 越界大小 + 偏移 三样齐了,你才有资格判断它值不值得往下做。少一样,都是在赌。
07可利用性判断
能崩不代表能利用。MITRE 和 CISA 的 2024 年 CWE Top 25(基于 2023–2024 年 31,770 条 CVE 记录)里,越界写(CWE-787)排第 2、越界读(CWE-125)排第 6、释放后重用(CWE-416)排第 8——内存破坏类占了前八里三个席位,是最常见的那一类。但排位高不等于都好利用,得看你能写多少、控什么:
| | | | |
| — | — | — | — |
| 漏洞类型 | 能写几个字节 | 能控什么 | 典型利用难度 |
| 栈溢出 | 可覆盖返回地址 | 控制执行流 | 低(有保护则中) |
| 堆溢出 | 取决于相邻块 | 改写堆元数据/指针 | 中–高 |
| UAF | 可复用已释放块 | 类型混淆/任意读写 | 高 |
| 越界读 | 只读,不写 | 泄露内存内容 | 低(组合则高) |
| 整数溢出 | 间接导致分配偏小 | 触发上述任一 | 中(常作跳板) |
越界读最容易被低估。它不能直接写,但能读。读出来的若是堆地址、模块基址、canary,ASLR 就废了——信息泄露本身就是高危。你会遇到”只是读了一下”就被定成严重的案例,因为它把别的漏洞的利用门槛削平了。
判断可利用性,问自己一句:攻击者拿了这块越界,下一步能改变什么?能改执行流,就是高危;只能让程序崩,那它只是 DoS。中间那一大片,靠的就是你对这块内存布局的理解。
08写报告与披露
报告的读者是修代码的人,不是看你文笔的人。四样东西缺一不可:影响范围(哪些版本、什么配置中招)、触发条件(怎么跑到那行)、PoC(最小可复现输入)、修复建议(在哪加哪道检查)。别写”建议加强输入校验”这种废话,写”在 varint.c:41 的循环里加上输入边界判断”。
披露时限有个绕不开的惯例:Google Project Zero 的90+30 政策——给厂商 90 天修,补丁出来后 30 天才公开细节;若 90 天内修不完,可申请 14 天宽限;若发现已在野外被利用,则压缩到 7 天。据其 2025 年披露政策 FAQ,自宽限机制启用以来约 96.9% 的问题都在期限内修掉了。这不是铁律,但你得有个自己的时间锚点,别让厂商无限期拖。
申请 CVE 走 CNA(CVE Numbering Authority)体系。全球已有 300 多家 CNA,大厂和开源基金会都在其中——能直接给自己产品发号。如果你测的项目属于某家 CNA 的 scope,就通过它走;否则去 MITRE 的 cveform.mitre.org 选 “Report Vulnerability / Request CVE ID”,填邮箱、受影响组件、攻击向量、建议描述,提交后等编号,编号先处于保留状态,公开时再走一次提交流程。流程不复杂,难的是前面把证据做扎实。
一个常犯的错:跳过厂商直接公开。多数项目的安全策略会写明联系窗口和禁发期,你绕过去,不仅拿不到配合,还可能把自己放进”不守规矩的研究者”名单。先协调,再公开。
09现实的一面
说点不中听的。绝大多数 crash 是重复的,绝大多数去重后的 bug 是不可利用的,跑三天零收获是常态。我见过有人挂了一周 fuzzer,产出一个 crash,去重完发现是别人两年前报过的。
这不是劝退。是想让你把预期放对:挖掘不是”努力就有回报”的活,是”大部分时间在空转、偶尔撞上一次”的活。你能不能留下来,不取决于你多会写 harness,取决于空转那三天你还在不在。
ONE LINE
机器负责穷举,你负责判断哪里值得穷举。
参考来源
· Google Project Zero,Vulnerability Disclosure Policy / FAQ(projectzero.google,2025):90+30 披露时限、14 天宽限、7 天在野利用、约 96.9% 期限内修复
· MITRE / CISA,CWE Top 25 Most Dangerous Software Weaknesses(2024):基于 2023–2024 年 31,770 条 CVE 记录,CWE-787 第 2、CWE-125 第 6、CWE-416 第 8
· AFL++ Documentation(aflplus.plus),Parallel Fuzzing / Instrumentation:AFL_LLVM_INSTRUMENT=CLASSIC、AFL_USE_ASAN=1、-M/-S 主从并行、afl-tmin
· Google,AddressSanitizer 文档(Serebryany & Vyukov):heap/stack/global-buffer-overflow 与 use-after-free 报告结构、READ of size 与 region 偏移解读
· CVE Program / MITRE,cveform.mitre.org 与 CNA Partner 信息(cve.org):全球 300+ CNA、”Report Vulnerability / Request CVE ID” 流程、协调披露
· Python Testing & Debugging,Fuzzing a C Extension with Atheris and ASan:ASan 报告的四个部分与”四问”解读框架
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:船山信安 Sink
Sink《从 fuzz 到 PoC:一次二进制漏洞挖掘的完整流程》