Q1ngying

今朝梦醒与君别,遥盼春风寄相思

0%

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函数的参数作为输入即可
即:

1
2
3
4
5
6
7
- function testNum() external {
- uint256 num = 2;
- ... some logic about `num` ...
- }
+ function testNum(uint256 num) external {
+ ... some logic about `num` ...
+ }

举例:

  • target:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 的一个工具包:

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

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

1
contract TargetFuzzingTest is stdInvariant, Test {}

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

1
2
3
4
5
function setUp() public {
statefulFuzzCatches = new StatefulFuzzCatches();

targetContract(address(statefulFuzzCatches));
}

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

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

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

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

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

有条件的 input

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 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,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 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 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 我们要进行的限制

// 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 函数中设置的是:

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

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

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

然后调用targetSelectortargetContract

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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);
}

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 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);
}
}