使用 Chimera 编写多模糊器不变性测试

  • Dacian
  • 发布于 2024-09-28 14:17
  • 阅读 12

本文介绍了Chimera框架,它允许智能合约开发者和审计员使用同一代码库编写适用于Foundry、Echidna和Medusa三种fuzzer的智能合约不变性模糊测试。文章通过一个简化的Vesting合约的漏洞示例,展示了如何使用Chimera编写测试设置、定义不变量、封装目标函数以及编写适用于不同fuzzer的前端合约,最后通过修复漏洞验证了测试的有效性。

当你编写智能合约不变性模糊测试时,应该使用 Foundry、Echidna 还是 Medusa 模糊器?当你可以使用 Chimera 从同一代码库中使用所有 3 个模糊器来编写你的模糊测试时,为什么只满足于一个!

Chimera 最好的理解是作为一个“瑞士军刀” - 收集了一系列工具,允许智能合约开发者和审计员使用尽可能少或尽可能多的工具,以实现编写一个代码库的目标,该代码库可用于执行多个模糊器。

让我们来探索如何使用 Chimera 编写一个智能合约不变性模糊测试套件,使用一个简化的真实 发现 版本,该发现来自最近的 私有审计。完整的代码可以在我的 Solidity 模糊测试挑战 仓库中找到。

存在漏洞的 Vesting 合约

我们存在漏洞的合约 Vesting.sol 实现了一个简化的 vesting 方案,用户可以获得积分,一旦 vesting 周期到期,这些积分可以兑换为 tokens。大部分复杂性已被移除,仅包含与我们的示例相关的代码和功能:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

contract Vesting {
    uint24 public constant TOTAL_POINTS = 100_000;

    struct AllocationInput {
        address recipient;
        uint24 points;
        uint8  vestingWeeks;
    }

    struct AllocationData {
        uint24 points;
        uint8  vestingWeeks;
        bool   claimed;
    }

    mapping(address recipient => AllocationData data) public allocations;

    constructor(AllocationInput[] memory allocInput) {
        uint256 inputLength = allocInput.length;
        require(inputLength > 0, "No allocations");

        uint24 totalPoints;
        for(uint256 i; i<inputLength; i++) {
            require(allocInput[i].points != 0, "Zero points invalid");
            require(allocations[allocInput[i].recipient].points == 0, "Already set");

            totalPoints += allocInput[i].points;
            require(totalPoints <= TOTAL_POINTS, "Too many points");

            allocations[allocInput[i].recipient].points = allocInput[i].points;
            allocations[allocInput[i].recipient].vestingWeeks = allocInput[i].vestingWeeks;
        }

        require(totalPoints == TOTAL_POINTS, "Not enough points");
    }

    // users entitled to an allocation can transfer their points to
    // another address if they haven't claimed
    // 有权获得分配的用户可以将其积分转移到
    // 另一个地址,如果他们尚未 claim
    function transferPoints(address to, uint24 points) external {
        require(points != 0, "Zero points invalid");

        AllocationData memory fromAllocation = allocations[msg.sender];
        require(fromAllocation.points >= points, "Insufficient points");
        require(!fromAllocation.claimed, "Already claimed");

        AllocationData memory toAllocation = allocations[to];
        require(!toAllocation.claimed, "Already claimed");

        // enforce identical vesting periods if `to` has an active vesting period
        // 如果 `to` 具有活跃的 vesting 周期,则强制执行相同的 vesting 周期
        if(toAllocation.vestingWeeks != 0) {
            require(fromAllocation.vestingWeeks == toAllocation.vestingWeeks, "Vesting mismatch");
        }

        allocations[msg.sender].points = fromAllocation.points - points;
        allocations[to].points = toAllocation.points + points;

        // if `to` had no active vesting period, copy from `from`
        // 如果 `to` 没有活跃的 vesting 周期,则从 `from` 复制
        if (toAllocation.vestingWeeks == 0) {
            allocations[to].vestingWeeks = fromAllocation.vestingWeeks;
        }
    }
}

以不变量的方式思考

编写好的不变性模糊测试的主要挑战是学习以合约和协议不变性的角度思考。一种系统的方法是以“合约生命周期”和“白盒/黑盒”的方式思考。

合约生命周期

智能合约有 3 个主要的生命阶段:

  • 构建/初始化

  • 正常运行

  • 可选的结束状态

思考哪些属性在每个生命阶段中必须保持为真,这可能会很有帮助。例如,在我们的合约中,在初始化状态下,分配给接收者的总积分数必须等于预期的总积分数。由于这个不变量的实现成本(在 gas 成本方面)很“便宜”,因此将其直接实现到我们的代码中是有意义的。

在合约生命周期的正常运行和结束阶段,一个好的不变性属性是:

  • 分配给用户的总积分数必须保持等于总积分数

