Foundry Fuzz 测试完全指南:从入门到生产级智能合约安全测试

12 阅读3分钟

Foundry Fuzz 测试完全指南:从入门到生产级智能合约安全测试

作者按:4年区块链开发经验,用Foundry做过20+项目审计。这篇文章是我实战经验的系统总结,不是翻译文档,而是踩过坑之后提炼的最佳实践。


前言:为什么你需要Fuzz测试

做智能合约审计这么多年,我见过太多"看起来没问题"的合约上线后被黑客利用。

典型案例:某DeFi借贷协议的清算函数,单元测试全部通过,审计报告也没问题。上线3天后被攻击,原因是一个精度损失导致的边界条件bug——在特定价格区间,清算金额计算结果偏差0.01%。

这种bug用人工设计的测试用例几乎不可能发现,但Fuzz测试可以。

Fuzz测试的本质

Fuzz测试不是"随机乱试"。它的核心思想是:

定义合约必须满足的不变量(Invariant),然后用大量随机输入去尝试打破它。

与单元测试的区别:

维度单元测试Fuzz测试Invariant测试
输入固定值随机生成随机生成 + 随机调用序列
覆盖已知边界单函数边界跨函数状态组合
代码量少(但收益最高)
发现能力已知bug单函数边界bug复杂状态交互bug

我的建议:优先写Invariant测试,ROI最高。


一、环境搭建

1.1 安装Foundry

# macOS / Linux
curl -L https://foundry.paradigm.xyz | bash
source ~/.zshrc  # 或 ~/.bashrc
foundryup

# 验证
forge --version
# forge 0.3.0 (...)

1.2 项目结构

fuzz-security-demo/
├── foundry.toml          # 配置文件
├── src/
│   ├── Vault.sol         # 被测合约
│   └── Token.sol         # 被测合约
├── test/
│   ├── Vault/
│   │   ├── VaultFuzz.t.sol        # Fuzz测试
│   │   ├── VaultInvariant.t.sol   # Invariant测试
│   │   └── VaultHandler.sol       # Handler模式
│   └── Token/
│       └── TokenFuzz.t.sol
├── script/
│   └── Deploy.s.sol
└── lib/
    └── forge-std/

1.3 foundry.toml 生产级配置

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
via_ir = true  # 启用IR优化器,更安全但编译慢

[rpc_endpoints]
mainnet = "${MAINNET_RPC}"

# Fuzz测试配置
[fuzz]
runs = 10000              # 每个测试跑1万次
max_test_rejects = 65536  # vm.assume最大拒绝次数
seed = "0x42"             # 固定种子,结果可复现
dictionary_weight = 40    # 字典命中率权重
include_storage = true    # 从链上存储中获取测试数据

# Invariant测试配置
[invariant]
runs = 256                # 测试轮数
depth = 200               # 每轮调用深度
fail_on_revert = false    # Handler回滚不算失败
shrink_run_limit = 5000   # 缩减失败序列的尝试次数

# Gas优化配置
[gas_report]
ignore_contracts = ["Test"]

1.4 常见问题排查

问题原因解决方案
Fuzz test rejected too many inputsvm.assume()过滤太多改用bound()或增大max_test_rejects
编译超时via_ir = true + 大合约先不用IR优化,或增加内存
Fuzz运行太慢runs设太高本地用1000,CI用10000
结果不可复现没有固定seed设置seed = "0x42"
OOG in fuzz test测试用例gas消耗大forge test --gas-report定位

二、基础Fuzz测试

2.1 被测合约:一个有多个漏洞的Vault

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    mapping(address => uint256) public balances;
    uint256 public totalDeposits;
    address public owner;
    bool public locked;
    
    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
    
    constructor() {
        owner = msg.sender;
    }
    
    function deposit() external payable nonReentrant {
        require(msg.value > 0, "Zero deposit");
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
        emit Deposited(msg.sender, msg.value);
    }
    
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        totalDeposits -= amount;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
        emit Withdrawn(msg.sender, amount);
    }
    
    function emergencyWithdraw() external onlyOwner {
        require(locked, "Not locked");
        payable(owner).transfer(address(this).balance);
    }
    
    function setLocked(bool _locked) external onlyOwner {
        locked = _locked;
    }
}

2.2 Fuzz测试:余额不变量

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../../src/Vault.sol";

