智能合约安全之闭源合约

 

在上篇文章中,我们了解了如何分析智能合约的交易,并通过 hctf 的一道题目来具体分析了如何利用流量来发现合约的漏洞。在接下来的文章中,我们继续将范围从开源合约扩展到闭源合约。

 

ABI 说明

根据官方文档的说明:

在 以太坊Ethereum 生态系统中, 应用二进制接口Application Binary Interface(ABI) 是从区块链外部与合约进行交互以及合约与合约间进行交互的一种标准方式。 数据会根据其类型按照这份手册中说明的方法进行编码。这种编码并不是可以自描述的,而是需要一种特定的概要(schema)来进行解码。

简单理解,就是智能合约里的函数调用需要符合一定的规范。下面来看具体的规范说明:

函数选择器

一个函数调用数据的前 4 字节,指定了要调用的函数。这就是某个函数签名的 Keccak(SHA-3)哈希的前 4 字节(高位在左的大端序)(译注:这里的“高位在左的大端序“,指最高位字节存储在最低位地址上的一种串行化编码方式,即高位字节在左)。 这种签名被定义为基础原型的规范表达,基础原型即是函数名称加上由括号括起来的参数类型列表,参数类型间由一个逗号分隔开,且没有空格。

以上文合约里的 transfer 函数为例,

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

按照定义,该函数的基础原型为 transfer(address,uint256) ,计算函数签名的方式为 bytes4(keccak256("transfer(address,uint256)")),由此得到 a9059cbb

为了验证我们计算出的签名是否正确,我们可以利用 Ethereum Function Signature Database 来验证我们的签名,结果如下:

函数签名

参数编码

从第5字节开始是被编码的参数。这种编码也被用在其他地方,比如,返回值和事件的参数也会被用同样的方式进行编码,而用来指定函数的4个字节则不需要再进行编码。

参数主要分为三种类型:

  • 基础类型,
    • uint<M>M 位的无符号整数,0 < M <= 256M % 8 == 0。例如:uint32uint8uint256
    • int<M>:以 2 的补码作为符号的 M 位整数,0 < M <= 256M % 8 == 0
    • address:除了字面上的意思和语言类型的区别以外,等价于 uint160。在计算和 函数选择器Function Selector中,通常使用 address
    • uintintuint256int256 各自的同义词。在计算和 函数选择器Function Selector 中,通常使用 uint256int256
    • bool:等价于 uint8,取值限定为 0 或 1 。在计算和 函数选择器Function Selector 中,通常使用 bool
    • …… 其他不常用的基础类型
  • 定长数组类型
    • <type>[M]:有 M 个元素的定长数组,M >= 0,数组元素为给定类型
  • 非定长类型:
    • bytes:动态大小的字节序列。
    • string:动态大小的 unicode 字符串,通常呈现为 UTF-8 编码。
    • <type>[]:元素为给定类型的变长数组

继续以 transfer 函数为例,可以看到其两个参数类型分别为 addressuint256,如果我们想用 0xeBEcEa6d769B79CF6379D6420832923F795892da69 做参数调用该函数的话,我们需要传递 68 字节的数据,可以分解为:

  • 0xa9059cbb:方法ID。来自 ASCII 格式的 transfer(address,uint256) 签名的 Keccak 哈希的前 4 字节。
  • 0x000000000000000000000000eBEcEa6d769B79CF6379D6420832923F795892da:第一个参数,一个被用 0 值字节补充到 32 字节的地址。
  • 0x0000000000000000000000000000000000000000000000000000000000000045:第二个参数,一个被用 0 值字节补充到 32 字节的 uint256 值 69

 

智能合约逆向

在了解了 ABI 的相关知识后,下一步是对闭源合约进行逆向分析。目前为止,智能合约 bytecode 的反编译工具有很多,比如 JEB 就有一款 Ethereum Smart Contract Decompiler,比如说在线工具 https://ethervm.io/decompile ,下面将使用在线的反编译工具,对相应的例子进行解析。将如下合约部署在测试链 ropsten 上:

pragma solidity ^0.4.24;

contract Test {
    address public owner;
    mapping (address => uint256) public balances; 

    constructor () public {
        owner = msg.sender;
    }

    function gift(address _addr) public {
        balances[_addr] += 1 ether;
    }

    function burn() public {
        balances[msg.sender] -= 1 ether;
    }

    function testFunction(bool _bool, address _addr, uint _uint) public {

    }

    function testString(string _str) public {

    }
}

可以看到 Etherscan 只显示了合约的字节码:

bytecode

使用 https://ethervm.io/decompile/ropsten/0x6c0cdee7a6e6ef6b82f168608985e146c01fb09c 进行反编译,可以看到该工具会结合上文提到的 Ethereum Function Signature Database 来还原函数签名:

