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 inputs | vm.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次 | ~2s | 255次 |
bound(x, 1, 1000) | 256次 | ~0.5s | 0次 |
结论:能用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测试编写原则
- 先写Invariant,再写Fuzz:Invariant的Bug发现率最高
- 每个公开函数都要有Fuzz测试:至少测试正常路径和边界条件
- Ghost变量要精确:它们是你发现复杂bug的关键
- Handler模式处理复杂交互:多合约交互必须用Handler
- 固定种子: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 Book — Fuzz Testing
- Foundry Book — Invariant Testing
- Trail of Bits — Foundry Fuzzing Best Practices
#Foundry #Fuzz测试 #智能合约安全 #Solidity #DeFi安全 #Web3开发