以太坊智能合约安全

引言

智能合约就是自主执行的合约,其条款是用代码规定的。

虽然这个概念已经存在一段时间了,但至少从1996年Nick Szabo提出了这一概念以来,直到图灵完备的以太坊区块链来临,智能合约的使用才变得普遍。

以太坊的智能合约存在于合约地址里,能以交易命令来调用。用代码编写并存放在不可更改的公链上的合约执行起来会产生一定的风险与安全问题。我们将会在本文中讨论这些问题和可能的缓解措施。

代码即法律?

对智能合约理念的字面解释造成了“代码即法律” ( “Code is Law” ) 的范式理解,意思是那些智能合约具有约束力,并被诠释为合法文件。所有意识到无法创建完全无错代码的软件工程师在想到计算机程序具有合法约束力的时候,手心都会捏一把汗。这里存在很多明显的问题:

  1. 代码有漏洞。写无bug代码是一件非常困难的事情,即使做好了所有可能的预防措施,在相当复杂的软件中也总会存在意想不到的执行路径或者可能的漏洞。
  2. 法律合约也需要解释与仲裁。创建毫无漏洞法律合约是非常困难的。在任何大型合约中,拼写错误可能出现,一些条款需要解释和仲裁。这就是为什么在出现争议时我们需要法庭。如果在某个法律合约中,40页中有39页标定的销售价格为100美金,而在某一页上的价格中多了个“0”,法院将以“合约精神”为准进行裁定。而计算机只会执行写好的条款,区块链的不变性增加了这个问题,因为合约无法轻易修改。
  3. 软件工程师不是法律专家,反之亦然。起草一份好的合约需要与编程不同的技能,一名能够编写非常完善计算机程序的人员不一定会写法律合约。

两起著名的利用智能合约漏洞的事件

The DAO 攻击

这件事很多人都早已谈了许多,我们就不在这重复了。您可以在这里找到关于攻击和善后的完整概述。

简单来说,在 2016 年 6 月,一名黑客企图转移一大笔众筹资金 (350 万个 ETH, 占当时ETH总数的15%)至他自己的子合约,这笔资金被锁定在该子合约中 28 天,因而大家要与时间竞赛寻找解决方案。

这事件要注意的重点在于,攻击者是通过使合约以意料之外的方式运行而发起的攻击。这个事件中,攻击者利用了 可重入漏洞Reentrancy Vulnerabilities )。我们将会在这篇文章中对可重入进行深入讨论。

黑客攻击Parity事件

事实上,这是第二次 Parity 所提供的的多重签名钱包合约受到黑客入侵了。众多创业公司使用的多重签名钱包的逻辑大多通过库合约实现。每个钱包都包含一个轻量级的客户端合约,连接到这个单点故障(译注:即上述库合约)。

-Parity 多重签名钱包结构-

这个库合约中存在一个重大的漏洞,问题在于其中一个初始化函数只能被调用一次。

2017年11月,一名男子通过实施合约初始化,将自己变成了合约所有者。这允许他调用所有者才能调用的 函数,他利用这一特权调用了以下的函数:

// kills the contract sending everything to `_to`.
function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    suicide(_to);
}

这相当于一个能使合约无效的自毁按钮。调用这个函数会导致客户合约的所有资金有可能被永久冻结。

直到撰写本文之时,该事件到底是黑客造成的故意攻击还是意外仍然是个谜,尽管肇事者声称这是意外行为。

两次攻击都说明了即使是由以太坊生态系统的大佬来写相对简单的合约,也容易出现基本的错误,带来严重的后果。

已知的漏洞与陷阱

私钥外泄

使用不安全的私钥纯粹是用户的失误,而不是一个漏洞。然而,尽管如此,我们还是要指出来,因为私钥外泄总是意外地发生,有些玩家专门从不安全的地址中窃取资金。

把开发地址(如 Ganache/TestPRC 使用的地址)用于生产的事情经常发生。这些地址是由公开的私钥生成。一些用户甚至无意识地把这些私钥导入到钱包软件,因为他们使用 Ganache 的种子词(seed words)生成了相同的私钥。攻击者则监视着 TestPRC 地址,不管多少数量的以太币只要转移到以太坊主链上的 TestPRC 地址都会立刻消失(在2个区块内)。一项有趣的研究对这一十分有利可图的“清扫(sweeping)”活动进行了调查,并发现每个清扫者(sweeper)的账户都设法累积了高达2300万美元的资金。

可重入与竞态条件(Race Conditions)

如果一个函数在执行完成前被调用了数次,发生意料不到的行为时,可重入漏洞就可能出现。

让我们看看下面这个函数,它可以用于从合约中提取调用者的总余额:

mapping (address => uint) private balances;
function payOut () {
    require(msg.sender.call.value(balances[msg.sender])()); 
    balances[msg.sender] = 0;
}

调用 call.value() 会导致合约外部代码的执行。若调用者是另一份合约,这就意味着合约回退措施的执行。这可能会在余额调整为 0 之前再次调用 payOut(), 从而获得比可用资金更多的资金。

这种情况下的解决方法就是使用替代函数 send()transfer() 后两者函数能为基础运作提供足够的 Gas,而想要再次调用 p*ayOut()* 时 Gas 就不足了。

