本文介绍了使用Medusa和create-chimera-app工具进行智能合约的不变量测试方法。不变量测试是一种高级集成测试手段,通过生成随机调用序列来检查合约内的某些不变量是否保持。文章详细介绍了不变量测试的基本原理、Medusa工具的工作流程,以及如何使用create-chimera-app框架搭建测试脚手架,并通过案例演示如何利用这些工具解决智能合约中的漏洞。
不变量测试是目前最高效的智能合约测试方法之一。在我较早编写的 Foundry 高级测试: Fuzz、Invariant与形式化证明 曾介绍过基于 Foundry 的不变量测试方法,但该文已经编写于 2 年前。在过去 2 年中,有一系列新的工具产生,比如 medusa 工具,并且出现了专业的不变量测试服务商,比如 Recon。
本文将基于 Recon 编写的 create-chimera-app 和 Medusa 工具介绍不变量测试的方法。和过去的文章一致,本文依旧会使用案例演示的方法,本文主要使用的案例是 Damn Vulnerable DeFi,该网站内包含一些 CTF 题目,我们会使用不变量测试解决这些问题。事实上, Solidity Fuzzing Challenge 已经尝试使用不同的不变量测试工具解决这些问题了。
Update: 在最新一次更新后,本文可能不会使用
Damn Vulnerable DeFi
作为案例,本文认为 How Echidna inflated 100s of Millions in Voting Power: Writing and Breaking Properties 可能是一个更优秀的案例,此处仍需一些评估
在本文的开始,我们首先简单讨论一下不变量测试的基础含义。我们可以认为不变量测试是一种高级的集成测试手段,我们会生成一系列随机调用对合约进行调用,最后检查合约内的某些不变量是否保持。那么什么是合约不变量( invariants
,在很多情况下,合约不变量也被称为属性 property
,一般两者都会被混用)呢?简单的案例包括:
当然,实际上在智能合约最初设计时,我们就已经有意无意使用了不变量的概念,此处笔者建议开发者在最初讨论系统设计时就考虑不变量问题,并将所有设计过程中考虑的不变量归纳到表格内部,等待最终进行不变量测试时使用。当然,在开发过程中,开发者也可以随时关注编写的代码是否打破了不变量。不变量是极其重要的智能合约设计规则,以至于 Trail of Bits 曾撰文呼吁智能合约工程师使用不变量驱动开发。本节中的部分内容就来自 Trail of Bits 的这篇呼吁不变量驱动开发的文章 The call for invariant-driven development。Trail of Bits 也开源了一份常见 ERC 的 不变量测试列表。值得注意的,不变量测试不一定只检测 x = y
这种完全相等性测试,我们也可以设置 x < y
这种不等测试,在很多情况下,进行不等测试是更加有效的。
对于不变量该如何更加正确的识别,Dacian 在 Writing Multi-Fuzzer Invariant Tests Using Chimera 指出我们可以将智能合约的生命周期视为三个阶段:
每一个阶段都可能存在不变量。比如 AMM 在正常执行时,假如不存在手续费,那么一定会存在 x * y = k 的结果。或者比如 ERC4626 内一个用户存款后立即取款,当金库内的资金没有增加时,该用户应该可以将所有资产取出,假如不可以,那么就代表 ERC4626 金库受到了攻击,比如经典的 Inflation attack。
最近知名安全测试工程师 Rappie 在 推文 内指出了自己设计不变量的方法。首先,我们可以分析一些简单的不变量,比如“所有余额的总量等于当前代币的总供应量”,这种简单的不变量就可以帮助我们发现一系列问题。进一步深入的不变量需要安全测试工程师与开发团队进行交流,了解开发团队最担心的问题有哪些,然后根据这些开发者所担忧的问题进一步构造不变量测试。
Recon 的工程师 GalloDaSballo 曾在 Push & Pop #59 - Alex - Recon Extension 和 How Echidna inflated 100s of Millions in Voting Power: Writing and Breaking Properties 给出了自己对于不变量测试的一些定义。该工程师将不变量测试分为以下几种类型:
GalloDaSballo 强调了在进行不变量测试中使用 数学归纳法 的重要。数学上的归纳法是指我们只需要证明当 \(n = 0\) 时命题成立,同时证明在 \(n = k\) 时,\(n = k + 1\) 情况下命题也是成立的,如此我们就证明了命题的正确性。不变量测试中很多验证的底层思想都类似数学归纳法。
关于不变量测试,业界也存在了很多实战类型文章,读者可以按需阅读:
当然,读者也可以直接阅读本文中的内容,本文的内容会较为连贯,阅读完成本文后也可以使用本文提供的技术框架重新实现上述几篇文章内的案例。
Medusa 是一个使用 go 语言开发的基于 go-ethereum 的不变量测试工具, Solidity Fuzzing Challenge 显示该测试工具具有较高的性能和良好的调用顺序生成器,可以快速寻找到协议代码内打破不变量的情况。假如读者对 Medusa 的性能等感兴趣,可以尝试阅读 Medusa 开发商 Trail of Bits 编写的博客 Unleashing Medusa: Fast and scalable smart contract fuzzing。
对于 medusa 测试工具的安装,读者可以自行参考 文档。但是对于 MacOS 用户而言,可以直接使用以下命令即可:
brew install medusa
而 create-chimera-app 是一个由 Fuzz 服务商 Recon 编写的不变量测试框架,该框架最有趣的是同时支持 Foundry / Medusa / echidna 等多个不变量测试工具。但在本文中,我们主要使用 Foundry 和 Medusa 两个工具。该框架倡导开发者首先使用 Foundry 内的各种工具完成一些调用队列的正确性测试,保证特定的调用队列可以正常执行,然后使用 Medusa 随机生成调用队列,假如 Medusa 发现特殊情况,比如打破了不变量,那么我们可以将该出现问题的调用队列转移到 Foundry 工具内进行 Debug。在具体表现上, create-chimera-app
内存在 CryticToFoundry
合约,该合约就是用来存放一些单纯的 Foundry 单元测试来辅助 Medusa 工作。
需要注意的,medusa 在 slither 辅助下可以更好的工作,所以此处建议读者使用以下命令安装 slither 工具。更加具体的说, slither
会在 medusa 运行前对智能合约内内容进行提取,提取后的数据可以进一步辅助 medusa 进行测试。
brew install slither-analyzer
另一个较为重要的工具是 recon-extension。该工具为开发者提供了一键生成测试脚手架的功能,此处建议读者安装。笔者为了避免 recon-extension 对日常开发环境的影响使用了 VSCode Profiles 的特性,该特性可以使得开发者构建出 VSCode 配置不同的几个工作空间,笔者专门为 recon-extension 创建了一个专门用于不变量测试的工作空间。
在介绍具体的不变量测试前,我们最好对 Medusa 等不变量测试工具有一定了解。不变量测试工具的基础原理是生成一系列的调用序列然后按照调用序列直接对一系列目标函数进行调用。作为合约工程师,我们一般会构建一个被称为 TargetFunctions
的合约,该合约内部包含所有我们认为需要被 medusa 调用的函数。以 create-chimera-app 框架为例,该框架内的 CryticTester
合约实际上就是 medusa 调用序列所调用的合约,该合约的具体内容如下:
import {CryticAsserts} from "@chimera/CryticAsserts.sol";
import {TargetFunctions} from "./TargetFunctions.sol";
// echidna . --contract CryticTester --config echidna.yaml --format text --workers 16 --test-limit 1000000
// medusa fuzz
contract CryticTester is TargetFunctions, CryticAsserts {
constructor() payable {
setup();
}
}
此处的 TargetFunctions
已经在上文有所提及。与大家认知不同的是,不变量测试其实也是随机生成的调用函数中的一环,只是不变量测试一定会在随机生成的调用队列内出现。还是以 create-chimera-app 框架为例,我们可以在 AdminTargets
合约(该合约是 TargetFunctions
的父合约, TargetFunctions
与该合约存在继承关系)内找到如下定义:
abstract contract AdminTargets is
BaseTargetFunctions,
Properties
{
/// CUSTOM TARGET FUNCTIONS - Add your own target functions here ///
/// AUTO GENERATED TARGET FUNCTIONS - WARNING: DO NOT DELETE OR MODIFY THIS LINE ///
}
此处的 Properties
就是包含不变量测试的合约,所以 TargetFunctions
内部已经包含了不变量测试的有关代码。当我们已知 Medusa 如何判断哪些函数需要被调用后,我们会希望进一步了解这些测试序列如何生成?这部分内容在 medusa fuzzing lifecycle 已有部分介绍,非常建议读者阅读这部分文档。
在此处,我们先看一段来自 Medusa 的配置文件 medusa.json
内的内容:
{
"callSequenceLength": 100,
"corpusDirectory": "medusa",
"targetContracts": [\
"CryticTester"\
],
}
此处的内容中 targetContracts
其实就是 Medusa 所需要随机调用的合约,而 callSequenceLength
代表每一次生成随机调用序列的长度,所以不变量测试其实并不是进行大量调用后再判断不变量是否被满足,而是存在很多随机调用序列执行,但每一个调用序列执行完成后,测试工具会擦除当前 EVM 的状态。
对于随机序列的生成,Medusa 使用了两种方式:
corpus
概念。在理解该概念之前,我们需要理解测试覆盖率引导测试数据生成。此处的测试覆盖率引导指 Medusa 内部会跟踪当前生成的 calldata 所覆盖的字节码范围,并追求测试覆盖率最大化的目标。为了实现此目标,Medusa 每发现一个会增加测试覆盖率的调用序列就会将其记录到 corpus
内部,并且根据 corpus
内部的调用序列进行微调已获得更好的覆盖范围corpus
是 Medusa 最优秀的特性,我建议读者可以在模糊测试长时间没有覆盖率提升时对 corpus
内部的调用队列进行观察。读者可以在 medusa/call_sequences
文件夹内部找到很多 JSON 文件,每一个 JSON 文件内都包含一个 Medusa 找到的 corpus
队列。 corpus
队列是一个由 call
数据构成的列表:
{
"call": {
"from": "0x7fa9385be102ac3eac297483dd6233d62b3e1496",
"to": "0x7d8cb8f412b3ee9ac79558791333f41d2b1ccdac",
"nonce": 19,
"value": "0x0",
"gasLimit": 12500000,
"gasPrice": "0x1",
"gasFeeCap": "0x0",
"gasTipCap": "0x0",
"data": "0x280203d7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe000000000000000000000000965467f65080360c1683ecb814d0b318e6cc733d000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000035453540000000000000000000000000000000000000000000000000000000000",
"dataAbiValues": {
"methodSignature": "morpho_supply(uint256,uint256,address,bytes)",
"inputValues": [\
"115792089237316195423570985008687907853269984665640564039457584007913129639934",\
"115792089237316195423570985008687907853269984665640564039457584007913129639934",\
"0x965467f65080360c1683ecb814D0B318e6cC733d",\
"545354"\
]
},
"AccessList": null,
"SkipAccountChecks": true
},
"blockNumberDelay": 0,
"blockTimestampDelay": 214979
}
以下伪代码显示了 Medusa 的基础工作原理:
## Generate a new call sequence or mutate one from the corpus
sequence = generator.NewCallSequence()
## Iteratively execute each call in the call sequence
for i < len(sequence) {
# Retrieve the i-th element in the sequence
tx = sequence[i]
# Run the transaction on the blockchain and retrieve the result
result = blockchain.executeTransaction(tx)
# Update coverage
increasedCoverage = coverageTracker.updateCoverage()
# If coverage increased, add sequence[:i+1] to the corpus
if increasedCoveraged {
corpus.addCallSequence(tx[:i+1])
}
# Check for invariant failures
encounteredFailure = tester.checkForInvariantFailures(result)
# Let user know we had a failing test case
if encounteredFailure {
reportFailedTestCase()
}
}
从 Medusa 生成随机序列的方法可以看出,假如 Medusa 生成了一些可能导致 revert 的序列,那么 Medusa 就会调整自己的随机序列,并尽可能减少 revert 的发生。Meudsa 将定义为 coverage-guided fuzzer
。所谓的 coverage-guided fuzzer 指模糊测试程序使用一系列方法目标是最大限度地扩大其测试的系统覆盖率。
可能有读者希望利用某种方法操作调用序列随机性,比如我们希望调用序列内一定需要包括 repay
和 liquidate
函数,此处我们将使用到在 Medusa 文档内没有提到的 Shrink
特性 。该特性实际位于上文伪代码中的 checkForInvariantFailures
函数内部。该特性会在属性测试失败后被触发,Medusa 就会修改之前的调用队列,查找可移除的冗余调用并且验证修改后的调用队列满足 属性测试要求,所以开发者可以构建一些特殊的属性测试,这些测试并不检查不变量而是用于检查调用序列是否调用了某一个函数。以上文的 repay
和 liquidate
函数必须被调用为例,我们可以在目标函数内构造如下调用:
function morpho_liquidate(address borrower, uint256 seizedAssets, uint256 repaidShares, bytes memory data)
public
updateGhosts
asActor
{
morpho.liquidate(marketParams, borrower, seizedAssets, repaidShares, data);
hasLiquidated = true;
}
function morpho_repay(uint256 assets, uint256 shares, address onBehalf, bytes memory data)
public
updateGhosts
asActor
{
morpho.repay(marketParams, assets, shares, onBehalf, data);
hasRepaid = true;
}
上述代码中的 hasLiquidated
和 hasRepaid
作为状态变量标识函数是否被调用,然后我们可以在属性测试内增加以下属性测试:
function canary_hasDoneRepay() public {
t(!hasRepaid, "canary_hasDoneRepay");
}
function canary_hasDoneLiquidate() public {
t(!hasLiquidated, "canary_hasDoneLiquidate");
}
此时,假如 Medusa 没有对 morpho_repay
和 morpho_liquidate
函数进行调用,那么该序列中的调用到 canary_hasDoneRepay
和 canary_hasDoneLiquidate
就会触发属性测试失败,此时 Medusa 会使用 Shrink 机制优化调用序列。
假如读者对 shrink 的细节感兴趣,可以阅读此处的 源代码
我们讨论另一个问题,假如读者曾经使用过 Foundry 的标准库进行测试,会发现 Foundry 为用户提供了很多 cheatcode
用于修改各种区块状态或者交易上下文,比如我们可以使用 startPrank
修改之后 call
调用的 msg.sender
。但是 Medusa 等工具支持这些 cheatcode 吗?此处我们就要介绍一个被称为 HEVM 工具,该工具提供了最早期的 cheatcode 并以此构造了测试工具。后续开发的 Foundry 等工具基本都实现了 HEVM 内定义的 cheatcode,而 Medusa 等现代测试工具也不例外。所以理论上只要使用 HEVM 内的 cheatcode 就可以实现测试的最大兼容性。实际上, create-chimera-app 框架的核心代码也就是构造基于 HEVM 内的 cheatcode 构造不同测试的通用代码。
最后,我们讨论最后一个问题,不变量测试工具如何判断测试失败?在 Foundry 测试中,我们一旦使用 assertEq
等判断函数,这些函数本质上调用了 VM_ADDRESS
合约。我们可以认为该合约是一个预汇编合约,所有对该合约的调用会被转移到 Foundry 内的 Rust 程序组件处理。而 Medusa 等不变量测试使用了 solidity 原生的 assert
方法,我们可以在 chimera
内看到如下代码:
function gt(uint256 a, uint256 b, string memory reason) internal virtual override {
if (!(a > b)) {
emit Log(reason);
assert(false);
}
}
此处 assert
会释放一些特殊的 revert
报错信息,Medusa 通过捕获这些特殊的报错信息确定测试是否失败。注意,这些测试报错与我们在合约内定义的 revert
并不完全一致,Medusa 可以通过这种不一致分别测试是被 assert
中断还是被正常中断。Medusa 内的相关代码如下:
// checkAssertionFailures checks the results of the last call for assertion failures.
// Returns the method ID, a boolean indicating if an assertion test failed, or an error if one occurs.
func (t *AssertionTestCaseProvider) checkAssertionFailures(callSequence calls.CallSequence) (*contracts.ContractMethodID, bool, error) {
// If we have an empty call sequence, we cannot have an assertion failure
if len(callSequence) == 0 {
return nil, false, nil
}
// Obtain the contract and method from the last call made in our sequence
lastCall := callSequence[len(callSequence)-1]
lastCallMethod, err := lastCall.Method()
if err != nil {
return nil, false, err
}
methodId := contracts.GetContractMethodID(lastCall.Contract, lastCallMethod)
// Check if we encountered an enabled panic code.
// Try to unpack our error and return data for a panic code and verify that that panic code should be treated as a failing case.
// Solidity >0.8.0 introduced asserts failing as reverts but with special return data. But we indicate we also
// want to be backwards compatible with older Solidity which simply hit an invalid opcode and did not actually
// have a panic code.
lastExecutionResult := lastCall.ChainReference.MessageResults().ExecutionResult
panicCode := abiutils.GetSolidityPanicCode(lastExecutionResult.Err, lastExecutionResult.ReturnData, true)
failure := false
if panicCode != nil {
failure = encounteredAssertionFailure(panicCode.Uint64(), t.fuzzer.config.Fuzzing.Testing.AssertionTesting.PanicCodeConfig)
}
return &methodId, failure, nil
}
本节主要介绍 create-chimera-app 框架的具体架构以及不同的文件具体含义。 create-chimera-app
的核心文件都位于 test/recon
文件夹内,此处笔者对 RewardsManager-Invariants 为例介绍。我们可以使用 git clone https://github.com/GalloDaSballo/RewardsManager-Invariants.git
将该项目 clone 到本地。然后直接使用 rm -rf test/recon
删除掉原有的测试文件。接下来,我们可以使用 recon-extension
插件初始化不变量测试项目,只需要选择目标合约 RewardsManager
然后点击 Scaffold
即可。
等待插件安装依赖并生成一系列脚手架文件。最后,我们可以在 test/recon
文件夹内找到以下内容:
.
├── BeforeAfter.sol
├── CryticTester.sol
├── CryticToFoundry.sol
├── Properties.sol
├── Setup.sol
├── TargetFunctions.sol
└── targets
├── AdminTargets.sol
├── DoomsdayTargets.sol
├── ManagersTargets.sol
└── RewardsManagerTargets.sol
看上去 Recon 扩展生成了一系列复杂的脚手架代码,似乎很难理解。读者应该根据上文介绍的 Medusa 的基础原理对这些合约进行理解。这些合约本质上就是提供了 Medusa 需要随机调用的函数和属性测试的具体内容。
我们可以首先阅读 Setup
合约,该合约与 foundry 测试中的 setUp
函数作用一致,用来配置不变量测试开始前的基础合约环境。我们一般会在此合约内完成测试目标合约的部署以及与测试有关的一些其他配置,比如为借贷协议配置预言机等。我们可以看到 SetUp.sol
内部包含以下内容:
abstract contract Setup is BaseSetup, ActorManager, AssetManager, Utils {
RewardsManager rewardsManager;
/// === Setup === ///
/// This contains all calls to be performed in the tester constructor, both for Echidna and Foundry
function setup() internal virtual override {
rewardsManager = new RewardsManager(); // TODO: Add parameters here
}
/// === MODIFIERS === ///
/// Prank admin and actor
modifier asAdmin {
vm.prank(address(this));
_;
}
modifier asActor {
vm.prank(address(_getActor()));
_;
}
}
其中 setup
函数内,我们可以进行合约部署等。剩下的 asAdmin
和 asActor
是系统生成的用于修改调用者的修饰器,该修饰器会在 TargetFunctions
中使用,其用途是在不变量测试过程中修改调用目标函数的请求者地址。我们可以看到 asAdmin
修饰符会将调用者修改为当前的 SetUp
合约,而 asActor
则会调用 _getActor
函数在已有的调用者序列内随机抽取调用者来调用目标合约。
ActorManager
合约内部对_getActor
进行了实现,实际上ActorManager
模块的作用就是提供了一系列辅助函数用于调整不变量测试过程中随机调用目标函数时所使用的调用者地址,我们可以通过_addActor
函数增加调用者序列内的调用者地址,使用_switchActor
修改调用者。但是需要注意的,我们一般会直接将_switchActor
函数也作为不变量测试过程中可以被调用的函数,不变量测试工具为自动调用该函数修改 actor 地址
TargetFunctions
文件内定义了在不变量测试中,fuzz 工具会生成的随机调用目标函数,该合约的代码如下:
abstract contract TargetFunctions is
AdminTargets,
DoomsdayTargets,
ManagersTargets,
RewardsManagerTargets
{
/// CUSTOM TARGET FUNCTIONS - Add your own target functions here ///
/// AUTO GENERATED TARGET FUNCTIONS - WARNING: DO NOT DELETE OR MODIFY THIS LINE ///
}
我们可以看到该合约其实是一个空合约,但继承了 targets
文件夹内的四个合约。我们会在后文依次介绍每一个合约内的具体内容。我们首先分析 RewardsManagerTargets
合约,该合约部分代码如下:
function rewardsManager_accrueUser(uint256 epochId, address vault, address user) public asActor {
rewardsManager.accrueUser(epochId, vault, user);
}
function rewardsManager_accrueVault(uint256 epochId, address vault) public asActor {
rewardsManager.accrueVault(epochId, vault);
}
事实上,该合约其实就是根据 rewardsManager
合约的 ABI 生成的, RewardsManagerTargets
内部包含所有在 rewardsManager
合约内定义的可以在外部被调用的函数。该合约也是我们未来修改加工的基础,有了 RewardsManagerTargets
合约,我们可以在上面编写其他函数。
ManagersTargets
合约内主要包含 asset
和 actor
管理。包含以下几个函数:
switchActor
更换当前调用过程中的 actor
,该函数被调用后会影响后续 asActor
修饰符内的 _getActor
函数返回值switch_asset
在资产列表内获取一个资产地址并更换随后所有调用过程中使用的资产add_new_asset
向资产列表内增加资产asset_approve
和 asset_mint
顾名思义分别用于资产的授权和铸造简单来说,我们可以将 ManagersTargets
视为一个维护随机调用过程中 actor 和 asset 的上下文工具,我们可以通过调用内部的函数实现 actor 的增加和切换,以及资产的增加、切换、铸造和授权等功能。
AdminTargets
的作用非常简单,该合约内主要存储那些 asAdmin
修饰符修饰的函数,比如那些待测试合约内只能由 owner
调用的函数。而 DoomsdayTargets
合约内的主要存储那些上文介绍的符合 Doomsday Properties
的测试。我们可以看到该合约内存在以下修饰符:
modifier stateless() {
_;
revert("stateless");
}
这是因为 Doomsday Properties
测试都是测试极端情况下的系统表现,所以这些属性测试一旦被调用就会大幅度干扰合约正常的状态,这会导致其他测试无法正常执行。所以理论上所有的 Doomsday Properties
测试都应该是 stateless
,该函数被调用但是调用会被 revert
。在上文中,我们提及此处的 revert("stateless")
和 assert
导致的 revert
并不一致,所以我们实际上可以在 Doomsday 测试内编写不变量测试,而执行完成后的 revert("stateless");
并不会影响不变量测试。
BeforeAfter
和 Properties
都是直接为不变量测试服务的,我们会在后文介绍不变量编写时详细介绍,此处我们只需要知道 BeforeAfter
内包含 updateGhosts
函数,可以更新一些 Ghosts 变量,这些变量主要在调用序列中跟踪一些状态,值得注意的,我们不能在 updateGhosts
内部编写可能发生 revert
的函数,因为这些函数可能导致 updateGhosts
失败进一步导致逻辑代码调用失败。这种失败会导致覆盖范围在异常降低,甚至导致某些情况始终无法覆盖。 Properties
是核心编写不变量测试的合约。
最后,我们介绍一些不太重要的其他合约:
CryticTester
Medusa \ Echidna 测试工具等入口合约,此处的 Crytic
其实就是 Medusa \ Echidna 工具的开发商CryticToFoundry
用于存储一些在 Foundry 内执行的测试,假如 Medusa 等工具在不变量测试内出现了报错,我们可以将报错信息转化为 Foundry 测试代码放到 CryticToFoundry
内执行,简单来说,我们可以在这个合约内使用 Foundry 编写一些临时的调用代码
- 本文转载自: hackmd.io/evm0Y5w7RF2MhS... , 如有侵权请联系管理员删除。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!