数千份以太坊 Token 合约不兼容问题浮出水面,严重影响 DAPP 生态

数千份以太坊 Token 合约不兼容问题浮出水面,严重影响 DAPP 生态

如今以太坊上已经部署了超过 7万个ERC20 Token智能合约,基于 ERC20 合约的 DAPP 正在迅速发展,包括去中心化交易所、资产托管、钱包等应用。但是 SECBIT(安比)实验室 不断发现有大量 ERC20 Token 合约没有遵守 EIP20 规范,这些非标准合约将会对 DAPP 的生态造成严重影响。特别是自从今年4月17日,以太坊的智能合约语言编译器 Solidity 升级至 0.4.22 版本后,编译产生的合约代码将会无法兼容一些非标准的智能合约,这会对 DAPP 的开发带来很大的困扰。这个问题首当其冲地影响去中心化交易所(DEX),一些非标准 Token可能无法正常完成交易/转账,另外一些针对 ERC20 Token 的 DAPP 也会受到影响。根据 SECBIT(安比)实验室不完全统计,存在这种不兼容问题的 ERC20 合约至少 2603份。但时至今日,这个问题并没有引起各方足够的重视。

5月10日,以太坊 Solidity 社区收到以下一个Issue(Enforcing ABI length checks for return data from calls can be breaking)提到了编译器升级后对非标准 Token 合约的影响。

问题解决方案直达:github.com/sec-bit/badE

问题描述

根据 Issue 中的描述信息可知,该问题是由ERC20 Token合约中 transfer() 函数未严格按照EIP20规范的实现所引起的。在以太坊官方的 EIP20 Token合约规范文档中,对各个接口和 Event 的实现都明确的做了规定。

interface ERC20Interface {

    function totalSupply() external constant returns (uint);
    function balanceOf(address tokenOwner) external constant returns (uint balance);
    function allowance(address tokenOwner, address spender) external constant returns (uint remaining);
    function transfer(address to, uint tokens) external returns (bool success);
    function approve(address spender, uint tokens) external returns (bool success);
    function transferFrom(address from, address to, uint tokens) external returns (bool success);

    event Transfer(address indexed from, address indexed to, uint tokens);
    event Approval(address indexed tokenOwner, address indexed spender, uint tokens);
}

每个函数都应包含返回值,其中transfer()函数应返回一个bool值。但是大量实际部署的Token合约,并没有严格按照 EIP20 规范来实现,如下列一段知名Token合约(市值63Billion USD)的代码所示,transfer函数没有返回值。

/**
 * @dev transfer token for a specified address
 * @param _to The address to transfer to.
 * @param _value The amount to be transferred.
 */
function transfer(address _to, uint _value) onlyPayloadSize(2 * 32) {
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    Transfer(msg.sender, _to, _value);
}

Token合约中,没有返回值的transfer()函数,并不符合EIP20合约规范。对于普通账户直接调用transfer()函数进行转账的场景不会有任何影响。但若外部合约按照EIP20规范的ABI解析去调用transfer()函数,在solidity编译器升级至0.4.22版本以前,合约调用也不会出现异常。但当合约升级至0.4.22后,transfer函数调用将发生revert。

interface Token {
  function transfer(address _to, uint256 _value) returns (bool);
}

contract Wallet {
  function transfer(address token) {
      ...
    require(Token(token).transfer(to,value));
      ...
  }
}

这种不符合EIP规范的写法,为什么在之前的版本上可以正常调用,当编译器升级到0.4.22版本后就无法兼容了呢?

在solidity中,函数的调用通过 signature 调用合约查找对应的函数,而 signature 是通过函数名和参数类型哈希得到的,与返回值无关。因此不管是否有返回值的同名相同参数类型的函数,其 signature 都是相同的。

signature = bytes4(sha3(transfer(address, uint256)))

call(g, a, v, in, insize, out, outsize)函数中,in和out分别为函数的输入、输出地址,由于用户仅需为首次使用的内存支付一次gas,所以通常将in和out设为同一个地址,即函数的输入输出值在内存中保存在同一位置。在有返回值的情况下,函数执行完成后,函数输出值将会覆盖掉输入值。如果尝试读取一个没有返回值的函数,输入值将不会被修改,那么会读取到的即输入的内容。

在0.4.22之前的版本中,当外部合约调用没有返回值的 transfer()函数,外部合约还是会在内存中查询函数的返回值。由于没有真正的返回值,外部调用合约返回值本应在内存中存储的对应的位置查找,将查到的数据作为返回值。实际上,查询到的返回值并不是 transfer()函数的返回值,通常是一个大于0的值,外部调用合约将其视为true。这个的返回值并不会影响外部合约对 transfer()函数的调用。于是就造成了即使没有返回值,外部合约调用transfer函数时,也不会有问题,这就自然而然的掩盖了这个问题。