若合约含有两个共享状态的函数,那么不需要重复调用函数也可能会发生相似的竞态条件(Race Conditions)。因此,最好的做法是在转账前更改状态,即转移资金前,在上述代码中把余额设为 0。

The DAO 攻击利用了该漏洞的一种变体。

下/上溢

余额一般用无符号的整数表示,在 Solidity 语言中通常为 256 位数字。当无符号整数上溢(overflow)或下溢(underflow)时,其数值会发生明显变化。让我们看看下面一个比较常见的下溢例子 (为了清晰一点我把数字缩短了):

  0x0003
- 0x0004
--------
  0xFFFF

这里很容易看出问题,减去一个比可用余额大的值便导致下溢。得到的余额是一个很大的数字。

还要注意的是由于舍入误差(Rounding Errors),在整数中算术分割(Arithmetics Division)是很麻烦的。

解决方法是时刻对代码进行下溢、上溢检查。使用安全数字库能协助检查,比如 OpenZeppelin 的 SafeMath

交易顺序假设

交易进入未确认的交易池,并可能被矿工无序地包含在区块中,这取决于矿工的交易选择标准,有可能是一些旨在从交易费中获取最大收益的算法,但也可以是其它任何标准。因此,打包在区块中的交易顺序与交易生成的顺序完全不同。因此,合约代码无法对交易顺序作出任何假设。

因为交易在记忆池(Mempool)是可见的,其执行是可预测的,所以除了合约执行出现意外结果的情况,还有一个可能的攻击面。交易打包中可能出现的一个问题就是,延迟某个交易可能被流氓矿工用作个人利益。事实上,能够在交易执行前意识到某些交易(的存在)对任何人来说都是有利的,而不仅仅是矿工。

对时间戳的依赖

时间戳(Timestamps)是由矿工生成。因此,合约不应该让关键操作依赖于区块时间戳,例如把时间戳用作一个生成随机数的种子。Consensys 在他们的指导手册中给出了“12分钟规定”,表明如果你依赖时间戳的代码能够处理 12 分钟的误差,那么使用block.timestamp 是安全的。

短地址攻击

Golem team 揭露了一个有趣的攻击,详情请看这里。该漏洞影响了 ERC20 代币传输和一些类似的合约,该漏洞的问题在于交易字节代码可以是任意大小,而以太坊虚拟机(Ethereum virtual machine,简称EVM)会在其尾部缺失的字节填充0。

实施该攻击需要找到一个以十六进制(hex)形式表示且结尾为若干个 0 的地址,并在提币请求中省略这些结尾的 0。当该合约发起一个转账请求时,短地址被插入,其余的交易字节代码被移位。

举个例子,省略结尾的两个 0 会导致交易数据中地址之后的字节发生 1 个字节的移位。地址后面是交易数据中的参数,通常是无符号的前置 0 的 256 位整数。这些前置的 0 会移入地址字段,使地址有效并确保交易目的地是正确的。

参数字段中一个字节的移位也很容易导致提币量变为原来的256倍。在EVM用 0 填充缺失的结尾字节后,交易成功,然后转走 256 倍的金额。

因此,利用省略两个十六进制0的地址的漏洞使攻击者可以从一个余额为 1000 个代币的账户中提取 256000 个代币,以此类推。省略 4 个结尾的 0 则是 2^16 倍。

为了避免这种攻击,你的合约应该验证地址。

拒绝服务攻击(DoS Attacks)

有时通过使合约交易超过能够包含在一个区块中的最大 Gas 量,来迫使合约交易失败。在这篇拍卖合约的解读中解释了这个经典例子。迫使合约退还大量没有接受的小投标会增加 Gas 消耗量,如果能耗超过了区块 Gas 上限,那么整个交易失败。

这个问题的解决方法是避免许多交易调用可能由相同的函数调用引起的情况,尤其是如果调用次数会受到外部影响。

推荐的付款模式是让客户请求转账,而不是一次性转账出去,如 Solidity 官方文件所述。

缓解措施与结论

为了强调“代码即法律”这一范式理解的危害,本文我们阐述了可能发生的漏洞以及过去攻击者是如何利用这些漏洞的例子。

最近的历史事件表明在公链上执行图灵完备的智能合约是危险的,其安全性远不足以取代传统法律系统的语言准确度与解释和仲裁空间。

但这并不意味着我们应该抛弃智能合约。智能合约是非常有用的工具,能开发出有趣的应用程序。然而,我们不能认为智能合约能取代具有法律约束力的合约,它只是用于自动化的补充工具。

另外,我们应该做好预防措施去避免漏洞:

  • 使用开放的资源与社区接受的库合约的实质标准 (de facto standards),例如 Open Zeppelin’s contracts。
  • 使用推荐的模式与最优操作指导手册,例如 Consensys 提供的。
  • 考虑由信誉好的供应商审核您的智能合约。

原文链接: https://medium.com/cryptronics/ethereum-smart-contract-security-73b0ede73fa8
作者: Stefan Beyer
翻译&校对: 杨哲 & Elisa

本文由作者授权 EthFans 翻译及再出版。

你可能还会喜欢:

教程 | Solidity 中 revert(), assert() 和 require() 的使用方法
科普 | 为什么使用提款(Withdrawal)模式?
干货 | 运行在区块链上的交易所

评论