智能合约安全进阶:使用Echidna与Medusa进行不变量模糊测试

发布时间:2026/7/29 5:14:45

智能合约安全进阶:使用Echidna与Medusa进行不变量模糊测试 1. 项目概述为什么智能合约需要“模糊测试”在智能合约开发的世界里写完代码、通过单元测试甚至审计报告拿到手心里那块石头就真的落地了吗作为一个在DeFi和NFT项目里摸爬滚打多年的开发者我可以很负责任地告诉你远远不够。传统测试就像拿着手电筒在黑暗的房间里找东西你只能照亮你预先知道要去检查的地方。而智能合约里潜藏的往往是那些你“没想到”的交互路径和极端输入组合这些才是导致资金损失、协议瘫痪的罪魁祸首。这就是“模糊测试”的价值所在。它不是一个具体的工具而是一种方法论通过自动生成大量、随机、甚至“畸形”的输入数据去“轰炸”你的合约函数试图触发那些隐藏在角落里的漏洞。Foundry这个以速度和开发者体验著称的Solidity开发框架原生集成了强大的模糊测试能力。但今天我们要聊的是更上一层楼的玩法——利用专门的模糊测试工具Echidna和Medusa进行“不变量测试”。这不再是简单地测试函数会不会崩而是去验证你的合约在任何随机操作序列下那些必须始终为真的核心逻辑断言即“不变量”是否会被打破。比如一个借贷协议的总存款永远大于等于总借款一个AMM池的恒定乘积公式在每次交易后依然成立。发现一个违反不变量的案例往往就等同于发现了一个高危漏洞。2. 核心思路拆解从单元测试到不变量守护2.1 模糊测试 vs. 不变量测试思维的跃迁很多刚接触的朋友容易混淆这两个概念。我们可以这样理解传统单元测试/普通模糊测试关注的是“给定特定输入输出是否符合预期”。例如测试transfer函数我传入正确的地址和金额余额变化是否正确模糊测试则会随机生成成千上万个(地址, 金额)对去调用它看会不会有意外崩溃或溢出。不变量测试关注的是“无论发生什么操作某些状态或关系必须永远成立”。它测试的不是单个函数的输出而是整个合约状态机在经历一系列随机函数调用可能包括存款、取款、转账、交换等后其核心安全属性是否依然完好无损。为什么这个跃迁至关重要因为智能合约漏洞常常出现在“状态组合”和“操作序列”中。一个单独调用withdraw函数是安全的但如果在一次闪电贷攻击的同一个交易里先deposit再withdraw再deposit状态可能就被钻了空子。不变量测试通过模拟随机的、多步骤的用户交互能极大地提高发现这类复杂漏洞的概率。2.2 Echidna 与 MedusaFoundry 生态下的两把利刃Foundry 自带的forge test模糊测试已经很强大了那为什么还要引入 Echidna 和 MedusaEchidna基于属性的模糊测试器核心思想你需要用 Solidity 或 Haskell 定义一些“属性”即不变量。Echidna 会生成随机交易序列疯狂调用你的合约试图找到一个能令任何一条属性为假的序列。一旦找到它会自动将其简化为最短、最易于理解的复现步骤。优势非常擅长发现算术溢出、条件竞争、逻辑缺陷。它的“收缩”功能是杀手锏能把一个复杂的攻击路径简化到寥寥几步让调试变得极其轻松。在 Foundry 中的角色可以单独使用也可以与 Foundry 项目集成直接调用你编译好的合约。Medusa多合约、状态感知的模糊测试引擎核心思想Medusa 专注于测试多个合约之间的交互。它不仅能生成随机调用还能“感知”合约的存储布局和 ABI从而更智能地生成能改变状态的有效输入。它特别适合测试复杂的 DeFi 乐高组合。优势对多合约系统和状态空间的探索更深。它能理解 ERC20、ERC721 等标准接口生成更符合真实场景的调用序列。在 Foundry 中的角色通常作为 Foundry 项目的插件或外部工具运行读取项目配置和合约进行深度测试。简单对比与选型建议如果你的合约逻辑复杂有明确的数学关系或状态约束如“总量恒定”、“余额非负”Echidna是你的首选。如果你的项目由多个相互交互的合约组成如一个DAO、一个带有治理和资金池的协议Medusa能提供更全面的覆盖。最理想的流程是先用 Foundry 自带的模糊测试做基础覆盖再用 Echidna 验证核心属性最后用 Medusa 进行多合约集成压力测试。3. 环境搭建与工具集成实操3.1 基础 Foundry 项目准备假设我们已经有一个基础的 Foundry 项目。如果没有可以快速初始化forge init my-fuzz-project cd my-fuzz-project项目结构大致如下my-fuzz-project/ ├── src/ ├── script/ ├── test/ └── foundry.toml我们所有的测试代码都将放在test/目录下。3.2 Echidna 的安装与集成Echidna 的安装非常方便推荐使用包管理器。macOS/Linux (使用 Homebrew):brew tap crytic/echidna brew install echidna验证安装echidna --version在 Foundry 项目中为 Echidna 创建测试Echidna 测试是独立的 Solidity 文件。我们在test/目录下创建一个新文件例如EchidnaInvariants.t.sol。一个关键点是Echidna 需要合约的字节码。最简单的方式是让 Echidna 直接使用 Foundry 的构建输出。我们需要在foundry.toml中确保构建输出目录是标准的[profile.default] out out src src libs [lib]然后在 Echidna 测试合约中我们可以通过HEVM作弊码或直接部署的方式来获取已编译的合约。更常见的模式是在 Echidna 测试合约内部直接编写你要测试的合约的逻辑或者继承并部署它。3.3 Medusa 的安装与初步配置Medusa 的集成相对较新通常通过cargo(Rust 包管理器) 安装。安装 Medusa:cargo install medusa-fuzz或者从 GitHub 仓库克隆并编译git clone https://github.com/crytic/medusa.git cd medusa cargo install --path .配置 MedusaMedusa 需要一个配置文件如medusa.json来指定要测试的合约、工作目录、测试时长等。这个配置文件需要指向 Foundry 项目的构建输出out/目录和 ABI 文件。{ fuzz: { contracts: [out/MyContract.sol/MyContract.json], work_dir: medusa_work, duration: 300 } }注意Medusa 的生态和文档相比 Echidna 仍在快速发展中具体配置方式可能随版本更新而变化。务必查阅其官方 GitHub 仓库的最新文档。4. 编写不变量定义合约的“神圣法则”这是不变量测试中最核心、也最需要开发者深思熟虑的一步。写不好不变量测试就是无的放矢。4.1 不变量的常见类型与示例状态不变量关于合约存储变量的永恒真理。示例ERC20// 总供应量必须等于所有账户余额之和 function invariant_totalSupplyEqualsSumOfBalances() public view { uint256 sum 0; for (uint256 i 0; i users.length; i) { sum token.balanceOf(users[i]); } // 这里需要忽略一些地址如销毁地址(0x0)或合约自身取决于逻辑 assert(sum token.totalSupply()); }数学关系不变量基于公式的约束。示例恒定乘积做市商// 交易后 reserve0 * reserve1 k (允许轻微滑点) function invariant_constantProduct() public view { (uint256 reserve0, uint256 reserve1, ) uniswapPair.getReserves(); uint256 k reserve0 * reserve1; // 模拟一次随机交换后检查 // ... 这里需要结合Echidna的setup和test函数来动态验证 // 断言新的 reserve0 * reserve1 k * (1 - slippageTolerance) }访问控制不变量权限必须被遵守。示例// 只有Owner才能设置的关键参数其值必须在一个安全范围内 function invariant_criticalParamInRange() public view { assert(vault.withdrawalLimit MIN_LIMIT vault.withdrawalLimit MAX_LIMIT); assert(vault.feePercentage MAX_FEE); }资产安全不变量资金永远不会凭空消失或增加。示例借贷池// 池中抵押物的总价值必须 所有未偿还贷款的总价值 function invariant_solvency() public view { uint256 totalCollateralValue calculateTotalCollateralValue(); uint256 totalDebtValue calculateTotalDebtValue(); assert(totalCollateralValue totalDebtValue); }4.2 编写 Echidna 不变量测试合约让我们为一个简单的“保险柜”合约编写一个完整的 Echidna 测试。1. 首先这是我们的被测合约 (src/Vault.sol):// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Vault { mapping(address uint256) public balances; uint256 public totalLocked; function deposit() external payable { require(msg.value 0, “Deposit must be positive”); balances[msg.sender] msg.value; totalLocked msg.value; } function withdraw(uint256 amount) external { require(amount 0, “Withdraw must be positive”); require(balances[msg.sender] amount, “Insufficient balance”); balances[msg.sender] - amount; totalLocked - amount; payable(msg.sender).transfer(amount); } }2. 接着编写 Echidna 测试 (test/EchidnaVault.t.sol):// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import “../src/Vault.sol”; import {Test} from “forge-std/Test.sol”; contract EchidnaVaultTest is Test { Vault vault; // Echidna 会自动调用这个函数进行初始化 function setUp() public { vault new Vault(); } // 这是一个Echidna可以调用的“操作”。Echidna会随机生成 msg.value。 function deposit(uint256 value) public payable { // 限制随机值范围避免过大导致测试变慢 value _between(value, 1, 100 ether); // 使用作弊码给调用者充值模拟真实存款 deal(address(this), value); (bool success, ) address(vault).call{value: value}(abi.encodeWithSignature(“deposit()”)); // 我们允许失败例如如果value为0Echidna会处理 } // 另一个随机操作取款 function withdraw(uint256 amount) public { amount _between(amount, 1, type(uint256).max); (bool success, ) address(vault).call(abi.encodeWithSignature(“withdraw(uint256)”, amount)); // 同样允许失败余额不足 } // 核心不变量定义 // 属性1总锁定金额必须等于所有用户余额之和 // Echidna 会自动寻找使此函数返回 false 的交易序列 function invariant_totalLockedEqualsSumOfBalances() public view { // 注意在实际中我们无法遍历所有地址。 // 这是一个简化示例。更实际的做法是维护一个测试用户列表。 // 这里我们断言一个更简单的属性总锁定金额非负且合理。 assert(vault.totalLocked() 0); // 更严谨的测试需要维护测试账户状态并在每个操作后更新和断言。 } // 属性2保险柜的以太坊余额必须等于 totalLocked // 这是一个更强的不变量 function invariant_vaultBalanceMatchesTotalLocked() public view { assert(address(vault).balance vault.totalLocked()); } // 属性3任何用户的余额不应为负数Solidity uint256 本身不会为负但这里强调属性 // 我们可以通过检查一个特定测试地址的余额来示例 address internal constant TEST_ADDRESS address(0x123); function invariant_testAddressBalanceNonNegative() public view { assert(vault.balances(TEST_ADDRESS) 0); } // 一个辅助函数用于生成范围内的随机数模拟Echidna的输入限制 function _between(uint256 value, uint256 low, uint256 high) internal pure returns (uint256) { return low (value % (high - low 1)); } }实操心得在编写不变量时最大的挑战是“状态追踪”。Echidna 测试合约本身需要维护一份它认为的“预期状态”如每个测试用户的余额然后在每次随机操作后更新这份预期状态并与真实合约的状态进行比对。上面的示例中invariant_totalLockedEqualsSumOfBalances是理想情况实现它需要更复杂的测试架构。而invariant_vaultBalanceMatchesTotalLocked则是一个巧妙且强大的不变量它直接利用区块链的余额属性无需维护复杂状态。4.3 配置并运行 Echidna创建一个 Echidna 配置文件echidna.yaml# echidna.yaml testMode: assertion # 测试模式assertion断言失败, optimization优化属性, exploration探索 testLimit: 50000 # 生成测试的总次数 seqLen: 100 # 每个测试序列的最大交易数 shrinkLimit: 5000 # 尝试收缩失败用例的步骤限制 corpusDir: “corpus” # 保存成功测试用例的目录有助于提升覆盖率 checkAsserts: true # 检查 Solidity 的 assert()运行测试echidna test/EchidnaVault.t.sol --contract EchidnaVaultTest --config echidna.yamlEchidna 将开始运行并输出类似以下的结果Analyzing contract: /path/to/EchidnaVault.t.sol:EchidnaVaultTest invariant_vaultBalanceMatchesTotalLocked: failed! Call sequence: deposit(1) — 成功 withdraw(2) — 成功 (但逻辑上应失败) … (Echidna 收缩后的最小序列)如果发现违反不变量Echidna 会输出导致失败的最小操作序列。这就是黄金时刻——你很可能发现了一个漏洞。在上面的例子中withdraw(2)在只存了 1 wei 的情况下成功了这立刻指向了withdraw函数的关键漏洞它没有使用call的返回值且transfer在失败时会回滚整个交易但如果我们用call并忽略返回值恶意合约可能利用重入攻击。Echidna 通过随机测试发现了这个“余额不匹配”的场景。5. 高级策略与实战避坑指南5.1 处理复杂状态与外部依赖现实中的合约依赖价格预言机、其他代币合约等。测试时需要“模拟”这些依赖。使用作弊码CheatcodesFoundry 的vm作弊码在 Echidna 中部分可用通过HEVM接口或者你可以在setUp中部署“模拟合约”。// 在setUp中部署一个模拟的预言机 MockOracle oracle new MockOracle(); vault.setOracle(address(oracle)); // 然后在Echidna的测试函数中可以随机改变预言机的价格 function setRandomPrice(uint256 price) public { price _between(price, 1 ether, 10000 ether); oracle.setPrice(price); }维护测试状态机对于有复杂状态的合约在测试合约内部维护一个“影子状态”。每次调用真实合约后也在影子状态上执行相同操作然后比较两者是否一致。5.2 提升模糊测试效率的技巧种子与语料库使用--seed参数复现之前的测试使用--corpus-dir收集并重用之前成功的测试输入能显著提高覆盖率。自定义生成器Echidna 允许你编写函数来生成更有效的随机输入而不是完全随机的uint256。例如为地址生成器添加权重使其更倾向于测试中的特定用户。function generateValidWithdrawAmount(address user) internal view returns (uint256) { uint256 balance vault.balances(user); if (balance 0) return 0; // 生成一个介于 1 和 balance 之间的随机数 return 1 (uint256(keccak256(abi.encodePacked(user, block.timestamp))) % balance); }限制搜索空间通过require或assume如果Echidna支持在测试函数开头过滤掉无意义的输入节省计算资源。function deposit(uint256 value) public payable { // 假设我们不测试超大额存款 if (value 1000 ether) return; // ... 剩余存款逻辑 }5.3 Medusa 在多合约场景下的应用示例假设我们有一个Token合约和一个Staking合约。Medusa 可以更好地测试它们的交互。1. 编写 Medusa 配置 (medusa.json):{ “fuzz”: { “contracts”: [ “out/Token.sol/Token.json”, “out/Staking.sol/Staking.json” ], “work_dir”: “medusa_work”, “duration”: 600, “sender_count”: 5, “seq_len”: 50 } }2. 运行 Medusa:medusa fuzz --config medusa.jsonMedusa 会部署这些合约并生成多个发送者sender_count每个发送者执行一系列随机调用seq_len持续10分钟duration。它会监控所有调用检查是否有异常如断言失败、 revert 非预期、余额异常等。3. 解析结果Medusa 会输出一个报告列出所有发现的错误、代码位置以及触发错误的交易序列。你需要仔细分析这些序列理解多个合约间的状态是如何被一步步破坏的。6. 常见问题排查与调试心得6.1 Echidna/Medusa 运行失败或报错问题现象可能原因解决方案echidna命令未找到未安装或未加入PATH重新安装或使用nix-shell等环境。Error: Failed to detect Solc versionEchidna 找不到合适的 Solidity 编译器。在项目目录下运行确保有foundry.toml。或使用--solc参数指定 solc 路径。Assertion failed但肉眼没看出问题不变量写得过于严格或有误。检查不变量逻辑。可能是浮点精度、时间依赖如block.timestamp导致。使用vm.skip(true)在特定条件下跳过断言。测试运行极慢搜索空间太大或操作函数消耗 Gas 过多。在测试函数开头用require限制输入范围。使用--testLimit减少总次数先快速验证。Medusa 报Failed to deploy contract构建输出路径不对或合约构造函数需要参数。检查medusa.json中contracts路径是否正确。确保在 Foundry 中已成功编译 (forge build)。6.2 如何解读和缩小失败案例当 Echidna 报告一个不变量被违反时它会给出一串调用序列。第一步不是直接看代码而是重现它。使用 Echidna 的--test模式echidna test.sol --contract TestContract --test “invariant_xxx”可以单独运行某个属性测试。将失败序列转化为 Foundry 测试这是最有效的调试方法。将 Echidna 输出的调用序列手写成一个 Foundry 的test函数。function test_EchidnaCounterExample() public { vault new Vault(); // 步骤1: deposit(1) deal(address(this), 1); (bool suc1, ) address(vault).call{value: 1}(“”); // 步骤2: withdraw(2) (bool suc2, ) address(vault).call(abi.encodeWithSignature(“withdraw(uint256)”, 2)); // 检查不变量 assert(address(vault).balance vault.totalLocked()); // 这里会失败 }然后在 Foundry 中运行forge test --match-test test_EchidnaCounterExample -vvv使用-vvv获得详细的调用追踪一步步观察状态如何变化精准定位漏洞所在行。6.3 模糊测试的局限性认知即使通过了 Echidna 和 Medusa 的百万次随机测试也绝不代表合约绝对安全。覆盖率的幻觉高分支覆盖率不等于高状态空间覆盖率。合约可能有无穷的状态路径。不变量本身的正确性如果开发者错误地定义了不变量例如误以为某个数学关系成立那么测试将无法发现违反真实安全属性的漏洞。不变量测试的质量完全取决于你定义的不变量的质量。外部性与时间涉及区块链外部状态如特定区块哈希、其他合约的复杂状态或精确时间依赖的漏洞模糊测试难以捕获。因此模糊测试和不变量测试是安全工具箱中极其强大的一环但它必须与形式验证、人工审计、漏洞赏金计划等其他手段结合使用才能构建相对坚固的防线。在我自己的开发流程里Echidna 测试已经成为继单元测试后的一个强制性关卡。每次编写核心状态转换函数时我都会同步思考“它的不变量是什么” 并将之转化为测试代码。这个过程本身就是一次对合约逻辑的深度审查往往在写测试的时候就能提前发现设计上的模糊点或潜在风险。把模糊测试从“事后检查”变为“设计辅助”这才是它带来的最大价值。

相关新闻