作者:昏鴉,Al1ex
時間:2020年9月16日

事件起因

2020年9月14日晚20:00點,未經安全審計的波場最新Defi項目Myrose.finance登陸Tokenpocket錢包,首批支持JST、USDT、SUN、DACC挖礦,并將逐步開通ZEUS、PEARL、CRT等的挖礦,整個挖礦周期將共計產出8400枚ROSE,預計將分發給至少3000名礦工,ROSE定位于波場DeFi領域的基礎資產,不斷為持有者創造經濟價值。

項目上線之后引來了眾多的用戶(高達5700多人)參與挖礦,好景不長,在20:09左右有用戶在Telegram"Rose中文社區群"中發文表示USDT無法提現:

Telegram

截止發文為止,無法提現的USDT數量高達6,997,184.377651 USDT(約700萬USDT),隨后官方下線USDT挖礦項目。

https://tronscan.io/#/contract/TM9797VRM66LyKXq2TbxP1sNmuQWBrsnYw/token-balances

total_value

分析復現

我們直接通過模擬合約在remix上測試。

USDT模擬測試合約代碼如下,USDT_Ethereum和USDT_Tron分別模擬兩個不同平臺的USDT代幣合約,分別代表transfer函數有顯式return true和無顯式return true

pragma solidity ^0.5.0;

import "IERC20.sol";
import "SafeMath.sol";

contract USDT_Ethereum is IERC20 {
    using SafeMath for uint256;

    uint256 internal _totalSupply;

    mapping(address => uint256) internal _balances;
    mapping (address => mapping (address => uint)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint value);

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }

    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }
    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }
    function approve(address spender, uint amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }
    function _approve(address owner, address spender, uint amount) internal {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
    function mint(address account, uint amount) external {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply.add(amount);
        _balances[account] = _balances[account].add(amount);
        emit Transfer(address(0), account, amount);
    }

    function _transfer(address _from ,address _to, uint256 _value) internal returns (bool) {
        require(_to != address(0));
        require(_value <= _balances[msg.sender]);

        _balances[_from] = _balances[_from].sub(_value, "ERC20: transfer amount exceeds balance");
        _balances[_to] = _balances[_to].add(_value);
        emit Transfer(_from, _to, _value);
        return true;
    }
    function transfer(address to, uint value) public returns (bool) {
        _transfer(msg.sender, to, value);
        return true;//顯式return true
    }
    function transferFrom(address from, address to, uint value) public returns (bool) {
        _transfer(from, to, value);
        _approve(from, msg.sender, _allowances[from][msg.sender].sub(value, "ERC20: transfer amount exceeds allowance"));
        return true;
    }
}

contract USDT_Tron is IERC20 {
    using SafeMath for uint256;

    uint256 internal _totalSupply;

    mapping(address => uint256) internal _balances;
    mapping (address => mapping (address => uint)) private _allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint value);

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }

    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }
    function allowance(address owner, address spender) external view returns (uint256) {
        return _allowances[owner][spender];
    }
    function approve(address spender, uint amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }
    function _approve(address owner, address spender, uint amount) internal {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");

        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
    function mint(address account, uint amount) external {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply.add(amount);
        _balances[account] = _balances[account].add(amount);
        emit Transfer(address(0), account, amount);
    }

    function _transfer(address _from ,address _to, uint256 _value) internal returns (bool) {
        require(_to != address(0));
        require(_value <= _balances[msg.sender]);

        _balances[_from] = _balances[_from].sub(_value, "ERC20: transfer amount exceeds balance");
        _balances[_to] = _balances[_to].add(_value);
        emit Transfer(_from, _to, _value);
        return true;
    }
    function transfer(address to, uint value) public returns (bool) {
        _transfer(msg.sender, to, value);
        //return true;//無顯式return,默認返回false
    }
    function transferFrom(address from, address to, uint value) public returns (bool) {
        _transfer(from, to, value);
        _approve(from, msg.sender, _allowances[from][msg.sender].sub(value, "ERC20: transfer amount exceeds allowance"));
        return true;
    }
}

