Foundry 不变量测试详解

理解有状态模糊测试、handler 模式、targetContract/bound/ghost 变量与真实不变量案例

6 分钟阅读
Foundry 不变量测试详解

Foundry 不变量测试详解

目录


一、什么是不变量测试

不变量测试是一种通过**有状态模糊测试(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_totalDepositedghost_totalWithdrawn 累计追踪;
  • bound 约束金额到合理范围。

3. PoolInvariant.t.sol

  • setUp 部署池子 + handler,targetContract(handler)
  • invariant_solvencyaddress(pool).balance >= pool.initialPoolBalance()
  • invariant_accountingpool 余额 == ghost_totalDeposited - ghost_totalWithdrawn + 初始(用 ghost 变量);
  • foundry.toml 配 runs=500、depth=50。

测试要求(Foundry)

  1. invariant_solvency失败并打印出导致失败的调用序列(证明模糊器找到了 side-entrance 攻击);
  2. 修复漏洞后,同样的不变量测试通过;
  3. test_ReplayFailure:把模糊器报告的失败序列写成具体单元测试复现;
  4. 对照实验:把 changeBalance 加上 onlyOwner,验证不变量恢复;
  5. fail_on_revert 设 true 和 false 各跑一次,对比 reverts 计数和行为差异;
  6. ghost 变量不变量在所有合法序列下成立。

验证步骤(本地为主)

  1. forge test --match-contract PoolInvariant -vvv 运行,观察失败时打印的 call sequence;
  2. 根据序列定位漏洞,修复后重跑变绿;
  3. 调大 runs/depth,观察能否发现更深层的问题;
  4. forge test --match-test invariant_ --gas-report 查看消耗。

进阶挑战(可选)

  • 给一个真实的 DamnVulnerableDeFi 关卡(如 Side Entrance、Unstoppable)写不变量测试,用它自动发现官方设计的漏洞;
  • 写注释回答:为什么无状态模糊测试发现不了 side-entrance 攻击,而有状态不变量测试能?关键差异在哪?

💬 评论