Foundry中的不变测试

本文介绍了在 Solidity 智能合约中使用 Foundry 测试套件进行不变性测试的方法。不变性测试是一种验证代码正确性的测试方法,与单元测试和模糊测试类似。文章详细介绍了如何设置和运行不变性测试,并通过多个示例演示了如何检测和修复智能合约中的漏洞。

介绍

在本文中,我们将讨论不变性以及如何使用 foundry 测试套件对 Solidity 智能合约进行不变性测试。

不变性测试是一种类似于单元测试和模糊测试的测试方法,用于验证代码的正确性。如果你不熟悉单元测试,请参阅我们的 关于使用 foundry 的单元测试文章

为了跟进本文的实践方面,你需要熟悉 Solidity,并且在计算机上已安装 foundry。否则,请查看如何在 这里 完成该操作。

附带的仓库

如果你只想复制和粘贴一些代码,请克隆我们在此提供的仓库。你还可以利用该仓库来跟随本教程。 github.com/RareSkills/invariant-testing-foundry-tutorial

为什么你应该测试不变性

不变性测试允许我们测试智能合约的某些方面,这些方面单元测试可能会遗漏。单元测试仅覆盖测试中指定的属性,而其他内容则未覆盖。但是通过不变性测试,智能合约在多个随机状态下被尝试和测试,以查找代码中的缺陷。

通过测试这些不变性,开发者可以捕捉潜在问题,这些问题可能是单元测试或手动代码审查无法检测到的。

什么是不变性

不变性是必须在特定设置的良好定义的假设下始终为真的条件。例如,在一个 ERC20 合约 中,一个不变性将是合约中所有余额的总和应等于总供应量。如果某个函数调用或交易违反了这一不变性,则表明代码出现了错误,系统不再正常运作。

相较于单元测试验证特定行为,不变性则描述整个系统的某些状况。以下是一些例子:

  • 如果没有调用 mint 或 burn,ERC20 代币的总供应量不会变化
  • 一段时间内来自 智能合约 的总奖励不能超过一定比例
  • 用户无法提取超过其存款 + 一定上限的奖励

开始

在 foundry 中,不变性测试是一种 有状态模糊测试,合约的函数会在模糊测试器的作用下随机调用,并随机输入,通过这些操作尝试突破任一指定的不变性。有状态模糊测试意味着一次调用的测试状态会保留到下一次调用。

让我们初始化一个新的 foundry 项目以对智能合约进行不变性测试。

运行以下命令:

forge init invariant-exercise
cd invariant-exercise

现在我们已经准备好我们的 foundry 项目。

Foundry 配置

我们可以在 foundry.toml 文件中为我们的不变性测试设置可选配置值。如果未设置任何配置值,则 foundry 会使用默认值。我们将在本文章中逐步设置重要的配置值。要查看所有可用的不变性配置,请 访问此处

  • runs: 每组不变性测试执行的运行次数(默认值为 256)。
  • depth: 在一次运行中执行的调用次数以尝试打破不变性(默认值为 15)。
  • fail_on_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");
    }
}

这是一个简单的合约,任何人都可以存入以太并提取它们。

存入的以太应该始终随时可被存款人提取,因为没有任何限制。

我们的不变性应该是,任何存入的金额都应由同一人提取且提取金额相同。

我们将实现一个不变性测试来确认以下内容:

  • 存款人可以提取存入的以太。
  • 存入的数额应是存款人提取的相同金额。

让我们通过为这两个情况编写不变性测试来验证我们的代码是正确的。转到我们 foundry 项目的测试文件夹,将 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 {}
}

解释测试

我们即将进行的被称为 "开放测试"。开放测试是将目标合约的默认配置设置为测试函数中进行的所有合约。你可以在 这里 进一步阅读。

不变性:存款人可以提取存入的以太且存入的数额应是存款人提取的相同金额。

验证这一点的代码是 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 使用该关键字来识别这是一个不变性测试。

我们开始从测试合约存入一枚以太。由于 Deposit 合约通过 balance 数组跟踪存入的金额,因此我们会在存入后立即记录我们的余额 {这应该等于我们存入的以太,)。

接下来,我们调用 withdraw 函数提取以太,并再次记录我们的余额(此时应为零)。

这一余额记录工作通过 balanceBeforebalanceAfter 本地变量完成。

我们期待存入的金额为一枚以太,因此通过 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 指的是任何函数在智能合约中被调用时,由于错误或异常导致交易回退的次数。

期望回退

我们看到测试成功,并且测试在打破我们不变性方面调用了合约 3840 次函数调用,如调用次数所示。

它还回退了 1917 次。这可能是由于不变性测试或模糊器尝试在没有满足函数要求的情况下调用智能合约中的任何函数。我们将修改我们的 foundry.toml 文件,并添加以下不变性测试配置以确认此事。

[invariant]
fail_on_revert = true

这将导致测试在尝试打破我们不变性时如果发生回退而失败。

现在,我们重新运行测试 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 函数,即使我们没有指定这一点(请注意,这完全是随机的,你在不同的尝试中可能会获得不同的结果)。这是因为通过开放测试方法,所有合约的函数都可以供模糊器使用。稍后在讨论 "不变性目标" 时,我们将看到如何在特别情况下排除或包含特定合约/函数。