然而,这个不变性属性是“昂贵的”,因为它在智能合约级别迭代和求和每个用户的积分在成本上是令人望而却步的。因此,这种不变性应该由开发者“链下”实现,作为协议不变性模糊测试的一部分。

白盒/黑盒

思考不变量的另一种方式是以白盒或黑盒的方式。

“白盒”不变量是我们基于对智能合约工作方式的内部知识来实现的,而“黑盒”不变量是我们可以从协议的设计或文档中收集到的属性,而不需要了解协议实际如何实现其功能的内部细节。

对于合约的完整版本,一个有用的“结束状态”不变量是已分发给用户的 tokens 总数等于已 vested 的 tokens 数量。这个不变量是“黑盒”,因为我们不需要了解合约的底层实现的任何信息,我们只知道这个属性存在,并且在结束状态下应该是正确的。

编写测试设置

我们需要做的第一件事是创建一个 Setup.sol 文件,该文件将继承自 Chimera 的 BaseSetup 合约。我们的 Setup 合约可以:

  • 直接继承自被测试的合约,或者将该合约作为成员变量

  • 包含“ghost”变量,用于跟踪重要的状态,以便在不变性检查期间与合约状态进行比较

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { Vesting } from "../../src/09-vesting/Vesting.sol";
import { BaseSetup } from "@chimera/BaseSetup.sol";

abstract contract Setup is BaseSetup {
    // contract being tested
    // 被测试的合约
    uint24 constant TOTAL_POINTS = 100_000;
    Vesting vesting;

    // ghost variables
    // ghost 变量
    address[] recipients;

    function setup() internal virtual override {
        // use two recipients with equal allocation
        // 使用两个具有相等分配的接收者
        recipients.push(address(0x1111));
        recipients.push(address(0x2222));

        // prepare allocation array
        // 准备分配数组
        Vesting.AllocationInput[] memory inputs
            = new Vesting.AllocationInput[](2);
        inputs[0].recipient = recipients[0];
        inputs[0].points = TOTAL_POINTS / 2;
        inputs[0].vestingWeeks = 10;
        inputs[1].recipient = recipients[1];
        inputs[1].points = TOTAL_POINTS / 2;
        inputs[1].vestingWeeks = 10;

        vesting = new Vesting(inputs);
    }
}

避免在 Setup 中使用特定于模糊器的作弊码

在编写测试设置时,我们需要避免使用特定于模糊器的作弊码。一个好的方法是只使用来自 Chimera 的 IHevm 接口的作弊码,这可以防止使用任何无法在所有模糊器上工作的作弊码。

实现不变量

接下来,我们创建一个 Properties.sol 文件来定义我们的不变量;它继承自我们的 Setup 合约和 Chimera 的 Asserts 合约。在这个文件中,我们将实现不变量,就像 Echidna/Medusa 风格的不变量一样,这些不变量是返回布尔值的函数,我们将使用 property_ 作为函数前缀:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { Setup } from "./Setup.sol";
import { Asserts } from "@chimera/Asserts.sol";

abstract contract Properties is Setup, Asserts {

    function property_users_points_sum_eq_total_points() public view returns(bool result) {
        uint24 totalPoints;

        // sum up all user points
        // 将所有用户积分加起来
        for(uint256 i; i<recipients.length; i++) {
            (uint24 points, , ) = vesting.allocations(recipients[i]);

            totalPoints += points;
        }

        // true if invariant held, false otherwise
        // 如果不变量成立,则为 true,否则为 false
        if(totalPoints == TOTAL_POINTS) result = true;

        // note: Solidity always initializes to default values
        // 注意:Solidity 总是初始化为默认值
        // so no need to explicitly set result = false as false
        // 所以不需要显式设置 result = false,因为 false
        // is the default value for bool
        // 是 bool 的默认值
    }
}

包装目标函数

完成设置并定义不变量后,下一步是创建“处理程序”,该处理程序“包装”被测试合约的目标函数。总的目标是:

  • 尽可能有机地行使合约的功能

  • 阻止因未能满足基本前提条件而回滚的浪费运行

“处理程序”是一个带有 handler_ 前缀的函数,后跟包装该函数并满足所需前提条件的底层函数名称。创建一个新文件 TargetFunctions.sol,该文件继承自我们的 Properties 合约和 Chimera 的 BaseTargetFunctions 合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { Properties } from "./Properties.sol";
import { BaseTargetFunctions } from "@chimera/BaseTargetFunctions.sol";
import { IHevm, vm } from "@chimera/Hevm.sol";