以太坊在2017年10月份的拜占庭硬分叉中,采用了一系列的EIP修改提案,其中包括在EVM层面加入revertRETURNDATASIZE等指令操作符。而在solidity编译器0.4.22版本中,正式引入了对 RETURNDATASIZE 的支持。这个操作符的作用是得到被调用合约函数返回值的大小。

因此,在Solidity 0.4.22 版本编译出的代码在调用外部合约时,将会对函数返回值进行校验,若返回值的长度小于RETURNDATASIZE存储的长度,函数将无法正常调用,进而引发revert。这个操作符使得外部合约调用操作更为安全,但也因此导致上文所述的无返回值 transfer()函数无法被正常调用。

这个不兼容性问题意味着什么?

智能合约语言 Solidity 编译器升级之后,未来会造成大量的 ERC20 Token 合约与 DAPP (使用 0.4.22 以上的Solidity编译器编译)产生不兼容的问题。这个不兼容性不仅限于transfer() 函数,还有 transferFrom()函数与 approve()函数。

Solidity 团队的核心开发者 Christian Reitwiessner 认为这个编译器升级非常有必要,升级虽然造成了一些Token兼容性问题的暴露,但是这个升级是正确的做法。因为根据 EVM 的内存布局,函数调用数据(call data)与函数返回数据(return data)是共同使用一块内存区域,如果 transfer() 函数没有调用 RETURN 指令返回任何值,那么如果调用者去用 RETURNDATACOPY 来取返回值的时候,会将内存中的脏数据取回。脏数据的值很大概率会为 非零值,这在EVM里面代表 “true”。但是这里请注意,脏数据也很有可能是 ,代表 “false”。这就意味着:即使token合约正常完成了转账,但是却返回 “false”,导致外部的 DAPP 误认为 转账没有成功,进而可能引发安全漏洞。

社区中也有人提到这一点:

假设1: 如果transfer()函数没有返回值,那么当调用者合约去取 RETURNDATA 的时候,通常拿到的脏数据是 function signature的值,即该值是transfer()函数的signature的 SHA-3 哈希("0xa9059cbb"),也就是说,通常情况下,这个值为非零值,也就是相当于 return true。

如果上述假设成立,那么这个不兼容性问题至少不会导致 DAPP 逻辑上的混乱,未来不会产生没有安全漏洞。但是很多人也指出,依赖这种假设是非常危险的,会埋下安全隐患。

那么有读者会问,EIP20规范中并没有提及 transfer() 函数的返回值为false的含义,并且 EIP20 规范明确说了如果转账不成功,应该直接revert。如下图所示:

但是实际部署的合约中存在着一大批的新老合约不符合EIP20规范。这些合约在某些情况下会返回 “false”,以表示转账未成功。但是我们同时看到,EIP20规范对返回值语焉不详。

这对于 DAPP (solidity > 0.4.22)而言,将面临着三种不同的 Token 合约转账语义:

  1. 合约的transfer() 函数在转账未成功时,返回false
  2. 合约的transfer() 函数在转账未成功时,执行revert
  3. 合约的transfer() 函数在转账能够成功,但是由于缺少返回值而导致合约调用 revert,导致转账失败

非标准的合约会给 DAPP 开发带来非常大的困扰。可以预见,随着很多DAPP的升级,会有越来越多的 ERC20 Token API调用会失败(transfer()函数会因为EVM执行revert而失败结束)。不兼容的 Token 合约数量现在已经达到 2603个,市值TOP100 的 Token列表中,不兼容的 Token 数量为11个。

为何有如此大量的合约不满足规范?

SECBIT实验室通过深入分析问题合约发现,其中大量的问题合约都是参考了一些权威的模板实现的,但是这些权威模板也有不兼容性问题。其中业界颇具口碑的 openzeppelin-solidity 早期的合约中,ERC20Basic合约中 transfer()函数一直无返回值,在52120a8c42(2017年3月21日) 版本中,将 StandardToken 合约也改为了无返回值,直到在6331dd125d(2017年7月13日)中才将所有合约的transfer()函数修正。因此参考这个阶段(2017年3月 -- 2017年7月)间参考 openzeppelin 合约实现的 Token合约均有可能存在该问题。

另外以太坊官网提供的 Token 合约模板也属于问题合约模板:

下面是受权威模板影响的 Token 合约数量统计:

  1. 参考以太坊官方网站提供的问题合约模板(ethereum.org/token),已知有1703份。
  2. 参考Open-Zeppelin的问题合约,已知有 990份。

我们如何应对?

  1. Token方重新发布合约
  2. DAPP 开发者需要通过一个安全的调用代码来访问各种不兼容 ERC20 的Token合约
  3. 以太坊硬分叉升级