反编译

继续看反编译出的代码,可以看到虽然和源代码相比更加冗余,但仍然有一定的可读性:

反编译结果

继续看反编出代码的 main 函数,该函数简化后如下:

function main() {
    memory[0x40:0x60] = 0x80;

    if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); }

    var var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;

    if (var0 == 0x27e235e3) {
        // Dispatch table entry for balances(address)
        ...
    } else if (var0 == 0x44df8e70) {
        // Dispatch table entry for burn()
        ...
    } else if (var0 == 0x61cb5a01) {
        // Dispatch table entry for testString(string)
        ...
    } else if (var0 == 0x8da5cb5b) {
        // Dispatch table entry for owner()
        ...
    } else if (var0 == 0xbee8a5f8) {
        // Dispatch table entry for 0xbee8a5f8 (unknown)
        ...
    } else if (var0 == 0xcbfc4bce) {
        // Dispatch table entry for gift(address)
        ...
    } else { revert(memory[0x00:0x00]); }
}

可以看到 main 函数主要分为三个步骤:

  1. if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); } 判断合约数据是否合法,不合法则回退状态
  2. var var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff; 即取交易数据的前 4 字节作为函数选择器,进入分支语句中执行对应函数
  3. 如果找到对应函数,则执行;如果找不到对应函数,则直接回退

交易分析

针对一条交易,我们对合约调用流程进行具体分析,交易数据如下:

0xbee8a5f80000000000000000000000000000000000000000000000000000000000000001000000000000000000000000ebecea6d769b79cf6379d6420832923f795892da000000000000000000000000000000000000000000000000000000000000077f

我们选取数据的前四字节 0xbee8a5f8 作为函数选择器,由此定位到 main 函数里的代码:

} else if (var0 == 0xbee8a5f8) {
    // Dispatch table entry for 0xbee8a5f8 (unknown)
    var1 = msg.value;

    if (var1) { revert(memory[0x00:0x00]); }

    var1 = 0x0202;
    var2 = !!msg.data[0x04:0x24];
    var var3 = msg.data[0x24:0x44] & 0xffffffffffffffffffffffffffffffffffffffff;
    var var4 = msg.data[0x44:0x64];
    func_02DE(var2, var3, var4);
    stop();
}
// …… 省略其他函数
function func_02DE(var arg0, var arg1, var arg2) {}

可以看到,交易的后 96 个字节会被分割成 3 个 32 个字节的参数,传入函数 func_02DE,该交易的数据最终可以分解为:

  • 0xbee8a5f8 – 函数选择器
  • 0000000000000000000000000000000000000000000000000000000000000001 – 参数 1
  • 000000000000000000000000ebecea6d769b79cf6379d6420832923f795892da – 参数 2
  • 000000000000000000000000000000000000000000000000000000000000077f – 参数 3

同源代码印证后可以看到的我们实际执行的是以下函数,函数定义和交易里传递的参数一致:

function testFunction(bool _bool, address _addr, uint _uint) public {

}

 

利用交易信息重现攻击 – v2.0

接下来我们将结合刚结束的 roarctf 原题进行实战分析,题目相应地址如下:https://ropsten.etherscan.io/address/0x8d73365bb00a9a1a06100fdfdc22fd8a61cfff93

pragma solidity ^0.4.24;

// 把存款存入银行,一年后才可以取哦hhh。

contract FatherOwned {
    address public owner;
    modifier onlyOwner{ if (msg.sender != owner) revert(); _; }
    function FatherOwned() { owner = msg.sender; }
}

contract HoneyLock is StandardToken,FatherOwned {
    uint256 guessCode;
    uint256 guessValue;
    string public constant name = 'Coin';
    string public constant symbol = '0x0';
    uint public constant decimals = 3;
    uint public time = now + 1 years;
    uint public airDrop = 1 * (10 ** decimals);
    mapping(address => uint256) public timeHouse;
    mapping(address => bool) public takeRecord;
    address public owner;
    event FLAG(string b64email, string slogan);

    function HoneyLock() public {
      owner = msg.sender;
      takeRecord[owner] = true;
      balances[owner] = airDrop;
      timeHouse[owner] = time;
      Transfer(0x0, owner, airDrop);
  }

    function takeMoney() public returns(bool) {
      require(takeRecord[msg.sender] == false);
      balances[msg.sender] = airDrop;
      takeRecord[msg.sender] = true;
      timeHouse[msg.sender] = time;
      Transfer(0x0, msg.sender, airDrop);
      return true;
    }

    modifier lock() {
      require (now > timeHouse[msg.sender]);
      _;
    }

    function transfer(address _to, uint256 _value) lock public returns(bool) {
      super.transfer(_to, _value);
      Transfer(msg.sender, _to , _value);
    }


    function useCode(uint256 code) public payable {
      require ((code == guessCode) && (msg.value >= guessValue)); 
      owner = msg.sender;
    }

    function withdraw() public onlyOwner {
      require(takeRecord[msg.sender] == true);
      balances[msg.sender] == 0;
    }

    function CaptureTheFlag(string b64email) public returns(bool){
      require (takeRecord[msg.sender] == true);
      require (balances[msg.sender] == 0);
      emit FLAG(b64email, "Congratulations to capture the flag!");
    }

}

