智能合约安全之交易分析

 

众所周知,智能合约的特性之一是公开透明,该特性的表现之一就是,区块链上的所有交易也是公开可见的,任何地址与智能合约所进行的交易都会被存储在链上。

在正常情况下,我们可能很少去关注这些交易信息,但在某些情况下,这些交易信息可能成为辅助我们研究的利器。比如当智能合约爆出漏洞时,为了利用智能合约的漏洞,攻击者往往需要构造特定的交易来触发漏洞,而这些交易信息最终将会被存储在链上。通过分析这些存储在链上的交易信息,我们就能推断出攻击者是如何利用漏洞,进而达到复现或者是修复漏洞的目的。

 

交易数据分析

首先,以最经典的 ERC 20 代币为例,笔者在测试链上部署了如下代码:

pragma solidity ^0.4.24;

interface tokenRecipient { function receiveApproval(address _from, uint256 _value, address _token, bytes _extraData) public; }

contract TokenERC20 {
    string public name;
    string public symbol;
    string public hint;
    uint8 public decimals = 18;
    uint256 public totalSupply;

    mapping (address => uint256) public balanceOf;  //
    mapping (address => mapping (address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);

    event Burn(address indexed from, uint256 value);

    function TokenERC20(uint256 initialSupply, string tokenName, string tokenSymbol) public {
        totalSupply = initialSupply * 10 ** uint256(decimals);
        balanceOf[msg.sender] = totalSupply;
        name = tokenName;
        symbol = tokenSymbol;
    }


    function _transfer(address _from, address _to, uint _value) internal {
        require(_to != 0x0);
        require(balanceOf[_from] >= _value);
        require(balanceOf[_to] + _value > balanceOf[_to]);
        uint previousBalances = balanceOf[_from] + balanceOf[_to];
        balanceOf[_from] -= _value;
        balanceOf[_to] += _value;
        Transfer(_from, _to, _value);
        assert(balanceOf[_from] + balanceOf[_to] == previousBalances);
    }

    function transfer(address _to, uint256 _value) public returns (bool) {
        _transfer(msg.sender, _to, _value);
        return true;
    }

    function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
        require(_value <= allowance[_from][msg.sender]);
        allowance[_from][msg.sender] -= _value;
        _transfer(_from, _to, _value);
        return true;
    }

    function approve(address _spender, uint256 _value) public
        returns (bool success) {
        allowance[msg.sender][_spender] = _value;
        return true;
    }

    function approveAndCall(address _spender, uint256 _value, bytes _extraData) public returns (bool success) {
        tokenRecipient spender = tokenRecipient(_spender);
        if (approve(_spender, _value)) {
            spender.receiveApproval(msg.sender, _value, this, _extraData);
            return true;
        }
    }

    function burn(uint256 _value) public returns (bool success) {
        require(balanceOf[msg.sender] >= _value);
        balanceOf[msg.sender] -= _value;
        totalSupply -= _value;
        Burn(msg.sender, _value);
        return true;
    }

    function burnFrom(address _from, uint256 _value) public returns (bool success) {
        require(balanceOf[_from] >= _value);
        require(_value <= allowance[_from][msg.sender]);
        balanceOf[_from] -= _value;
        allowance[_from][msg.sender] -= _value;
        totalSupply -= _value;
        Burn(_from, _value);
        return true;
    }

    function gift() public returns (bool success) {
        require(balanceOf[msg.sender] == 0);
        balanceOf[msg.sender] += 1000;
        return true;
    }

    function setHint(string _hint) public {
        hint = _hint;
    }
}

然后随机调用了几个函数,利用 etherscan 平台,可以清晰地看到这几个交易:

txns

可以看到一条记录由七个字段构成:Txn Hash / Block / Age / From / To / Value / Txn Fee,一般来说较有价值的是以下几个字段:

  • Age => 交易的时间戳
  • From => 交易的发起者
  • Value => 交易所携带 eth 数目

下面再看一个交易的具体细节:

tx

可以看到大部分数据,如 Transaction HashBlockFromTo 等已经出现在上一张表格中,这里需要关注的是 Input Data,也就是我们具体调用的函数和相应参数。点击 Decode Input Data,原始数据会被解码成具体的参数:

decode

根据 decode 的结果,可以看到,调用的是 setHint 函数,函数参数名是 _hint,参数类型是 string,值为 this is a simple hint,对应的原始代码如下:

function setHint(string _hint) public {
    hint = _hint;
}

再看多参数的函数调用,可以看到和上一个函数一样,etherscan 对函数参数名、参数类型、具体的值做了解析,然后参数的顺序跟函数声明里的参数顺序保持一致。

function approve(address _spender, uint256 _value) public returns (bool success) {
    allowance[msg.sender][_spender] = _value;
    return true;
}

 

Internal Transaction

看完了普通的 Transaction,再看 Internal Transcation,可以看到 etherscan 对这两种交易做了很明显的区分:

Internal Transcation

按网上的描述和笔者的理解,Internal Transaction 并不是一个真正的 Transaction,它是在一笔交易执行过程中,合约根据一定条件,进行转账或者是调用新合约等一系列动作产生的结果,正如 etherscan 上标注的一样,Internal Transactions as a result of Contract Execution

