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 更快——它缩小范围而不是丢弃输入。只在模糊器无法推断的关系中使用 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——只需检查最终状态。如果 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(); // 非所有者应始终回滚
        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 中,每次推送运行轻量套件,每晚运行重度套件。


有用吗?查看这些资源: