FuzzingTest进阶
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);
}
}
运行结果:
运行结果我们看到一个问题,虽然按照我们的预期,但是一共运行了 2048 次,发生了 2048 次回滚。这意味着可能有一些更复杂的问题,当我们把 foundry.toml
配置文件中的fail_on_revert
修改为 true
,配合-vvvv
再运行一次,发现:
原因是,HandlerStatefulFuzzCatches:depositToken
收到了不合法的参数,因为 fuzzing test 的参数是完全随机的,所以传入depositToken
函数的 token
时,发生了回滚。
不过上述的测试还有很多的问题,如:
- 我们没有对 ERC20 Token 进行 approve 授权,也就是我们无法
withdraw
- 我们刚刚只由一个用户,但实际上是有很多的用户的
所以有些时候,我们实际上想要控制它产生的随机性。这个时候就需要使用 Handler Stateful fuzzing。来进行一些限制。但是,需要注意:
进行太多的限制,可能会错过一些 bug,但有些情况还必须加以限制,这就是需要进行权衡的。
Handler Stateful fuzzing
第一步便是要创建Handler
合约来对我们的 fuzzing 进行限制
Handler 的作用,下图做出了很好的解释:
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 合约,需要引入 Handler
、Test
、StdInvariant
、以及要进行 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;
...
然后调用targetSelector
和targetContract
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);
}
}