contract VaultFuzzTest is Test {
    Vault vault;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address eve = makeAddr("eve");
    
    function setUp() public {
        vault = new Vault();
        vm.deal(alice, 100 ether);
        vm.deal(bob, 100 ether);
        vm.deal(eve, 100 ether);
    }
    
    /// @notice 测试存款功能正确性
    function testFuzz_Deposit(address user, uint256 amount) public {
        amount = bound(amount, 0.01 ether, 50 ether);
        vm.assume(user != address(0));
        vm.assume(user != address(vault));
        vm.deal(user, amount);
        
        uint256 balBefore = address(vault).balance;
        
        vm.prank(user);
        vault.deposit{value: amount}();
        
        assertEq(vault.balances(user), amount);
        assertEq(address(vault).balance, balBefore + amount);
        assertEq(vault.totalDeposits(), amount);
    }
    
    /// @notice 测试超额取款应该回滚
    function testFuzz_CannotOverdraw(uint256 depositAmt, uint256 withdrawAmt) public {
        depositAmt = bound(depositAmt, 1 ether, 10 ether);
        withdrawAmt = bound(withdrawAmt, depositAmt + 1, depositAmt + 100 ether);
        
        vm.prank(alice);
        vault.deposit{value: depositAmt}();
        
        vm.prank(alice);
        vm.expectRevert("Insufficient balance");
        vault.withdraw(withdrawAmt);
    }
    
    /// @notice 核心不变量:合约余额 >= 记录的总存款
    function testFuzz_TotalBalanceConsistency(
        uint256 aliceDeposit,
        uint256 bobDeposit,
        uint256 aliceWithdraw
    ) public {
        aliceDeposit = bound(aliceDeposit, 1 ether, 50 ether);
        bobDeposit = bound(bobDeposit, 1 ether, 50 ether);
        
        vm.prank(alice);
        vault.deposit{value: aliceDeposit}();
        
        vm.prank(bob);
        vault.deposit{value: bobDeposit}();
        
        aliceWithdraw = bound(aliceWithdraw, 0, aliceDeposit);
        
        vm.prank(alice);
        vault.withdraw(aliceWithdraw);
        
        // 核心检查:偿付能力
        assertGe(address(vault).balance, vault.totalDeposits(), "Solvency violated");
    }
}

2.3 bound() vs vm.assume() — 深度对比

// ❌ 不推荐:vm.assume 会消耗运行次数
function testFuzz_Bad(uint256 x) public {
    vm.assume(x > 100 && x < 1000);
}

// ✅ 推荐:bound 不消耗运行次数
function testFuzz_Good(uint256 x) public {
    x = bound(x, 101, 999);
}

底层原理

  • vm.assume():条件不满足时丢弃输入,重新生成。消耗一次运行机会。
  • bound():将输入映射到指定范围。不消耗运行机会。

性能对比(runs=256,目标范围 1~1000):

方法有效测试次数耗时拒绝次数
vm.assume(x < 1000)~1次~2s255次
bound(x, 1, 1000)256次~0.5s0次

结论:能用bound()就用bound()


三、专项Fuzz测试

3.1 Invariant测试 — Handler模式

这是Foundry Fuzz测试的最高级用法,也是实际项目中用得最多的模式。

contract VaultHandler is Test {
    Vault public vault;
    
    // Ghost variables — 离线状态追踪
    uint256 public ghost_totalDeposits;
    uint256 public ghost_totalWithdrawals;
    mapping(address => uint256) public ghost_balances;
    
    address[] public actors;
    address internal currentActor;
    
    modifier useActor(uint256 seed) {
        currentActor = actors[bound(seed, 0, actors.length - 1)];
        vm.startPrank(currentActor);
        _;
        vm.stopPrank();
    }
    
    constructor(Vault _vault) {
        vault = _vault;
        for (uint256 i = 0; i < 20; i++) {
            address actor = makeAddr(string(abi.encodePacked("actor_", Strings.toString(i))));
            actors.push(actor);
            vm.deal(actor, 100 ether);
        }
    }
    
    function deposit(uint256 amount, uint256 actorSeed) external useActor(actorSeed) {
        amount = bound(amount, 0.001 ether, 10 ether);
        vault.deposit{value: amount}();
        ghost_totalDeposits += amount;
        ghost_balances[currentActor] += amount;
    }
    
    function withdraw(uint256 amount, uint256 actorSeed) external useActor(actorSeed) {
        uint256 userBal = vault.balances(currentActor);
        if (userBal == 0) return;
        amount = bound(amount, 1, userBal);
        vault.withdraw(amount);
        ghost_totalWithdrawals += amount;
        ghost_balances[currentActor] -= amount;
    }
}
contract VaultInvariantTest is Test {
    Vault vault;
    VaultHandler handler;
    
    function setUp() public {
        vault = new Vault();
        handler = new VaultHandler(vault);
        targetContract(address(handler));
        
        bytes4[] memory selectors = new bytes4[](2);
        selectors[0] = VaultHandler.deposit.selector;
        selectors[1] = VaultHandler.withdraw.selector;
        targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
    }
    
    /// @notice 偿付能力不变量
    function invariant_solvency() public view {
        assertGe(
            address(vault).balance,
            handler.ghost_totalDeposits() - handler.ghost_totalWithdrawals(),
            "SOLVIENCY BROKEN"
        );
    }
    
    /// @notice 总存款一致性
    function invariant_totalDepositsMatch() public view {
        assertEq(
            vault.totalDeposits(),
            handler.ghost_totalDeposits() - handler.ghost_totalWithdrawals(),
            "Total deposits don't match ghost tracking"
        );
    }
}