Myrose模擬測試合約代碼如下:

pragma solidity ^0.5.0;

import "IERC20.sol";
import "Address.sol";
import "SafeERC20.sol";
import "SafeMath.sol";

contract Test {
    using Address for address;
    using SafeERC20 for IERC20;
    using SafeMath for uint256;

    uint256 internal _totalSupply;
    mapping(address => uint256) internal _balances;

    constructor() public {
        _totalSupply = 1 * 10 ** 18;
        _balances[msg.sender] = _totalSupply;
    }
    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }
    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }

    function withdraw(address yAddr,uint256 amount) public {
        _totalSupply = _totalSupply.sub(amount);
        _balances[msg.sender] = _balances[msg.sender].sub(amount);
        IERC20 y = IERC20(yAddr);
        y.safeTransfer(msg.sender, amount);
    }
}

Remix部署USDT_EthereumUSDT_TronTest三個合約。

調用USDT_Ethereum和USDT_Tron的mint函數給Test合約地址增添一些代幣。

然后調用Test合約的withdraw函數提現測試。

success-and-false

可以看到USDT_Ethereum提現成功,USDT_Tron提現失敗。

失敗的回滾信息中,正是safeTransfer函數中對最后返回值的校驗。

function safeTransfer(IERC20 token, address to, uint value) internal {
    callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));
}

function callOptionalReturn(IERC20 token, bytes memory data) private {
    require(address(token).isContract(), "SafeERC20: call to non-contract");

    // solhint-disable-next-line avoid-low-level-calls
    (bool success, bytes memory returndata) = address(token).call(data);
    require(success, "SafeERC20: low-level call failed");

    if (returndata.length > 0) { // Return data is optional
        // solhint-disable-next-line max-line-length
        require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");//require校驗返回的bool數值,false則回滾,提示操作失敗
    }
}

Missing Return Value Bug

上文的合約模擬實驗揭示了以太坊與波場兩個不同平臺下USDT代幣合約中transfer函數關于返回值處理差異性帶來的安全風險,而關于"missing return value bug"這一個問題,早在2018年就有研究人員在Medium上公開討論過,只不過是針對以太坊的,這里對以太坊中的"missing return value bug"問題做一個簡單的介紹:

ERC20標準是以太坊平臺上最常見的Token標準,ERC20被定義為一個接口,該接口指定在符合ERC20的智能合同中必須實現哪些功能和事件。目前,主要的接口如下所示:

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);
}

在ERC20的開發過程中,有研究人員對于ERC20合約中的transfer函數的正確返回值進行了討論,主要分為兩個陣營:一方認為,如果transfer函數允許在調用合約中處理Failed error,那么應該在被調用合約中返回false值,另一方聲稱,在無法確保安全的情況下,ERC20應該revert交易,關于這個問題在當時被認為都是符合ERC20標準的,并未達成一致。

事實證明,很大比例的ERC20 Token在傳遞函數的返回值方面表現出了另一種特殊的方式,有些智能合約的Transfer函數不返回任何東西,對應的函數接口大致如下:

interface BadERC20Basic {
  function balanceOf(address who) external constant returns (uint);
  function transfer(address to, uint value) external;
  function allowance(address owner, address spender) external constant returns (uint);
  function transferFrom(address from, address to, uint value) external;
  function approve(address spender, uint value) external;

  event Approval(address indexed owner, address indexed spender, uint value);
  event Transfer(address indexed from, address indexed to, uint value);
}

那么符合ERC20標準的接口的合約試圖與不符合ERC20的合約進行交互,會發生什么呢?下面我們通過一個合約示例來做解釋說明:

interface Token {
  function transfer() returns (bool);
}

contract GoodToken is Token {
  function transfer() returns (bool) { return true; }
}

