1.可重入漏洞
该漏洞主要是因为智能合约调用一个未知的合约地址,攻击者可以精心构造一份智能合约,在回调函数中加入恶意代码。当智能合约向恶意合约地址发送以太币的时候,合约上的恶意代码将会被触发,这段恶意代码通常会进行开发者意想不到的操作。有点像编程语言里面的间接递归函数调用。在臭名昭著的The DAO事件中黑客使用了这种攻击,最终导致了以太坊的硬分叉。下例根据DAO合约改进而来:
EtherStore.sol
Attack.sol
假设已有许多其他用户将以太币存入EtherStore合约,因此当前余额为10个以太币。那么当攻击者调用攻击合约的pwnEtherStore函数时,将会发生以下情况:
1) Attack.sol-第10行-调用EtherStore合约的depositFunds函数,msg.value为1 Ether(及Gas),msg.sender为攻击合约的地址。执行结果为balances[攻击合约地址]=1 Ether。
2) Attack.sol-第11行-调用EtherStore合约的withdrawFunds函数,传入参数1 Ether。
3) EtherStore.sol-第18行-在EtherStore合约的withdrawFunds函数中,前三个require将成功通过,来到第18行合约将1 Ether发回给攻击合约。
4) Attack.sol-第18行-发送给攻击合约的以太币将执行回退函数。
5) Attack.sol-第19行-EtherStore合约之前有10 Ether,现在由于转账变成了9 Ether,可以成功通过if语句。
6) Attack.sol-第20行-攻击合约再次调用withdrawFunds函数,并“重新进入”EtherStore合约。
7) EtherStore.sol-第12行-再次调用withdrawFunds函数时,由于前一次调用没有执行第19和20行,因此仍有balances[攻击合约地址]=1 Ether,withdrawalLimit变量也没有改变,所以能够成功通过前三个require。
8) EtherStore.sol-第18行-提取另外1 Ether给攻击合约。
9) 步骤(4)-(8)将循环执行,直到EtherStore.balance<= 1,即不满足Attack.sol第19行的if语句。然后终于可以执行EtherStore.sol第19和20行,设置balances和lastWithdrawTime映射,程序结束。
最终攻击者通过这笔交易,从EtherStore合约中提取了几乎全部的以太币。
2.危险的DELEGATECALL
智能合约在使用DELEGATECALL时,会调用存在于其他智能合约中的代码,但是会保持当前的上下文关系,这种特性虽然方便了开发者使用,却加大了设计安全代码库的难度,攻击者会利用保持上下文不变的特性修改原有上下文的内容,从而进行攻击。2017年,恶意用户通过此漏洞攻击Parity钱包,导致价值将近1.7亿美元的ETH被冻结。具体例子如下:
Lib.sol
UseLib.sol
Lib.sol是用来控制合约的起始时间和终止时间的,UseLib.sol是对Lib.sol代码进行使用的智能合约。
这个漏洞和智能合约对于storage变量的存储位置有关。在Lib.sol合约中变量的存储位置关系图如下:
在UseLib.sol合约中变量的存储位置关系图如下:
当运行UseLib.sol中第8行代码时,会调用Lib合约中的set_start函数,由于DELEGATECALL中的上下文不变的特性,要修改的slot[0]中的内容并不是开发者预想中的变量start,而变成了当前上下文中的变量lib。因此,修改lib地址变量后,就可为攻击者提供有效的攻击途径了。
值得一提的是,在实际的开发环境中,开发者为了兼顾代码的灵活性,经常会出现如下的DELEGATECALL滥用的写法:
这将引起public函数调用的问题,由于合约中DELEGATECALL函数的调用地址和调用的字符序列都由用户传入,那么完全可以调用任意地址的任意函数。
下面讲一下Parity钱包中曾爆出的两次安全事件。
⦁第一次安全事件
漏洞代码如下:
Parity钱包提供了一个多签合约的模板,用户使用这个模板可以很容易生成自己的多签智能合约。上面这段代码是生成多签合约的一部分,它的代码量很少,实际业务逻辑都是通过delegatecall内嵌式地调用了库合约WalletLibrary。这样做的一个主要好处是:多签合约的主逻辑(代码量较大)作为库合约只需要在以太坊上部署一次,而不会作为用户多签合约的一部分重复部署,因此可以为用户节省部署多合约所耗费的大量Gas。
上面这段代码是钱包的初始化函数和库合约的初始化函数。通过观察容易发现,我们可以DELEGATECALL调用initWallet函数,注意此处参数列表中的_owners,因为是多签合约,所以这里的address[]是地址数组,该函数原本的作用是用多重所有者的地址列表来初始化钱包,函数会继续向底层调用initMultiowned函数。经过这一步,合约的所有者就被改变了,相当于获取了Linux系统的root权限。接着便可以以owner身份调用execute函数提取合约余额到攻击者的地址了。
⦁解决方案
此漏洞产生的原因关键在于initWallet函数没有检查,以防止合约初始化后再次调用initMultiowned函数,进而使得合约的所有者被改成攻击者。因此问题的核心在于越权的函数调用,可以通过对initWallet和initMultiowned等相关函数重新定义如下权限来解决:
通过检查m_numOwners变量值,若已经初始化,则直接返回,不允许再执行initWallet等函数。
⦁第二次安全事件
漏洞代码如下:
可以看到,为修复第一次安全事件的漏洞,确保初始化逻辑只执行一次,initWallet和initMultiowned函数增加了only_uninitialized限定条件。但是在本次安全事件中,黑客直接调用了库合约的初始化方法,而对于调用者而言,这个库合约是未经初始化的,因此攻击者通过初始化参数的设置,将自己变成了owner,然后作为owner调用kill函数,抹除了库合约的所有代码。最终使得所有依赖这个库合约的用户多签合约都无法执行,代币全部被锁在合约内无法转移。
3.算数上溢/下溢
上溢/下溢在很多程序语言中都存在,在以太坊虚拟机中,uint类型最大为256位,超过此范围会出现上下溢情况。2018年美链(BEC)使用的batchTranfer函数由于存在上溢漏洞,造成了巨大的经济损失,下面以此为例子对该漏洞进行说明:
上图为美链接口batchTranfer函数的具体实现。通过观察代码可以发现,合约中对uint256 amount = uint256(cnt) * _value;没有进行溢出判断。因此,假设uint256的最大值为MAX,而uint256 amount = uint256(cnt) * _value=MAX+1,这将导致amount为0。在转账的时候就会出现,balances[msg.sender]=balances[msg.sender]-amount=balances[msg.sender]-0,而balances[_receiver]=balances[_receiver]+_value,那么就可以无限转账了。