大多數駭客攻擊發生在開發者未曾考慮的邊緣案例。模糊測試會向你的合約丟入隨機輸入,直到找出問題——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 更快——它縮小範圍而不是丟棄輸入。只有在模糊器無法推斷的關係(例如 sender != to)才使用 assume。
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);
// 如果重入成功,vault 餘額會小於 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(); // 非擁有者應該永遠 revert
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.