Fuzzing Test

Fuzzing Test 简介

Fuzzing Test(Fuzzing):向你的系统提供随机数据,并试图破坏它

为每一个可能的整数(或场景)编写一个用例来测试是基本做不到的(对于一些复杂逻辑的合约)。所以我们需要一种编程的方法在我们代码中找到一个特殊的场景

目前有两种常见的方法来寻找这些边缘情况:

  • Fuzzing Test/Invariant Test(模糊测试/不变量测试)
  • Symbolic Execution/Formal Verification (符号执行/形式验证)

而 Fuzzing Test 也可以分为两类:

  • Stateless Fuzzing(无状态模糊测试):每进行一次 Fuzzing Test 的时候,会丢弃上一次的状态,即完全运行一个新的 Fuzzing Test
  • Stateful Fuzzing(有状态模糊测试):有状态模糊测试是我们上一次模糊测试运行的结果的状态,作为下一次模糊测试的初始状态

注意,在 Foundry 中,使用 Invariant 来描述这种有状态的模糊测试

  • Fuzzing Tests = Random Data to one function = Stateless Fuzzing
  • Invariant Tests = Random Data & Random Function Calls to may function = Stateful Fuzzing

不变量和属性

不变量(Invariant):在系统中应该是在保持的系统属性

一些常见的不变量举例:

  • 新发行的 Token 低于通货膨胀率
  • 随机抽奖应该只有一个中奖者
  • 没人能从协议中提取出高于他们之前存入的金额的存款

Stateless Fuzzing

最简单的一种模糊测试方法,在 Foundry 中进行无状态的模糊测试,只需要将单元测试中的单一变量,变为通过test函数的参数作为输入即可
即:

- function testNum() external {
-	uint256 num = 2;
-	... some logic about `num` ...
- }
+ function testNum(uint256 num) external {
+ 	... some logic about `num` ...
+ }

举例:

  • target:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

// INVARIANT: doMath should never return 0
contract StatelessFuzzCatches {
    /*
     * @dev Should never return 0
     */
    function doMath(uint128 myNumber) public pure returns (uint256) {
        if (myNumber == 2) {
            return 0;
        }
        return 1;
    }
}
  • test:
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.18;

import {StatelessFuzzCatches} from "../../src/invariant-break/StatelessFuzzCatches.sol";
import {Test, console2} from "forge-std/Test.sol";

contract StatelessFuzzCatchesTest is Test {
    StatelessFuzzCatches sfc;

    function setUp() public {
        sfc = new StatelessFuzzCatches();
    }

    function testFuzzCatchesBugStateless(uint128 randomNumber) public view {
        assert(sfc.doMath(randomNumber) != 0);
    }
}

Stateful Fuzzing

有些情况下,使用 Stateless Fuzzing 无法发现一些路径依赖的一些bug情况,这个时候就需要使用 Stateful Fuzzing。

比如,我们要对这个合约进行测试:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

// INVARIANT: doMoreMathAgain should never return 0
contract StatefulFuzzCatches {
    uint256 public myValue = 1;
    uint256 public storedValue = 100;

    /*
     * @dev Should never return 0
     */
    function doMoreMathAgain(uint128 myNumber) public returns (uint256) {
        uint256 response = (uint256(myNumber) / 1) + myValue;
        storedValue = response;
        return response;
    }

    function changeValue(uint256 newValue) public {
        myValue = newValue;
    }
}