或者换种更简单的理解方式,以太坊中有两种账户:

  • 外部账户 外部(用户)直接控制的账户,通过私钥控制,没有相关代码
  • 合约账户 合约控制的账户,存在相关代码且可以执行

Transaction 就是外部账户发起的,比如说向某个账户转账,调用合约函数等;而 Internal Transaction 则是合约账户发起的,比如 A 合约调用了 B 合约的 gift() 函数,那么这个动作就会被 etherscan 标记为内部交易。

下面结合具体的例子,继续细看 Internal Transaction

交易链接如下:https://ropsten.etherscan.io/tx/0xf0b5ca1c856d5594fc6e89fc31b84b550fcb1f3d4c56529da3fac637cac497d8 ,可以看到是外部账户 0x10108bab01d0811f233b703dccb005db27df764f 向合约账户 0xcf05704193697c509ba64e941ea34f3fc2477614 发起的一个交易。

继续看 Internal Transactions 标签,可以看到由于交易的过程中,0xcf05704193697c509ba64e941ea34f3fc2477614 会调用地址为 0xDb73fb49aAD40119149387CB5583BF31660457B6 的合约,所以这两个合约间的操作被 etherscan 标记为 Internal Transaction

很多情况下,Internal Transaction 是攻击者用于隐藏自己的一种方式,但由于智能合约公开透明的特性,这种隐藏的手段最终也只能略微增加对流量分析的难度。

 

利用交易信息重现攻击

前面已经铺垫了很多基础知识,下面来看如何利用交易信息来重现攻击。以去年 hctf 一道智能合约的题目 ez2win 为例,我们将通过对交易信息分析进行解题。

合约地址:https://ropsten.etherscan.io/address/0x71feca5f0ff0123a60ef2871ba6a6e5d289942ef ,关键部分的源码如下:

contract D2GBToken is ERC20 {

  string public constant name = "D2GB";
  string public constant symbol = "D2GB";
  uint8 public constant decimals = 18;

  uint256 public constant INITIAL_SUPPLY = 20000000000 * (10 ** uint256(decimals));

  /**
  * @dev Constructor that gives msg.sender all of existing tokens.
  */
  constructor() public {
    _totalSupply = INITIAL_SUPPLY;
    _balances[msg.sender] = INITIAL_SUPPLY;
    initialized[msg.sender] = true;
    emit Transfer(address(0), msg.sender, INITIAL_SUPPLY);
  }


  //flag
  function PayForFlag(string b64email) public payable returns (bool success){

    require (_balances[msg.sender] > 10000000);
      emit GetFlag(b64email, "Get flag!");
  }
}

首先,为了快速定位到成功获得 flag 队伍的交易,我们可以利用 PayForFlag 函数会触发 event 的特性,从 Events 中定位关键交易:

Events

选取第二条交易,定位交易者的地址:

From

然后根据合约地址,定位跟该合约相关的所有交易:

Attacker Txns

其中 Txn Hash0xe1f5645b9ead7a8…0xa2a7e26c2ea1a4… 的交易为最终获得 flag 的交易,那么可以推测后两条交易则是利用合约漏洞,使其满足 _balances[msg.sender] > 10000000 的关键交易,继续查看相应交易的内容:

0x4e8426c7a1d14d3833b42ac4f5ceeba8745778c18533f76972894e773e178026

0x2da4bcea221ae75dfaa8bc170fdcee97abfa550fe09bfd9f46f5aee1d2c013a6

可以看到这两个交易其实都是执行了 _transfer 函数,查看相应源码,可以很明显地看到,由于没有 private 修饰符的修饰,该函数可以被外部调用,导致的实际效果就是,任意用户可以从其他用户那转走任意数目的 balances(每次的数目不超过 10000000):

function _transfer(address from, address to, uint256 value) {
    require(value <= _balances[from]);
    require(to != address(0));
    require(value <= 10000000);

    _balances[from] = _balances[from].sub(value);
    _balances[to] = _balances[to].add(value);
}

那我们直接模仿构造相应交易:

txn1 – _transfer

# Name Type Data
0 from address 56d08c5d7ccee25aebdc4ae0274557c462ce1fd7
1 to address 10108bab01d0811f233b703dccb005db27df764f
2 value uint256 100000

txn2 – _transfer

# Name Type Data
0 from address f41010e4ec32715c0690f7baecdc61d56ba8b1b1
1 to address 10108bab01d0811f233b703dccb005db27df764f
2 value uint256 10000000

txn3 – PayForFlag

# Name Type Data
0 b64email string MTE0NTE0QDE5MTkub2lzaGlp

成功触发 GetFlag event :

GetFlag

 

总结

可以看到,合理地对智能合约的交易进行分析,能有效地帮助我们定位智能合约漏洞,对漏洞的复现和修复有着极大的意义。

到目前为止, 本文介绍的都是有源码智能合约的交易分析,无源码的智能合约交易分析更为复杂,在下一篇文章中,我将更进一步介绍,如何在没有源代码的情况下,对智能合约的交易进行分析。

(完)