Balsn CTF 2019 - Bank 复现笔记

Balsn CTF 2019 - Bank

前几天Retr_0师傅发给我一道区块链题strictmathematician,仔细研究了两三天,学习到了很多新的知识,在此记录一下。由于这道题是pikachu师傅基于Balsn CTF 2019的Bank为原型出的一道题,所以我在pikachu师傅的指点下先去看了Balsn CTF 2019的Bank,以下为复现笔记。(官方wp

Source

首先查看题目给出的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
pragma solidity ^0.4.24;

contract Bank {
event SendEther(address addr);
event SendFlag(address addr);

address public owner; // 0
uint randomNumber = RN; // 1

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

struct SafeBox {
bool done; // 0_0_1
function(uint, bytes12) internal callback; // 0_1_9
bytes12 hash; // 0_9_21
uint value; // 1
}
SafeBox[] safeboxes; // 2

struct FailedAttempt {
uint idx; // 0
uint time; // 1
bytes12 triedPass; // 2_0_12
address origin; // 2_12_32
}
mapping(address => FailedAttempt[]) failedLogs; // 3

modifier onlyPass(uint idx, bytes12 pass) {
if (bytes12(sha3(pass)) != safeboxes[idx].hash) {
FailedAttempt info;
info.idx = idx;
info.time = now;
info.triedPass = pass;
info.origin = tx.origin;
failedLogs[msg.sender].push(info);
}
else {
_;
}
}

function deposit(bytes12 hash) payable public returns(uint) {
SafeBox box;
box.done = false;
box.hash = hash;
box.value = msg.value;
if (msg.sender == owner) {
box.callback = sendFlag;
}
else {
require(msg.value >= 1 ether);
box.value -= 0.01 ether;
box.callback = sendEther;
}
safeboxes.push(box);
return safeboxes.length-1;
}

function withdraw(uint idx, bytes12 pass) public payable {
SafeBox box = safeboxes[idx];
require(!box.done);
box.callback(idx, pass);
box.done = true;
}

function sendEther(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
msg.sender.transfer(safeboxes[idx].value);
emit SendEther(msg.sender);
}

function sendFlag(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
require(msg.value >= 100000000 ether);
emit SendFlag(msg.sender);
selfdestruct(owner);
}

}

内存排布

首先需要弄明白的是两个struct结构体SafeBoxFailedAttempt的内存排布,这里我参考了ctf-wiki。

一直以来我对EVM的字节序一直搞不清楚,为了弄清数据在内存或者Storage里每个字节到底是怎么存储的,我查阅了一些资料,然后发现ethervm.io上已经说的非常清楚了:Ethereum VM是大端机器,字长是256-bits,也就是32个字节。所以说,数据总是从低地址开始存储

举个例子,uint类型的数据0x80,存放在地址为0x40的内存中的情形,如下图所示:

0x80所占的字节数是32字节,MSB是\x00,LSB是\x80,因为EVM是大端机,所以说MSB存储在低地址,也就是MSB从地址0x40开始存储,一直到0x5f地址处的字节为LSB\x80

对于bytes、string这种类型的变量,首字节也是存放在低地址,然后依次往高地址存储

这里问了pikachu师傅Remix里Storage存储的k-v对前那一串数字是什么含义,原来是key的keccak值,应该是为了方便定位以该Slot为数组的元素实际的存储地址。

1
2
3
4
keccak256(0)=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
keccak256(1)=0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6
w3.keccak(hexstr='290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563')
HexBytes('0x510e4e770828ddbf7f7b00ab00a9f6adaf81c0dc9cc85f1f8249c256942d61d9')

根据ctf-wiki,结构体成员总是按slot对齐的,并且在每个slot内部从右到左(从高地址到低地址)紧密排列。官方wp里给出的结构体成员布局:

合约本身的Storage布局

1
2
3
4
5
6
7
8
9
-----------------------------------------------------
| unused (12) | owner (20) | <- slot 0
-----------------------------------------------------
| randomNumber (32) | <- slot 1
-----------------------------------------------------
| safeboxes.length (32) | <- slot 2
-----------------------------------------------------
| occupied by failedLogs but unused (32) | <- slot 3
-----------------------------------------------------

FailedAttempt

1
2
3
4
5
6
7
-----------------------------------------------------
| idx (32) |
-----------------------------------------------------
| time (32) |
-----------------------------------------------------
| origin (20) | triedPass (12) |
-----------------------------------------------------

SafeBox

1
2
3
4
5
-----------------------------------------------------
| unused (11) | hash (12) | callback (8) | done (1) |
-----------------------------------------------------
| value (32) |
-----------------------------------------------------

未初始化的Storage指针

在Solidity 0.5.0之前,没有初始化的结构体默认会指向Stroage[0],而不是指向memory。这一默认的行为在0.5.0之后被取消,变量的存储位置必须被显式地声明为memory或者是storage才可以通过编译。所以说源代码中onlyPass的info变量和deposit的box变量都是指向合约Storage存储的首地址的。这一行为使得我们可以将合约的Storage[0]视为结构体一样来修改,破坏了合约Storage中原有的值。

function type

源码中第16行的function(uint, bytes12) internal callback;是我第一次见的用法,在我常用的所有反编译器中都看不到这个变量的用法,而且在solidity文档中也搜索不到,导致我一度认为这个变量是伪变量,不存在实际的操作。

然后自己调试了,调试完后在官方文档里找到了一段对应的描述

Calling an internal function is realized by jumping to its entry label, just like when calling a function of the current contract internally.

这种function类型的变量占据8个字节,就像C语言里的函数指针一样,调用这个变量所指向的函数的时候会使用JUMP指令跳转到该变量所表示的地址上。

如果jump到了非JUMPDEST的指令会发生什么?

经过调试,会直接停止

调试

之前一直用的是Remix套件进行编译和调试,但是调试这个合约的时候运行第一条指令整个Debugger框就没了,想想Remix也不是万能的,不能只会一种工具而产生依赖,于是在tkmk师傅的指导下用geth搭建了自己的私链来调试智能合约交易。

搭建私链

参考了网上已有的教程

自己的启动参数,其中networkid和identity都是可以随便填的,–http –http.corsdomain好像是为了后续调试的,没有细究。

1
geth --identity "ainevsia" -networkid="35634" --datadir data --nodiscover --http --http.corsdomain "http://localhost:8000" --allow-insecure-unlock console

不想反复unlock自己的账号,用这里的方法

web3py

私链的好处就是自己可以完全掌控整条链,不过我在搭建完私链之后第一个想法就是有没有像etherscan一样的web界面可以让我查交易什么的。搜索一番后,其实是有一些开源的小的web应用的,不过好多都没更新了,网上提到最多的一个我也没安装上,而且看简介功能也很少。询问了tkmk师傅,他是用纯命令行操作的,理论上来说命令行可以完成一切etherscan所支持的操作。

但是我geth默认的console不会用,js调用合约传参都不知道该怎么传指定类型的参数,于是又请教了tkmk师傅,去学习了web3py的api,感觉对我来说web3py确实比web3.js好用的多。

web3py的原理是使用JSON-RPC(remote process communication)协议向节点发送指定的RPC操作,接收节点传回的数据。这里的节点就是我们用geth启动起来的。

列举一些常用的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from web3 import Web3
import json
from datetime import datetime

w3 = Web3(Web3.IPCProvider())
w3.isConnected() # 确认已经连接上远程节点

bytecode='0x' # 粘贴remix编译好的bytecode,前面要加0x
s='jsonformatstr' # 粘贴remix编译好的abi 可以先用http://www.bejson.com/jsonviewernew/ 去掉空格什么的
abi=json.loads(s)

# 合约对象
Bank = w3.eth.contract(abi=abi, bytecode=bytecode)

# Submit the transaction that deploys the contract
tx_hash = Bank.constructor().transact()

# 等待被收入block中
tx_receipt = w3.eth.waitForTransactionReceipt(tx_hash)

# 合约实例
bank = w3.eth.contract(address=tx_receipt.contractAddress,abi=abi)

# 调用合约方法
tx_hash = bank.functions.deposit(b'123456789012').transact({'gas':1000000,'value':w3.toWei(1,'ether')})
tx_receipt = w3.eth.waitForTransactionReceipt(tx_hash)
tx_receipt['status'] # 返回状态,返回0说明可能revert了或者fail了

# 拉回tx_hash这笔交易的执行日志
a=w3.manager.request_blocking('debug_traceTransaction', [tx_hash])

# 过滤日志
list(filter(lambda x: x.op=='REVERT',a.structLogs))
list(filter(lambda x: x.pc==0x192, a.structLogs))

# 显示最近一笔交易的时间
print(datetime.fromtimestamp(w3.eth.get_block('latest')['timestamp']))

# geth console命令,挖一块block,然后停止
miner.start(1); admin.sleepBlocks(1);miner.stop();

# 设置默认用户
w3.eth.default_account = w3.eth.accounts[0]

Debugger

我认为只有学会了如何使用调试器去一步步调试一个东西,才能说入门了一个领域,不然怎么知道它最底层的原理呢?

所以说我刚刚学会了使用w3.manager.request_blocking('debug_traceTransaction', [tx_hash])这个api来调试,我才刚刚入门智能合约安全。

这个api是tkmk师傅告诉我的,我网上搜了也搜不到,但确实就是存在的。

写了一个python脚本来提供一个半交互式的单步调试环境,提供了快进到某条指令的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from web3 import Web3
from datetime import datetime
import json

class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'

w3 = Web3(Web3.IPCProvider())
assert w3.isConnected()

# replace with your tx hash
tx_hash_str = '0x8481706e0686aa8e22960456a99c45a29a47b189a074c5d981267bc0113100d7'

print('[DEBUGing] ' + tx_hash_str)

gdb = w3.manager.request_blocking('debug_traceTransaction', [tx_hash_str])
trace = gdb.structLogs
pc = ''

tab = 8

for state in trace:
if pc != '':
if state.pc != int(pc, 0x10): # jump until pc
continue
print(bcolors.WARNING + '--------------------------------------------------------------------' + bcolors.ENDC)
print('PC'.ljust(tab) + hex(state.pc))
print('OPCODE'.ljust(tab) + state.op)
print(bcolors.OKBLUE + 'STACK' + bcolors.ENDC)
for e in state.stack:
print(''.ljust(tab) + e)
print(bcolors.OKCYAN + 'MEMORY' + bcolors.ENDC)
for i, e in enumerate(state.memory):
print(hex(0x20 * i).ljust(tab) + e)
if len(state.storage) > 0:
print(bcolors.OKGREEN + 'STORAGE' + bcolors.ENDC)
for k in state.storage:
print(' '.ljust(tab - 4) + '┌── ' + k + ' ──┐')
print(' '.ljust(tab - 4) + '└─> ' + state.storage[k] + ' <─┘')
pc = input('> ')
if pc == 'q':
break

print('[+] Transaction status: ' + str(w3.eth.waitForTransactionReceipt(tx_hash_str)['status']))

Exploit

所以说这道题的问题就在与未初始化的Storage指针,导致我们可以在对应的safeboxes数组和FailedAttempt数组的push操作执行之前对stroage进行一番操作。

但是仅仅对slot[0] slot[1] slot[2]进行修改是没有什么作用的,因为push上去的永远都是本次操作所期望的值,我们必须要通过对slot[0] slot[1] slot[2]进行修改产生更加大的影响。

这一道题非常巧妙的一点在于slot[2]这个位置刚好就是safeboxes数组的长度字段,我们可以通过FailedAttempt数组来将slot[2]改为一个非常大的数值,这样safeboxes数组可以索引到的位置就向后发生了延伸,和FailedAttempt数组的前几个结构体发生了重叠。

我们通过在FailedAttempt数组提前布局好伪造的safebox结构体,就可以通过safeboxes数组索引到该结构体并执行对应位置的代码。

实例分析

攻击者地址0xe2aD27f6079866683a6eD2dF8D7bEa81FA6B19a0

  1. Calculate target = keccak256(keccak256(msg.sender||3)) + 2
    计算.FailedAttempt 起始地址,+2是发生重叠的地址
    0x8e40412da029db6c7a0bad33ca5eeb9582fc94c26e3877c561efdc9c03dae65c + 2
    0x8e40412da029db6c7a0bad33ca5eeb9582fc94c26e3877c561efdc9c03dae65e
  2. Calculate base = keccak256(2).
    计算safebox起始地址
    0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace
  3. Calculate idx = (target - base) // 2.
    0x26f45c99c6c0dbc5c3aa250b8321d7f68038b0d051dbb359b3bd07b0b00fc5c8
  4. If (target - base) % 2 == 1, then idx += 2, and do step 7 twice. This happens when the triedPass of the first element of failedLogs does not overlap with the callback variable, so we choose the second element instead.
    (nop)
  5. If (msg.sender << (12*8)) < idx, then choose another player account, and restart from step 1. This happens when the overwritten length of safeboxes is not large enough to overlap with failedLogs.
    (nop)
  6. Call deposit(0x000000000000000000000000) with 1 ether.
    满足safeboxes数组有一个元素,可以进入onlyPass
  7. Call withdraw(0, 0x111111111111110000070f00).
    进入onlyPass布置callback指针
  8. Call withdraw(idx, 0x000000000000000000000000), and the SendFlag event will be emitted.
    执行callback指针

感谢

  • Retr_0
  • tkmk
  • pikachu

总结

后续希望能够阅读geth的源码深入了解EVM的许多细节。