这种随机函数调用试图以各种方式破坏我们不变性。但是,如代码所示,当发送者没有余额时,该函数会回退。

由于不变性测试是这样运行的,我们即使测试通过也会看到某些回退情况。

(记得将 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(完全是随机数)

这会更改测试合约的余额。而且,它本应用于存入一枚以太。所以,现在它的余额是 2193,而不再是一枚以太。这打破了“不论存在多少存入,存款人提取的金额都应与存入的数额相同”这一不变性。

为了证明传递给 changeBalance 函数的 address 是测试合约的地址,我们可以在测试中代入一个想要的 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) 进行的(在最后的调用序列中可以看到)而不是测试合约的地址。

从逻辑上讲,这也打破了第一项不变性:“存款人可以提取存入的以太。” 我们引入的 changeBalance 函数可通过任何 address 调用,且可以用零值作为 amount 来更改 balance

这将使假设已经进行了存款的 address 现在具有零余额,因此他们即使用自己的以太在合约中,也无法提取。

条件不变性

虽然不变性应始终成立,但某些不变性需要某些条件成立。例如,不变性 assertEq(token.totalSupply(), 0); 应仅在未发生铸币时成立。如果代币已铸造,则总供应量不会为零。

这些不变性称为条件不变性,因为协议或智能合约必须在某些条件下才能保持不变。要了解更多信息,你可以在 这里 查看。

修改不变性测试配置

如果我们想增加每次测试的运行次数,可以在 foundry.toml 文件中添加配置,如前面在本文中所述。

foundry.toml 文件的 [invariant] 部分下方添加以下内容。

[invariant] # 不变性部分
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

测试仍然通过,但这次的 runscallsreverts 次数明显高于通常情况,因为我们在配置中进行了修改。你可以选择使用从零到 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`

一个更大的数字意味着更多的测试场景,但更大的数字会使测试变慢。

接近现实的示例

我们已经用我们的合约覆盖了不变性测试的基础知识,但让我们进一步进行一个流行合约的、不变性测试。

我们将测试 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"
        );
    }
}

该合约已稍作改动(同时注意到了 openzeppelin 的导入),以符合我们的 foundry 项目及期望。我们还安装了必要的依赖(即 OpenZeppelin 的 Address 库导入)。

该合约在 flashLoan 函数上存在漏洞,可以导致某人利用它并抽走其以太余额。攻击者可以调用 flashLoan 函数以借款,并将同样的借款存回合约,作为攻击者的余额,之后他们可以提取余额,即使这最初是借款而不是他们自己的以太。

那么,我们在这里的不变性将是什么?

首先,重要的是要注意该合约有一个 payable 构造函数,借用时使用的以太会在部署时存入。初始存入的以太无法被提取。以太只能通过 deposit 函数添加到合约中,并在调用者之前存入时才允许通过 withdraw 函数提取。

换句话说,我们可以说不变性为:

assert(address(SideEntranceLenderPool).balance >= SideEntranceLenderPool.initialPoolBalance()); 

initialPoolBalance 是一个公共状态变量,用于存储部署时存入的以太量)。

我们断言 SideEntranceLenderPool 的以太余额始终大于或等于部署时存入的以太量。

如果一切正常,该不变性应该成立。但是,正如前面所说,漏洞允许某人从合约中借款后将其存回,并在之后提取余额。

在下一部分中,我们将引入一个新概念,foundry 不变性测试中的 — 处理程序,以获得更好的结果。

基于 Handler 的测试

处理程序合约用于测试更复杂的协议或合约。它作为一个包装合约,将用于与我们希望操作的合约进行交互或发出调用。

当环境需要以某种方式配置(即构造函数使用某些参数调用)时,这一点特别重要。

具体来说,在测试文件中的 setUp 函数里,我们会部署处理程序合约,处理程序合约将调用池合约,并在测试中使用测试助手函数 targetContract(address target) 将仅此处理程序合约设置为目标合约。

因此,模糊器将仅随机调用处理程序合约的函数。

另另一个好处是,如果主要合约(此情况下为 SideEntranceLenderPool 合约)中的某个函数在调用之前需要某些条件,那么我们可以在处理程序合约中在函数调用之前轻松定义该条件。

处理程序合约也可以继承 forge-std Test 并使用 foundry 欺骗功能,如 vm.dealvm.prank 等。接下来,我们将对此进行演示。

让我们在 test 文件夹中创建一个 /handler 文件夹,并在其中创建一个 handler.sol 文件。

这是我们处理程序合约的代码。

import {SideEntranceLenderPool} from "../../src/SideEntranceLenderPool.sol";

import "forge-std/Test.sol";

contract Handler is Test {
    // 池合约
    SideEntranceLenderPool pool;

    // 用于检查在利用后处理程序是否可以提取以太
    bool canWithdraw;

    constructor(SideEntranceLenderPool _pool) {
        pool = _pool;

        vm.deal(address(this), 10 ether);
    }

    // 合约在闪电贷期间调用此函数
    function execute() external payable {
        pool.deposit{value: msg.value}();
        canWithdraw = true;
    }

    // 提取池中以太余额的函数
    function withdraw() external {
        if (canWithdraw) pool.withdraw();
    }

    // 调用池的闪电贷函数,以模糊金额
    function flashLoan(uint amount) external {
        pool.flashLoan(amount);
    }

    receive() external payable {}
}

我们在处理程序合约中定义了调用 SideEntranceLenderPool 合约函数的层,方便我们测试更多边缘案例并实际利用漏洞。

处理程序合约(Handler)继承 forge-std Test,并在处理程序合约的构造函数中使用 vm.deal 方法为合约提供以太。

不变性目标和测试助手

Foundry 配备了在 forge-std 库中使用的测试助手函数,允许我们指定目标合约、目标工件、目标选择器和目标工件选择器。

一些助手函数包括:

  • targetContract(address newTargetedContract_)
  • targetSelector(FuzzSelector memory newTargetedSelector_)
  • excludeContract(address newExcludedContract_)

要查看所有可用的测试助手函数,请查看 这里这里

我们将在测试文件夹中创建一个 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 {
        // 部署池合约,存入 25 以太
        pool = new SideEntranceLenderPool{value: 25 ether}();
        // 部署处理程序合约
        handler = new Handler(pool);
        // 将处理程序合约设置为测试的目标
        targetContract(address(handler));
    }

    // 不变性测试函数
    function invariant_poolBalanceAlwaysGtThanInitialBalance() external {
        // 断言池的余额不会低于初始余额(在部署期间存入的 25 以太)
        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 #初始余额为 25 以太
    └─ ← "Assertion violated"

现在我们可以可视化整个调用序列,看到了不变性是如何被打破的。

带有数学语句的示例

此示例将是一个无状态的模糊测试,即行为不依赖于之前的调用。这里的意图是演示模糊测试的局限性及其解决方法。我们可以添加一些存储变量来将其变成有状态的模糊测试,但这会是一个分散注意力的主题。

这是我们的示例合约:

// 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 函数时变为 false(x - 11111) * (x - 11113) < 0

这看似简单,但让我们看看模糊测试器能否找到一个数字并打破不变性。

我们也将在此处使用处理程序方法,因此在 /test/ 处理程序文件夹中创建一个 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

测试通过,模糊测试器未能打破不变性。但是,确实存在使得不变性为 false 的数字,我们稍后将展示,当前则让我们增加测试的运行次数,看看它能否找到该数字。

将运行次数设置为 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

即使在高达 20,000 次运行时测试也未能使其打破不变性,而使得 okfalse 的数字但是是存在的。通过 desmos graph,该方程应输入可显示此类输出,如下面图像中的蓝色圈定部分。

image

该图像表明所需的数字是 11112

让我们设定调控模糊测试器的冲突范围:将 x = bound(x, 11_000, 100_000); 加入处理程序合约的 notOkay 函数中(第二个处理程序合约)。

该函数应现在为:```solidity function notOkay(int x) external { x = bound(x, 10_000, 100_000); quadratic.notOkay(x); }


