做合约安全久了,会特别清楚一件事:单测写得再多,也只是把你“已经想到的路径”验证了一遍。真正让审计人员焦虑的,往往是那些组合起来的状态路径——一个不是 owner 的地址,在一个没人预料到的时机,调用了一个看似人畜无害的函数,最后把账本搞出了缺口。智能合约 Fuzzing 就是为这种情况准备的。它把大量的随机输入、随机调用者、随机序列丢给合约执行,再用你提前写好的不变量去判断每一次执行是否把状态搞坏了。这篇文章我想把合约领域的 Fuzzing 从原理到工具,再到一条能直接落地的流水线拆开讲清楚,适合正在做合约开发、安全审计,或者对链上安全测试感兴趣的读者。
1. 先想明白一件事:Fuzzing 不是在制造崩溃
很多人第一次接触 Fuzzing,脑子里还留着传统安全里“喂一堆畸形文件让程序崩溃”的印象。放到智能合约上这个直觉不太对。EVM 本身是一个带边界检查的沙箱,普通合约很难像 C 程序那样直接崩出内存破坏。你用 Fuzzing 去跑合约,正常情况下它只会告诉你两类事:某笔交易 revert 了,或者某个你定义的断言失败了。前者常见但大多无关紧要,后者才是真正的金矿。
1.1 输入不仅仅是参数,更是调用序列
传统 Fuzzing 的输入,通常是文件、网络包、命令行参数。智能合约的输入空间要大得多,除了函数参数,还包括调用者是谁、当前区块时间、余额多少、外部合约返回什么数据、前面已经发生了一串什么交易。
举一个例子。一个借贷合约,单次测试时你把“存款”和“取款”分开测,两段都通过,并不代表“先存后取再存”一定安全。更隐蔽的情况是:用户 A 存款后,用户 B 触发清算,然后 A 又在这个被清算后的状态下去取款。这种调用序列才是漏洞滋生的地方,而普通单元测试几乎不可能主动覆盖到。
所以合约 Fuzzing 真正在做的,是把你从“拍脑袋设计用例”里解放出来。你只需要提供一个足够合理的业务动作集合,也就是 Handler,再提供一组不变量,剩下的组合爆炸问题交给工具去搜。工具会自己尝试各种各样的调用顺序、参数组合、账户组合,直到找到一个让你的不变量变假的路径。
1.2 覆盖率和不变量缺一不可
Fuzzing 工具有一个共同的追求:覆盖率。代码覆盖率越高,说明越多的分支被执行过,构造出异常状态的可能性也会更高。但覆盖率只是“过程指标”,不是“结果指标”。
真正能让你抓住漏洞的,是断言,或者说“不变量”。不变量是一条关于系统状态的数学规则,它应该在任意时刻都为真。举个例子:
- 一个资金池中,所有用户的余额之和永远等于池子里的资产总额。
- 提款后,用户账户余额的减少量必须等于池子资产的减少量。
- 在没有管理员操作的情况下,协议的总债务不能大于总抵押品价值。
- 任何用户都不能通过一连串的交易凭空增加自己的余额。
Fuzzer 是一个很勤奋的搜索器,但它不知道什么是对的。你把不变量写得越贴近业务本质,Fuzzer 越有可能替你发现隐蔽问题。反过来,如果什么断言都不写,覆盖率再高也只是告诉你“我跑过很多代码,但我不确定它们是不是对的”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具选型:不是越重磅越好
合约 Fuzzing 的常见工具链,目前主要分为三块:Foundry 内置的 fuzz/invariant 能力、Trail of Bits 出品的 Echidna、以及后起之秀 Medusa。每个工具都不是银弹,它们的能力边界和适用场景,需要结合自己的项目阶段来判断。
2.1 Foundry 内置 fuzz 与 invariant 模式
Foundry 已经不只是“合约测试框架”了。它的核心测试机制天然支持两种能力:一种是普通 test 函数里的参数化 fuzz,给函数传随机值跑断言;另一种是 invariant 测试,它会随机调用你在 target 合约里注册的多个函数,并在每轮调用后检查 invariant 是否仍然成立。
Foundry 最大的优势是集成度。你的单元测试、集成测试、fuzz 测试可以放在同一个工程里,用同一套 vm cheatcode 控制时间、地址、余额、存储、甚至创建伪随机数。如果项目本身已经用 Foundry 做测试,再加 invariant 测试的成本很低,不需要额外引入复杂的配置。
2.2 Echidna / Medusa 更适合的状态空间
Echidna 是一个基于 EVM 的专用模糊测试器,它的思路在传统 fuzzer 里很经典:维护一组输入语料库,执行后分析覆盖率,根据覆盖率反馈去变异新的输入。Echidna 支持长序列执行,可以在一轮测试里连续执行几十上百笔交易,这对于发现跨多笔交易的状态漏洞,比如某种只在特定操作顺序下出现的重入、逻辑绕过、精度问题,很有价值。
Medusa 是后来用 Go 实现的类似工具,思路相近,但执行效率和并发度更好。单论生态成熟度,Echidna 的文档、示例、社区资料更全,我建议刚开始做合约专用 fuzz 的团队优先考虑 Echidna。Medusa 适合在已有测试框架跑顺后,作为一个更重的补充工具去尝试。
2.3 一张选型判断表
| 维度 | Foundry | Echidna | Medusa |
|---|---|---|---|
| 集成成本 | 最低,和测试工程一体 | 中,需要额外写 property 合约 | 中 |
| 状态序列长度 | 可配置,但每次 run 较短 | 可配置 seqLen,适合长序列 | 适合长序列 |
| 覆盖率反馈 | 较粗,主要靠分支反馈 | 内置覆盖率引导 | 内置覆盖率引导 |
| 上手门槛 | 低 | 中 | 中 |
| 典型场景 | 快速回归、协议内嵌测试 | 独立安全审计、复杂状态机 | 大规模审计、并行执行 |
如果项目还在开发早期,想给核心业务逻辑建一套“每改完代码就跑一遍”的安全回归网,我建议直接上 Foundry。如果是审计一个已经跑了很多 TVL 的复杂协议,需要长时间、长链路地去摸状态,Echidna 更靠谱。最理想的状态是两者都跑,只是职责不同。
3. Foundry 流程从零搭:配置与 handler 是关键
下面是一条我实测过很多次的落地路径。目标不是造一个花哨的框架,而是让你在半小时内就能对一个业务合约启动 fuzz,并且跑出来的失败结果可复现、可定位。
3.1 foundry.toml 里值得认真调的几项
很多人用 Foundry fuzz 时直接使用默认配置,这样也能跑,但你会浪费很多时间在低质量随机上。下面是我常用的一个最小配置:
toml复制[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[fuzz]
runs = 5000
max_test_rejects = 65536
seed = "0x1234"
dictionary_weight = 40
include_storage = true
include_push_bytes = true
说明一下各项意义。runs 表示每个 fuzz 测试函数最多执行多少轮随机输入,数值越大越可能探到深路径,但耗时也会上升。max_test_rejects 是防止 fuzzer 因为大量 vm.assume 条件导致始终在“拒绝输入”而空转,超过这个上限当前用例会直接判失败。seed 固定了随机种子,为了复现偶发失败时非常有用,CI 里建议固定一个种子,本地复现时再改。dictionary_weight 控制了从合约中提取的常量值参与变异的权重,比如某些关键 magic number,让 fuzzer 更容易命中边界。include_storage 会把当前 EVM 存储中的值当作下一步变异的参考,对跨调用状态感知有帮助。
运行命令也稍微说明下:
bash复制forge test --match-contract PoolInvariant -vvv
如果你想临时调高轮数,而不想改配置文件,可以这样覆盖:
bash复制FOUNDRY_FUZZ_RUNS=20000 forge test --match-contract PoolInvariant -vvv
3.2 测试对象:一个随时会出问题的简单池子
假设我们有一个最基础的 ETH 储蓄池。用户存 ETH,得到等额的余额记账,之后可以按余额取回 ETH。这个池子太简单,正常实现几乎不会出问题,但它足够用来演示整个 fuzz 流程。
solidity复制// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Pool {
mapping(address => uint256) public balances;
uint256 public totalSupply;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
totalSupply += msg.value;
}
function withdraw(uint256 amount) external {
require(amount