abstract contract TargetFunctions is BaseTargetFunctions, Properties {

    function handler_transferPoints(uint256 recipientIndex,
                                    uint256 senderIndex,
                                    uint24 pointsToTransfer) external {
        // get an index into the recipients array to randomly
        // 从 recipients 数组中获取一个索引来随机
        // select a valid recipient
        // 选择一个有效的接收者
        //
        // note: using `between` provided by Chimera instead of
        // 注意:使用 Chimera 提供的 `between` 而不是
        // Foundry's `bound` for cross-fuzzer compatibility
        // Foundry 的 `bound` 以实现跨模糊器兼容性
        recipientIndex = between(recipientIndex, 0, recipients.length-1);
        senderIndex    = between(senderIndex, 0, recipients.length-1);

        address sender = recipients[senderIndex];
        address recipient = recipients[recipientIndex];

        (uint24 senderMaxPoints, , ) = vesting.allocations(sender);

        pointsToTransfer = uint24(between(pointsToTransfer, 1, senderMaxPoints));

        // note: using `vm` from Chimera's IHevm
        // 注意:使用 Chimera 的 IHevm 中的 `vm`
        // for cross-fuzzer cheatcode compatibility
        // 以实现跨模糊器作弊码兼容性
        vm.prank(sender);
        vesting.transferPoints(recipient, pointsToTransfer);
    }
}

编写 Echidna & Medusa 前端

现在我们所有的组件都已就位,我们只需要为我们的模糊器编写“前端”合约并提供任何配置文件。首先,让我们编写 Echidna & Medusa 前端 VestingCryticTester.sol,它用于执行 Echidna 和 Medusa 模糊器。它继承自我们的 TargetFunctions 合约和 Chimera 的 CryticAsserts 合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { TargetFunctions } from "./TargetFunctions.sol";
import { CryticAsserts } from "@chimera/CryticAsserts.sol";

// configure solc-select to use compiler version:
// 配置 solc-select 以使用编译器版本:
// solc-select install 0.8.23
// solc-select use 0.8.23
//
// run from base project directory with:
// 从基本项目目录运行:
// echidna . --contract VestingCryticTester --config test/09-vesting/echidna.yaml
// medusa --config test/09-vesting/medusa.json fuzz
contract VestingCryticTester is TargetFunctions, CryticAsserts {
  constructor() payable {
    setup();
  }
}

Echidna 配置

我们还需要创建以下配置文件 echidna.yaml

## don't allow fuzzer to use all functions
## 不允许模糊器使用所有函数
## since we are using handlers
## 因为我们正在使用处理程序
allContracts: false

## record fuzzer coverage to see what parts of the code
## 记录模糊器覆盖率以查看代码的哪些部分
## fuzzer executes
## 模糊器执行
corpusDir: "./test/09-vesting/coverage-echidna"

## prefix of invariant function
## 不变量函数的前缀
prefix: "property_"

## instruct foundry to compile tests
## 指示 Foundry 编译测试
cryticArgs: ["--foundry-compile-all"]

Medusa 配置

类似地,我们需要创建 Medusa 的配置文件 medusa.json

{
    "fuzzing": {
        "workers": 10,
        "workerResetLimit": 50,
        "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time",
        "timeout": 10,
        "testLimit": 0,
        "callSequenceLength": 100,
        "_COMMENT_TESTING_8": "added directory to store coverage data",
        "corpusDirectory": "coverage-medusa",
        "coverageEnabled": true,
        "_COMMENT_TESTING_2": "added test contract to deploymentOrder",
        "targetContracts": ["VestingCryticTester"],
        "targetContractsBalances": [],
        "constructorArgs": {},
        "deployerAddress": "0x30000",
        "senderAddresses": [\
            "0x10000",\
            "0x20000",\
            "0x30000"\
        ],
        "blockNumberDelayMax": 60480,
        "blockTimestampDelayMax": 604800,
        "blockGasLimit": 125000000,
        "transactionGasLimit": 12500000,
        "testing": {
            "stopOnFailedTest": true,
            "stopOnFailedContractMatching": true,
            "stopOnNoTests": true,
            "testAllContracts": false,
            "traceAll": false,
            "assertionTesting": {
                "enabled": false,
                "testViewMethods": false,
                "assertionModes": {
                    "failOnCompilerInsertedPanic": false,
                    "failOnAssertion": true,
                    "failOnArithmeticUnderflow": false,
                    "failOnDivideByZero": false,
                    "failOnEnumTypeConversionOutOfBounds": false,
                    "failOnIncorrectStorageAccess": false,
                    "failOnPopEmptyArray": false,
                    "failOnOutOfBoundsArrayAccess": false,
                    "failOnAllocateTooMuchMemory": false,
                    "failOnCallUninitializedVariable": false
                }
            },
            "propertyTesting": {
                "enabled": true,
                "_COMMENT_TESTING_6": "changed prefix to match invariant function",
                "testPrefixes": [\
                    "property_"\
                ]
            },
            "optimizationTesting": {
                "enabled": false,
                "testPrefixes": [\
                    "optimize_"\
                ]
            }
        },
        "chainConfig": {
            "codeSizeCheckDisabled": true,
            "cheatCodes": {
                "cheatCodesEnabled": true,
                "enableFFI": false
            }
        }
    },
    "compilation": {
        "platform": "crytic-compile",
        "platformConfig": {
            "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from",
            "target": "./../../.",
            "solcVersion": "",
            "exportDirectory": "",
            "args": ["--foundry-compile-all"]
        }
    },
    "logging": {
        "level": "info",
        "logDirectory": ""
    }
}

