简介
在本文中,我们将讨论不变量(invariants),以及如何使用 Foundry 测试套件对 Solidity 智能合约执行不变性测试(invariant test)。
不变性测试是另一种与单元测试(unit tests)和模糊测试(fuzzing)类似的测试方法,用于验证代码的正确性。如果你对单元测试不熟悉,请参阅我们关于使用 Foundry 进行单元测试的文章。
为了跟上本文的实践部分,你需要熟悉 Solidity 并在你的计算机上安装好 Foundry。否则,请参阅此处了解如何操作。
配套仓库
如果你只想复制并粘贴一些代码,请克隆我们在此提供的仓库。你也可以使用该仓库来跟随本教程的步骤。github.com/RareSkills/invariant-testing-foundry-tutorial
为什么要进行不变性测试
不变性测试允许我们测试智能合约中单元测试可能遗漏的方面。单元测试仅覆盖测试中指定的属性,不涉及其他内容。但通过不变性测试,智能合约会在多种随机状态下被尝试和测试,从而发现代码中的缺陷。
通过测试这些不变量,开发者可以捕获单元测试或手动代码审查可能无法发现的潜在问题。
什么是不变量
不变量是在一组定义良好的假设下必须始终为真的条件。例如,在一个 ERC20 contract 中,一个不变量将是合约中所有余额的总和应等于总供应量(total supply)。如果函数调用或交易违反了此不变量,则说明代码出了问题,系统将不再正常运行。
单元测试验证的是特定的行为,而不变量则描述了整个系统的某些特征。以下是一些示例:
- 如果没有调用 mint 或 burn,ERC20 代币的总供应量不会改变
- 在固定周期内,来自智能合约的总奖励不能超过特定百分比
- 用户提取的金额不能超过其存款与某些上限奖励的总和
开始指南
Foundry 中的不变性测试是一种有状态模糊测试(stateful fuzz test),在测试中,合约的函数会被 fuzzer 使用随机输入随机调用,所有这些都是为了试图打破任何指定的不变量。有状态模糊测试意味着测试在一次调用时的状态会被保存到下一次调用中。
让我们初始化一个新的 Foundry 项目,以在智能合约上执行不变性测试。
运行以下命令:
forge init invariant-exercise
cd invariant-exercise
现在我们的 Foundry 项目准备就绪了。
Foundry 配置
我们可以在 foundry.toml 文件中为我们的不变性测试设置可选的配置值。如果没有设置配置值,Foundry 会使用默认值。在本文中,我们将只设置重要的配置值。要查看所有可用的不变性配置,请访问此处。
- runs:每个不变性测试组必须执行的运行次数(默认值为 256)。
- depth:在一次运行中尝试打破不变量而执行的调用次数(默认值为 15)。
fail_on_revert:如果发生 revert,则不变性模糊测试失败(默认值为 false)。
foundry.toml 中的示例配置如下所示:
[invariant]
runs = 1000
depth = 1000
或者,可以在环境变量中设置这些参数,例如 FOUNDRY_INVARIANT_RUNS=10000。
一个简单的例子
现在将 Foundry 自带的 Counter.sol 重命名为 Deposit.sol,并粘贴此代码。
contract Deposit {
address public seller = msg.sender;
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balance[msg.sender];
balance[msg.sender] = 0;
(bool s, ) = msg.sender.call{value: amount}("");
require(s, "failed to send");
}
}
这是一个简单的合约,允许任何人存入并提取 ether。
由于没有任何限制,存款人应该始终能够提取存入的 ether。
我们的不变量应该是,任何人存入的任何金额都应能由同一个人全额提取。
我们将实现一个不变性测试来确认:
- 存款人可以提取存入的 ether。
- 存款人提取的金额应与其存入的金额相同。
让我们通过为这两种情况编写一个不变性测试来验证我们的代码是否正确。前往 Foundry 项目中的 test 文件夹,将 Counter.t.sol 重命名为 Deposit.t.sol,然后粘贴以下代码。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Deposit.sol";
contract InvariantDeposit is Test {
Deposit deposit;
function setUp() external {
deposit = new Deposit();
vm.deal(address(deposit), 100 ether);
}
function invariant_alwaysWithdrawable() external payable {
deposit.deposit{value: 1 ether}();
uint256 balanceBefore = deposit.balance(address(this));
assertEq(balanceBefore, 1 ether);
deposit.withdraw();
uint256 balanceAfter = deposit.balance(address(this));
assertGt(balanceBefore, balanceAfter);
}
receive() external payable {}
}
解释测试
我们要进行的是“开放测试(Open Testing)”。开放测试是指目标合约的默认配置设置为测试函数内部署的所有合约。如果需要,你可以在此处进一步阅读相关内容。
不变量:存款人可以提取存入的 ether,并且存款人提取的金额应与其存入的金额相同。
验证此情况是否正确的代码是 invariant_alwaysWithdrawable 测试函数,如下所示:
function invariant_alwaysWithdrawable() external payable {
deposit.deposit{value: 1 ether}();
uint256 balanceBefore = deposit.balance(address(this));
assertEq(balanceBefore, 1 ether);
deposit.withdraw();
uint256 balanceAfter = deposit.balance(address(this));
assertGt(balanceBefore, balanceAfter);
}
请注意,测试函数以 invariant 关键字开头。这很重要,因为 Foundry 通过它来识别这是一个不变性测试。
我们首先从测试合约中存入 1 ether。由于 Deposit 合约通过 balance 映射来跟踪存入的金额,因此我们在存入后立即使用它来记录我们的余额(这应该等于 1 ether,因为这是我们存入的金额)。
接下来,我们调用 withdraw 函数取回 ether,并再次记录我们的余额(此时应该为零)。
这种余额记录是通过 balanceBefore 和 balanceAfter 局部变量完成的。
我们预期存入的金额为 1 ether,因此我们通过 assertEq(balanceBefore, 1 ether); 来确认这一点。
为了确认不变量成立,我们预期 balanceBefore 会大于 balanceAfter,因为这是我们存款时的余额。
为了验证这一点,我们使用 Foundry 的断言 assertGt(balanceBefore, balanceAfter);
如果我们使用 forge test --mt invariant_alwaysWithdrawable 运行测试,会得到以下输出:
Running 1 test for test/Deposit.t.sol:InvariantDeposit
[PASS] invariant_alwaysWithdrawable() (runs: 256, calls: 3840, reverts: 1917)
Test result: ok. 1 passed; 0 failed; finished in 347.19ms
测试参数
runs 参数指的是某个特定测试函数执行的次数。每次运行测试函数时,它都会传递不同的输入或条件,以测试不同的场景并确保合约在不同条件下均能正确运作。
Calls 指的是在单次测试运行期间调用智能合约中函数的次数。
Reverts 指的是由于错误或异常导致调用智能合约中的任何函数进而引发交易发生 revert 的次数。
预期 Revert
我们可以看到测试成功了,正如 calls 的数量所示,测试为了打破我们的不变量,对合约的函数进行了 3840 次调用。
它还触发了 1917 次 revert。这可能是在不变性测试或 fuzzer 尝试调用智能合约中的某个函数,却没有满足该函数前置条件时发生的。我们将修改 foundry.toml 文件并添加以下不变性测试配置来确认这一点。
[invariant]
fail_on_revert = true
如果在试图打破我们的不变量时发生 revert,这会让测试失败。
现在,我们使用 forge test --mt invariant_alwaysWithdrawable 重新运行测试,我们得到了以下结果:
Test result: FAILED. 0 passed; 1 failed; finished in 8.53ms
Failing tests:
Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit
[FAIL. Reason: no balance]
[Sequence]
sender=0x00000000000000000000000000000000e3d670d7 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
invariant_alwaysWithdrawable() (runs: 1, calls: 1, reverts: 1)
Encountered a total of 1 failing tests, 0 tests succeeded
我们可以看到,不变性测试从一开始就随机调用了 withdraw 函数,即使我们并没有指定这样做(注意这是完全随机的,你在不同尝试中可能会得到不同的结果)。这是因为通过开放测试方法,我们合约的所有函数都可以供 fuzzer 调用。在本文稍后讨论“Invariant targets”时,我们将看到如何排除或包含特定的合约/函数。
这种随机函数调用试图千方百计地打破我们的不变量。但正如代码中指定的那样,如果发送者没有余额,该函数将会 revert。
因为不变性测试是这样表现的,所以即使我们的测试通过,我们也会看到一些 revert 情况。
(请记住将 fail_on_revert 更改为 false,这样我们的测试就不会停止运行)。
为测试向合约引入漏洞
为了进一步测试,让我们向合约引入一个漏洞,允许任何人更改任何地址存入的余额。
将以下代码添加到 Deposit 合约中:
function changeBalance(address depositor, uint amount) public {
balance[depositor] = amount;
}
现在我们重新运行测试:
forge test --mt invariant_alwaysWithdrawable
并得到了以下输出:
Test result: FAILED. 0 passed; 1 failed; finished in 74.09ms
Failing tests:
Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit
[FAIL. Reason: Assertion failed.]
[Sequence]
sender=0x0000000000000000000000000000000000000f7a addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x73575ade2424045cf0df8fa1712dde9137c56416 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0xba2840574eA60882e96881D1cC3C1d7D90af0e1d, 3]
sender=0xff1cb1b0420410582bfd4b6b345769b2cc4a51f1 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x000000000000000000000808080808149a59da1d addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x6383b40e80395f66de7f61df26bc9bafbbf3cb0f addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x97c68648bd6e6ed8a62e640937543f7bf47e39ba addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 2193]
invariant_alwaysWithdrawable() (runs: 31, calls: 456, reverts: 160)
Encountered a total of 1 failing tests, 0 tests succeeded
注意调用序列中的最后一个函数调用。我们可以看到 changeBalance 函数被调用了。这里传递的参数是:1. Foundry 测试合约的 address;以及 2. 2193(完全随机的数字)。
这将改变测试合约的余额,该合约之前曾被我们用来存入 1 ether。所以我们的余额不再是 1 ether,而是变为了 2193。因此,这打破了“存款人提取的金额应与其存入的金额相同”这一不变量。
为了确认传递给 changeBalance 函数的 address 是测试合约的 address,我们可以在测试中模拟(impersonate)一个特定的 address。
但 changeBalance() 并没有在测试中出现,它是怎么被调用的?!
这正是不变性测试的了不起之处。尽管我们从未明确调用 changeBalance(),但不变性测试器在执行测试中明确的函数调用序列时,仍随机插入了对它的调用。
这使得不变性测试能够测试那些我们“没有想到的”方面。
更改用户的余额而不是合约的余额
让我们将测试函数修改为:
function invariant_alwaysWithdrawable() external payable {
vm.startPrank(address(0xaa));
vm.deal(address(0xaa), 10 ether);
deposit.deposit{value: 1 ether}();
uint256 balanceBefore = deposit.balance(address(0xaa));
vm.stopPrank();
assertEq(balanceBefore, 1 ether);
vm.prank(address(0xaa));
deposit.withdraw();
uint256 balanceAfter = deposit.balance(address(0xaa));
vm.stopPrank();
assertGt(balanceBefore, balanceAfter);
}
我们所做的事情与之前相同,只不过不再是测试合约作为 msg.sender,而是换成我们刚刚模拟指定的 address(0xaa)。
现在使用 forge test --mt invariant_alwaysWithdrawable 重新运行测试,我们得到了以下结果:
Test result: FAILED. 0 passed; 1 failed; finished in 85.64ms
Failing tests:
Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit
[FAIL. Reason: Assertion failed.]
[Sequence]
sender=0x00000000000000000000000000000000000000e6 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000090c5013b addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000000000001 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x00000000000000000000000000000000000000A1, 296312983667185193009]
sender=0x000000000000000000000000000000000000000c addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x0000000000000000000000000000000000000009 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000000000fc5 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x00000000000000000000000000000000000005fb addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x0000000000000000000000000000000000000005 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0xb30de0face1af7a50fbd59f1a0d9f31e9282d40f addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000000000a94 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x00000000000000000000000000000000000000AA, 4594637]
invariant_alwaysWithdrawable() (runs: 2, calls: 33, reverts: 8)
Encountered a total of 1 failing tests, 0 tests succeeded
相同的操作被重复执行,但这次使用的是 address(0xaa)(如我们在最后一次调用序列中看到的那样),而不是测试合约的地址。
从逻辑上讲,它也打破了第一个不变量:“存款人可以提取存入的 ether。”我们引入的 changeBalance 函数可以利用任何 address 及作为 amount 的零来调用,从而改变 balance。
这将导致那些原先已有存款的地址现在的余额归零,从而即使他们的 ether 还在合约中也无法提取。
条件不变量
虽然不变量应该始终成立,但有些不变量需要某些条件才能成立。例如,像 assertEq(token.totalSupply(), 0); 这样的不变量只有在没有进行任何 mint 操作时才应成立。如果代币被铸造了,总供应量就不会为零。
这些不变量被称为条件不变量(conditional invariants),因为协议或智能合约必须处于特定条件下它们才会成立。如需了解更多信息,你可以在此处查看。
更改不变性测试配置
如本文前面所述,如果我们想增加每个测试的运行次数,我们可以在 foundry.toml 文件中添加配置。
在 foundry.toml 文件的 [invariant] 部分下方添加以下内容。
[invariant] #invariant section
fail_on_revert = false
runs = 1215
depth = 23
现在使用 forge test --mt invariant_alwaysWithdrawable 重新运行测试(请确保已删除或注释掉 changeBalance 函数)。
Running 1 test for test/Deposit.t.sol:InvariantDeposit
[PASS] invariant_alwaysWithdrawable() (runs: 1215, calls: 27945, reverts: 13965)
Test result: ok. 1 passed; 0 failed; finished in 4.39s
测试仍然通过了,但由于我们在配置中进行了修改,这次 runs、calls 和 revert 的次数明显高于平时。你可以选择使用从 0 到 uint32.max 的任意数字。
如果我们将 runs 参数设置为大于 uint32 的数字,当我们尝试运行测试时,Foundry 会抛出错误。
例如,让我们将其设置为 23000000000000 并尝试运行测试。
我们得到了这个错误:
Error:
failed to extract foundry config:
foundry config error: invalid value signed int `23000000000000`, expected u32 for setting `invariant.depth`
更大的数字意味着更多的测试场景,但也会让测试变得更慢。
接近真实的示例
我们已经通过合约涵盖了在 Foundry 中进行不变性测试的至少一部分基础知识,但让我们进一步深入,在一个热门合约上执行不变性测试。
我们将测试 SideEntranceLenderPool 合约,这是著名的 Damn Vulnerable DeFi CTF 第四关的合约。
合约内容如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "openzeppelin-contracts/contracts/utils/Address.sol";
interface IFlashLoanEtherReceiver {
function execute() external payable;
}
/**
* @title SideEntranceLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/contract SideEntranceLenderPool {
using Address for address payable;
mapping(address => uint256) private balances;
uint256 public initialPoolBalance;
constructor() payable {
initialPoolBalance = address(this).balance;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amountToWithdraw = balances[msg.sender];
balances[msg.sender] = 0;
payable(msg.sender).sendValue(amountToWithdraw);
}
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
require(balanceBefore >= amount, "Not enough ETH in balance");
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
require(
address(this).balance >= balanceBefore,
"Flash loan hasn't been paid back"
);
}
}
为了适应我们的 Foundry 项目及需求,合约做了一些小修改(另请注意 OpenZeppelin 导入)。我们还安装了必要的依赖项(导入 OpenZeppelin Address 库)。
该合约在 flashLoan 函数中存在漏洞,允许攻击者对其进行利用并抽干其 ether 余额。攻击者可以调用 flashLoan 函数借出一笔贷款,然后使用 deposit 函数将相同的贷款作为攻击者的余额存回合约中。之后,他们可以提取该余额并带着它逃跑,即便这笔钱原本是一笔贷款而非他们自己的 ether。
那么,我们在这里的不变量是什么?
首先,需要注意的是,该合约具有 payable 构造函数,并且用于贷款的 ether 是在部署期间存入的。最初存入的 ether 也没有办法被提取。只能使用 deposit 函数向合约添加 ether,并使用 withdraw 函数提取(前提是函数调用者之前有存款)。
考虑到这一点,我们可以说这个不变量将是
assert(address(SideEntranceLenderPool).balance >= SideEntranceLenderPool.initialPoolBalance());
(initialPoolBalance 是一个公开状态变量,用于存储在部署期间存入了多少 ether)。
我们断言 SideEntranceLenderPool 的 ether 余额始终大于或等于在部署期间存入的 ether。
如果一切正常,这个不变量应该成立。但正如前面所说,漏洞的存在使得某些人可以存入从合约中借来的贷款并在以后将其提取出来。
在下一节中,我们将在 Foundry 不变性测试中引入一个新概念——处理程序(Handler),以获得更好的测试结果。
基于处理程序的测试(Handler-based testing)
处理程序(handler)合约用于测试更复杂的协议或合约。它充当一个包装器(wrapper)合约,用于与我们所需的合约进行交互或调用。
当需要以特定方式配置环境时(即使用特定参数调用构造函数),它尤为必要。
它的工作原理是:在测试文件的 setUp 函数中,我们部署即将对池子合约进行调用的处理程序合约,并使用 targetContract(address target) 测试辅助函数,仅将此处理程序合约设置为测试中的目标合约。
正因如此,只有处理程序合约的函数才会被 fuzzer 随机调用。
另一个好处是,如果主合约(在本例中为 SideEntranceLenderPool 合约)中的某个函数在调用前需要满足特定条件,我们可以很容易地在调用该函数之前在处理程序合约中去定义该条件。
处理程序合约也可以继承 forge-std Test 并使用 Foundry 的 cheatsheets,如 vm.deal、vm.prank 等。我们将在接下来的过程中进行演示。
让我们在 test 文件夹内创建一个 /handler 文件夹,并在其中创建一个 handler.sol 文件。
以下是我们的处理程序合约代码。
import {SideEntranceLenderPool} from "../../src/SideEntranceLenderPool.sol";
import "forge-std/Test.sol";
contract Handler is Test {
// the pool contract
SideEntranceLenderPool pool;
// used to check if the handler can withdraw ether after the exploit
bool canWithdraw;
constructor(SideEntranceLenderPool _pool) {
pool = _pool;
vm.deal(address(this), 10 ether);
}
// this function will be called by the pool during the flashloan
function execute() external payable {
pool.deposit{value: msg.value}();
canWithdraw = true;
}
// used for withdrawing ether balance in the pool
function withdraw() external {
if (canWithdraw) pool.withdraw();
}
// call the flashloan function of the pool, with a fuzzed amount
function flashLoan(uint amount) external {
pool.flashLoan(amount);
}
receive() external payable {}
}
我们在处理程序合约中定义了调用 SideEntranceLenderPool 合约函数的那些函数。这是为了使我们能够测试更多的边缘情况,并在实际上去利用该漏洞。
如前所述,处理程序合约继承了 forge-std Test,并在处理程序合约的构造函数中使用了 vm.deal 方法来为合约提供一些 ether。
不变性目标与测试辅助函数(Invariant Targets and test helpers)
Foundry 的 forge-std 库中自带了测试辅助函数,允许我们指定我们的目标合约(target contracts)、目标工件(target artifacts)、目标选择器(target selectors)和目标工件选择器(target artifacts selectors)。
其中一些辅助函数包括:
targetContract(address newTargetedContract_)targetSelector(FuzzSelector memory newTargetedSelector_)excludeContract(address newExcludedContract_).
我们将在 test 文件夹内创建一个 SideEntranceLenderPool.t.sol 测试文件。这就是我们将为 SideEntranceLenderPool 合约定义不变性测试,并指定处理程序合约为不变性目标的地方。
将以下代码粘贴到测试文件中:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "forge-std/Vm.sol";
import "forge-std/console2.sol";
import "../src/SideEntranceLenderPool.sol";
import "./handlers/Handler.sol";
contract InvariantSideEntranceLenderPool is Test {
SideEntranceLenderPool pool;
Handler handler;
function setUp() external {
// deploy the pool contract with 25 ether
pool = new SideEntranceLenderPool{value: 25 ether}();
// deploy the handler contract
handler = new Handler(pool);
// set the handler contract as the target for our test
targetContract(address(handler));
}
// invariant test function
function invariant_poolBalanceAlwaysGtThanInitialBalance() external {
// assert that the pool balance will never go below the initial balance (the 10 ether deposited during deployment)
assert(address(pool).balance >= pool.initialPoolBalance());
}
}
粘贴代码后,让我们使用以下命令运行测试:
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance
我们得到如下输出:
Test result: FAILED. 0 passed; 1 failed; finished in 19.08ms
Failing tests:
Encountered 1 failing test in test/SideEntranceLenderPool.t.sol:InvariantSideEntranceLenderPool
[FAIL. Reason: Assertion violated]
[Sequence]
sender=0x0000000000000000000000000000000000000531 addr=[test/handlers/Handler.sol:Handler]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=flashLoan(uint256), args=[3041954473]
sender=0x0000000000000000000000000000000000000423 addr=[test/handlers/Handler.sol:Handler]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=withdraw(), args=[]
invariant_poolBalanceAlwaysGtThanInitialBalance() (runs: 1, calls: 8, reverts: 0)
测试成功打破了不变量并找出了漏洞利用路径。
它首先调用了 flashLoan 函数,然后调用了 withdraw 函数。
要查看完整的堆栈跟踪和调用序列,我们可以使用以下命令重新运行测试:
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance -vvvv
[45514] Handler::flashLoan(3041954473)
├─ [40246] SideEntranceLenderPool::flashLoan(3041954473)
│ ├─ [32885] Handler::execute{value: 3041954473}()
│ │ ├─ [22437] SideEntranceLenderPool::deposit{value: 3041954473}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
└─ ← ()
[14076] Handler::withdraw()
├─ [9828] SideEntranceLenderPool::withdraw()
│ ├─ [55] Handler::receive{value: 3041954473}()
│ │ └─ ← ()
│ └─ ← ()
└─ ← ()
[7724] InvariantSideEntranceLenderPool::invariant_poolBalanceAlwaysGtThanInitialBalance()
├─ [2261] SideEntranceLenderPool::initialPoolBalance() [staticcall]
│ └─ ← 25000000000000000000 #initial balance was 25 ether
└─ ← "Assertion violated"
现在我们可以可视化整个调用序列,并了解不变量是如何被打破的。
一个数学语句示例
这个例子将是一个无状态模糊测试(stateless fuzz),即其行为不依赖于之前的调用。此处的意图是展示模糊测试的局限性以及如何解决它。我们可以添加一些存储变量将其转换为有状态模糊测试(stateful fuzz),但这会偏离当前的重点。
以下是我们的示例合约:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Quadratic {
bool public ok = true;
function notOkay(int x) external {
if ((x - 11111) * (x - 11113) < 0) {
ok = false;
}
}
}
这是一个非常直观的例子,我们只需要测试 ok 布尔变量始终为 true,也就是:assertTrue(quadratic.ok());
只有当调用 notOkay 函数时传入的数字满足 (x - 11111) * (x - 11113) < 0 这一条件,它才会变为 false。
这看起来可能很容易,但让我们看看 fuzzer 是否能找到一个数字并打破该不变量。
这里我们同样将使用 handler 方法,所以在 /test/ handler 文件夹内创建一个 Handler_2.sol 文件并粘贴此代码。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "../../src/Quadratic.sol";
import "forge-std/Test.sol";
contract Handler_2 is Test {
Quadratic quadratic;
constructor(Quadratic _quadratic) {
quadratic = _quadratic;
}
function notOkay(int x) external {
quadratic.notOkay(x);
}
}
现在在 test 文件夹内创建一个 Quadratic.t.sol 文件并将此代码粘贴到其中:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "./handlers/Handler_2.sol";
import "../src/Quadratic.sol";
contract InvariantQuadratic is Test {
Quadratic quadratic;
Handler_2 handler;
function setUp() external {
quadratic = new Quadratic();
handler = new Handler_2(quadratic);
targetContract(address(handler));
}
function invariant_NotOkay() external {
assertTrue(quadratic.ok());
}
}
我们在 invariant_NotOkay 函数中定义了不变量。
使用以下命令运行测试:
forge test --mt invariant_NotOkay
我们得到了:
Running 1 test for test/Quadratic.t.sol:InvariantQuadratic
[PASS] invariant_NotOkay() (runs: 256, calls: 3840, reverts: 760)
Test result: ok. 1 passed; 0 failed; finished in 576.70ms
测试通过了,fuzzer 未能打破不变量。但是能够打破此不变量的数字是存在的,我们稍后会展示它是什么,但目前,让我们增加测试的运行次数(runs),看看它是否能找到。
将 runs 次数设置为 20,000。
[invariant]
runs = 20000
我们重新运行了测试并得到了以下结果:
Running 1 test for test/Quadratic.t.sol:InvariantQuadratic
[PASS] invariant_NotOkay() (runs: 20000, calls: 300000, reverts: 74275)
Test result: ok. 1 passed; 0 failed; finished in 92.41s
即使运行次数很高,测试仍然未能打破不变量,即未能传入一个使 ok 为 false 的数字。
为了确认该数字确实存在,当输入该方程时,desmos 图形显示了此数字,如下面图像中用蓝色圈出的部分所示。

图片显示我们需要寻找的数字是 11112。
让我们尝试使用 x = bound(x, 11_000, 100_000); 来限制 fuzzer 在处理程序合约中使用的数字范围。将这行代码添加到处理程序合约(第二个处理程序合约)的 notOkay 函数中。
现在它看起来应该像这样:
function notOkay(int x) external {
x = bound(x, 10_000, 100_000);
quadratic.notOkay(x);
}
bound 辅助函数由 forge-std Test 库提供;我们可以用来限制模糊测试输入的范围。
使用以下命令重新运行测试:
forge test --mt invariant_NotOkay -vvv
我们得到了以下结果:
Test result: FAILED. 0 passed; 1 failed; finished in 20.49s
Failing tests:
Encountered 1 failing test in test/Quadratic.t.sol:InvariantQuadratic
[FAIL. Reason: Assertion failed.]
[Sequence]
sender=0x000000000000000000000000000000000001373a addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-5675015641267]
sender=0x0000000000000000000000000000000000002df6 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-3]
sender=0x0000000000000000000000000000000000009208 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[1912195698230241887953774934318906299036]
sender=0x00000000000000000000000000000000000172fd addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x41b9a90e4836f4df4fe8ed9933c618c49163d8c3 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x0000000000000000000000000000000000005001 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[57896044618658097711785492504343953926634992332820282019728792003956564819794]
sender=0x000000000000000000000000000000000000e860 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x2039383034370000000000000000000000000000 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[5137619242564313626262060176411679498446697733570]
sender=0x0000000000000000000000000000000000008ead addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x2d4326d8f5a6b7c3ef871eb0063dc7771fd571d8 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0xc7ebe193ccfed949da23e957c37020d88a068c34 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[57896044618658097711785492504343953926634992332813620401282714769779013280756]
sender=0xd72485927db413065ce2730222fc574be7f38a83 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-57896044618658097711785492504343953926634992332820282019728792003956564809711]
invariant_NotOkay() (runs: 3470, calls: 52047, reverts: 0)
Encountered a total of 1 failing tests, 0 tests succeeded
由于模糊测试输入范围受到限制,这次不变量确实打破了断言,但观察调用序列,我们看不到 notOkay 函数是在何处传入 11112 被调用的。
我们使用了详细输出标志 -vvv 来查看发生了什么。
测试结果中还有日志跟踪,如下所示:
invariant_NotOkay() (runs: 3470, calls: 52047, reverts: 0)
Logs:
Bound result 23762
Bound result 89998
Bound result 44363
Bound result 88972
Bound result 11664
Bound result 33484
Bound result 11112
Traces:
[14840] Handler_2::notOkay(-5675015641267)
├─ [0] VM::toString(23762) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053233373632000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 23762) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(23762)
│ └─ ← ()
└─ ← ()
[14840] Handler_2::notOkay(-3)
├─ [0] VM::toString(89998) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053839393938000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 89998) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(89998)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(1912195698230241887953774934318906299036)
├─ [0] VM::toString(44363) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053434333633000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 44363) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(44363)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(57896044618658097711785492504343953926634992332820282019728792003956564819794)
├─ [0] VM::toString(88972) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053838393732000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 88972) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(88972)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(5137619242564313626262060176411679498446697733570)
├─ [0] VM::toString(11664) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053131363634000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 11664) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(11664)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(57896044618658097711785492504343953926634992332813620401282714769779013280756)
├─ [0] VM::toString(33484) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053333343834000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 33484) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(33484)
│ └─ ← ()
└─ ← ()
[15887] Handler_2::notOkay(-57896044618658097711785492504343953926634992332820282019728792003956564809711)
├─ [0] VM::toString(11112) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053131313132000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 11112) [staticcall]
│ └─ ← ()
├─ [4500] Quadratic::notOkay(11112)
│ └─ ← ()
└─ ← ()
日志跟踪显示,fuzzer 调用 notOkay 函数时传入的数字与所需的数字相差甚远。不过,正如最后的绑定结果和调用序列所示,bound 函数不断改变这些输入,直到获得了正确的数字。
在应测试特定数字范围的情况下,使用 bound 函数会非常方便,且有助于获得更好的结果。
结论
在本文中,我们了解了什么是不变量、为什么它们很重要,以及如何在 Foundry 中执行不变性测试。
我们还讨论了条件不变量、基于处理程序的设置,以及如何和何时限制 fuzzer 的输入值范围。
了解更多
我们的高级 Solidity 培训教授了除单元测试之外的现代智能合约测试。查看相关链接以了解更多信息。
作者信息
本文由 Jesse Raymond(LinkedIn,Twitter)作为 RareSkills 研究与技术写作计划的一部分共同撰写。
最初发布于 2023 年 4 月 28 日