3.2 重入攻击Fuzz测试

contract ReentrancyAttacker is Test {
    Vault vault;
    uint256 public attackPhase;
    uint256 public reentrancyCount;
    
    constructor(address _vault) { vault = Vault(_vault); }
    
    function attack(uint256 amount) external payable {
        vault.deposit{value: amount}();
        attackPhase = 1;
        vault.withdraw(amount);
    }
    
    receive() external payable {
        if (attackPhase == 1 && reentrancyCount < 15) {
            reentrancyCount++;
            vault.withdraw(vault.balances(address(this)));
        }
    }
}

contract ReentrancyFuzzTest is Test {
    Vault vault;
    
    function setUp() public { vault = new Vault(); }
    
    function testFuzz_NoReentrancyDrain(uint256 attackAmount) public {
        attackAmount = bound(attackAmount, 0.1 ether, 10 ether);
        ReentrancyAttacker attacker = new ReentrancyAttacker(address(vault));
        vm.deal(address(attacker), attackAmount);
        attacker.attack{value: attackAmount}();
        assertLe(vault.balances(address(attacker)), attackAmount, "Reentrancy detected");
    }
}

3.3 权限控制Fuzz测试

contract AccessControlFuzzTest is Test {
    Vault vault;
    address owner;
    address notOwner;
    
    function setUp() public {
        vault = new Vault();
        owner = vault.owner();
        notOwner = makeAddr("notOwner");
    }
    
    function testFuzz_EmergencyWithdrawOnlyOwner(address caller) public {
        vm.assume(caller != owner);
        vm.assume(caller != address(0));
        vault.setLocked(true);
        vm.prank(caller);
        vm.expectRevert("Not owner");
        vault.emergencyWithdraw();
    }
}

四、CI/CD集成

GitHub Actions 完整Pipeline

name: Smart Contract Security Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 0 * * 1'

jobs:
  fuzz-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        runs: [1000, 10000]
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - uses: foundry-rs/foundry-toolchain@v1
      - name: Run fuzz tests
        run: |
          forge test \
            --match-test "testFuzz_" \
            --fuzz.runs ${{ matrix.runs }} \
            --fuzz.seed "0x42" \
            -vvv

  invariant-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - uses: foundry-rs/foundry-toolchain@v1
      - name: Run invariant tests
        run: |
          forge test \
            --match-contract "Invariant" \
            --invariant.runs 256 \
            --invariant.depth 200 \
            -vvv

  slither:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - uses: crytic/slither-action@v0.4.0
        with:
          fail-on: high
          slither-args: "--exclude-dependencies --filter-paths lib/"

五、实战经验总结

5.1 Fuzz测试编写原则

  1. 先写Invariant,再写Fuzz:Invariant的Bug发现率最高
  2. 每个公开函数都要有Fuzz测试:至少测试正常路径和边界条件
  3. Ghost变量要精确:它们是你发现复杂bug的关键
  4. Handler模式处理复杂交互:多合约交互必须用Handler
  5. 固定种子:CI和本地用同一个seed,确保结果一致

5.2 常见Anti-pattern

// ❌ 测试太窄
function testFuzz_Transfer(uint256 amount) public {
    amount = 1 ether;  // 固定值,不是真正的fuzz
    token.transfer(bob, amount);
}

// ❌ 没有断言
function testFuzz_Deposit(uint256 amount) public {
    amount = bound(amount, 1, 100 ether);
    vault.deposit{value: amount}();
    // 没有任何assertion!测试永远通过
}

// ✅ 正确做法
function deposit(uint256 amount) external {
    amount = bound(amount, 0.01 ether, 10 ether);
    vault.deposit{value: amount}();
    ghost_deposits += amount;
    assertEq(vault.balances(currentActor), ghost_balances[currentActor]);
}

5.3 工具配合

工具类型擅长局限
Slither静态分析已知漏洞模式误报多
Mythril符号执行整数溢出、重入速度慢
Foundry Fuzz动态Fuzz边界条件、状态组合不能100%覆盖

最佳实践:Slither初筛 + Foundry Fuzz深度测试。


参考

#Foundry #Fuzz测试 #智能合约安全 #Solidity #DeFi安全 #Web3开发