智能合约开发必读:ERC20 Token合约你可能不知道的坑

前不久,轰动一时的BEC事件在业内和链圈掀起了不小的反响,智能合约安全一度成为焦点话题。

通过一个小小的整数溢出漏洞,黑客盗取了巨量的BEC token,迫使交易所不得不暂时停止交易。一时间BEC价格呈现断崖式下跌。随后,SMT token 的智能合约也因为相同的问题遭到了攻击。两次事件都给项目发行方和token持有者造成了巨额的损失。

据统计,截止2018年5月12日为止,以太坊上部署的合约总量共计 1628059 个,其中ERC20合约数量为 71233 个,占比 4.3%。这些合约所代表的经济价值更是难以估量。

上图反映了自以太坊平台诞生以来,ERC20代币合约的创建情况,整体呈现增长趋势。自2017年6月开始便持续走高,而2018年的增长幅度尤为明显,平均每日合约创建数约为 320 个,尤其在今年三月份,一度达到峰值,一天内创建的合约数量更是超过600个。随着区块链热度的进一步加强,越来越多的区块链项目也在萌芽,相信2018年新合约的数量还会逐步上升。

公开数据表明,大量已经部署的智能合约或多或少都存在着一定的安全风险和漏洞,BEC事件也只是冰山一角。那么就ERC20合约而言,除了整数溢出漏洞以外,还有可能面临哪些风险呢?

可重入

若一个程序或子程序可以「在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错」,则称其为可重入(reentrant或re-entrancy)的。

在智能合约代码中,黑客可以利用fallback函数,来递归调用包含 call.value() 的函数 ,从合约中重复提取以太币。通常造成该类事件的原因是余额校验不到位或余额更新不及时。

当ERC20代币合约中涉及以太币的转出,应当尤为注意。以一个 withdraw 函数为例,这也是ERC20合约中经常出现的函数。以下代码中首先进行余额校验,并向msg.sender地址转出以太币,再修改 balance 数组中余额值。当函数执行到 msg.sender.call.value(amount)() 时,黑客就可以通过msg.sender的 fallback函数来重复调用 withdraw,进而重复执行 msg.sender.call.value(_amount)(),直到gas全部消耗完毕或者合约中的以太余额全部被取完。于是就给黑客实现 reentrancy 攻击创造了条件。

function withdraw(uint _amount) public {
  if(balances[msg.sender] >= _amount) {
    if(!msg.sender.call.value(_amount)()) {
      throw;
    }
    balances[msg.sender] -= _amount;
  }
}

因此,我们强烈建议智能合约开发者在转账之前做好余额校验工作,并且将余额计算放在转账之前处理。

function withdraw(uint _amount) public {
  if(balances[msg.sender] >= _amount) {
    balances[msg.sender] -= _amount;
    if(!msg.sender.call.value(_amount)()) {
      throw;
    }
  }
}

另外,值得重视的是,除了 call.value() 以外,任何直接或者间接调用call方法的步骤,都有可能引起回调,从而引发重入的安全事件。

转账方式风险

当然,杜绝上述问题的一个更好的方式就是不使用 call.value() 进行转账。发起以太币转账的方式有三种transfer() ,send() 和 call.value()。我们对这三种方式进行比较。

如上图所示,其中最安全的方式当属 transfer(),一旦转账失败,transfer() 会抛出异常直接触发 revert() 事件,而另外两者不会,需要开发人员手动处理返回值。send() 与 transfer() 唯一的区别也在于返回值,通常我们可以认为addr.transfer(v) 就相当于require(addr.send(v))。

而call.value() 与另外两者一个最明显的区别在于gas的限制上面,call.value()允许消耗掉所有的gas。但另外两种方式由于gas消耗限制到2300,不足以完成递归调用,这也是能够避免 reentrancy 攻击的原因,上述 withdraw 的代码可以按照以下的写法来实现。

function withdraw(uint _amount) public {
  if(balances[msg.sender] >= _amount) {
    balances[msg.sender] -= _amount;
    msg.sender.transfer(_amount);
  }
}

所以,我们强烈建议开发人员,在ERC20合约开发过程中,如果遇到以太币转出的情况,如非必要,尽量选择使用transfer()函数来完成。

避免混淆 tx.origin 和 msg.sender