编写 Foundry 前端

需要的最后一部分是我们的 Foundry “前端”合约;创建一个新文件 VestingCryticToFoundry.sol。这个合约将:

  • 继承自我们的 TargetFunctions 合约和 Chimera 的 FoundryAsserts 合约

  • 以编程方式实现所需的设置,因为 Foundry 不使用配置文件

  • 将我们的每个 Echidna/Medusa 风格的 property_* 不变量包装到 Foundry 风格的 invariant_* 函数中,这些函数只是断言 property_* 函数返回 true

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { TargetFunctions } from "./TargetFunctions.sol";
import { FoundryAsserts } from "@chimera/FoundryAsserts.sol";
import { Test } from "forge-std/Test.sol";

// run from base project directory with:
// 从基本项目目录运行:
// forge test --match-contract VestingCryticToFoundry
// (if an invariant fails add -vvvvv on the end to see what failed)
// (如果一个不变量失败,在末尾添加 -vvvvv 以查看失败的原因)
//
// get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f):
// 获取覆盖率报告(参见 https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f):
//
// 1) forge coverage --report lcov --report-file test/09-vesting/coverage-foundry.lcov --match-contract VestingCryticToFoundry
// 2) genhtml test/09-vesting/coverage-foundry.lcov -o test/09-vesting/coverage-foundry
// 3) open test/09-vesting/coverage-foundry/index.html in your browser and
//    navigate to the relevant source file to see line-by-line execution records
//    在浏览器中打开 test/09-vesting/coverage-foundry/index.html 并
//    导航到相关的源文件以查看逐行执行记录

contract VestingCryticToFoundry is Test, TargetFunctions, FoundryAsserts {
    function setUp() public {
      setup();

      // Foundry doesn't use config files but does
      // Foundry 不使用配置文件,但会
      // the setup programmatically here
      // 在此处以编程方式进行设置

      // target the fuzzer on this contract as it will
      // 将模糊器定位到此合约上,因为它将
      // contain the handler functions
      // 包含处理函数
      targetContract(address(this));

      // handler functions to target during invariant tests
      // 在不变性测试期间要定位的处理函数
      bytes4[] memory selectors = new bytes4[](1);
      selectors[0] = this.handler_transferPoints.selector;

      targetSelector(FuzzSelector({ addr: address(this), selectors: selectors }));
    }

    // wrap every "property_*" invariant function into
    // 将每个 "property_*" 不变量函数包装到
    // a Foundry-style "invariant_*" function
    // 一个 Foundry 风格的 "invariant_*" 函数中
    function invariant_users_points_sum_eq_total_points() public {
      assertTrue(property_users_points_sum_eq_total_points());
    }
}

运行所有模糊器

现在我们可以从项目目录运行所有 3 个模糊器,如下所示:

echidna . --contract VestingCryticTester --config test/09-vesting/echidna.yaml
medusa --config test/09-vesting/medusa.json fuzz
forge test --match-contract VestingCryticToFoundry

此外,所有 3 个模糊器都将生成自己的 coverage 文件夹,我们可以在其中检查逐行覆盖率。在为你的协议构建测试套件时,请检查覆盖率非常重要,以确保测试套件正在执行所有相关的行。

使用 Chimera 构建模糊测试套件还可以通过 getrecon.xyz 轻松实现基于云的模糊测试。

验证修复

所有 3 个模糊器都可以轻松打破不变量;Vesting::transferPoints 函数容易受到自我转移的攻击,用户可以将积分转移给自己,最终会增加他们的积分。用户可以利用这一点来给自己最大的积分,然后耗尽整个 token 分配。

要修复此问题,请阻止 Vesting::transferPoints 中的自我转移:

    function transferPoints(address to, uint24 points) external {
        require(points != 0, "Zero points invalid");
+       require(msg.sender != to, "Self transfer invalid");

然后重新运行所有 3 个模糊器,并验证没有模糊器能够打破不变量。

结论

Chimera 是一个出色的框架,它使智能合约开发者和审计员可以更轻松地编写不变性模糊测试套件,这些套件可以跨多个模糊器使用。智能合约开发者应强烈考虑定义合约和协议不变量,并使用 Chimera 为其协议创建多模糊器不变性模糊测试套件。

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

0 条评论

请先 登录 后评论
Dacian
Dacian
in your storage