可以看到题目给了部分代码,最关键的代码在CaptureTheFlag 函数中,要求用户满足以下两个条件才能获得 flag:

  1. takeRecord[msg.sender] == true
  2. balances[msg.sender] == 0

但在测试链上的代码是不开源的,说明除了给出的部分代码外,必然还有一些隐藏的代码才能使得用户满足上述条件。因此我们利用在线的反编译工具 https://ethervm.io/decompile/ropsten/0x8d73365bb00a9a1a06100fdfdc22fd8a61cfff93 反编译源码。然后,按照惯例,我们继续定位 Events 里的重要事件:

Events

定位到交易 https://ropsten.etherscan.io/tx/0xfefec668f21dd180dfe684066a75632b194c5e64d46497678b56dadb4bf1a385 然后再定位交易发起者到相关交易:

txns

可以看到关键交易是 https://ropsten.etherscan.io/tx/0xdb777f8aca6a16cdf9e58d0b0216daf8bd4cf1f45310a27a231f48a6d15b25d1 相应的交易数据为:

0x5ad0ae39000000000000000000000000967f8ac6502ecba2635d9e4eea2f65ad4940b1b1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000967f8ac6502ecba2635d9e4eea2f65ad4c6cf08c

定位到反编译后的代码:

} else if (var0 == 0x5ad0ae39) {
    // Dispatch table entry for 0x5ad0ae39 (unknown)
    var1 = msg.value;

    if (var1) { revert(memory[0x00:0x00]); }

    var1 = 0x037c;
    var2 = msg.data[0x04:0x24] & 0xffffffffffffffffffffffffffffffffffffffff;
    var3 = msg.data[0x24:0x44] & 0xffffffffffffffffffffffffffffffffffffffff;
    var4 = msg.data[0x44:0x64];
    var5 = msg.data[0x64:0x84];
    var1 = func_09D7(var2, var3, var4, var5);
    var temp19 = memory[0x40:0x60];
    memory[temp19:temp19 + 0x20] = !!var1;
    var temp20 = memory[0x40:0x60];
    return memory[temp20:temp20 + (temp19 + 0x20) - temp20];
} 

// 省略其他无关代码……

function func_09D7(var arg0, var arg1, var arg2, var arg3) returns (var r0) {
    var var0 = 0x00;
    memory[0x00:0x20] = arg0 & 0xffffffffffffffffffffffffffffffffffffffff;
    memory[0x20:0x40] = 0x03;
    var temp0 = keccak256(memory[0x00:0x40]);
    memory[0x00:0x20] = msg.sender;
    memory[0x20:0x40] = temp0;

    if (arg2 > storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }

    if (storage[0x02] + msg.sender != arg3) { revert(memory[0x00:0x00]); }

    var temp1 = arg2;
    var temp2 = arg0;
    memory[0x00:0x20] = temp2 & 0xffffffffffffffffffffffffffffffffffffffff;
    memory[0x20:0x40] = 0x00;
    var temp3 = storage[keccak256(memory[0x00:0x40])] - temp1;
    memory[0x00:0x20] = temp2 & 0xffffffffffffffffffffffffffffffffffffffff;
    memory[0x20:0x40] = 0x00;
    storage[keccak256(memory[0x00:0x40])] = temp3;
    var temp4 = arg1;
    memory[0x00:0x20] = temp4 & 0xffffffffffffffffffffffffffffffffffffffff;
    memory[0x20:0x40] = 0x00;
    var temp5 = storage[keccak256(memory[0x00:0x40])] + temp1;
    memory[0x00:0x20] = temp4 & 0xffffffffffffffffffffffffffffffffffffffff;
    memory[0x20:0x40] = 0x00;
    storage[keccak256(memory[0x00:0x40])] = temp5;
    memory[0x00:0x20] = temp2 & 0xffffffffffffffffffffffffffffffffffffffff;
    memory[0x20:0x40] = 0x03;
    var temp6 = keccak256(memory[0x00:0x40]);
    memory[0x00:0x20] = msg.sender;
    memory[0x20:0x40] = temp6;
    var temp7 = storage[keccak256(memory[0x00:0x40])] - temp1;
    memory[0x00:0x20] = temp2 & 0xffffffffffffffffffffffffffffffffffffffff;
    memory[0x20:0x40] = 0x03;
    var temp8 = keccak256(memory[0x00:0x40]);
    memory[0x00:0x20] = msg.sender;
    memory[0x20:0x40] = temp8;
    storage[keccak256(memory[0x00:0x40])] = temp7;
    return 0x01;
}