contract BadToken {
  function transfer() {}
}

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

在solidity中,函數選擇器是從它的函數名和輸入參數的類型中派生出來的:

selector = bytes4(sha3(“transfer()”))

函數的返回值不是函數選擇器的一部分,因此,沒有返回值的函數transfer()和函數transfer()返回(bool)具有相同的函數選擇器,但它們仍然不同,由于缺少返回值,編譯器不會接受transfer()函數作為令牌接口的實現,所以Goodtoken是Token接口的實現,而Badtoken不是。

當我們通過合約去外部調用BadToken時,Bad token會處理該transfer調用,并且不返回布爾返回值,之后調用合約會在內存中查找返回值,但是由于被調用的合約中的Transfer函數沒有寫返回值,所以它會將在這個內存位置找到的任何內容作為外部調用的返回值。

完全巧合的是,因為調用方期望返回值的內存槽與存儲調用的函數選擇器的內存槽重疊,這被EVM解釋為返回值“真”。因此,完全是運氣使然,EVM的表現就像程序員們希望它的表現一樣。

自從去年10月拜占庭硬叉以來,EVM有了一個新的操作碼,叫做returndatasize,這個操作碼存儲(顧名思義)外部調用返回數據的大小,這是一個非常有用的操作碼,因為它允許在函數調用中返回動態大小的數組。

這個操作碼在solidity 0.4.22更新中被采用,現在,代碼在外部調用后檢查返回值的大小,并在返回數據比預期的短的情況下revert事務,這比從某個內存插槽中讀取數據安全得多,但是這種新的行為對于我們的BadToken來說是一個巨大的問題。

如上所述,最大的風險是用solc ≥ 0.4.22編譯的智能合約(預期為ERC0接口)將無法與我們的Badtokens交互,這可能意味著發送到這樣的合約的Token將永遠停留在那里,即使該合約具有轉移ERC 20 Token的功能。

類似問題的合約:

{'addr': '0xae616e72d3d89e847f74e8ace41ca68bbf56af79', 'name': 'GOOD', 'decimals': 6}
{'addr': '0x93e682107d1e9defb0b5ee701c71707a4b2e46bc', 'name': 'MCAP', 'decimals': 8}
{'addr': '0xb97048628db6b661d4c2aa833e95dbe1a905b280', 'name': 'PAY', 'decimals': 18}
{'addr': '0x4470bb87d77b963a013db939be332f927f2b992e', 'name': 'ADX', 'decimals': 4}
{'addr': '0xd26114cd6ee289accf82350c8d8487fedb8a0c07', 'name': 'OMG', 'decimals': 18}
{'addr': '0xb8c77482e45f1f44de1745f52c74426c631bdd52', 'name': 'BNB', 'decimals': 18}
{'addr': '0xf433089366899d83a9f26a773d59ec7ecf30355e', 'name': 'MTL', 'decimals': 8}
{'addr': '0xc63e7b1dece63a77ed7e4aeef5efb3b05c81438d', 'name': 'FUCKOLD', 'decimals': 4}
{'addr': '0xab16e0d25c06cb376259cc18c1de4aca57605589', 'name': 'FUCK', 'decimals': 4}
{'addr': '0xe3818504c1b32bf1557b16c238b2e01fd3149c17', 'name': 'PLR', 'decimals': 18}
{'addr': '0xe2e6d4be086c6938b53b22144855eef674281639', 'name': 'LNK', 'decimals': 18}
{'addr': '0x2bdc0d42996017fce214b21607a515da41a9e0c5', 'name': 'SKIN', 'decimals': 6}
{'addr': '0xea1f346faf023f974eb5adaf088bbcdf02d761f4', 'name': 'TIX', 'decimals': 18}
{'addr': '0x177d39ac676ed1c67a2b268ad7f1e58826e5b0af', 'name': 'CDT', 'decimals': 18}

有兩種方法可以修復這個錯誤:

第一種:受影響的Token合約開放團隊需要修改他們的合約,這可以通過重新部署Token合約或者更新合約來完成(如果有合約更新邏輯設計)。

第二種:重新包裝Bad Transfer函數,對于這種包裝有不同的建議,例如:

library ERC20SafeTransfer {
    function safeTransfer(address _tokenAddress, address _to, uint256 _value) internal returns (bool success) {
        // note: both of these could be replaced with manual mstore's to reduce cost if desired
        bytes memory msg = abi.encodeWithSignature("transfer(address,uint256)", _to, _value);
        uint msgSize = msg.length;

        assembly {
            // pre-set scratch space to all bits set
            mstore(0x00, 0xff)

            // note: this requires tangerine whistle compatible EVM
            if iszero(call(gas(), _tokenAddress, 0, add(msg, 0x20), msgSize, 0x00, 0x20)) { revert(0, 0) }

            switch mload(0x00)
            case 0xff {
                // token is not fully ERC20 compatible, didn't return anything, assume it was successful
                success := 1
            }
            case 0x01 {
                success := 1
            }
            case 0x00 {
                success := 0
            }
            default {
                // unexpected value, what could this be?
                revert(0, 0)
            }
        }
    }
}

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

contract TestERC20SafeTransfer {
    using ERC20SafeTransfer for ERC20;
    function ping(address _token, address _to, uint _amount) {
        require(ERC20(_token).safeTransfer(_to, _amount));
    }
}

另一方面,正在編寫ERC 20合約的開發人員需要意識到這個錯誤,這樣他們就可以預料到BadToken的意外行為并處理它們,這可以通過預期BadER 20接口并在調用后檢查返回數據來確定我們調用的是Godtoken還是BadToken來實現:

pragma solidity ^0.4.24;

/*
 * WARNING: Proof of concept. Do not use in production. No warranty.
*/

interface BadERC20 {

  function transfer(address to, uint value) external;
}

contract BadERC20Aware {

    function safeTransfer(address token, address to , uint value) public returns (bool result) {
        BadERC20(token).transfer(to,value);

        assembly {
            switch returndatasize()   
                case 0 {                      // This is our BadToken
                    result := not(0)          // result is true
                }
                case 32 {                     // This is our GoodToken
                    returndatacopy(0, 0, 32) 
                    result := mload(0)        // result == returndata of external call
                }
                default {                     // This is not an ERC20 token
                    revert(0, 0) 
                }
        }
    require(result);                          // revert() if result is false
    }
}

事件總結

造成本次事件的主要原因還是在于波場USDT的transfer函數未使用TIP20規范的寫法導致函數在執行時未返回對應的值,最終返回默認的false,從而導致在使用safeTransfer調用USDT的transfer時永遠都只返回false,導致用戶無法提現。

所以,在波場部署有關USDT的合約,需要注意額外針對USDT合約進行適配,上線前務必做好充足的審計與測試,盡可能減少意外事件的發生


智能合約審計服務

針對目前主流的以太坊應用,知道創宇提供專業權威的智能合約審計服務,規避因合約安全問題導致的財產損失,為各類以太坊應用安全保駕護航。

知道創宇404智能合約安全審計團隊: https://www.scanv.com/lca/index.html
聯系電話:(086) 136 8133 5016(沈經理,工作日:10:00-18:00)

歡迎掃碼咨詢:

區塊鏈行業安全解決方案

黑客通過DDoS攻擊、CC攻擊、系統漏洞、代碼漏洞、業務流程漏洞、API-Key漏洞等進行攻擊和入侵,給區塊鏈項目的管理運營團隊及用戶造成巨大的經濟損失。知道創宇十余年安全經驗,憑借多重防護+云端大數據技術,為區塊鏈應用提供專屬安全解決方案。

歡迎掃碼咨詢:

參考鏈接

[1] Missing-Return-Value-Bug
https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1337/