使用Echidna测试智能合约库的完整指南

本文详细介绍了如何使用Echidna模糊测试工具来检测智能合约库中的漏洞,包括如何通过差异模糊测试发现Set Protocol审计期间的bug,以及如何为智能合约库指定和检查有效属性。

使用Echidna测试智能合约库

在本文中,我们将展示如何使用Echidna模糊测试工具来测试智能合约。特别是,您将了解如何:

  1. 通过差异模糊测试的变体发现我们在Set Protocol审计期间发现的bug
  2. 为您自己的智能合约库指定和检查有用的属性

我们将演示如何使用crytic.io完成所有这些操作,它提供了GitHub集成和额外的安全检查。

库可能引入风险

在单个智能合约中发现bug至关重要:合约可能管理着重要的经济资源,无论是代币还是以太币,漏洞造成的损失可能高达数百万美元。但可以说,以太坊区块链上还有比任何单个合约更重要的代码:库代码。

库可能被许多高价值合约共享,因此像SafeMath中一个微妙的未知bug可能允许攻击者利用不止一个而是多个关键合约。这种基础设施代码的关键性在区块链之外的上下文中也得到了很好的理解——像TLS或sqlite这样广泛使用的库中的bug具有传染性,可能感染所有依赖该漏洞库的代码。

库测试通常侧重于检测内存安全漏洞。然而,在区块链上,我们不太担心避免堆栈破坏或从包含私钥的区域进行memcpy;我们最担心的是库代码的语义正确性。智能合约在一个“代码即法律”的金融世界中运行,如果库在某些情况下计算出错误的结果,那么这种“法律漏洞”可能会传播到调用合约,并允许攻击者使合约行为异常。

这种漏洞可能还有其他后果,而不仅仅是使库产生错误的结果;如果攻击者可以迫使库代码意外回退,那么他们就拥有了潜在的拒绝服务攻击的关键。如果攻击者可以使库函数进入失控循环,他们可以将拒绝服务与高昂的gas消耗结合起来。

这就是Trail of Bits在一个用于管理地址数组的旧版本库中发现的bug的本质,如对Set Protocol代码的审计中所述。

有问题的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
* 返回是否存在重复项。运行时间为O(n^2)。
* @param A 要搜索的数组
* @return 如果有重复项则返回true,否则返回false
*/
function hasDuplicate(address[] memory A) returns (bool)
{
    for (uint256 i = 0; i < A.length - 1; i++) {
        for (uint256 j = i + 1; j < A.length; j++) {
            if (A[i] == A[j]) {
                return true;
            }
        }
    }
    return false;
}

问题是,如果A.length为0(A为空),那么A.length - 1会下溢,外部(i)循环会遍历整个uint256值集。在这种情况下,内部(j)循环不会执行,所以我们有一个紧密的循环基本上永远什么都不做。当然,这个过程总是会耗尽gas,并且调用hasDuplicate的交易将失败。如果攻击者可以在正确的位置产生一个空数组,那么使用hasDuplicate对地址数组强制执行某些不变量的合约可能会被禁用——甚至可能是永久性的。

有关详细信息,请参阅我们的示例代码,并查看本教程以了解如何使用Echidna。

在高层次上,该库提供了管理地址数组的便捷函数。一个典型的用例涉及使用地址白名单进行访问控制。AddressArrayUtils.sol有19个函数需要测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function indexOf(address[] memory A, address a)
function contains(address[] memory A, address a)
function indexOfFromEnd(address[] A, address a)
function extend(address[] memory A, address[] memory B)
function append(address[] memory A, address a)
function sExtend(address[] storage A, address[] storage B)
function intersect(address[] memory A, address[] memory B)
function union(address[] memory A, address[] memory B)
function unionB(address[] memory A, address[] memory B)
function difference(address[] memory A, address[] memory B)
function sReverse(address[] storage A)
function pop(address[] memory A, uint256 index)
function remove(address[] memory A, address a)
function sPop(address[] storage A, uint256 index)
function sPopCheap(address[] storage A, uint256 index)
function sRemoveCheap(address[] storage A, address a)
function hasDuplicate(address[] memory A)
function isEqual(address[] memory A, address[] memory B)
function argGet(address[] memory A, uint256[] memory indexArray)

看起来很多,但许多函数在效果上是相似的,因为AddressArrayUtils提供了extend、reverse、pop和remove的功能版本(操作内存数组参数)和变异版本(需要存储数组)。您可以看到,一旦我们为pop编写了测试,为sPop编写测试可能不会太难。

基于属性的模糊测试101

我们的工作是获取我们感兴趣的函数——这里是所有函数——然后:

  1. 弄清楚每个函数的作用,然后
  2. 编写一个测试以确保函数做到这一点!

当然,一种方法是编写大量单元测试,但这有问题。如果我们想彻底测试库,这将是一项繁重的工作,而且坦率地说,我们可能会做得很糟糕。我们能否想到每一个角落案例?即使我们试图覆盖所有源代码,像hasDuplicate bug这样涉及缺失源代码的bug也很容易被遗漏。

我们希望使用基于属性的测试来指定所有可能输入的一般行为,然后生成大量输入。编写行为的一般描述比编写任何具体的“给定输入X,函数应该做/返回Y”测试更难。但编写所有需要的具体测试的工作将是过度的。最重要的是,即使是做得非常好的手动单元测试也找不到攻击者正在寻找的那种奇怪的边缘案例bug。

Echidna测试工具:hasDuplicate

测试库的代码最明显的一点是它比库本身还大!在这种情况下并不罕见。不要让这吓到您;与库不同,测试工具可以作为一个进行中的工作,慢慢改进和扩展,效果很好。测试开发本质上是增量的,即使是很小的努力,如果您有像Echidna这样的工具来放大您的投资,也会带来相当大的好处。

举一个具体的例子,让我们看看hasDuplicate bug。我们想检查:

  1. 如果有重复项,hasDuplicate会报告它,以及
  2. 如果没有重复项,hasDuplicate会报告没有。

我们可以重新实现hasDuplicate本身,但这在一般情况下没有太大帮助(在这里,它可能让我们找到bug)。如果我们有另一个独立开发的高质量地址数组实用程序库,我们可以进行比较,这种方法称为差异测试。不幸的是,我们通常没有这样的参考库。

我们在这里的方法是应用一个较弱的差异测试版本,通过寻找库中另一个可以检测重复项而不调用hasDuplicate的函数。为此,我们将使用indexOf和indexOfFromEnd来检查项的索引(从0开始)是否与从数组末尾搜索时的索引相同:

1
2
3
4
5
6
7
8
9
for (uint i = 0; i < addrs1.length; i++) {
    (i1, b) = AddressArrayUtils.indexOf(addrs1, addrs1[i]);
    (i2, b) = AddressArrayUtils.indexOfFromEnd(addrs1, addrs1[i]);
    if (i1 != (i2-1)) { // -1因为fromEnd返回偏移1
        hasDup = true;
    }
}
return hasDup == AddressArrayUtils.hasDuplicate(addrs1);
}

在我们的addressarrayutils演示中查看完整的示例代码

这段代码遍历addrs1并找到每个元素的第一次出现的索引。如果没有重复项,当然,这将始终是i本身。然后代码找到该元素的最后一次出现的索引(即从末尾)。如果这两个索引不同,则存在重复项。在Echidna中,属性只是布尔Solidity函数,通常在属性满足时返回true(我们将在下面看到例外),如果它们回退或返回false则失败。现在我们的hasDuplicate测试正在测试hasDuplicate和两个indexOf函数。如果它们不一致,Echidna会告诉我们。

现在我们可以添加几个函数来模糊设置addrs1。

让我们在Crytic上运行这个属性:

hasDuplicate的属性测试在Crytic中失败

首先,crytic_hasDuplicate失败:

1
2
3
crytic_hasDuplicate: failed!
Call sequence:
    set_addr(0x0)

触发交易序列非常简单:不要向addrs1添加任何内容,然后调用hasDuplicate。就是这样——由此产生的失控循环将耗尽您的gas预算,Crytic/Echidna会告诉您属性失败。当Echidna将失败最小化为最简单的序列时,0x0地址会出现。

我们的其他属性(crytic_revert_remove和crytic_remove)通过了,这很好。如果我们修复hasDuplicate中的bug,那么我们的所有测试都将通过:

所有三个属性测试现在在Crytic中通过

crytic_hasDuplicate: fuzzing (2928/10000)告诉我们,由于昂贵的hasDuplicate属性没有快速失败,在我们达到五分钟的超时之前,每个属性只执行了10,000个最大测试中的3,000个。

Echidna测试工具:库的其余部分

现在我们已经看到了一个测试示例,以下是构建其余测试的一些基本建议(正如我们在addressarrayutils_demo存储库中所做的那样):

  • 尝试不同的方式计算相同的东西。函数的“差异”版本越多,就越有可能发现其中一个是否错误。例如,看看我们如何交叉检查indexOf、contains和indexOfFromEnd。
  • 测试回退。如果像我们在这里所做的那样,在属性名称前添加前缀_revert_,则只有在所有调用都回退时属性才通过。这确保了代码在应该失败时失败。
  • 不要忘记检查明显的简单不变量,例如,数组与自身的差异始终为空(ourEqual(AddressArrayUtils.difference(addrs1, addrs1), empty))。
  • 其他测试中的不变量检查和前提条件也可以作为对测试函数的交叉检查。请注意,hasDuplicate在许多并非旨在检查hasDuplicate的测试中被调用;只是知道一个数组没有重复项可以建立许多其他行为的额外不变量,例如,在任何位置删除地址X后,数组将不再包含X。

使用Crytic启动和运行

您可以通过下载和安装该工具或使用我们的docker构建来自己运行Echidna测试——但使用Crytic平台将基于属性的Echidna测试、Slither静态分析(包括公共版本Slither中没有的新分析器)、可升级性检查以及您自己的单元测试集成到一个与您的版本控制绑定的无缝环境中。此外,addressarrayutils_demo存储库展示了基于属性测试所需的所有内容:它可以像创建一个最小的Truffle设置、添加一个带有Echidna属性的crytic.sol文件以及在Crytic中打开存储库配置中的基于属性的测试一样简单。

立即注册Crytic,如果您有任何问题,请加入我们的Slack频道(#crytic)或在Twitter上关注@CryticCI。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计