所以可以将交易数据进行如下分割:

  • 0x5ad0ae39 – 函数选择器
  • 000000000000000000000000967f8ac6502ecba2635d9e4eea2f65ad4940b1b1 – arg0
  • 0000000000000000000000000000000000000000000000000000000000000000 – arg1
  • 00000000000000000000000000000000000000000000000000000000000003e8 – arg2
  • 000000000000000000000000967f8ac6502ecba2635d9e4eea2f65ad4c6cf08c – arg3

结合代码可知,参数需要满足如下两个条件:

  1. if (arg2 > storage[keccak256(memory[0x00:0x40])]) { revert(memory[0x00:0x00]); }
  2. if (storage[0x02] + msg.sender != arg3) { revert(memory[0x00:0x00]); }

可以看出 arg3 是一个特意构造的参数,用来满足 msg.sender + storage[0x02] 这一条件限制,其中 storage[0x02] 可以从链上读取,值为 0x32c3edb。

如果 msg.sender 是 0xeBEcEa6d769B79CF6379D6420832923F795892da,那么相应的 arg3 则为 0xeBEcEa6d769B79CF6379D6420832923F795892da + 0x32c3edb = 0xebecea6d769b79cf6379d6420832923f7c84d1b5。

再看其他参数,很明显 arg2 是需要从 balances 中转走的金额,arg0 和 arg1 分别是转出的地址和转入的地址。很明显该函数是个可以清空 balances 的后门函数,但此时尚不能直接调用该函数,因为不满足第一个条件,为此我们需要继续定位之前的两个关键交易:

approve

takeMoney

根据这三个交易,我们可以推断出攻击流程如下:

  1. 调用 takeMoney 函数,满足 takeRecord[msg.sender] == true 要求,但会使得 balances 不为 0
  2. 调用 approve 函数,使得用户可以向其他账户转账
  3. 调用后门函数,将用户的余额全部转给地址为 0 的账户,满足 balances[msg.sender] == 0 的要求

因此我们模仿构造以下四个交易:

// takeMoney, 满足 takeRecord[msg.sender] == true
code = 'fcae8c06'
web3.eth.sendTransaction({from: '0xeBEcEa6d769B79CF6379D6420832923F795892da', to: '0x8D73365BB00a9a1A06100fDFDc22fd8a61CfFF93', data: code}, console.log)
// approve,满足下一个交易的要求
code = '095ea7b3000000000000000000000000ebecea6d769b79cf6379d6420832923f795892da00000000000000000000000000000000000000000000000000000000000003e8'
web3.eth.sendTransaction({from: '0xeBEcEa6d769B79CF6379D6420832923F795892da', to: '0x8D73365BB00a9a1A06100fDFDc22fd8a61CfFF93', data: code}, console.log)
// 转账,清空 balances
code = '5ad0ae39000000000000000000000000ebecea6d769b79cf6379d6420832923f795892da000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000ebecea6d769b79cf6379d6420832923f7c84d1b5'
web3.eth.sendTransaction({from: '0xeBEcEa6d769B79CF6379D6420832923F795892da', to: '0x8D73365BB00a9a1A06100fDFDc22fd8a61CfFF93', data: code}, console.log)
// CaptureTheFlag
code = '95907291000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000144d5445304e5445305147396a4c6d4e7662513d3d000000000000000000000000'
web3.eth.sendTransaction({from: '0xeBEcEa6d769B79CF6379D6420832923F795892da', to: '0x8D73365BB00a9a1A06100fDFDc22fd8a61CfFF93', data: code}, console.log)

最终可以在 Etherscan 上看到成功触发了 FLAG 事件:

Success

 

总结

同开源合约相比,闭源合约由于其不公开源代码的原因,在分析上难度更大,但并非不能分析。简单总结闭源合约交易的分析流程,可以分为三个步骤:

  1. 利用反编译工具,将闭源合约的字节码转换为可读的源码
  2. 解析交易数据,提取出函数选择器和相应参数
  3. 利用函数选择器找到对应函数代码,进而理解攻击的原理

 

参考链接

(完)