三天竟然爆发两起大漏洞事件!我们来教你如何跳过以太坊的坑
2018年04月26日 00:00:00阅读数:1314
“现在进入你还是先行者,最后观望者进场才是韭菜。”美图董事长蔡文胜在三点钟群中的预言一语成谶。在4月22日,随着BEC智能合约漏洞的爆出,一行代码蒸发了6447277680人民币。然而时隔三天,SMT的智能合约又爆出漏洞,SMT在火币Pro的价格下跌近20%。一时间,无论先行者还是准“韭菜”,都惨遭收割。
区块链做为一款能与价值交互的产品,难免不被人们神化。理性地分析一下,程序中的漏洞总是不可避免的,很难保证代码百分百不出错,即使大公司也只能通过发布测试版本来降低漏洞出现的概率。今天让我们来看看智能合约的初创者——以太坊智能合约都有什么“坑”,并且怎么写代码才不被坑。
作者 | ConsenSys Diligence
译者 | Guoxi
4月25日上午,火币Pro发布公告,虚拟币SMT项目方反馈25日凌晨发现其交易存在异常问题,经初步排查,SMT的以太坊智能合约存在漏洞。火币Pro也同期检测到TXID为0x0775e55c402281e8ff24cf37d6f2079bf2a768cf7254593287b5f8a0f621fb83的异常。受此影响,火币Pro暂停所有币种的充提币业务。当天,截止暂停交易,SMT在火币Pro的价格下跌近20%。
而这类漏洞不是第一次发生了,距离上一次发生仅隔了三天。
在4月22日,BEC出现异常交易,据分析,BEC 智能合约中的 batchTransfer 批量转账函数存在漏洞,攻击者可传入很大的 value 数值,使 cnt *value 后超过 unit256 的最大值使其溢出导致 amount 变为 0。
简单的说,BEC的某一段代码忘记使用safeMath方法,导致系统产生了整数溢出漏洞,利用该漏洞,黑客可以通过转账手段生成大量原本合约中不存在的代币,并将这些“无中生有”的代币在市场进行抛售。
由于黑客转出的代币数量远远超过BEC发行数量70亿枚,加之由此引发的恐慌抛售,BEC的64亿市值瞬间几乎归零。
在这两起事件之后 PeckShield 团队利用自动化系统扫描以太坊智能合约并对它们进行分析。结果发现,多个 ERC-20 智能合约都存在 BatchOverFlow 安全隐患。若不做好严格的代码审计和安全防护,亿级资金的损失只在一瞬间。那怎么才能避免这种情况发生呢?以下是已发现的智能合约攻击方式,为了资产安全,你必须知晓并在写智能合约时避开这些漏洞。
竞态条件引发的2种漏洞
竞态条件(race condition)就是指设备或系统出现不恰当的执行时序,而得到不正确的结果。
在执行智能合约时调用外部合约有很大的风险,因为这个外部合约可以接管你当前合约的控制流程,恶意的外部合约可能会更改你合约中的关键数据,这对当前合约造成的影响是巨大的。两个合约绕来绕去,是不是听起来很拗口?通俗地给你解释一下。
设想一下当你在转账时突然有个人出现在你的面前,打断了你的操作并趁你不注意修改了你的转账信息,当你发现钱款转错人后已为时已晚。这种漏洞有很多种表现形式,它也是史上最大智能合约漏洞事件——The DAO的“罪魁祸首”。The DAO事件造成了价值6000万美元的以太坊被盗,且6000万美元的损失是按当时17.5美元的以太坊价格估算得出的,这也导致了以太坊当时的硬分叉。
漏洞一:函数可重入性
可重入性(Reentrancy)一般可以理解为一个函数在同时多次调用,例如操作系统在进程调度过程中,或者单片机、处理器等的中断的时候会发生重入的现象。
这个漏洞第一种可能出现的情况是:在调用其他函数的操作完成之前,这个被调的函数可能会多次执行。这可能会导致智能合约中的几个函数以破坏性的方式进行交互。
因为用户的余额一直没有被置0,直到函数执行的结束。第二次(之后一次)调用其他函数的操作仍会成功,并且会一次一次地取消对账户余额的置0操作。The DAO事件中以太坊被盗就是因为攻击者执行了这样的操作。
解决方案,在给出的示例中,为了避免碰到这个漏洞,我们的解决方案是:使用函数send()而不是函数call.value()(),这将阻止任何外部代码的执行。
但是如果无法避免要调用外部函数时,防止这种攻击的下一个简便方法就是确保在你调用外部函数时已完成所有要执行的内部操作。
请注意,如果你有另一个函数也调用了withdrawBalance(),那么它也可能会受到相同的攻击,因此你必须将这种调用不可信合约的函数视为不可信函数,接下来我会进一步讨论潜在的解决方案。
漏洞二:跨函数的竞态条件
攻击者也可以对共享相同状态的两个不同函数进行类似的攻击。
在这种情况下,攻击者可以在代码执行到调用withdrawBalance()时调用transfer() 函数,由于他们的余额在此时还未被置0,所以即使他们已经收到退款,他们也还能转移通证,这个漏洞也被用在了The DAO事件中。
同样的原理,同样的注意事项。注意在这个例子中,这两个函数都是同一个智能合约的组成部分,同样的,当多个合约共享同一状态时,这几个合约之间也可能会出现这个漏洞。
由于竞态条件可能发生在多个函数之间,甚至是多个智能合约之间,所以旨在防止重入现象的解决方案都是明显不够的。
解决方案,这儿有两种解决方案,一是我们建议先完成所有的内部工作,然后再调用外部函数;二是使用互斥锁。
1.首先第一种解决方案,先完成所有的内部工作,然后再调用外部函数。如果你在编写智能合约时仔细地遵循这个规则,那么就可以避免出现竞态条件。但是,你不仅需要注意避免过早地调用外部函数,还要注意这个外部函数调用的外部函数,例如,下面的操作就是不安全的。
尽管函数getFirstWithdrawalBonus()不直接调用外部的合约,但在函数withdraw()中的调用足以使其进入竞态条件之中。因此,你需要将函数withdraw()视为不可信函数。
除了修复漏洞使这种重入现象变得不可能外,还要标记出不可信的函数。这种标记要注意一次次的调用关系,因为函数untrustedGetFirstWithdrawalBonus()调用了不可信函数untrustedWithdraw(),这意味着调用了一个外部的合约,因此你必须将函数untrustedGetFirstWithdrawalBonus()也列为不可信函数。
2.第二中解决方案是使用互斥锁。即让你“锁定”某些状态,后期只能由锁的所有者对这些状态进行更改,如下所示,这是一个简单的例子:
如果用户在第一次调用结束前尝试再次调用withdraw() 函数,那么这个锁定会阻止这个操作,从而使运行结果不受影响。这可能是一种有效的解决方案,但是当你要同时运行多个合约时,这种方案也会变得很棘手,以下是一个不安全的例子:
这种情况下攻击者可以调用函数getLock()锁定合约,然后不再调用函数releaseLock()解锁合约。如果他们这样做,那么合约将被永久锁定,并且永远不能做出进一步的更改。如果你使用互斥锁来防止竞态条件,你需要确保不会出现这种声明了锁定但永远没有解锁的情况。在编写智能合约时使用互斥锁还有很多其他的潜在风险,例如死锁或活锁。如果你决定采用这种方式,一定要大量阅读关于互斥锁的文献,避免“踩雷”。
有些人可能会反对使用竞态条件这个术语,因为以太坊并没有真正地实现并行性。然而,逻辑上不同的进程争夺资源的基本特征仍然存在,所以同样的漏洞和潜在的解决方案也同样适用。
交易顺序依赖与非法预先交易导致的漏洞
交易顺序依赖(Transaction-Ordering Dependence,TOD)
非法预先交易(Front Running)非法预先交易是经纪人从客户交易中获利的一种不道德做法。在手中持有客户交易委托的情况下抢先为自己的账户进行交易。
以下是区块链固有的不同类型的竞态条件:在区块内部,交易本身的顺序很容易受到人为操控。
由于在矿工挖矿时,每笔交易都会在内存池中待一段时间,因此可以想象到交易被打包进区块前会发生什么。对于去中心化的市场,可更改的交易顺序会带来很多的麻烦。比如市场上常见的买入某些代币的交易。而防范这一点十分地困难,因为它会涉及到合约中具体的实现细节。例如,在去中心化市场中,由于可以防止高频交易,故批量拍卖的效果更好。另一种解决方法就是采用预先提交方案的机制,别着急,后面我会详细介绍这个机制的细节。
漏洞三:时间戳依赖
请注意,区块的时间戳可被矿工人为操纵,所以要留意时间戳的所有直接和间接使用。
还有很多与时间戳相关的注意事项,编程前一定要认真学习。
整数的上溢和下溢导致的漏洞
想象一个很简单的转移通证的场景:
如果你的账户余额达到了以太坊中最大的无符号整型值(2^256),那么你的余额再增加就无法表示了。因为平时遇到这种现象进位就可以了,但在这里无符号整型值只有256位,进位的第257位是不显示的,所以你没有猜错,当你进位后你的余额就会回到0。在计算机科学中这种现象就叫做整数的上溢。
当然了,这种现象也不太常见,因为它需要同时保证你真的有这么多余额,你的智能合约中还没考虑到上溢问题。考虑一下这个无符号整型值是否有机会达到这么大一个数字,再考虑一下这个无符号整型值如果改变当前数值,以及谁有权做出这样的改变。如果智能合约中任何用户都可以调用函数来更新这个无符号整型值,那么这个智能合约就会很容易受到攻击。如果只有管理员可以做出更改,那么它才可能是安全的。如果合约中规定用户的账户余额每次只能增加1,那么这个合约可能也很安全,因为现在还没有可行的方法让你短时间内达到这个限制。
账户余额达到最大时再增加就会被清零,你会瞬间从最富有的人变成最穷的人。不知你有没有想到可以从最穷的人变成最富有的人?没错,下溢也是这个道理,如果这个无符号整型值小于0,那么它需要向前借位,而借的那一位并不显示,所以你的余额就会下溢达到最大值。
看到这里,你一定要小心使用像8位,16位和24位的无符号整型值,因为8位无符号整型值最大仅可以表示255,所以相比之下它们更容易达到最大值而发生溢出现象。
对待溢出现象请千万小心,之前有程序员整理了20个智能合约中上溢和下溢的场景。
漏洞四:存储操作中的深度下溢
Doug Hoyte在2017年的以太坊黑客比赛中提出了这个漏洞,这也让他获得了比赛中的荣誉。这个想法很有意思,因为它引起了人们对C类语言下溢如何影响以太坊编程语言Solidity的担忧。这是一个简化了的版本:
一般来说,如果不经过keccak256哈希计算(当然,这是不现实的),变量manipulateMe的存储位置就不会被影响。但由于动态数组是按顺序存储的,如果攻击者想要改变manipulateMe这个变量,他只需要这样做:
-
调用函数popBonusCode()来实现下溢。(请注意,以太坊编程语言Solidity并没有内置的pop函数。)
-
计算变量manipulateMe的存储位置。
-
使用函数modifyBonusCode()修改和更新变量manipulateMe的值。
实际上,人们都知道这种数组存在的漏洞。但如果这样的数组被掩埋在更复杂的智能合约架构之下,谁又能轻易发现呢?这样它就可以任意地对变量进行恶意篡改。
解决方案,在考虑使用动态数组时,使用一个容器式的数据结构是一种不错的选择。Solidity CRUD的第1部分和第2部分文章详细介绍了这个漏洞。
意外恢复导致的漏洞
漏洞五:利用交易失败,促使意外恢复
考虑一个简单的拍卖合同:
当智能合约准备给商品原主人付款时,如果付款失败,它将恢复。这意味着一个恶意的投标人可以在拍下商品的同时确保给商品原主人的付款总是付款失败。这样他们可以阻止其他人调用bid()函数,成为商品的新主人。如前所述,为了资金安全,建议拍卖时建立一个预授权方式的付款合约。
另一个例子是当智能合约通过数组的迭代向用户付款时,例如给众筹合约的支持者退款。通常要确保每笔付款都成功,如果哪一笔付款失败了,则会恢复,重新付款。问题是如果一笔付款失败了,那么你要恢复整个付款系统。这意味着如果哪一笔付款卡住了,这次迭代付款永远都不会完成,因为一个地址出错,所有人都拿不到这笔钱。
解决方案,这里我们的建议是使用预授权方式付款。
区块燃料限制导致的漏洞
漏洞六:利用区块燃料上限引发漏洞
你可能已经注意到了前一个例子中的另一个问题:如果要一次性地支付给所有人,你可能会遇到达到区块中燃料上限的情况。每个以太坊的区块都只能处理一定的最大计算量,如果你试图超过这个限制,那么你的交易将会失败。
即使没有黑客故意攻击你,这都是一个问题。如果攻击者能够操控你所需的燃料,情况就会变得更加糟糕。在前面的例子中,攻击者可以添加一堆地址,每个地址都需要很少量的退款。因此,加上给攻击者地址退款使用的燃料,可能会导致超过区块燃料上限,从而阻止退款交易的发生。
解决方案,我们推荐使用预授权方式付款来解决这个漏洞。
如果你绝对需要遍历未知大小的数组,那么你应该规划一下应该把它们分到多少个区块中,每个区块需要多少笔交易。这样你只需要留意现在进行到哪个区块中的交易了,出错后仅需从当前区块开始恢复,如下所示:
你需要确保在等待payOut()函数的下一次迭代时处理的其他交易不出现错误。所以只有在绝对必要的时候再使用这种模式。
强行给智能合约中加入以太币导致的漏洞
漏洞七:强行给智能合约中加入以太币,引发程序逻辑漏洞
原则上,我们可以将以太币强制发送到智能合约中而不触发回退函数。当给回退函数加入重要功能或计算智能合约的收支平衡时,这是一个重要的考虑因素。请看下面这个例子:
这个智能合约的逻辑似乎不允许对智能合约付款,以防发生一些“不好的事情”。但是还是存在一些方法可以强制将以太币送到合约中,使智能合约的余额大于0。
智能合约中的自毁方法允许用户向指定的受益人发送任意数量的以太币,而这个自毁方法并不会触及合约的回退功能。
在部署一个智能合约之前,可以预先算出合约的地址并将以太币发送到该地址。
解决方案,智能合约的开发者应该意识到以太币可以被强制送到智能合约中,并应该相应地设计智能合约逻辑。一般情况下,需要假设无法限制智能合约的资金来源。
对已被弃用的协议进行攻击导致的漏洞
漏洞八:利用已被弃用的协议进行攻击
这些攻击由于以太坊协议的改变或以太坊编程语言solidity的改进而不能使用。在这里记录仅供参考,不做过多说明。