我们能看出来,我们通过changeValue(0) -> doMoreMathAgain(0)的调用,doMoreMathAgain()会返回0但是,当我们使用无状态的Fuzzing Test时,无法发现类似问题(合约逻辑简单时,我们能够手动审查出来,但是当合约逻辑复杂时,使用 fuzzing test 会大大缩短我们审计的时间)。所以,我们就需要使用有状态的模糊测试(在 Foundry 中称为invariant

在 Foundry 中进行有状态的模糊测试时,需要引入 Foundry 的一个工具包:

import {StdInvariant} from "forge-std/StdInvariant.sol";

使用方法直接继承即可,不过需要注意继承的顺序:

contract TargetFuzzingTest is stdInvariant, Test {}

在合约中的 setUp 函数中,要使用一个targetContract()函数,调用这个函数的目的是告诉 Foundry 我们要对哪个合约中的所有函数都进行 fuzzing。 Foundry 足够聪明,它能够调用目标合约中的所有公共函数。

function setUp() public {
	statefulFuzzCatches = new StatefulFuzzCatches();
	
	targetContract(address(statefulFuzzCatches));
}

对于状态较少的模糊测试是行不通的,所以我们需要进行完全模糊测试。我们如何告诉 Foundry 我们要进行有状态的模糊测试我们可以在定义test函数时,在函数前加上statefullFuzz_invariant_前缀来加以区分:

function invariant_catchBugs() public view {
    assert(statefulFuzzCatches.storedValue() != 0);
}

在进行 stateful fuzzing test 时,需要在 foundry.toml 中修改一些参数:

[invariant]
runs = 64 # 运行次数
depth = 32 # 深度,即一次调用,调用其他合约的次数,合约逻辑余额复杂,深度越深越好
fail_on_revert = false # 遇到回滚时,是否停止测试(因为有些时候会涉及到 overflow 导致 evm revert,此处是为了排除这种情况

更多关于 foundry.toml 的参数,详情查看 foundry 官方文档

有条件的 input

有些时候,我们的 fuzzing test 对我们传入的部分参数需要进行限制,比如如下合约:

// example
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

/*
 * This contract represents a vault for ERC20 tokens.
 *
 * INVARIANT: Users must always be able to withdraw the exact balance amout out. 
 */
contract HandlerStatefulFuzzCatches {
    error HandlerStatefulFuzzCatches__UnsupportedToken();

    using SafeERC20 for IERC20;

    mapping(IERC20 => bool) public tokenIsSupported;
    mapping(address user => mapping(IERC20 token => uint256 balance)) public tokenBalances;

    modifier requireSupportedToken(IERC20 token) {
        if (!tokenIsSupported[token]) revert HandlerStatefulFuzzCatches__UnsupportedToken();
        _;
    }

    constructor(IERC20[] memory _supportedTokens) {
        for (uint256 i; i < _supportedTokens.length; i++) {
            tokenIsSupported[_supportedTokens[i]] = true;
        }
    }

    function depositToken(IERC20 token, uint256 amount) external requireSupportedToken(token) {
        tokenBalances[msg.sender][token] += amount;
        token.safeTransferFrom(msg.sender, address(this), amount);
    }

    function withdrawToken(IERC20 token) external requireSupportedToken(token) {
        uint256 currentBalance = tokenBalances[msg.sender][token];
        tokenBalances[msg.sender][token] = 0;
        token.safeTransfer(msg.sender, currentBalance);
    }
}

我们进行 stateful fuzzing,脚本如下:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.20;

import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {HandlerStatefulFuzzCatches} from "../../../src/invariant-break/HandlerStatefulFuzzCatches.sol";
import {MockUSDC} from "../../mocks/MockUSDC.sol";
import {YeildERC20} from "../../mocks/YeildERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract AttemptedBreakTest is StdInvariant, Test {
    HandlerStatefulFuzzCatches handlerStatefulFuzzCatches;
    YeildERC20 yeildERC20;
    MockUSDC mockUSDC;
    IERC20[] supposedTokens;
    uint256 startingAmount;

    // 这次的 fuzzing test 需要一个用户来进行存款,取款,所以我们可以通过 Foundry cheatcode 来创建一个用户
    address user = makeAddr("user");

    function setUp() public {
        // 使用 cheatcode,这样能把下面这些合约的 msg.sender 设置为 `user`
        vm.startPrank(user);
        yeildERC20 = new YeildERC20();
        mockUSDC = new MockUSDC();
        startingAmount = yeildERC20.INITIAL_SUPPLY();
        mockUSDC.mint(user, startingAmount);
        vm.stopPrank();

        supposedTokens.push(mockUSDC);
        supposedTokens.push(yeildERC20);
        handlerStatefulFuzzCatches = new HandlerStatefulFuzzCatches(supposedTokens);

        // 要进行 fuzzing 的合约
        targetContract(address(handlerStatefulFuzzCatches));
    }

    function testStartingAmountTheSame() public view {
        assert(startingAmount == yeildERC20.balanceOf(user));
        assert(startingAmount == mockUSDC.balanceOf(user));
    }

    function invariant_testInvaraintBreaks() public {
        vm.startPrank(user);
        handlerStatefulFuzzCatches.withdrawToken(mockUSDC);
        handlerStatefulFuzzCatches.withdrawToken(yeildERC20);
        vm.stopPrank();

        assert(mockUSDC.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
        assert(yeildERC20.balanceOf(address(handlerStatefulFuzzCatches)) == 0);

        assert(mockUSDC.balanceOf(user) == startingAmount);
        assert(yeildERC20.balanceOf(user) == startingAmount);
    }
}

运行结果:

image-20240417201404300

运行结果我们看到一个问题,虽然按照我们的预期,但是一共运行了 2048 次,发生了 2048 次回滚。这意味着可能有一些更复杂的问题,当我们把 foundry.toml 配置文件中的fail_on_revert 修改为 true ,配合-vvvv再运行一次,发现:

image-20240417201442682

原因是,HandlerStatefulFuzzCatches:depositToken收到了不合法的参数,因为 fuzzing test 的参数是完全随机的,所以传入depositToken函数的 token 时,发生了回滚。

不过上述的测试还有很多的问题,如:

  • 我们没有对 ERC20 Token 进行 approve 授权,也就是我们无法 withdraw
  • 我们刚刚只由一个用户,但实际上是有很多的用户的

所以有些时候,我们实际上想要控制它产生的随机性。这个时候就需要使用 Handler Stateful fuzzing。来进行一些限制。但是,需要注意:

进行太多的限制,可能会错过一些 bug,但有些情况还必须加以限制,这就是需要进行权衡的。

Handler Stateful fuzzing

第一步便是要创建Handler合约来对我们的 fuzzing 进行限制

Handler 的作用,下图做出了很好的解释:

image-20240417205633056

Handler 合约

对于 Handler 合约,他也需要继承 Test 合约,并且,还需要用到一些待测试合约的函数来加以限制。所以,不可避免需要引入Test合约和Target合约。

我们就是把我们想要 Foundry 调用的随机方式写进 Handler 合约中,然后再让 Foundry 对 Handler 进行随机调用。对于刚刚 Stateful Fuzzing 无法成功 Fuzzing 的合约,它的 Handler 如下:

// 我们要进行的限制

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.20;

import {Test} from "forge-std/Test.sol";
import {HandlerStatefulFuzzCatches} from "../../../src/invariant-break/HandlerStatefulFuzzCatches.sol";
import {YeildERC20} from "../../mocks/YeildERC20.sol";
import {MockUSDC} from "../../mocks/MockUSDC.sol";

contract Handler is Test {
    HandlerStatefulFuzzCatches handlerStatefulFuzzCatches;
    MockUSDC mockUSDC;
    YeildERC20 yeildERC20;
    address user;

    constructor(
        HandlerStatefulFuzzCatches _handlerStatefulFuzzCatches,
        MockUSDC _mockUSDC,
        YeildERC20 _yeildERC20,
        address _user
    ) {
        handlerStatefulFuzzCatches = _handlerStatefulFuzzCatches;
        mockUSDC = _mockUSDC;
        yeildERC20 = _yeildERC20;
        user = _user;
    }

    // 这个合约将告诉 Foundry 如何和我们的协议进行交互的方式

    function depositYeildERC20(uint256 _amount) public {
        uint256 amount = bound(_amount, 0, yeildERC20.balanceOf(user));
        vm.startPrank(user);
        yeildERC20.approve(address(handlerStatefulFuzzCatches), amount);
        handlerStatefulFuzzCatches.depositToken(yeildERC20, amount);
        vm.stopPrank();
    }

    function depositMockUSDC(uint256 _amount) public {
        uint256 amount = bound(_amount, 0, mockUSDC.balanceOf(user));
        vm.startPrank(user);
        mockUSDC.approve(address(handlerStatefulFuzzCatches), amount);
        handlerStatefulFuzzCatches.depositToken(mockUSDC, amount);
        vm.stopPrank();
    }

    function withdrawYeildERC20() public {
        vm.startPrank(user);
        handlerStatefulFuzzCatches.withdrawToken(yeildERC20);
        vm.stopPrank();
    }

    function withdrawMockUSDC() public {
        vm.startPrank(user);
        handlerStatefulFuzzCatches.withdrawToken(mockUSDC);
        vm.stopPrank();
    }
}

Handler 写完之后,我们便可以编写我们的 Fuzzing 合约。

Invariant 合约

这个合约就是我们主要的 Fuzzing 合约了,他和上面的 Stateful Fuzzing 合约还是有些出入的。关于 Invariant 合约,需要引入 HandlerTestStdInvariant、以及要进行 Fuzzing 的合约。

在 Stateful Test 的setUp 函数中设置的是:

handlerStatefulFuzzCatches = new HandlerStatefulFuzzCatches(supposedTokens);
targetContract(address(handlerStatefulFuzzCatches));

但是在 handler Fuzzing 中,我们市要对 handler 进行模糊测试。这时候我们要新建一个字节数组,告诉 Foundry 我们要调用的函数选择器:

bytes4[] memory selectors = new bytes4[](4);
selectors[0] = handler.depositTeildERC20.selector;
...

然后调用targetSelectortargetContract

targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
targetContract(address(handler));

最后我们要进行的 Fuzzing Test 函数:

function invariant_testInvaraintHandler() public {
	vm.startPrank(user);
	handlerStatefulFuzzCatches.withdrawToken(mockUSDC);
	handlerStatefulFuzzCatches.withdrawToken(yeildERC20);
	vm.stopPrank();

    assert(mockUSDC.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
	assert(yeildERC20.balanceOf(address(handlerStatefulFuzzCatches)) == 0);

	assert(mockUSDC.balanceOf(user) == startingAmount);
	assert(yeildERC20.balanceOf(user) == startingAmount);
}

完整代码:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.20;

import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {HandlerStatefulFuzzCatches} from "../../../src/invariant-break/HandlerStatefulFuzzCatches.sol";
import {MockUSDC} from "../../mocks/MockUSDC.sol";
import {YeildERC20} from "../../mocks/YeildERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Handler} from "./Handler.t.sol";

contract AttemptedBreakTest is StdInvariant, Test {
    HandlerStatefulFuzzCatches handlerStatefulFuzzCatches;
    YeildERC20 yeildERC20;
    MockUSDC mockUSDC;
    IERC20[] supposedTokens;
    uint256 startingAmount;

    // 这次的 fuzzing test 需要一个用户来进行存款,取款,所以我们可以通过 Foundry cheatcode 来创建一个用户
    address user = makeAddr("user");

    Handler handler;

    function setUp() public {
        // 使用 cheatcode,这样能把下面这些合约的 msg.sender 设置为 `user`
        vm.startPrank(user);
        yeildERC20 = new YeildERC20();
        mockUSDC = new MockUSDC();
        startingAmount = yeildERC20.INITIAL_SUPPLY();
        mockUSDC.mint(user, startingAmount);
        vm.stopPrank();

        supposedTokens.push(mockUSDC);
        supposedTokens.push(yeildERC20);
        handlerStatefulFuzzCatches = new HandlerStatefulFuzzCatches(supposedTokens);
        // handler fuzzing test 不再像 open stateful fuzzing 直接对要进行模糊测试的合约进行 fuzzing,而是通过 handler 再 待测试合约的方式
        // targetContract(address(handlerStatefulFuzzCatches));
        handler = new Handler(handlerStatefulFuzzCatches, mockUSDC, yeildERC20, user);

        // 告诉 Foundry 我们要调用的函数选择器
        bytes4[] memory selectors = new bytes4[](4);
        selectors[0] = handler.depositYeildERC20.selector;
        selectors[1] = handler.depositMockUSDC.selector;
        selectors[2] = handler.withdrawMockUSDC.selector;
        selectors[3] = handler.withdrawYeildERC20.selector;

        targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
        targetContract(address(handler));
    }

    function invariant_testInvaraintHandler() public {
        vm.startPrank(user);
        handlerStatefulFuzzCatches.withdrawToken(mockUSDC);
        handlerStatefulFuzzCatches.withdrawToken(yeildERC20);
        vm.stopPrank();

        assert(mockUSDC.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
        assert(yeildERC20.balanceOf(address(handlerStatefulFuzzCatches)) == 0);

        assert(mockUSDC.balanceOf(user) == startingAmount);
        assert(yeildERC20.balanceOf(user) == startingAmount);
    }
}