tiancaijb

大多數駭客攻擊發生在開發者未曾考慮的邊緣案例。模糊測試會向你的合約丟入隨機輸入,直到找出問題——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

重點: boundvm.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——只要檢查最終狀態。如果 totalSupplybalance 出現偏差,模糊器就找到了路徑。


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 中,每次推送執行輕量套件,每晚執行重度套件。


有幫助嗎?歡迎查看這些資源: