HiBlock区块链社区

智能合约审计指南

2018-03-25  本文已影响285人  风静縠纹平

译者注:
智能合约代码的审计,目前还不是技术社区内经常会讨论的主题。今年3月6日,发表在博客网站【Schneier on Security】上的一篇博客(原文链接:【https://www.schneier.com/blog/archives/2018/03/security_vulner_13.html】,原文中附有一篇专业的研究报告【Finding The Greedy, Prodigal, and Suicidal Contracts at Scale】)指出,目前在以太坊中,有89%的智能合约代码都或多或少存在安全漏洞/隐患,这显然是一个非常惊人的调查结果,对社区而言也是一个巨大的风险因素。而随着智能合约的增多乃至未来可能的大规模发展,相信对各种合约代码的审计也将会变成一个专门的、专业的领域,并且是不能够、也不应该被忽视的。
本文译自Merunas Grincalaitis(一位以太坊开发者)于2017年9月18日发表在Medium上的文章,原文链接:【https://medium.com/@merunasgrincalaitis/how-to-audit-a-smart-contract-most-dangerous-attacks-in-solidity-ae402a7e7868】。本文是作者结合自己所写的一份智能合约代码来讲述智能合约审计要点的技术文章,并包含了对Solidity语言可能遇到的几种危险攻击的介绍。对于以太坊智能合约开发者而言有一定的参考和学习价值。

你有没有考虑过如何审计一个智能合约来找出安全漏洞?

你可以自己学习,或者你可以使用这份便利的一步步的指南来准确地知道在什么时候该做什么,并对合约进行审计。

我已经研究过很多智能合约的审计,并且我已经找到了从任何合约中提取所有重要信息的最常规步骤。

在本文中,你将会学到以下内容:

让我们直接开始审计合约吧:


如何审计一个智能合约

为了教会你如何进行审计,我会审计我自己写的一份合约。这样,你可以看到可以由你自行完成的真实世界的审计。

现在你也许会问:智能合约的审计到底是指什么?

智能合约审计就是仔细研究代码的过程,在这里就是指在把Solidity合约部署到以太坊主网络中并使用之前发现错误、漏洞和风险;因为一旦发布,这些代码将无法再被修改。这个定义仅仅是为了讨论目的。

请注意,审计不是验证代码安全的法律文件。没有人能100%确保代码不会在未来发生错误或产生漏洞。这仅仅是保证你的代码已被专家校订过,基本上是安全的。

讨论可能的改进,主要是为了找出那些可能会危害到用户的以太币的风险和漏洞。

好了,现在我们来看看一份智能合约审计报告的结构:

  1. 免责声明: 在这里你会说审计不是一个具有法律约束力的文件,它不保证任何东西。这只是一个讨论性质的文件。
  2. 审计概览和优良特性: 快速查看将被审计的智能合约并找到良好的实践。
  3. 对合约的攻击: 在本节中,你将讨论对合约的攻击以及会产生的结果。这只是为了验证它实际上是安全的。
  4. 合约中发现的严重漏洞: 可能严重损害合约完整性的关键问题。那些会允许攻击者窃取以太币的严重问题。
  5. 合约中发现的中等漏洞: 那些可能损害合约但危害有限的漏洞。比如一个允许人们修改随机变量的错误。
  6. 低严重性的漏洞: 这些问题并不会真正损害合约,并且可能已经存在于合约的已部署版本中。
  7. 逐行评注: 在这部分中,你将分析那些具有潜在改进可能的最重要的语句行。
  8. 审计总结: 你对合约的看法和关于审计的最终结论。

将这份结构说明保存在一个安全的地方,这是你安全地审计智能合约时需要做的全部内容。它将确实地帮助你找到那些难以发现的漏洞。

我建议你从第7点“逐行评注”开始,因为当逐行分析合约时,你会发现最重要的问题,你会看到缺少了什么,以及哪些地方应该修改或改进。

在后文中,我会给你展示一个免责声明,你可以把它作为审计的第一步。你可以从第1点开始看下去,直到结束。

接下来,我将向你展示使用这样的结构完成的审计结果,这是我针对我自己写的一个合约来做的。你还将在第3点中看到对于智能合约可能受到的最重要的攻击的介绍。

赌场合约审计

你可以在我的Github上看到审计的代码:https://github.com/merlox/casino-ethereum/blob/master/contracts/Casino.sol

以下就是我的合约Casino.sol的审计报告:

序言

在这份智能合约审计报告中将包含以下内容:

  1. 免责声明
  2. 审计概览和优良特性
  3. 对合约的攻击
  4. 合约中发现的严重漏洞
  5. 合约中发现的中等漏洞
  6. 低严重性的漏洞
  7. 逐行评注
  8. 审计总结

1、免责声明

审计不会对代码的实用性、代码的安全性、商业模式的适用性、商业模式的监管制度或任何其他有关合约适用性的说明以及合约在无错状态的行为作出声明或担保。审计文档仅用于讨论目的。

2、概述

该项目只有一个包含142行Solidity代码的文件 Casino.sol 。所有的函数和状态变量的注释都按照标准说明格式(即Ethereum Nature Specification Format,缩写为natspec,它是以太坊社区官方的代码注释格式说明,原文参考github:【https://github.com/ethereum/wiki/wiki/Ethereum-Natural-Specification-Format】,译者注)进行编写,这可以帮助我们快速地理解程序是如何工作。

该项目使用了一个中心化的服务实现了Oraclize API,来在区块链上生成真正的随机数字。

译者注:
Oraclize是一种为智能合约和区块链应用提供数据的独立服务,官网:【http://www.oraclize.it】。因为类似于比特币脚本或者以太坊智能合约这样的区块链应用无法直接获取链外的数据,所以就需要一种可以提供链外数据并可以与区块链进行数据交互的服务。Oraclize可以提供类似于资产/财务应用程序中的价格信息、可用于点对点保险的天气信息或者对赌合约所需要的随机数信息。
这里是指在这个项目的源代码中引入了一个实现了Oraclize API的开源的Solidity代码库。

在区块链上生成随机数字是一个相当困难的课题,因为以太坊的核心价值之一就是可预测性,其目标是确保没有未定义的值。

译者注:
这里之所以说在区块链上生成随机数很困难,是因为,无论采用何种算法,都需要使用时间戳作为生成随机数的“种子”(因为时间戳是计算机领域内唯一可以理论上保证“不会重复”的数值);而在智能合约中取得时间戳只能依赖某个节点(矿工)来做到。这就是说,合约中取得的时间戳是由运行其代码的节点(矿工)的计算机本地时间决定的;所以这个节点(矿工)的可信度就成了最大的问题。理论上,这个本地时间是可以由恶意程序伪造的,所以这种方法被认为是“不安全的”。通行的做法是采用一个链外(off-chain)的第三方服务,比如这里使用的Oraclize,来获取随机数。因为Oraclize是一种公共基础服务,不会针对特定的合约“作假”,所以这可以认为是“相对安全的”。

因为使用Oraclize可以在链外生成随机数字,所以使用它来产生可信的数字被认为是一种很好的做法。 它实现了修饰符和一个回调函数,用于验证信息是否来自可信实体。

此智能合约的目的是参与随机抽奖,人们在1到9之间下注。当有10个人下注时,奖金会自动分配给赢家。每个用户都有一个最低下注金额。

每个玩家在每局游戏中只能下一次注,并且只有在参与者数量达到要求时才会产生赢家号码。

优秀特性

这个合约提供了一系列很好的功能性代码:

3、对合约进行的攻击

为了检查合约的安全性,我们测试了多种攻击,以确保合约是安全的并遵循了最佳实践。

重入攻击(Reentrancy attack)

此攻击通过递归地调用ERC20代币中的 call.value() 方法来提取合约中的以太币,如果用户在发送以太币之后才更新发送者的 balance (即账户余额,译者注)的话,攻击就会生效。

当你调用一个函数将以太币发送给合约时,你可以使用fallback函数再次执行该函数,直到以太币被从合约中提取出来。

由于该合约使用了 transfer() 而不是 call.value() ,因此不存在重入攻击的风险;因为transfer函数只允许使用2300 gas,这只够用来产生事件日志数据并在失败时抛出异常。这样就无法递归调用发送者函数,从而避免了重入攻击。

因为transfer函数只会在每局游戏结束,向赢家分发奖励时才会被调用一次,所以重入式攻击在这里不会导致任何问题。

请注意,调用此函数的条件是投注次数大于或等于10次,但这个投注次数只有在 distributePrizes() 函数结束时才会被重置为0,这是有风险的;因为理论上是可以在投注次数被清零之前调用该函数并执行所有逻辑的。

所以我的建议是在函数开始时就更新条件、将投注次数设置为0,以确保 distributePrizes() 在被超出预期地多次调用时不会产生实际效果。

数值溢出(Over and under flows)

当一个 uint256 类型的变量值超出上限2**256(即2的256次方,译者注)时会发生溢出。其结果是变量值变为0,而不是更大。

例如,如果你想把一个unit类型的变量赋予大于2**256的值,它会简单地变为0,这是危险的。

另一方面,当你从0值中减去一个大于0的数字时,则会发生下溢出(underflow)。例如,如果你用0减去1,结果将是2**256,而不是-1。

在处理以太币的时候,这非常危险;然而在这个合约中并不存在减法操作,所以也不会有下溢出的风险。

唯一可能发生溢出的情况是当你调用 bet() 向某个数字下注时, totalBet 变量的值会相应增加:

totalBet += msg.value;

有人可能会发送大量的以太币而导致累加结果超过2**256,这会使totalBet变为0。这当然是不大可能发生的,但风险是有的。

所以我推荐使用类似于[OpenZeppelin’s SafeMath.sol]这样的库。它可以使你的计算处理更安全,免去发生溢出(overflow或者underflow)的风险。

可以将其导入来使用,对uint256类型激活它,然后使用 .mul().add().sub().div() 这些函数。例如:

import './SafeMath.sol';
contract Casino {
    using SafeMath for uint256;
    function example(uint256 _value) {
        uint number = msg.value.add(_value);
    }
}

重放攻击(Replay attack)

重放攻击是指在像以太坊这样的区块链上发起一笔交易,而后在像以太坊经典这样的另一个链上重复这笔交易的攻击。(就是说在主链上创建一个交易之后,在分岔链上重复同样的交易。译者注。)

以太币会像普通的交易那样,从一个链转移到另一个链。

基于由Vitalik Buterin提出的EIP 155【https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md】,从Geth的1.5.3版本和Parity的1.4.4版本开始,已经增加了对这个攻击的防护。

译者注:
EIP,即Ethereum Improvement Proposal(以太坊改进建议),官方地址【https://github.com/ethereum/EIPs】是由以太坊社区所共同维护的以太坊平台标准规范文档,涵盖了基础协议规格说明、客户端API以及合约标准规范等等内容。

所以使用合约的用户们需要自己升级客户端程序来保证针对这个攻击的安全性。

重排攻击(Reordering attack)

这种攻击是指矿工或其他方试图通过将自己的信息插入列表(list)或映射(mapping)中来与智能合约参与者进行“竞争”,从而使攻击者有机会将自己的信息存储到合约中。

当一个用户使用 bet() 函数下注以后,因为实际的数据是存储在链上的,所以任何人都可以简单地通过调用公有状态变量 playerBetsNumber 这个mapping看到所下注的数字。

这个mapping是用来表示每个人所选择的数字的,所以,结合交易数据,你就可以很容易地看到他们各自下注了多少以太币。这可能会发生在 distributePrizes() 函数中,因为它是在随机数生成处理的回调中被调用的。

因为这个函数起作用的条件在其结束之前才会被重置,所以这就有了重排攻击(reordering attack)的风险。

因此,我的建议就像我之前谈的那样:在 distributePrizes() 函数开始时就重置下注人数来避免其产生非预期的行为。

短地址攻击(Short address attack)

这种攻击是由Golem团队发现的针对ERC20代币的攻击:

但我们这个合约因为并不是ERC20代币合约,所以这种攻击并不能适用。

你可以参考这篇文章【http://vessenes.com/the-erc20-short-address-attack-explained/】来获得更多关于这种攻击的信息。

4、合约中发现的严重漏洞

审计中并未发现严重漏洞。

5、合约中发现的中等漏洞

checkPlayerExists() 应该是一个常态(constant)函数,然而实际上它并不是。因此这增加了调用这个函数的gas消耗,当有大量对此函数的调用发生时会产生很大的问题。

应该把它改为常态函数来避免昂贵的消耗gas的执行。

译者注:
Solidity语言中的常态(constant)函数,指的是在运行时不会改变合约状态的函数,也就是不会改变合约级别的状态变量(state variable)的值的函数。因为状态变量的更改是会保存到链上的,所以对状态变量的更改都要消耗gas(来支付给矿工),这是非常昂贵的。在本例中,因为 checkPlayerExists() 函数中访问了状态变量 playerBetsNumber 来判断是否已经有人下过注了,虽然这是个合约级别的变量,但这个函数并没有改变它的值,所以这个函数应该声明为 constant 以节省其对gas的消耗。

6、低严重性的漏洞

assert()require() 大体上是相同的,但assert函数一般用来在更改合约状态之后做校验,而require通常在函数的开头用做输入参数的检查。

7、逐行评注

这不是一个好实践。因为大版本的变化可能会使你的代码不稳定,所以我推荐使用一个固定的版本,比如‘0.4.11’。

即使函数默认是public类型,但显式地给函数指定类型仍然是一个好实践,它可以避免任何困惑。这里可以在这个函数声明的末尾确切地加上public声明。

同样的,在函数开头,一般更经常使用 require() 。请把所有在函数开头使用的 assert() 改为 require()

译者注:
在Solidity语言中, internal 关键字的效果,与面向对象语言比如C++、Java中的protected类型基本一致,此关键字限定的函数或者状态变量,仅在当前合约及当前合约的子合约(contacts deriving from this contract)中可以访问。 private 关键字则与其他语言中的此关键字相同,由其限定的函数或者状态变量仅在当前合约中可以访问。

译者注:
在Solidity中,函数关键字 publicexternal 在gas的消耗上是有区别的。因为 public 的函数既可以在合约外调用,又可以在合约内调用,所以虚拟机会在运行时为其分配内存,拷贝其所用到的所有变量。而 external 的函数只允许从合约外部进行调用,其调用会直接从calldata(即函数调用的二进制字节码数据)中获取参数,虚拟机不会为其分配内存并拷贝变量值,所以其gas消耗比 public 的函数要低很多。

译者注:
此函数与第98行的 generateNumberWinner() 函数一样,声明为 internal 或者 private 都是可以的。区别仅在于你希不希望子合约中可以使用它们。

8、审计总结

总体上讲,这个合约的代码有很好的注释,清晰地解释了每个函数的目的。

下注和分发奖励的机制非常简单,不会带来什么大问题。

我最终的建议是需要更加注意函数的可见性声明,因为这对于明确函数应该供谁来执行的问题非常重要。然后就是需要在编码中考虑 assertrequirekeccak 的使用上的最佳实践。

这是一个安全的合约,可以在其运行期间保证资金安全。


结论

以上就是我使用我在开篇介绍过的结构所进行的审计。希望你确实学到了一些东西并且可以对其他智能合约进行安全审计了。

请继续学习合约安全知识、编码最佳实践以及其他实用知识,并努力提高。

上一篇下一篇

猜你喜欢

热点阅读