大多数黑客攻击都发生在开发者从未考虑过的边缘情况。模糊测试会向你的合约抛出随机输入,直到发现问题——Foundry 让这个过程足够快,你可以在每次提交前运行它。以下是我实际使用的模式。
开始使用
forge install tiancaijb366-pixel/foundry-security-tests
Enter fullscreen mode Exit fullscreen mode
配套仓库包含所有这些可运行的示例。Fork 它,运行 forge test,然后将这些模式应用到你自己的合约中。
1. 使用 bound / vm.assume 进行基础模糊测试
不要编写十个 test_RevertIf_* 函数。给模糊器一个范围,让它找到那个差一的错误。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";
contract TokenFuzzTest is Test {
Token t;
function setUp() public {
t = new Token(1_000_000e18);
}
function testFuzz_Transfer_Bounds(address sender, address to, uint256 amount) public {
// `bound` 保持输入实用
amount = bound(amount, 1, t.balanceOf(sender));
// `vm.assume` 过滤——谨慎使用(会减慢模糊器)
vm.assume(sender != address(0) && to != address(0) && sender != to);
vm.prank(sender);
t.transfer(to, amount);
assertGe(t.balanceOf(sender), 0);
assertEq(t.totalSupply(), 1_000_000e18);
}
}
Enter fullscreen mode Exit fullscreen mode
关键点: bound 比 vm.assume 更快——它缩小范围而不是丢弃输入。只在模糊器无法推断的关系中使用 assume(例如,sender != to)。
2. 不变性测试:ERC20 totalSupply
不变性测试检查某个属性在随机调用序列中始终成立。它们能捕捉到单函数模糊测试遗漏的状态机错误。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {Token} from "../src/Token.sol";
contract TokenInvariantTest is StdInvariant, Test {
Token t;
function setUp() public {
t = new Token(1_000_000e18);
targetContract(address(t));
}
// 这应该 _始终_ 为真
function invariant_totalSupply() public {
assertEq(t.totalSupply(), 1_000_000e18);
}
}
Enter fullscreen mode Exit fullscreen mode
运行命令:
forge test --match-test invariant -vvv
Enter fullscreen mode Exit fullscreen mode
添加 --fuzz-runs 50000 进行严肃的模糊测试。在普通笔记本电脑上大约需要 30 秒,能发现你永远不会为它编写单元测试的东西。
幽灵变量模式: 当不变性涉及随时间变化的合约状态时,在测试合约中跟踪一个幽灵变量,在每次处理程序调用后镜像预期状态。
3. 通过模糊测试检测重入
Foundry 会在每次模糊运行后回滚状态,因此你可以暴力破解重入路径,而无需为每个场景设置单独的利用合约。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract ReentrancyFuzzTest is Test {
Vault v;
// 跟踪重入尝试
bool public attackAttempted;
uint256 public balanceBefore;
receive() external payable {
if (attackAttempted) return; // 只重入一次
attackAttempted = true;
// 尝试在第一次调用完成前抽干资金
v.withdraw(balanceBefore);
}
function testFuzz_Reentrancy(uint256 depositAmount) public {
depositAmount = bound(depositAmount, 1 ether, 100 ether);
// 资助受害者
v.deposit{value: depositAmount}();
balanceBefore = depositAmount;
// 从此合约发起攻击
attackAttempted = false;
v.withdraw(depositAmount);
// 如果重入成功,金库余额将小于 0
assertLe(address(v).balance, depositAmount);
}
}
Enter fullscreen mode Exit fullscreen mode
为什么有效: Foundry 隔离每次模糊运行。你不需要 expectRevert——只需检查最终状态。如果 totalSupply 或 balance 出现分歧,模糊器就找到了路径。
4. 访问控制模糊测试
真实审计中最常见的发现:一个 onlyOwner 修饰器没有覆盖所有状态改变路径。从每个调用者模糊每个函数。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract AccessControlFuzzTest is Test {
Vault v;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
function setUp() public {
vm.prank(owner);
v = new Vault();
deal(attacker, 100 ether);
}
// 作为攻击者模糊每个状态改变函数
function testFuzz_AccessControl_Withdraw(uint256 amount) public {
vm.assume(amount > 0 && amount <= 100 ether);
vm.prank(attacker);
vm.expectRevert(); // 非所有者应始终回滚
v.emergencyWithdraw(amount);
}
function testFuzz_AccessControl_Pause(bool paused) public {
vm.prank(attacker);
vm.expectRevert();
v.setPaused(paused);
}
function testFuzz_AccessControl_Mint(uint256 amount) public {
amount = bound(amount, 0, 1_000_000e18);
vm.prank(attacker);
vm.expectRevert();
v.mint(attacker, amount);
}
}
Enter fullscreen mode Exit fullscreen mode
专业提示: 构建一个 handler 合约,包装每个权限函数,并在不变性测试套件中从特权和非特权地址调用它。每个访问级别一个函数。
5. 预言机操纵模糊测试
DeFi 利用几乎总是涉及价格预言机返回被操纵的值。模糊预言机输入并检查对核心会计的影响。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {LendingPool} from "../src/LendingPool.sol";
import {MockOracle} from "../src/MockOracle.sol";
contract OracleManipulationFuzzTest is Test {
LendingPool pool;
MockOracle oracle;
address user = makeAddr("user");
function setUp() public {
oracle = new MockOracle(1000e8); // ETH/USD = $1000
pool = new LendingPool(address(oracle));
deal(user, 100 ether);
}
// 如果预言机价格大幅波动会发生什么?
function testFuzz_OracleManipulation(uint256 manipulatedPrice, uint256 collateral) public {
collateral = bound(collateral, 1 ether, 50 ether);
manipulatedPrice = bound(manipulatedPrice, 1e8, 100_000e8); // $1 到 $100k
// 用户存入抵押品
vm.prank(user);
pool.deposit{value: collateral}();
// 预言机被操纵(闪电贷、三明治攻击等)
oracle.setPrice(manipulatedPrice);
// 用户根据膨胀的抵押品借款——或被不公平清算
vm.prank(user);
pool.borrow();
// 检查:池子能否覆盖所有存款?
assertGe(address(pool).balance, pool.totalDeposits());
}
}
Enter fullscreen mode Exit fullscreen mode
这能捕捉到什么: 如果你的 borrow() 或 liquidation 数学假设价格保持在之前值的 5% 以内,模糊器将找到破坏它的确切值。然后你可以添加断路器或 TWAP 窗口。
运行完整套件
# 标准模糊(默认每个测试 256 次运行)
forge test
# 重度模糊(更接近审计员运行的)
forge test --fuzz-runs 50000 --ffi
# 不变性测试(调用序列)
forge test --match-test invariant --fuzz-runs 50000
Enter fullscreen mode Exit fullscreen mode
在 CI 中,每次推送运行轻量套件,每晚运行重度套件。
有用吗?查看这些资源:
- Foundry 速查表——我总是忘记的每个 forge 命令、作弊码和标志
- 合约审计清单——部署前要检查的内容
- Solidity 代码片段——常见模式的复制粘贴模式
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.