使用Diffusc对可升级智能合约进行差异模糊测试
2023年3月28日,自称为"社区驱动的DeFi代币"的SafeMoon在币安智能链上因流动性池漏洞损失了相当于890万美元的币安币(BNB)。该漏洞利用了SafeMoon的SFM代币合约升级中引入的一个简单错误,允许攻击者销毁流动性池中持有的代币并在出售足够多先前获取的代币以完全耗尽封装BNB池之前人为抬高其价格。
智能合约升级本应修复错误,但此类例子凸显了可升级性可能出现的严重问题。幸运的是,通过正确的测试实践可以避免此类错误。为此,我很高兴向您的智能合约安全工具箱介绍一个新工具Diffusc,这是我自2月以来作为Trail of Bits的助理一直在开发的项目。
Diffusc结合静态分析和差异模糊测试来比较两个可升级智能合约(USC)实现,可以在链上执行升级前发现意外行为差异。基于Slither和Echidna构建,Diffusc执行差异污点分析,使用结果在Solidity中生成差异模糊测试合约,然后将它们输入Echidna进行模糊测试。据我所知,这是智能合约差异模糊测试的首个实现,应在执行升级前与其他审计工具结合使用。
可升级智能合约
虽然还有其他设计可升级智能合约的方法,但迄今为止最常见的USC模式是基于delegatecall的代理模式。在这种模式中,代理合约存储实现合约的地址,该地址可由合约所有者或管理员更改。有许多子模式,但关键特征是在代理的回退函数中使用delegatecall,该函数捕获对代理本身未定义的所有函数调用。
关键的是,delegatecall与典型的call操作码不同,因为它从目标合约获取函数代码但在代理的上下文中执行,因此代理的存储用于所有业务逻辑。这允许交换实现而无需将状态迁移到新合约。有关USC代理模式的深入调查,请参阅Proxy Hunting: Understanding and Characterizing Proxy-based Upgradeable Smart Contracts in Blockchains以及我们关于可升级性的Trail of Bits博客文章。
差异模糊测试
模糊测试是一种安全分析技术,其中随机生成的输入被馈送到被测软件,同时模糊器监控其执行以查找错误。有多种变体,其中一种是差异模糊测试,其中两个类似实现接收相同输入,模糊器寻找两者之间执行的任何差异。
有几个专门用于测试智能合约的模糊器,其中Echidna是最成熟和功能最丰富的。虽然智能合约领域之外的模糊器通常监控被测软件的崩溃,但智能合约模糊测试通常寻找不变式违规。不变式可以插入到被测合约本身(即内部测试)或写入从外部合约调用被测合约的测试函数中(即外部测试 - 有关更多详细信息,请参阅我们对常见测试方法的介绍)。
智能合约的差异模糊测试使用外部测试,测试函数接受一些随机输入并将其馈送到两个实现中的匹配函数,然后比较两次调用的结果,断言它们应该相等。
Diffusc实现
Diffusc是一个人工辅助工具,旨在简化智能合约升级的验证:
- 利用Slither的静态分析识别受升级影响的所有函数
- 生成用于部署合约和与合约交互的包装器。包装器合约有两种模式:标准模式和分叉模式
- 用户应审查包装器中的错误,添加Diffusc无法自动推断的信息,并在适当位置添加其他不变式和前提条件
- 最后,Diffusc利用Echidna执行差异模糊测试并尝试发现升级问题。一些失败的测试可能需要额外的手动审查
图1. Diffusc高级架构
使用Slither差异升级版本
Diffusc的第一个组件是Slither中的一对实用程序扩展,它们将包含在静态分析工具的即将发布的版本中。可升级性实用程序主要做两件事:
- 比较两个USC实现以生成差异,通过污点分析增强以识别可能受其他地方更改影响的未修改代码
- 识别代理存储其实现地址的存储槽
困难的部分在于实现比较和查找受更改影响的代码。
查找新的和修改的函数
为了查找新函数和变量,我们比较两个USC的函数签名和变量列表。可以以相同方式找到缺失或修改的变量。为了查找修改的函数,我们依赖函数的中间表示(IR)(通过SlithIR),并遍历控制流图以查看函数是否匹配。这使我们能够寻找语义变化而不受内联代码注释或代码格式更改的影响。
例如,考虑引入代币分发错误的Compound升级的一个稍微简化版本。
2021年9月底至10月,Compound协议的Comptroller合约升级中引入的一个错误导致数千万美元的COMP代币错误分发给用户。在恳求甚至威胁用户归还资金后,Compound社区最终损失了约4000万美元的奖励代币,稀释了现有代币持有者的头寸。
新函数之一_upgradeSplitCompRewards()初始化了任何尚未累积任何奖励的现有市场,并在市场的相应supplyState结构中使用新的索引值。这个新函数由修改后的_become()函数调用,该函数作为升级过程的一部分被调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
function _become(SimpleUnitroller unitroller) public {
require(msg.sender == unitroller.admin(), "only unitroller admin can change brains");
require(unitroller._acceptImplementation() == 0, "change not authorized");
// TODO: Remove this post upgrade
SimpleComptrollerV2(address(unitroller))._upgradeSplitCompRewards();
}
function _upgradeSplitCompRewards() public {
require(msg.sender == comptrollerImplementation, "only brains can become itself");
uint32 blockNumber = safe32(getBlockNumber(), "block number exceeds 32 bits");
for (uint i = 0; i < allMarkets.length; i ++) {
CompMarketState storage supplyState = compSupplyState[address(allMarkets[i])];
if (supplyState.index == 0) {
// Initialize supply state index with default value
supplyState.index = compInitialIndex;
supplyState.block = blockNumber;
}
}
}
|
图2:新函数_upgradeSplitCompRewards(),由_become()调用
为了确认_become()已被修改,比较了两个版本之间的IR(上面第5行的注释在IR中被忽略):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
Function Comptroller._become(Unitroller) (*)
Expression: require(bool,string)(msg.sender == unitroller.admin(),only admin can upgrade)
IRs:
TMP_50(address) = HIGH_LEVEL_CALL, dest:unitroller(Unitroller), function:admin, args:[]
TMP_51(bool) = msg.sender == TMP_50
TMP_52(None) = SOLIDITY_CALL require(bool,string)(TMP_51,only admin can upgrade)
Expression: require(bool,string)(unitroller._acceptImplementation() == 0,not authorized)
IRs:
TMP_53(uint256) = HIGH_LEVEL_CALL, dest:unitroller(Unitroller), function:_acceptImplementation, args:[]
TMP_54(bool) = TMP_53 == 0
TMP_55(None) = SOLIDITY_CALL require(bool,string)(TMP_54,change not authorized)
// New IR from upgrade
Expression: Comptroller(address(unitroller))._upgradeSplitCompRewards()
IRs:
TMP_56 = CONVERT unitroller to address
TMP_57 = CONVERT TMP_56 to Comptroller
HIGH_LEVEL_CALL, dest:TMP_57(Comptroller), function:_upgradeSplitCompRewards, args:[]
|
图3. Comptroller._become()的IR,突出显示新函数调用
来自差异的污点分析
由于我们感兴趣的是这些更改如何影响代码的其他部分,我们还执行污点分析以找到可能有希望进行模糊测试的其他入口点。如果未修改的函数读取或写入也由新函数或修改函数写入的存储变量,或者如果函数对修改函数进行内部调用,我们认为未修改的函数被污染。如果变量由任何新的、修改的或受污染的函数写入,我们认为变量被污染。
以Compound为例。升级引入了两个新函数_initializeMarket和_upgradeSplitCompRewards - 两者都可以更改市场的供应和借入状态 - 同时用具有修改签名的新函数替换另外两个函数_setCompSpeeds和setCompSpeedInternal。升级还修改了七个函数,最值得注意的是distributeSupplierComp和distributeBorrowerComp。这些新的和修改的函数一起污染了21个状态变量,包括关键的compSupplyState和compBorrowState映射,以及26个函数。漏洞中心的claimComp函数被污染,因为它调用修改的函数distributeSupplierComp,该函数也读取受污染的变量compSupplyState。
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
29
30
31
32
33
34
35
36
37
38
|
function distributeSupplierComp(address cToken, address supplier) internal {
CompMarketState storage supplyState = compSupplyState[cToken];
// Double memory supplyIndex = Double({mantissa: supplyState.index});
uint supplyIndex = supplyState.index;
// Double memory supplierIndex = Double({mantissa: compSupplierIndex[cToken][supplier]});
uint supplierIndex = compSupplierIndex[cToken][supplier];
// Update supplier index to current index since we are distributing accrued COMP
// compSupplierIndex[cToken][supplier] = supplyIndex.mantissa;
compSupplierIndex[cToken][supplier] = supplyIndex;
// if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) {
if (supplierIndex == 0 && supplyIndex > compInitialIndex) {
// Covers case where user supplied tokens before market's supply state was set.
// Rewards the user with COMP accrued from when supplier rewards were first
// set for the market.
supplierIndex = compInitialIndex; // BUG: line not reached due to new initialization
}
// Calculate change in the cumulative sum of the COMP per cToken accrued
Double memory deltaIndex = Double({mantissa: sub_(supplyIndex, supplierIndex)});
uint supplierTokens = CToken(cToken).balanceOf(supplier);
// Calculate COMP accrued: cTokenAmount * accruedPerCToken
uint supplierDelta = mul_(supplierTokens, deltaIndex);
uint supplierAccrued = add_(compAccrued[supplier], supplierDelta);
compAccrued[supplier] = supplierAccrued;
}
function claimComp() public {
for (uint i = 0; i < allMarkets.length; i++) {
CToken cToken = allMarkets[i];
require(markets[address(cToken)].isListed, "market must be listed");
updateCompSupplyIndex(address(cToken));
distributeSupplierComp(address(cToken), msg.sender);
}
compAccrued[msg.sender] = grantCompInternal(msg.sender, compAccrued[msg.sender]);
}
|
图4. 对distributeSupplierComp()函数的更改,该函数由claimComp()调用
然而,有时仅模糊测试合约本身的新函数、修改函数和受污染函数可能不够。例如,在Compound的情况下,用户还可能与一个或多个市场(即cToken合约)交互,每个市场都调用Comptroller。此外,被测合约(即Comptroller)中的一些受污染函数可能对其他合约进行外部调用,导致这些合约的行为也出现差异。
因此,我们在比较期间还通过查找新的、修改的和受污染的函数中的任何外部调用来执行跨合约污点分析。如果找到任何外部调用,我们派生一组外部合约,每个合约都有其自己的受污染函数和变量列表,这些是由外部调用导致的。例如,如果我们查看由claimComp调用的grantCompInternal函数,我们发现对Comp.balanceOf和Comp.transfer的外部调用:
1
2
3
4
5
6
7
8
9
|
function grantCompInternal(address user, uint amount) internal returns (uint) {
Comp comp = Comp(getCompAddress());
uint compRemaining = comp.balanceOf(address(this));
if (amount > 0 && amount <= compRemaining) {
comp.transfer(user, amount);
return 0;
}
return amount;
}
|
图5. grantCompInternal()函数,包含对将COMP代币转移给用户的外部合约的调用
一旦找到这些外部调用,我们可以将两个函数标记为受污染,以及内部Comp.balances映射和Comp中读取或写入balances的任何其他函数。这种跨合约分析完成了污点分析。标准污点分析将待测试的Comptroller函数数量从69个减少到38个,减少了45%,而跨合约分析将Comp函数数量从19个减少到4个,CErc20/cToken函数数量从78个减少到16个,每个都减少了79%。
为Echidna生成差异模糊测试不变式测试
Diffusc对提供的两个USC实现以及通过命令行参数指定的任何其他目标执行差异静态分析,使用新的Slither实用程序。通过在此过程中收集的信息,该工具现在可以开始以Solidity测试合约的形式自动生成差异模糊测试不变式。
选择不变式
通常,在为智能合约编写不变式测试时,我们必须仔细识别关键不变式,这需要深入理解合约的业务逻辑和可能的状态空间。这非常重要,并且仍然适用于测试可升级智能合约,但不能轻易自动化。
由于创建Diffusc的目标始终是尽可能自动化,我们采用不同的方法来选择不变式:对于我们感兴趣的每个模糊测试函数,我们创建一个包装器方法,该方法使用相同输入调用两个实现上的函数,并断言两次调用的结果应该相同。每个包装器函数如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
function TargetContract_balanceOf(address a) public virtual {
hevm.prank(msg.sender);
(bool successV1, bytes memory outputV1) = address(proxyV1).call(
abi.encodeWithSelector(
targetContractV1.balanceOf.selector, a
)
);
hevm.prank(msg.sender);
(bool successV2, bytes memory outputV2) = address(proxyV2).call(
abi.encodeWithSelector(
targetContractV2.balanceOf.selector, a
)
);
assert(successV1 == successV2);
assert((!successV1 && !successV2) || keccak256(outputV1) == keccak256(outputV2));
}
|
图6. 自动生成的balanceOf()函数包装器,包括两个低级调用(每个实现一个)和不变式断言语句
我们使用低级调用,以便包装器函数可以检查对任一目标的调用是否回退,而不是包装器本身回退。这是因为我们希望检查两次调用是否一起成功或失败。因此,我们仅在两次调用都成功时比较返回值。如果通过命令行指定了代理合约,我们使用代理的地址而不是实现作为调用目标。我们使用hevm.prank(msg.sender)作弊代码函数来设置下一个调用的发送者,以防目标函数对发送者地址敏感。
一些函数会有预期的行为差异,这可能是首先升级的原因。这些需要用户手动审查生成的不变式并丢弃不相关的那些。在这里,我们只寻找具有不同行为的函数,更复杂的不变式仍然需要人工干预,因此Diffusc不能替代手动编写特定于项目的不变式。
标准模式和分叉模式
您可以在两种模式下运行Diffusc,这会影响测试代码的生成方式:
- 标准模式:所有合约都部署在本地测试网上,没有任何预先存在的状态。这是使用Echidna的标准方式。
- 分叉模式:合约从链上地址获取,Echidna使用链的两个分叉工作。
拥有这两种模式的原因是为了简化工具使用;每种模式在不同场景下更容易使用。分叉模式通常需要较少的手动工作,因为通常不需要在包装器合约的构造函数中提供自定义初始化逻辑 - 合约可能已经在链上初始化。分叉模式还可以自动发现任何输入ERC-20合约的代币持有者,并使用持有者的地址发送交易。
另一方面,标准模式比分叉模式更快,因为它不需要RPC请求或要求被测合约部署在链上。它最好用于与外部合约没有太多交互的合约,除非这些合约可以轻松部署和使用而无需太多设置。
上面的示例包装器方法是使用标准模式生成的,其中所有相关合约都使用给定的源代码文件部署到本地测试网。实际上,除了两个USC实现之外,测试合约的构造函数将部署每个目标合约两次,每个实现一次。这包括可选的代理合约,如果提供了一个,那么构造函数还必须在每个代理的正确槽中存储每个实现地址。例如,通用构造函数如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
constructor() public {
targetContractV1 = ITargetContractV1(address(new TargetContractV1()));
targetContractV2 = ITargetContractV2(address(new TargetContractV2()));
proxyV1 = IProxy(address(new Proxy()));
proxyV2 = IProxy(address(new Proxy()));
// Store the implementation addresses in the proxy slot 0.
hevm.store(
address(proxyV1),
bytes32(uint(0)),
bytes32(uint256(uint160(address(targetContractV1))))
);
hevm.store(
address(proxyV2),
bytes32(uint(0)),
bytes32(uint256(uint160(address(targetContractV1))))
);
}
|
图7. 在标准模式下生成的示例测试合约构造函数
由于分叉模式与预先存在的合约地址一起工作,它与标准模式之间最显著的区别是测试合约不部署任何合约,而是在其构造函数中存储它们的地址。因此,不可能有多个任何附加目标的部署,例如代理。相反,有必要维护网络的两个独立分叉,每个分叉使用相同的代理地址,但在代理的实现存储槽中存储不同的实现地址。例如,测试合约的构造函数可能如下所示:
1
2
3
4
5
6
7
|
constructor() public {
hevm.roll(13322796);
fork1 = hevm.createFork();
fork2 = hevm.createFork();
targetContractV1 = ITargetContractV1(0x75442Ac771a7243433e033F3F8EaB2631e22938f);
targetContractV2 = ITargetContractV2(0x374ABb8cE19A73f2c4EFAd642bda76c797f19233);
proxy = IProxy(0x3d9819210A31b4961b30EF54bE2aeD79B9c9
|