使用Diffusc进行可升级智能合约的差异模糊测试

本文介绍了Diffusc工具,它结合静态分析与差异模糊测试来比较可升级智能合约的两个版本实现,帮助在链上执行升级前发现行为差异,避免类似Compound协议中因升级引入的百万美元漏洞。

使用Diffusc进行可升级智能合约的差异模糊测试

2023年3月28日,自称为“以社区为中心的DeFi代币”的SafeMoon在币安智能链上因流动性池漏洞损失了相当于890万美元的币安币BNB。该漏洞利用了SafeMoon的SFM代币合约升级中引入的一个简单错误,允许攻击者销毁流动性池中持有的代币并人为抬高其价格,然后出售先前获得的足够代币以完全耗尽封装BNB池。

智能合约升级本应修复错误,但此类例子凸显了可升级性可能严重出错的情况。幸运的是,通过正确的测试实践可以避免此类错误。为此,我很高兴向您的智能合约安全工具箱介绍一个新工具Diffusc,这是我自2月以来作为Trail of Bits的助理一直在开发的项目。

Diffusc将静态分析与差异模糊测试相结合,比较两个可升级智能合约(USC)实现,从而在链上执行升级之前发现行为中的意外差异。Diffusc构建在Slither和Echidna之上,执行差异污点分析,使用结果在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使用新的Slither实用程序对提供的两个USC实现以及通过命令行参数指定的任何其他目标执行差异静态分析。利用在此过程中收集的信息,该工具现在可以开始以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(0x3d9819210A31b4961b30EF54bE2aeD79B9c
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计