智能合约Fuzzing实战:从单元测试盲区到不变量安全防线 最近好几个做合约开发的朋友都在问我同一个问题智能合约 Fuzzing 工具到底是不是一定要上上了之后会不会只是给自己找活干。我自己的答案很直接如果你还在靠手写几个单元测试就想守住合约里的资产那你的防线可能比想象中薄得多。我第一次被 Fuzzing “教育”是在一个内部测试里全套单元测试跑完绿油油结果 Echidna 跑了几分钟就弹出一串交易序列直接把不变量打穿。从那之后我就把 Fuzzing 正式并入了自己的合约开发流程。这篇文章不打算念 PPT就从实际使用角度聊聊工具怎么选、不变量怎么写、跑出问题后怎么排查以及我踩过哪些坑。适合所有已经开始写合约、但还没系统性引入 Fuzzing 的人参考。1. 先把话说透单元测试通过不等于合约安全1.1 单元测试擅长抓的是“已知问题”而不是“未知组合”单元测试的本质是你先想好“某个输入应该得到什么结果”然后写一个测试去验证。这个方法在业务逻辑简单、状态分支少的时候很有效可一旦合约里的状态变量多起来调用顺序不同会导致完全不同的结果。比如一个借贷合约你写了“用户 A 存款、用户 B 借款、用户 C 清算”的正常路径测试看起来覆盖了主要场景但攻击者不会按你的剧本走。他们会尝试更短的路径、更奇怪的金额、甚至在同一笔交易里反复调用某个函数直到找到那个单元测试里根本不存在于你脑图中的状态组合。我见过不止一个项目代码 review 和单测做得有声有色结果漏洞出在一个谁都没在意的小函数上——那个函数单独调用没问题但和其他函数组合在一起就能把协议的核心状态篡改掉。单元测试是“点状验证”Fuzzing 是“连续状态探索”后者补的正是前者最大的盲区。1.2 智能合约 Fuzzing 和传统 Fuzzing 的差异从“找崩溃”到“找不变量破坏”做传统安全的人对 Fuzzing 应该不陌生往函数里塞随机字节看程序会不会崩溃或者内存越界。智能合约的世界里EVM 本身就是个状态机每一笔交易都会把合约从一个状态迁移到另一个状态。智能合约 Fuzzing 的重点不是让虚拟机崩掉而是检查这个状态迁移过程中合约里那些“任何时候都必须成立”的规则有没有被破坏。这些规则就叫不变量。比如“所有用户余额之和必须等于合约总资产”“清算后坏账不能大于某个阈值”“代币总量守恒”等等。Fuzzer 会不断生成随机调用序列在每一笔交易前检查不变量是否成立一旦发现不成立就立刻把导致问题的调用序列记录下来。这个思路比单元测试更接近真实攻击者的视角他们也在随机排列函数、组合参数。1.3 Fuzzing 常见能暴露的问题类型不是所有漏洞都能被 Fuzzing 抓到但我实测下来这几类是它最擅长的状态变量之间的不一致比如记账余额和实际余额脱节某个函数忘了更新关键的累计变量。边界条件与整数溢出Solidity 0.8 起默认检查溢出但 unchecked 块、位运算、精度计算仍然是重灾区。权限与状态组合漏洞不同角色的操作顺序导致权限绕过。重入与跨函数重入单个函数加了重入锁但两个函数互相回调时锁没覆盖到。预言机或价格相关的极端输入价格突然大幅波动清算与借款流程出现逻辑冲突。这些问题的共同点是它们在“多步交易序列”中才会暴露单笔单元测试很难覆盖到。所以智能合约 Fuzzing 并不是锦上添花的工具而是补足测试盲区的必需品。2. 主流工具体检Echidna、Foundry fuzz、Medusa 的选择逻辑2.1 Echidna不变量测试的老大哥Echidna 出自 Trail of Bits是我最早接触也是目前最常用的智能合约 Fuzzing 工具。它的核心思路是你在一个测试合约里定义好以echidna_test开头的布尔函数如果某个函数返回false就认为不变量被破坏。它还会自动生成多笔交易序列并在每笔交易之间保留状态所以能发现那些需要多次调用才触发的深层问题。Echidna 的配置通过 YAML 文件控制比较关键的有testLimit执行多少笔交易、seqLen每条执行序列的长度、sender用哪些地址发起调用、corpusDir语料库目录等。它上手有一定门槛但控制粒度非常细。比如你可以指定某些函数只允许白名单地址调用也可以把测试限制在某个合约内部。对于追求精细不变量控制的项目Echidna 依然是首选。2.2 Foundry 内置 fuzz如果你已经在用 Forge别忽略它Foundry 是我日常开发的主力框架它内置的 fuzz 功能被很多人低估了。Foundry 的 fuzz 分为两种一种是传统 calldata fuzz也就是随机参数调用某个 Solidity 测试函数另一种是 invariant 测试函数名以invariant_开头配合foundry.toml里的[invariant]配置可以指定runs和depth也就是跑多少轮、每轮多少笔交易。Foundry invariant 测试的优势是和工程结合得非常紧密。你不需要另起一套测试框架直接在test/目录里写 Solidity 代码所有assert*语法、cheatcodes 都能复用。缺点是它的状态探索策略对比 Echidna 还是偏“朴素”对复杂业务场景的引导性弱一些。但作为日常快速验证工具它的性价比非常高。2.3 Medusa并行化思路和它的适用场景Medusa 也是 Trail of Bits 推出的工具最大的卖点是并行化。Echidna 默认跑单核遇到大型协议、状态空间非常庞大的合约时速度会很难受。Medusa 支持多核并行能在更短的时间内探索更多路径而且提供了网络服务接口方便外部系统动态控制 fuzz 过程。不过 Medusa 相对年轻在文档完备度和社区生态上还不如 Echidna。我自己一般是在项目到了相对后期、合约状态变得很复杂的时候才会把 Medusa 叫出来跑一轮大规模并行 fuzz。日常开发和小型合约Echidna 加 Foundry 已经完全够用。2.4 工具横向对比工具类型上手难度执行效率适合场景Echidna独立 fuzzer中单核为主精细不变量、复杂交易序列、协议级测试Foundry fuzz框架内置低较高日常开发、轻量回归、与测试代码无缝结合Medusa独立并行 fuzzer中高大型协议、需要快速并行探索的场景Mythril符号执行较高较低深度路径探索严格说不算 Fuzzing我自己的选型策略很简单开发早期用 Foundry 写 invariant 测试提交 PR 前跑一遍每周或在关键模块改动后再用 Echidna 跑一次全量 fuzz并把生成的语料库沉淀下来项目进入审计前如果有精力用 Medusa 做一轮并行压力测试。这个组合能覆盖绝大多数项目的成本与安全需求。3. 动手给一个简易 Airdrop 合约写上第一组不变量3.1 先准备一个有代表性的测试目标理论说太多容易晕直接看代码更实在。我构造一个简化版 Airdrop 合约它的问题是用户可以无限次领取代币但合约定义了领取总量上限LIMIT。现实中这类问题经常以更隐蔽的方式出现比如“每个用户只能领一次”的检查只在个别函数里有换个函数入口就绕过了。// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract Airdrop { uint256 public constant LIMIT 100 ether; uint256 public totalClaimed; mapping(address uint256) public claimedAmount; function claim() external payable { claimedAmount[msg.sender] msg.value; totalClaimed msg.value; } }看到问题了吧claim里没有判断totalClaimed msg.value LIMIT。单看注释或单测你可能会觉得“领取功能正常”但真实攻击者只要连续调用claim就能把总量打超。这正好适合拿来做 Fuzzing 演示因为它需要多笔交易才能暴露。3.2 从业务规则里提炼不变量写不变量之前先问自己这个协议在任何时候都必须满足的账本规则是什么对于这个 Airdrop 合约最核心的不变量显然是totalClaimed永远不能大于LIMIT。用 Solidity 表达就是totalClaimed LIMIT。有人可能会说这不就是加个require的事吗对修复确实就一行但难的是让测试体系先于攻击者发现它。代码评审和单测很容易漏掉这种“多笔交易后才越界”的问题而不变量测试一旦写好fuzzer 会不厌其烦地组合调用次数和金额直到把那条规则击穿。这就是把安全底线沉淀成代码约束的意义。3.3 编写 Echidna 不变量测试和配置文件Echidna 的测试合约也是 Solidity 文件通常放在test/目录下。我们需要写一个AirdropTest合约继承Airdrop再定义一个echidna_test_total_claimed_lte_limit函数// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; import ./Airdrop.sol; contract AirdropTest is Airdrop { function echidna_test_total_claimed_lte_limit() public view returns (bool) { return totalClaimed LIMIT; } }Echidna 会自动调用这个测试合约里所有可公开调用的函数包括继承来的claim。每笔claim调用之后它都会检查echidna_test_total_claimed_lte_limit的返回值一旦返回false就报告失败。接着写一个echidna.yaml配置文件testMode: property testLimit: 50000 seqLen: 100 sender: - 0x10000 - 0x20000 - 0x30000 corpusDir: corpus coverage: truetestMode: property表示按不变量模式测试。testLimit: 50000是总共执行的交易笔数上限。seqLen: 100表示每条执行序列最多包含 100 笔交易。sender配置了多个调用者地址模拟不同用户同时交互。coverage: true让 Echidna 记录覆盖率信息帮助判断探索充分度。运行命令很简单echidna test/AirdropTest.sol --contract AirdropTest --config echidna.yaml3.4 看输出fail 信息到底在说什么如果配置没问题Echidna 会开始不断生成随机调用序列。运行几秒后大概率就会输出类似下面的失败信息[FAIL] echidna_test_total_claimed_lte_limit() Call sequence: claim() value: 0x77359400 claim() value: 0x38D7EA4C68000 claim() value: 0x56BC75E2D63100000每行claim() value: ...代表一次向claim转账的交易value 后面的十六进制就是每次转账的 wei 金额。把十六进制金额加起来你很快会发现totalClaimed已经超过了100 ether。这一步跑通之后你对 Fuzzing 的感觉就基本建立了它不是在“证明”代码安全而是在“拼命找理由证明代码不安全”。只要有一条路径能打破不变量它一定会找出来。3.5 怎么区分真 Bug 和“不变量写错”Fuzzing 报错后第一反应不应该是改业务代码而是先重新审视不变量本身。在我经验里放大器很大比例的失败其实来自不变量写得和业务规则不一致。比如这个 Airdrop 合约如果业务上允许团队预留一部分额度那LIMIT可能只是“用户可领取额度”而不是“合约总发放额度”此时不变量totalClaimed LIMIT就写错了。正确的做法是先确认规则再决定不变量表达式。Fuzzing 的产出是“某条规则被打破”但这条规则是否代表真实业务约束需要你结合协议文档、产品需求和账本逻辑去判断。还有一个实用技巧如果新写的不变量一跑就 fail不要急着删掉重写先想办法用单笔交易手工复现它。如果手工找不到路径多半不是代码 bug而是不变量定义有了问题。4. 从 Echidna 弹交易序列到定位根因一次完整排查4.1 拿到最小复现序列后先别急着改代码Echidna 给出的调用序列往往不是最短的因为它是从某个随机 sequence 里截出来的。我拿到失败信息后第一件事是把十六进制金额转成十进制按顺序演算一遍状态变化确认哪一步导致不变量被打破。只要把状态变化关系理清了修复方案就会很清晰。比如上面的调用序列里三次claim分别是不同金额。演算之后发现第三次调用后totalClaimed直接越过了LIMIT。那问题就定位在claim缺少总额校验而不是某个极端金额触发了隐藏分支。实际项目里调用序列会复杂得多可能涉及五六个不同函数。这时候我会在本地用构式日志把状态变量打出来或者直接用 Foundry 写一个最小复现测试而不是靠眼睛在一堆十六进制数字里找原因。4.2 把交易序列翻译成 Foundry 回归测试为了确保同一个问题不再出现我会把 Fuzzing 暴露的路径固化成一个普通单测放进test/目录。这样以后 CI 每次都会跑相当于把一次随机发现变成了长期护城河。// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; import forge-std/Test.sol; import ../src/Airdrop.sol; contract AirdropRegressionTest is Test { Airdrop internal airdrop; function setUp() public { airdrop new Airdrop(); } function test_cannot_claim_more_than_limit() public { vm.deal(address(this), 1000 ether); airdrop.claim{value: 1 ether}(); airdrop.claim{value: 10 ether}(); airdrop.claim{value: 100 ether}(); assertLe(airdrop.totalClaimed(), airdrop.LIMIT()); } }用forge test --match-test test_cannot_claim_more_than_limit跑一下当前代码会失败。这很正常因为我们还没修复。现在这个测试就成了“会说话的 bug 报告”等修完它变绿说明该路径已关闭。4.3 修复并重新验证修复只需要在claim里加一个总额校验同时把状态更新放在检查之后function claim() external payable { require(totalClaimed msg.value LIMIT, exceed limit); claimedAmount[msg.sender] msg.value; totalClaimed msg.value; }然后重新跑两个测试forge test --match-test test_cannot_claim_more_than_limit echidna test/AirdropTest.sol --contract AirdropTest --config echidna.yaml两个都通过才算把这条路径真正关掉。这里我特别想强调很多人跑完 Foundry 单测就停了忘了再把 Echidna 跑一遍。其实后者更重要因为随机 Fuzzing 有可能发现你手动挑选的交易序列之外的组合不能省。4.4 排查过程中的那些坑时间戳、多个 sender、gas 配置真实合约比示例复杂实际排查时会遇到几个高频坑我一个个说。第一个是时间戳敏感。如果合约里大量使用block.timestampEchidna 的默认调用方式可能无法很好覆盖长期时间跨度。建议在测试合约里显式避免对时间戳的强依赖或者配合 Foundry 的vm.warp写回归测试把时间相关的路径单独验证。第二个是 sender 配置不足。很多合约的权限逻辑依赖msg.sender如果只用一个默认 sender 跑 Fuzz你测不到“多用户之间相互影响的路径”。所以我在echidna.yaml里通常会配 3 到 5 个不同 sender尽量模拟真实的多参与者环境。第三个是 gas 限制。Echidna 默认的 gas 上限有时候不够触发深层逻辑尤其是涉及循环、复杂计算或者存储扩展时。如果发现 Fuzzing 覆盖率始终上不去可以尝试调整测试环境的 gas 配置或在 Foundry 里设置gas_limit给 Fuzzer 足够的空间探索深层分支。这三个坑我都真实踩过每次都是排查半天才意识到是测试设置的问题而不是合约 bug。5. 把 Fuzzing 固化到日常开发流程里5.1 CI 里怎么安排 Fuzzing 任务Fuzzing 不能只在本地想起才跑否则它对你的保护是零散的。我自己的做法是把 Fuzzing 嵌进 GitHub Actions让每个 PR 都会自动跑一轮轻量 Fuzz每周或关键版本点再手动触发一次全量 Fuzz。Foundry 的集成是最顺滑的因为foundry-toolchain已经是很成熟的 action直接在 CI 里执行forge test --match-test invariant_就能复用本地的 invariant 配置。Echidna 则需要先安装好对应版本再运行刚才提到的命令。如果不想自己维护安装步骤社区也有一些封装好的 action 可用但建议先固定版本避免上游更新导致行为变化。一个精简的 CI 片段大致长这样name: fuzz on: [push, pull_request] jobs: fuzz: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: foundry-rs/foundry-toolchainv1 - name: Run Foundry invariant tests run: forge test --match-test invariant_ -vvv - name: Run Echidna run: echidna test/AirdropTest.sol --contract AirdropTest --config echidna.yaml这里有个容易被忽略的点CI 里的 Fuzz 参数要和本地开发参数做区分。本地可以跑短一点追求快速反馈CI 里要跑相对长的轮数因为这时候你不需要马上输出结果而是希望在合并前找到深层的状态组合问题。5.2 corpus 到底要不要提交到仓库Echidna 运行过程中会把有价值的调用序列存进corpusDir目录。这些序列可以加速下一次 Fuzzing因为 Fuzzer 会优先从历史序列附近的突变开始探索而不是每次从零随机。我的建议是提交一份规模可控的 corpus 到仓库。好处是 CI 构建时能立刻有初始终子减少了随机性带来的不稳定坏处是 corpus 会随着时间增长如果太大提交到仓库反而不值得。实践上我会定期清空重建只保留那些覆盖到关键分支的最小序列。这样既保留加速价值又不会让仓库变成一个垃圾场。5.3 覆盖率报告怎么看才有效Echidna 加coverage: true之后运行完会输出覆盖率信息。Foundry 也有forge coverage命令可以统计语句和分支覆盖。覆盖率数字本身不是目标但它是衡量 Fuzz 探索充分度的重要指标。我常用的方法是先跑第 5000 笔和跑第 50000 笔看覆盖率是否有明显提升。如果提升很小说明 Fuzzer 已经陷入重复需要添加 harness 或调整配置。如果覆盖率一直上不去更可能的原因是某些函数需要特定前置条件才能调用这时候应该在测试合约里增加辅助函数把 Fuzzer“引入”到那片路径里。需要注意的是行覆盖率很高不等于安全因为真正危险的往往不是“某行执行了没有”而是“多行状态之间的关联是否正确”。所以覆盖率报告必须和 invariant 结果一起看单看任何一项都是片面的。5.4 成本控制测试预算怎么定Fuzzing 是典型的“跑得越久效果越好”但成本也越高。我的经验是把测试预算分为三档开发阶段testLimit5000 或 Foundry invariant 100 轮目的是快速发现低级错误。PR 阶段testLimit30000 或 invariant 300 轮保证关键改动没有明显回归。审计前testLimit100000 以上配合 Medusa 并行跑尽量覆盖更多状态组合。说白了Fuzzing 的时间和资源消耗要注意性价比不能上来就无限跑。你完全可以在一次小的协议改动上只跑 5000 笔交易做冒烟而不是指望一周跑一次就能兜住所有问题。频率和深度之间需要找到适合你团队的平衡点。6. Fuzzing 的边界以及我这些年和它怎么相处6.1 Fuzzing 不是审计的替代品也不是万能药工具再强也有边界。Fuzzing 擅长在已知状态空间内发现违反不变量的问题但它不能证明一个合约不存在其他缺陷。比如复杂的业务逻辑设计错误、治理流程中的多签交互缺陷、跨合约协议之间的组合风险这些往往需要人工审计、“形式化验证”或更深层的架构审查来补充。我见过一种危险心态觉得跑了 Fuzzing 就是“安全了”把结果直接贴给审计方。这其实会适得其反因为审计方会默认 Fuzzing 已经覆盖了某些路径反而可能忽略测试盲区。正确做法是把 Fuzzing 当作审计前的第一道防线它把最明显的病治好再让专业审计去检查更深的问题。6.2 我经常提醒自己避免的三个误区误区一Fuzzing 就是要跑满最大轮数。不是Fuzz 的效果和“不变量质量”强相关不变量写得越贴近业务规则发现问题的效率越高。与其无脑加轮数不如花时间把不变量体系设计好。误区二不变量必须特别复杂才有价值。恰恰相反最好的不变量往往是最简单的账本守恒规则类似“总量守恒”“余额不小于 0”“累计值不超过上限”。复杂不变量不仅难写还容易写错反而消耗排查成本。误区三Fuzzing 报错就说明合约有漏洞。前面提到不变量本身可能写错也可能是测试环境与实际链上环境不一致。每次报错都要先冷静判断来源再决定要不要改业务代码。否则你会被一堆假阳性折腾到怀疑人生。6.3 我现在的实践习惯经过这几年反复试错我现在已经形成了固定习惯每次写新合约接口时先把接口需要保证的不变量用注释写在代码里然后才写实现。写完之后不急着补单元测试先建一个最基础的不变量测试跑一轮 Fuzz。等到主要逻辑稳定了再补单元测试和更复杂的 regression 测试。这个顺序和大多数人反着来但实际效果很好因为 Fuzz 会在我补单测之前就帮我扫掉一批“想当然”的逻辑错误。Fuzzing 工具链本身不神秘难的是把它变成你设计流程的一部分而不是事后补窟窿的应急手段。希望这些实战经验能让你少走一点我走过的弯路。