Foundry 不变量测试详解
目录
- 一、什么是不变量测试
- 二、单元测试 vs 模糊测试 vs 不变量测试
- 三、Foundry 不变量测试设置
- 四、开放测试 vs Handler 模式
- 五、目标辅助函数
- 六、bound 辅助函数
- 七、真实不变量案例
- 八、fail_on_revert 配置
- 九、Ghost 变量
- 十、总结
- 十一、动手练习项目:用不变量测试抓漏洞 InvariantHunt
一、什么是不变量测试
不变量测试是一种通过**有状态模糊测试(stateful fuzzing)**验证合约正确性的方法。
不变量测试能测出单元测试很可能漏掉的方面。
**不变量(invariant)**描述的是”在一组明确定义的假设下,永远成立的属性”。模糊器用各种随机输入、跨多次运行反复调用合约函数,试图找出违反不变量的情况。
经典例子:ERC20 里”所有余额之和永远等于 totalSupply”——无论怎么转账铸销,这条都该成立。
二、单元测试 vs 模糊测试 vs 不变量测试
| 类型 | 特点 |
|---|---|
| 单元测试 | 只验证你明确写出的特定行为 |
| 模糊测试(fuzz) | 用随机输入测函数,但通常无状态(调用之间不保留状态) |
| 不变量测试 | 有状态模糊:一次调用的状态保存给下一次,能发现跨操作序列的漏洞 |
关键区别:不变量测试会随机组合一长串函数调用,每次调用都基于上一次的状态——这正是它能发现”特定操作顺序才触发的漏洞”的原因。
三、Foundry 不变量测试设置
3.1 命名约定
不变量测试函数必须以 invariant_ 开头:
function invariant_alwaysWithdrawable() external {
// 断言逻辑
}
Foundry 会在每次随机调用序列之后,自动执行所有 invariant_ 函数检查。
3.2 配置参数
foundry.toml:
[invariant]
runs = 1000 # 测试轮数(默认 256)
depth = 1000 # 每轮调用多少个函数(默认 15)
fail_on_revert = false # 是否在 revert 时失败(默认 false)
也可用环境变量如 FOUNDRY_INVARIANT_RUNS=10000。
输出指标:runs(轮数)、calls(总调用数)、reverts(失败交易数)。
四、开放测试 vs Handler 模式
开放测试(Open Testing)
默认情况下,Foundry 把 setup 里部署的所有合约都作为目标,模糊器无差别地用随机参数调用任何 public/external 函数。简单合约够用,但复杂合约容易触发大量无意义的 revert。
Handler 模式
复杂协议要写一个 handler 合约包裹目标交互,约束调用方式:
contract Handler is Test {
TargetContract pool;
constructor(TargetContract _pool) {
pool = _pool;
vm.deal(address(this), 10 ether);
}
function flashLoan(uint amount) external {
pool.flashLoan(amount); // 受控地调用
}
receive() external payable {}
}
把 handler 注册为唯一目标:
function setUp() external {
pool = new TargetContract{value: 25 ether}();
handler = new Handler(pool);
targetContract(address(handler)); // 只模糊 handler 的函数
}
function invariant_poolSolvency() external {
assert(address(pool).balance >= pool.initialPoolBalance());
}
Handler 模式让你精确控制”模糊器能做哪些有意义的操作”,避免无效随机调用,大幅提高找 bug 效率。
五、目标辅助函数
forge-std/Test.sol 提供:
| 函数 | 作用 |
|---|---|
targetContract(address) | 指定要模糊的合约 |
targetSelector(FuzzSelector) | 只模糊特定函数签名 |
excludeContract(address) | 排除某合约 |
excludeSelector(FuzzSelector) | 排除某些函数 |
excludeSender(address) | 阻止特定地址调用 |
六、bound 辅助函数
把模糊输入约束到有意义的范围:
function testMathBoundary(int x) external {
x = bound(x, 10_000, 100_000); // 限制到 [10k, 100k]
quadratic.notOkay(x);
}
测数学边界时极大提升模糊器效率(否则它会浪费大量轮次在无意义的极端值上)。注意用 bound 而非 vm.assume——后者会丢弃不满足的输入、浪费轮次。
七、真实不变量案例
ERC20:所有余额之和 == totalSupply。
存款合约:
contract Deposit {
mapping(address => uint256) public balance;
function deposit() external payable { balance[msg.sender] += msg.value; }
function withdraw() external {
uint256 amount = balance[msg.sender];
balance[msg.sender] = 0;
(bool s,) = msg.sender.call{value: amount}("");
require(s, "failed to send");
}
}
不变量:存入的钱永远可取回、且等于原存款。
漏洞演示:如果加一个 changeBalance(address, uint) 让任何人能改余额,模糊器会很快随机调用它破坏不变量——立刻暴露设计缺陷。
SideEntranceLenderPool(DamnVulnerableDeFi):不变量”池子余额永不低于初始部署余额”。攻击者用 flashLoan 借款 → 在回调里 deposit 把借来的钱存回(满足还款检查)→ 再 withdraw 取走——掏空池子。不变量测试能立刻抓出这个跨函数序列漏洞。
八、fail_on_revert 配置
设 fail_on_revert = true 时,模糊过程中任何 revert 都会让测试失败——用于检测未处理的边界情况。设 false(默认)时,revert 只被计数、不导致失败。
权衡:Handler 模式下常设 true(因为 handler 已确保调用合法,任何 revert 都可疑);开放测试常设 false(随机调用难免合法地 revert)。
九、Ghost 变量
Ghost 变量是 handler 里用来跨多次调用累积追踪不变量属性的状态变量。例如累加所有 deposit 的总额,再在不变量里断言”合约余额 == ghost 累计存款”。它们让你表达”历史累计”类的不变量。
十、总结
- 不变量测试 = 有状态模糊,随机组合函数调用序列,检验永真属性;
- 比单元/无状态模糊更能发现”特定顺序才触发”的漏洞;
invariant_前缀函数 + foundry.toml 配 runs/depth/fail_on_revert;- 复杂协议用 handler 模式 + targetContract 精确控制模糊范围;
- bound 约束输入、ghost 变量追踪累计、fail_on_revert 控制严格度。
十一、动手练习项目:用不变量测试抓漏洞 InvariantHunt
项目目标
给一个含隐藏漏洞的存款/借贷合约写不变量测试,让模糊器自动找出漏洞;再实现 handler 模式和 ghost 变量。用 Foundry(本练习重点在测试而非部署)。
合约要求
1. VulnerablePool.sol(埋雷)
deposit()/withdraw()正常逻辑;- 故意埋一个漏洞:要么是
flashLoan的 side-entrance(回调里能 deposit 满足还款检查),要么是一个权限缺失的changeBalance; initialPoolBalance记录部署时余额。
2. Handler.sol
- 包裹 deposit/withdraw/flashLoan,
vm.deal给自己资金; - 用 ghost 变量
ghost_totalDeposited、ghost_totalWithdrawn累计追踪; bound约束金额到合理范围。
3. PoolInvariant.t.sol
setUp部署池子 + handler,targetContract(handler);invariant_solvency:address(pool).balance >= pool.initialPoolBalance();invariant_accounting:pool 余额 == ghost_totalDeposited - ghost_totalWithdrawn + 初始(用 ghost 变量);- foundry.toml 配 runs=500、depth=50。
测试要求(Foundry)
invariant_solvency应失败并打印出导致失败的调用序列(证明模糊器找到了 side-entrance 攻击);- 修复漏洞后,同样的不变量测试通过;
test_ReplayFailure:把模糊器报告的失败序列写成具体单元测试复现;- 对照实验:把
changeBalance加上 onlyOwner,验证不变量恢复; fail_on_revert设 true 和 false 各跑一次,对比 reverts 计数和行为差异;- ghost 变量不变量在所有合法序列下成立。
验证步骤(本地为主)
forge test --match-contract PoolInvariant -vvv运行,观察失败时打印的 call sequence;- 根据序列定位漏洞,修复后重跑变绿;
- 调大 runs/depth,观察能否发现更深层的问题;
- 用
forge test --match-test invariant_ --gas-report查看消耗。
进阶挑战(可选)
- 给一个真实的 DamnVulnerableDeFi 关卡(如 Side Entrance、Unstoppable)写不变量测试,用它自动发现官方设计的漏洞;
- 写注释回答:为什么无状态模糊测试发现不了 side-entrance 攻击,而有状态不变量测试能?关键差异在哪?