各位做智能合约开发、安全审计的朋友,今天想跟你们好好聊聊智能合约 Fuzzing 这个话题。市面上的文章大多停留在工具安装和命令演示的层面,真正讲清楚“为什么要这么写测试”“覆盖率到底怎么提上去”“跑出来的失败用例怎么定位”的干货很少。我过去一年在多个真实项目中把 Echidna、Medusa 和 Foundry 的 fuzz 测试都跑了个遍,踩了不少坑,也总结出一套相对稳定的打法。这篇就按我的实际经历来写,从思路到实操再到问题排查,把你可能遇到的关键环节都说透。
这篇文章不挑基础,哪怕你是第一次接触 Fuzzing,只要会写一点 Solidity,跟着思路走就能理解;如果你已经有一定审计经验,那里面关于不变量设计和漏洞定位的方法,可能正好补上你一直觉得“差一口气”的地方。
1. 内容整体设计与思路拆解
1.1 Fuzzing 解决的不是“找 bug”,而是“填证据”
先说一个最核心的认知转变。很多人把 Fuzzing 当成自动找漏洞的工具,跑出一个失败用例就欢呼“抓到漏洞了”。但真正用过一轮你才会发现,Fuzzing 更大的价值在于帮你建立“这块逻辑没问题”的信心。
以我最近审计的一个去中心化借贷协议为例。合约里有十几个公开函数,涉及借贷、清算、利率更新,手续费计算还是按区块粒度累计的。传统单测只能把文档里写的功能路径测一遍,但状态变量之间的约束关系——比如“总借款额必须等于所有用户借款之和”“利率更新后累计利息不能跳变”——在发生多笔交错交易后是否依然成立,单测根本覆盖不到。Fuzzing 正是用来处理这种“状态空间爆炸”问题的。
换句话说,单测是在验证“我知道会发生什么”,Fuzzing 是在帮你发现“我以为不会发生的事”。它的本质是让工具按照某种策略自动生成大量交易序列,在每个区块高度观察合约状态是否仍然满足你预先定义的不变量。那组不变量,才是你真正想守护的合约灵魂。
1.2 覆盖率看起来很美,但别让它主导你的测试方向
我刚开始做 Fuzzing 的时候,特别执着于覆盖率报告,觉得跑到 95% 以上就很安全。后来发现这是一条弯路,覆盖率只能告诉你“哪些代码行被执行过”,却没法告诉你“状态组合是否被充分探索”。
举一个真实的例子。我之前测过一个订单撮合合约,覆盖率很快到了 92%,但一个关键的边界 bug 依然没有被发现。原因是 trigger 函数的某一行里,有一个依赖外部预言机价格的比例计算,覆盖率看着高,但执行那条路径的时候价格总是处于一个正常区间。Fuzzer 生成的随机输入很难把预言机价格推到极端值附近,所以真正有问题的分支永远没有被触发。
后来我把 fuzz 工具和一个 全局状态设置函数(在 Echidna 里叫 setup,在 Foundry 里是 setUp)配合起来,把价格区间直接锚定在极端比例上,覆盖率没提高多少,但 bug 当场就炸出来了。所以我的经验是:覆盖率报告当作一个观测指标可以,但设计 Fuzzing 测试的时候,心里装的应该是“状态边界”而不是“行覆盖”。
1.3 为什么单靠“随机打乱函数调用”远远不够
很多人对 Fuzzing 有个误解,以为它就是随机调一堆函数,碰运气撞出问题。实际上,成熟一点的 Fuzzing 工具都带有覆盖率反馈机制,它们会根据上一次交易执行后新增哪些代码路径、哪些分支条件出现了新组合,来指导下一笔交易的参数生成和函数选择。这种思路是从传统二进制安全领域的 libFuzzer 借鉴过来的,目的就是避免纯粹随机带来的无效搜索。
以 Echidna 为例,它的核心是一个基于覆盖率引导的搜索器,同时配合延迟执行和交易序列变异,在保留历史交易上下文的前提下尝试小步修改。这样既不会像纯随机那样“失忆式探索”,也不会像符号执行那样因为路径条件太复杂而爆状态。理解了这层设计,你在给 Fuzzing 工具设定时间上限时就会更从容——给它十分钟,它不是愣跑十分钟,而是在做一种带方向性的搜索。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与工具选型要点
2.1 三大主流工具怎么选,只看四点
目前社区里常用的智能合约 Fuzzing 工具有三个流派:Echidna、Medusa 和 Foundry 自带的 fuzz。它们各有擅长,我做了个表帮你快速定位。
| 工具 | 底层语言 | 引导方式 | 典型场景 | 上手难度 |
|---|---|---|---|---|
| Echidna | Haskell | 覆盖率引导 + 变异 | 复杂不变量测试、长期运行找状态 bug | 中等,配置项丰富 |
| Medusa | Go | 覆盖率引导 | 高性能并行 Fuzzing、兼容 Echidna 用例 | 中等偏容易 |
| Foundry fuzz | Solidity | 覆盖率引导 | 单元测试内嵌入 fuzz 逻辑 | 低,配合日常测试 |
选择依据,我总结了四点:
- 你更看重一次性找深 bug,还是日常集成顺手:深度找 bug 用 Echidna 或 Medusa,顺手集成用 Foundry。
- 不变量是写在合约里还是写在测试里:Echidna 对在合约内声明
invariant_函数的方式最自然;Foundry 则把不变量放在测试合约里。 - 需不需要并行执行:Medusa 的并行能力在大型合约上有明显优势。
- 团队里其他人会不会长期维护:如果只有你一个人懂 Haskell 配置语法,工具再强也难落地。
2.2 什么样的智能合约最适合用 Fuzzing
不是所有合约都值得上 Fuzzing,投入产出比很重要。根据我近年来的观察,下面三类最适合:
- 状态机复杂的协议型合约:借贷协议、AMM、质押合约、期权协议。它们有大量用户操作会改变全局状态,且各状态之间存在强约束关系。
- 与外部预言机、跨链消息交互的合约:这类合约的数据源不可控,极容易产生价格边界问题。
- 资金池模式管理的合约:在多用户存入、赎回、奖励分配的交互中,很容易由精度取舍或四舍五入方向不一致引入累计偏差。
反过来说,那种“纯查询”“无状态”“只有 owner 可调”的简单合约,跑 fuzz 纯属浪费时间,单测加代码审查已经足够。
2.3 不变量设计是 Fuzzing 的灵魂,任何工具都会依赖它
Fuzzing 跑多久、参数多极端,最后都要落到一件核心事情上:你定义的不变量是否被违背。如果你定义错了,工具再努力也只是白费;如果你定义得太粗糙,又会有大量无效测试和漏网之鱼。我把设计不变量拆成以下几个方法。
第一类:资产守恒类。 这类不变量类似传统金融里的“账实相符”,典型如:totalSupply 必须等于所有用户余额之和;合约内 ETH 或 ERC20 总余额加上已发行份额对应的资产,必须和合约创建初期常量一致。之所以重要,是因为多数精度 bug、重入类 bug 最终都会破坏总量守恒。
第二类:权限边界类。 合约里很多高危函数只允许 owner 或治理调用,验证方法是设置一个没有权限的随机账户执行交易序列,在任意时刻调用受保护函数必须 revert。这类不变量不仅能抓权限缺失,还能找出“初始化函数可以被任意人先调用”这类常见问题——也就是业内常说的“初始化 front-running”。
第三类:业务一致性类。 它最需要审计者对业务深度理解。比如 AMM 中,在做任意交易后,spotPrice 必须等于 reserve0 / reserve1;或者在一个期权协议里,期权创建者的解锁时间必然晚于所有参与方的最后一个操作时间。这类不变量因为直接反映业务规则,一旦破坏,通常意味着比较严重的逻辑漏洞。
必须加一句非常关键的经验:不变量不要只写“等于某常量”,很多 bug 是由“线性关系”被破坏引入的。例如一个代币合约如果实现累计奖励,奖励只增不减,那就该写“每次调用 claim 后累计奖励总额必须单调不减”,而不是只盯着单个账户余额。
2.4 固定时间、固定随机序列会让人产生虚假安全感
这个坑我必须重点说。很多测试框架在随机种子固定时会给你一个稳定的通过结果,如果你直接把它当成安全承诺,那后续踩雷只是时间问题。
Foundry 里默认是无固定种子的,每次跑结果略不同;Echidna 则可以通过 --seed 指定种子来复现某次失败。正确做法是,在本地调试阶段固定种子复现问题,在持续集成(CI)阶段不固定种子,让它每次跑出新的随机探索路径。把长期运行的 Fuzzing 结果当作质量信号,而不是一次性的“通过”标签。
3. 实操过程与核心环节实现
接下来说一段可以照着操作的完整流程。我以 Echidna 为例,因为它的不变量设计语言最贴近合约本身的表达力,且社区成熟度高。
3.1 目标合约:有问题的代币分发合约
为了演示,我构造一个不算太复杂但有真实漏洞的代币分发合约。它有 deposit、withdraw、claimReward 三个主要函数,奖励按存款占比累计,但实现中存在一个隐蔽的精度问题——在某个取整方向上,用户可以赚取额外奖励。
solidity复制// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
contract RewardDistributor {
IERC20 public immutable token;
address public owner;
uint256 public totalDeposited;
uint256 public totalRewardPerShare; // 用一个扩大的基数表示,避免小数
mapping(address => uint256) public deposited;
mapping(address => uint256) public rewardDebt;
mapping(address => uint256) public pendingRewards;
uint256 private constant PRECISION = 1e18;
constructor(address _token) {
token = IERC20(_token);
owner = msg.sender;
}
function deposit(uint256 amount) external {
require(amount > 0, "amount zero");
token.transferFrom(msg.sender, address(this), amount);
_updateReward(msg.sender);
deposited[msg.sender] += amount;
totalDeposited += amount;
rewardDebt[msg.sender] = (deposited[msg.sender] * totalRewardPerShare) / PRECISION;
}
function withdraw(uint256 amount) external {
require(amount > 0 && amount <= deposited[msg.sender], "invalid amount");
_updateReward(msg.sender);
deposited[msg.sender] -= amount;
totalDeposited -= amount;
rewardDebt[msg.sender] = (deposited[msg.sender] * totalRewardPerShare) / PRECISION;
token.transfer(msg.sender, amount);
}
function _updateReward(address user) internal {
uint256 currentShare = deposited[user];
uint256 earned = (currentShare * totalRewardPerShare) / PRECISION - rewardDebt[user];
if (earned > 0) {
pendingRewards[user] += earned;
}
rewardDebt[user] = (currentShare * totalRewardPerShare) / PRECISION;
}
// 每次有人调用该函数,每个单位的存款积累奖励。管理员定期调用。
function addReward(uint256 amount) external {
require(msg.sender == owner, "not owner");
token.transferFrom(msg.sender, address(this), amount);
if (totalDeposited == 0) return;
totalRewardPerShare += (amount * PRECISION) / totalDeposited;
}
function claimReward() external {
_updateReward(msg.sender);
uint256 reward = pendingRewards[msg.sender];
pendingRewards[msg.sender] = 0;
token.transfer(msg.sender, reward);
}
}
看完代码,你的第一反应可能是“这个合约只有 admin 能加奖励,普通用户没有直接修改 totalRewardPerShare 的权利,问题出在哪呢?”问题恰恰出在这:在 存款为零 → 有人存款 → 有人取款 → 存款重新为零 的序列中,totalRewardPerShare 被保留下来,可它对应的“奖励基数”已经变化了,导致后续存款用户拿到不属于自己的奖励。这种动态状态错位,不通过 fuzz 的长序列很难在单测里构造到。
3.2 第一步:设计不变量测试合约
在 Echidna 的体系里,我们可以写一个测试合约,内部定义多个 invariant_ 开头的函数,Echidna 会在每一个随机交易序列的末尾检查它们是否仍然成立。我把关键的不变量提取出来。
solidity复制// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "./RewardDistributor.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract TestRewardDistributor is RewardDistributor {
ERC20 internal token;
address internal alice = address(0x100);
address internal bob = address(0x200);
address internal carol = address(0x300);
uint256 internal constant INIT_BALANCE = 1_000_000e18;
constructor() RewardDistributor(address(new ERC20("T", "T"))) {
token = ERC20(address(super.token));
// 给测试账户转初始代币
token.mint(alice, INIT_BALANCE);
token.mint(bob, INIT_BALANCE);
token.mint(carol, INIT_BALANCE);
token.mint(address(this), INIT_BALANCE);
}
// 模拟 admin 不定期加奖励
function addRewardFromOwner(uint256 amount) external {
token.mint(address(this), amount);
token.approve(address(this), amount);
super.addReward(amount);
}
function depositAsAlice(uint256 amount) external {
token.mint(alice, amount);
vm.startPrank(alice);
token.approve(address(this), amount);
super.deposit(amount);
vm.stopPrank();
}
function withdrawAsBob(uint256 amount) external {
vm.prank(bob);
super.withdraw(amount);
}
// 恒定不变量:任何时间,pendingRewards 之和加上已领取的奖励,不能超过累计进入合约的奖励+初始预留
// 由于缺少 accounting 变量,我们直接检查“资金无法从合约中无中生有”的守恒关系。
uint256 public totalClaimed;
function claimAsAlice() external {
vm.prank(alice);
uint256 before = token.balanceOf(alice);
super.claimReward();
uint256 after = token.balanceOf(alice);
totalClaimed += (after - before);
}
function invariant_totalAsset() public view {
// 合约持有的代币 + 所有人已经提走的资产 must == 初始总量 + 所有 mint
// 这个在实际合约里往往比较难定义,这里我们只演示一个守恒不变量:
// 任何一个账户的 pendingRewards 不能超过 totalDeposited * 10000 这种荒谬上限。
// 实战中应根据业务总量设计。
}
// 更有效的不变量:用户的 pendingRewards 与合约代币余额的总和,守恒于“零和逻辑”。
// 这里我们放到第三步结合 echidna 的 property 来写。
function invariant_pendingRewardsBelowTotalAssets() public view {
uint256 totalPending;
address[3] memory users = [alice, bob, carol];
for (uint256 i = 0; i < users.length; i++) {
totalPending += pendingRewards[users[i]];
}
// 合约自身余额必须能够覆盖所有 pendingRewards
require(totalPending <= token.balanceOf(address(this)), "pending > balance");
}
function invariant_singleUserCannotDrain() public view {
address[3] memory users = [alice, bob, carol];
for (uint256 i = 0; i < users.length; i++) {
require(pendingRewards[users[i]] <= 1e30, "user pending too large");
}
}
}
注意上面的测试合约里我用 vm.startPrank 和 vm.prank,这是 Foundry 风格的作弊码。如果用 Echidna 跑,实际要把这些换成对应合约内的身份控制方式,比如定义一个 onlyUser 修饰器。为了让你快速理解思路,我先保留这种写法。
3.3 第二步:用 Foundry 快速试水
Fuzz 和日常测试集成最顺的就是 Foundry。你可以直接写一个测试合约,在函数参数里放随机值,然后加 vm.assume 过滤条件。
solidity复制// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "../src/RewardDistributor.sol";
contract FuzzTest is Test {
RewardDistributor public dist;
MockERC20 public token;
address alice = address(0xA11CE);
address bob = address(0xB0B);
function setUp() public {
token = new MockERC20();
dist = new RewardDistributor(address(token));
token.mint(alice, 1_000_000e18);
token.mint(bob, 1_000_000e18);
vm.prank(alice);
token.approve(address(dist), type(uint256).max);
vm.prank(bob);
token.approve(address(dist), type(uint256).max);
}
function testFuzz_DepositWithdraw(uint256 amountA, uint256 amountB) public {
vm.assume(amountA > 0 && amountA <= 1_000_000e18);
vm.assume(amountB > 0 && amountB <= 1_000_000e18);
vm.prank(alice);
dist.deposit(amountA);
vm.prank(bob);
dist.deposit(amountB);
// 加一笔奖励
token.mint(address(this), 1000e18);
token.approve(address(dist), 1000e18);
dist.addReward(1000e18);
vm.prank(alice);
dist.withdraw(amountA);
vm.prank(bob);
dist.withdraw(amountB);
// 合约余额应为 0(所有奖励也都分配结束)
assertEq(token.balanceOf(address(dist)), 0);
}
}
这段代码看起来无懈可击,但如果拿它跑 fuzz,在 amountA、amountB 都很大时有概率失败。原因是合约里的精度舍入:奖励分配时的取整方向不一致,导致部分奖励永久停留在合约里,无法被领取,于是最后合约余额不是 0,而是残留几个 wei 或更大。真实场景中,这种“沉淀资产”是审计重点。
3.4 第三步:Echidna 跑长序列,捕获动态状态错位
Foundry fuzz 很适合做“单笔交易内参数随机”,但要探索长序列状态变化,Echidna 更强。创建一个 echidna.yaml 配置文件,指定测试时间上限、交易序列长度等。
我在项目中一般生成一个 echidna.yaml:
yaml复制testMode: property
testLimit: 100000
seqLen: 100
shrinkLimit: 5000
coverage: true
corpusDir: "corpus"
filterBlacklist: false
其中 seqLen: 100 表示每一轮测试最多执行 100 笔连续交易,这对状态机类合约很有必要。shrinkLimit 是失败用例最小化时的最大步数,设太大会拖慢回归,设太小又可能无法有效简化。我通常保持 5000。
然后运行:
bash复制echidna-test contracts/test/TestRewardDistributor.sol --contract TestRewardDistributor --config echidna.yaml
假如我们设计的漏洞真的存在,Echidna 会给出一个从初始状态到破坏不变量之间的交易序列。例如它可能生成:
addRewardFromOwner(1000):此时totalDeposited = 0,totalRewardPerShare不变。depositAsAlice(1e18):alice 存入,totalRewardPerShare仍然是 0。withdrawAsAlice(1e18):alice 全部取走,totalDeposited = 0。depositAsBob(1):bob 只存 1 wei,此时他的rewardDebt计算用到旧的totalRewardPerShare。addRewardFromOwner(2e18):由于totalDeposited = 1 wei,totalRewardPerShare被瞬间放大到非常离谱的值。- bob 的
pendingRewards被错误计算为他根本不可能获得的奖励。
看到这个序列你可能会感叹:这谁能想得到?确实,这就是 Fuzzing 的价值——它能搜索到人脑容易忽略的交互序列状态。
3.5 让 Fuzzing 结果落地的最小复现步骤
拿到失败序列后,不要直接在 Echidna 输出上分析,先让工具帮你做 shrink(失败用例最小化)——Echidna 本身就带这个流程。当它给出一个很短的精简序列,把它加工成一个 Foundry 的单测,固定下来作为回归测试。
solidity复制function testRegression_ShrinkCase() public {
// 复现 Echidna 发现的最小交易序列
vm.prank(alice);
dist.deposit(1e18);
vm.prank(alice);
dist.withdraw(1e18);
// 关键步骤
vm.prank(bob);
dist.deposit(1);
token.mint(address(this), 2e18);
token.approve(address(dist), 2e18);
dist.addReward(2e18);
vm.prank(bob);
dist.claimReward();
// 如果 bug 存在,bob 不该拿到这么多
assertLe(token.balanceOf(bob), 2e18);
}
这个小步骤,价值巨大,因为它让后续每个开发人员都能快速验证修复,也方便做回归比对。
4. Fuzzing 实战中的常见问题与排查技巧
4.1 Fuzzer 跑了几分钟都没发现 bug,是我合约写得好,还是配置有问题
有一个很常见的误判:跑 10 分钟没发现问题,就觉得自己合约固若金汤。实际上,很多合约由于状态空间大且不变量设计弱,工具大概率只是在一个浅层状态空间里徘徊。
我的排查顺序是:
- 看覆盖率:如果关键函数覆盖率低于 80%,先补足前置操作。比如有
onlyOwner函数,Echidna 不会自动用 owner 身份调用,你需要在测试合约里暴露包装函数,主动设置好身份再调用。 - 看交易序列长度:如果
seqLen小于 10,很多跨多步的状态bug根本不可能触发。 - 看失败条件是否太苛刻:比如你要求
token.balanceOf(alice) == 0,但精度舍入天然会留下一点残留,那么 fuzz 会一直“失败”。这其实是提示你的不变量超出了业务本身允许的范围。
4.2 Echidna 报错说找不到函数,或者一直只跑同一个函数
这通常与两个因素有关:一是测试合约内部的函数命名没按 Echidna 约定。Echidna 默认把 setup 函数之外的所有公开函数都当作可调用的,如果你在测试合约里定义了辅助函数,比如 onlyUser 修饰器相关函数且它是 public 的,工具就会尝试调用,然后因为这些函数并不符合业务行为而不断 revert。
二是 fuzz 过程中某些函数因为 require 条件太严格,导致工具探索不到新分支。解决方案是为这类受保护函数创建一个不校验身份的 public 包装,并在包装内部设置好一个默认用户,例如:
solidity复制function callWithdrawAsUser(uint256 amount) public {
vm.prank(alice);
withdraw(amount);
}
4.3 一个失败用例在修复后,换一个随机种子又跑出另一种失败
这是正常现象,不用慌,但要注意归类。Fuzzing 不是穷举所有路径,它是在大量随机采样中寻找反例。当你把第一个反例修复后,离下一个反例的距离并没有显著增减。
我的处理原则是:
- 将每一个反例视为一个独立类别,先修的应该是业务逻辑层的根因,而不是只修补该反例的具体“路径”。
- 修复后跑一次回归,再延长 fuzz 时间跑一次,看看是否有更容易触发的同类问题残留。
- 如果第二个反例和第一个反例来自同一个根因,大概率是修复没到位,或者当时的修复仅针对了特定取值方向。
4.4 覆盖率“虚高”的另一个陷阱:内部函数与库函数
在复杂协议中,很多逻辑被抽到内部函数或者库函数里。Echidna 的行覆盖率通常只统计到合约级别,容易让你误以为核心逻辑没覆盖到。所以你可能需要把关键库函数也包装进测试合约,或直接给 fuzz 工具单独设置一个包含库函数逻辑的白盒测试目标。
4.5 关于“确定性”和“随机性”的平衡技巧记录
实际操作中,一个比较实用的策略是把 Fuzzing 分成两轮:
- 第一轮:固定随机种子的快速回归,规模小,时间短,目的是保证每次提交代码后能快速发现问题。
- 第二轮:不固定随机种子的深度回归,时间较长,目的是在 CI 中尽可能探索不同的状态空间。
以 Echidna 为例,第一轮可以这么跑:
bash复制echidna-test TestRewardDistributor --config echidna.yaml --seed 12345 --testLimit 1000
第二轮则去掉 --seed,把 testLimit 提高到 100000。这样既能日常回归,又能定期换着花样搜索。
4.6 Fuzzing 与形式化验证、代码审计的关系
很多人容易走向两个极端:要么觉得 Fuzzing 万能,要么觉得代码审计比 Fuzzing 高级。我个人的经验是,Fuzzing 的不可替代性在于它能自主发现反例,尤其适合验证“不变量”这类性质;代码审计则强在识别业务逻辑漏洞、经济模型漏洞,比如代币经济激励是否可能被薅羊毛。两者交集地带的典型例子是权限和价格边界问题,Fuzzing 特别擅长,因为它天然会生成各种边界条件组合。因此我的标准流程是:先代码审计梳理高风险点,再针对高危模块设计不变量做 Fuzzing。
4.7 常见失败信息速查表
| 现象 | 根因 | 解决方向 |
|---|---|---|
Echidna 提示 [FAIL] 但复现不了 |
随机种子未固定 | 加 --seed 锁定种子复现 |
| Fuzz 一直执行同一个公开函数 | 其他函数因 require 被过滤 | 降低参数范围或提供身份包装 |
Foundry vm.assume 过滤太严格 |
随机值几乎都被丢弃,导致性能极低 | 改用有界变量取模,而不是无脑 assume |
| 覆盖率上不去但逻辑看似正确 | 前置条件制约了路径可达性 | 增加状态 setup 或让部分参数走极端值 |
| 失败用例 shrink 后逻辑仍然复杂 | 状态存在多路径叠加 | 对每个状态跳变点增加不变量辅助定位 |
5. 让 Fuzzing 测试长期可维护的几个习惯
5.1 把不变量当成合约规格的一部分来维护
不变量一旦写好,它会随着业务迭代自然演化。你新增了一个领取空投的功能,那回归测试里必然要新增一条“某个用户在任何情况下不能既拿到空投奖励又拿到存款奖励”之类的不变量。如果团队愿意把不变量文档单独维护,后续新人接手审计也会顺畅很多。
5.2 定期更新语料库和种子库
我发现很多人忽略了 corpus 目录的作用。Echidna 发现的会产生新覆盖率的交易会被记录到 corpusDir 里。下次跑 fuzz 时,工具会基于这些历史种子继续变异,等于站在之前探索路径的肩膀上前进。因此,不要随手把 corpus 目录加到 .gitignore,建议定期提交到版本库,作为团队的共享资产。
5.3 把 fuzz 纳入 CI,而不是仅在审计时跑一次
安全审计不是一锤子买卖,合约改了任何一行可能引入回归。建议在 CI 里固定一条“快速 Fuzzing”任务,例如 5 分钟内跑完一轮种子随机的测试。这样,任何代码变动都能第一时间触发 fuzz 回归,而不是等着季度审计时才发现问题。根据个人经验,这项投入的回报非常稳定——几乎每次项目上线前的最后一天,都能用它拦下几个“不明显但真实存在”的问题。
6. 从一次真实漏洞中说点最深的体会
打开 Echidna 生成的那个失败交易序列时,我盯着屏幕愣了好一会儿。单看每一笔交易都完全符合业务规则,账户余额也都正确,但组合起来就能凭空造出一大笔奖励。事后我拿着这个序列去复盘,发现如果按传统单测逐条写用例,几乎不可能凭空想到“先存后取再存一 wei 再充值奖励”这种路径。
这类漏洞的本质,是合约把“单位累计奖励”作为一个全局状态持久化,却没有在总存款清零时重置它。只要某次清零后再次有人存款,就可以用历史残留的累计值套利。它不涉及复杂的数学计算,纯粹是状态机器在特殊事件序列下陷入了不期望的状态组合。
那以后我的习惯就变成了:拿到协议后,花至少两小时写不变量,而不是急着写业务测试。不变量写得越“认真”——比如不仅写“不能大于”,还写“线性增量的绝对值不能超过某阈值”——fuzz 能帮你排除的 bug 类型就越广。
再补充一个小经验吧。如果你遇到一个特别难发现的 bug,在你准备修复之前,先复制当时的 contract state 快照、Echidna 的 seed 值和序列文件,因为修复后的“代码变化”往往会改变状态计算路径,让你再也无法回到原来的现场去验证修复是否真正解决了根因。
Fuzzing 工具链在持续进化,现在很多工具已经支持对多合约交互和跨函数调用的深度探索。但无论工具怎么变,准备一组高质量的不变量,永远是整个方案里最值得花时间的地方。希望这篇写得足够细,已经把你需要知道的关键环节都覆盖到了。下次再跑 fuzz 的时候,可以随时把这篇拿出来参照。