我们认为第一种方案能彻底解决这一不兼容性问题带来的安全风险,但是如此大量的 Token 合约重新发行的代价是极高的。第二种方案需要 DAPP 开发团队修改代码,以应对各种不兼容性情况,这对众多的 DAPP 开发团队是一个不小的负担。第三种方案可以解决 假设1,解除所有 Token 的安全隐患,但是硬分叉方案需要深入的分析讨论,近期难以得到妥善解决。

针对方案二,DAPP 可以采用下面的代码片段来作为调用 ERC20合约 transfer() 的中间层代码:

library ERC20AsmTransfer {    
    function asmTransfer(address _erc20Addr, address _to, uint256 _value) internal returns (bool result) {

        // Must be a contract addr first!
        assembly {
            if iszero(extcodesize(_erc20Addr)) { revert(0, 0) }
        }
        
        // call return false when something wrong
        require(_erc20Addr.call(bytes4(keccak256("transfer(address,uint256)")), _to, _value));
        
        // handle returndata
        assembly {
            switch returndatasize()
            case 0 { // not a std erc20
                result := not(0)
            }
            case 32 { // std erc20
                returndatacopy(0, 0, 32)
                result := mload(0)
            }
            default { // anything else, should revert for safety
                revert(0, 0)
            }
        }
        return result;
    }
}

这段代码使用 call 方法手动直接调用 transfer()函数,并使用内联 assembly code 手动获取 returndatasize() 进行判断。如果为 0,则表明被调用 ERC20 合约正常执行完毕,但没有返回值,即转账成功;如果为 32,则表明ERC20合约符合标准,直接进行 returndatacopy() 操作,调用 mload() 拿到返回值进行判断即可;如果为其他值则 revert。封装成函数替代 transfer 使用。

完整的中间层代码也需要支持 transferFrom() 有 approve() 函数,完整代码请参考 SECBIT 实验室github仓库github.com/sec-bit/badE

总结

这个不兼容性问题引入的根源是非常复杂而且涉及多方面的,包括以太坊智能合约虚拟机 EVM 架构、智能合约语言 Solidity 编译器,Token 合约 EIP20规范,ERC20合约代码模板。任何一方都难以对问题有充分全面的了解,一个月之前 Solidity社区中有人提出了这个问题,随后 Christian Reitwiessner 在博客中描述了这个问题,Lukas Cremer 在博客中提出了几个解决方案。Brendan Chou 在 github 上最早提出了一个解决方案

但是将近一个月之后,大多数的 DAPP 开发团队对此了解甚少,也缺乏对该问题的安全警惕意识。

SECBIT实验室再次呼吁,以太坊社区应加强沟通和技术推广。同时强烈建议项目发行方在项目开始之前咨询专业的智能合约技术团队,杜绝后患。

后续

SECBIT 实验室团队在发现该问题后,立即与几个知名去中心化交易所(DEX.top, loopring, ddex.io)取得联系,这些团队在第一时间确认问题并更新了代码,并反复确认了已部署运行的合约不影响交易所功能。团队也向几个知名的 Token 发行项目方报告了这一情况,均得到了及时反馈。同时团队在第一时间给以太坊官网提交了若干补丁修复 Token 问题合约模板,也迅速得到了回应。SECBIT 实验室感谢社区的积极应对,也希望更多的 Token发行方与 DAPP 开发团队能意识到这个兼容性带来的问题,确保 DAPP 生态的健康发展。

参考文献

[1] medium.com/@chris_77367/explaining-unexpected-reverts-starting-with-solidity-0-4-22-3ada6e82308c Explaining unexpected reverts starting with Solidity 0.4.22

[2] medium.com/coinmonks/mi Missing return value bug — At least 130 tokens affected

[3] github.com/ethereum/EIP eip-20

[4] github.com/ethereum/eth token advanced

[5] github.com/OpenZeppelin openzeppelin-solidity

[6] github.com/ethereum/sol Enforcing ABI length checks for return data from calls can be breaking

[7] solidity.readthedocs.io returndatasize

[8] gist.github.com/Brendan Brendan's implementation of ERC20SafeTransfer


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


SECBIT(安比)实验室是谁?

SECBIT(安比)实验室专注于智能合约安全问题,全方位监控智能合约安全漏洞、提供专业合约安全审计服务,在智能合约安全技术上开展深入研究,致力于参与共建共识、可信、有序的区块链经济体。

郭宇博士,SECBIT实验室创始人,中国科学技术大学博士、耶鲁大学访问学者、前中科大副教授,在十多年的时间里一直专注于程序的形式化验证技术,在金融安全行业具有丰富的产品研发经验,并是国内早期关注区块链技术的科研人员之一,是20余项区块链发明专利的核心发明人。

编辑于 2018-06-08