solidity提供了两种标准的方式来获取合约调用方的地址,tx.origin 和 msg.sender,但是这两者的含义是不同的,其中 msg.sender 是指直接调用当前合约的调用方地址,tx.origin 是指发起本次调用的起始调用方地址。

比如合约(或外账户地址)A去调用合约B,合约B调用合约C。此时在合约C中读取到的 msg.sender 即为合约B的地址,tx.origin 即为A的地址。

若从一个地址直接对合约发起调用,那么 msg.sender 和 tx.origin 是一样的,否则这两个地址就不一样。由于合约无法决定外部调用的关系,开发人员又往往容易混淆两者的含义,进而留下隐患。

以两段真实的ERC20合约为例,第一段将 tx.origin 地址设为合约的owner地址,第二段将 msg.sender 设为合约的owner地址。在智能合约开发过程中,一定先搞明白代码的实现意图,再选择使用 tx.origin 还是 msg.sender,使用 tx.origin 的时候要尤为慎重。

function Ownable() public {
  owner = tx.origin;
}
modifier onlyOwner() {
  require(msg.sender == owner);
  _;
}
function Ownable() {
   owner = msg.sender;
}
modifier onlyOwner() {
  require(msg.sender == owner);
  _;
}

依赖时间戳或者块高度

在 solidity 中,允许获取当前时间戳(或者说交易所在区块的区块高度)。但是,这并不是安全的。一方面,时间戳是打包交易时候由矿工设置的,存在一定的人为操作因素在里面,矿工完全可以对时间戳做轻微的改动;另一方面,我们不能完全排除以太坊未来会在出块时间上做出调整的可能性,因此通过块高度来预估时间是存在一定隐患的。

uint public startTime = 1507032000;                                              
uint public endTime = 1517659200;                                               

function purchase() whenNotPaused payable {
   require(!crowdsaleFinished);
   require(now >= startTime && now < endTime);       
        ...
}

上述例子中,通过块高度来限制ERC20代币购买的时间段,假如在合约发布后,购买结束前的时间段内,以太坊平台的出块时间做出了调整,那么购买时效也会发生变动。

另外,千万不要使用这两个值来产生随机数。因为在合约外部一定范围内(即同一个区块内)是可以获取到时间戳和块高度的,所以对于同一个区块中的合约来说,这两个值就变得不随机了,这便给黑客留下了可乘之机。

整数相除

在solidity中,整数相除遵循向下取整的原则。因此,当遇到不能整除的情况时,一定要谨慎处理。

function div(uint256 a, uint256 b) internal constant returns (uint256) {
    uint256 c = a / b;
    return c;
}

如上述代码所示,计算结果的关系是a == b * c + a % b,而并非a == b*c。在ERC20合约开发过程中,经常会遇到使用除法的场景,要当心除法取整的问题,避免引起数据前后不一致的麻烦。

关键字过时

随着solidity不断的升级更新,老版本上的一些用法也逐渐被标记为过时然后废弃掉。因此在合约开发和升级过程中,一定要当心过时的用法,避免造成不必要的损失。下表展示了部分过时用法和其替代用法可供参考。

智能合约掌握着巨额的经济价值,其影响力之大,波及范围之广可见一斑。哪怕一个很不起眼的小问题,都有可能造成不可挽回的经济损失,这对大多数的项目来说无疑是灭顶之灾。因此,智能合约的开发一定要慎之又慎,不要忽略任何细枝末节。

另外,强烈建议开发团队在合约发布之前,寻求专业的智能合约审计团队,对合约代码进行安全审计,杜绝隐患。值得一提的是,作为专业的智能合约安全审计团队,SECBIT实验室着力打造高效可靠的安全审计服务,并且利用静态分析工具,实现了对数十个风险点的自动检测。

以上数据均由SECBIT实验室提供。合作交流请联系 info@secbit.io。


SECBIT实验室由一群热爱区块链技术的极客组建,成员遍布在全球多个国家,专业领域涉及区块链底层架构、智能合约语言、形式化验证、密码学与安全协议、编译与分析技术、博弈论与加密经济学等诸多学科。SECBIT实验室目前聚焦智能合约的安全问题,助力区块链项目团队提高智能合约的可靠性与安全性,开展智能合约安全框架的理论技术研发。SECBIT实验室致力于参与共建共识、可信、有序的区块链经济体。