bound 辅助函数随 forge-std 测试库提供; 我们可以限制模糊输入的范围。

重新运行测试:

```terminal
forge test --mt invariant_NotOkay -vvv

我们得到以下结果:

测试结果:失败。0 通过; 1 失败; 耗时 20.49s

失败的测试:
在 test/Quadratic.t.sol:InvariantQuadratic 发现 1 个失败的测试
[失败。原因:断言失败。]
        [序列]
                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() (运行:3470,调用:52047,回退:0)

共遇到 1 个失败的测试,0 个测试通过

此次不变性确实打破了断言,因为模糊输入的范围有限,但从调用序列来看,我们无法看到 notOkay 函数在调用时使用 11112

我们使用了详细标志 -vvv 来查看发生了什么。

测试结果中还有日志跟踪,内容为:

invariant_NotOkay() (运行:3470,调用:52047,回退:0)
日志:
  Bound结果23762
  Bound结果89998
  Bound结果44363
  Bound结果88972
  Bound结果11664
  Bound结果33484
  Bound结果11112

跟踪:
  [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) 
    │   └─ ← ()
    └─ ← ()

日志跟踪显示,模糊测试器使用了与期望值相差甚远的数字来调用 notOkay 函数。然而,bound 函数仍然不断改变这些输入,直到得到正确的数字,如最后的 bound 结果和调用序列所示。

在需要测试特定数字范围的情况下使用 bound 函数会很方便,有助于获得更好的结果。

结论

在本文中,我们了解了什么是不变性,它的重要性以及如何在 foundry 中进行不变性测试。

我们还讨论了条件不变性、基于处理程序的设置,以及何时及如何限制模糊输入值的范围。

了解更多

我们的 高级 Solidity 培训 超越单元测试,教授现代智能合约测试。查看它以获取更多信息。

作者

本文由 Jesse Raymond 共同撰写 (LinkedInTwitter),作为 RareSkills 研究与技术写作项目的一部分。

最初出版于 2023 年 4 月 28 日

  • 原文链接: rareskills.io/post/invar...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/