Fork me on GitHub
pikachu's Blog

Balsn CTF 2019 - Bank

前言

  • 复现 balsn2019 ctf 中的 bank 区块链题目
  • wtcl ,复现了一个下午
  • 具体分析及官方 WP 如下: https://x9453.github.io/2020/01/16/Balsn-CTF-2019-Bank/
  • 复现地址为: ropsten@0x85B0446Dc5B5f32cbB674Dc8e49Fc27Ebaff2Ee2
  • 外部账户地址为: 0x785a8D0d84ad29c96f8e1F26BfDb3E6CB72cAe9b

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;
uint randomNumber = 0;

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

struct SafeBox {
bool done;
function(uint, bytes12) internal callback;
bytes12 hash;
uint value;
}
SafeBox[] safeboxes;

struct FailedAttempt {
uint idx;
uint time;
bytes12 triedPass;
address origin;
}
mapping(address => FailedAttempt[]) failedLogs;

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);
}

}

Analyse

  • 合约创建后, slot 布局如下
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 布局如下,在代码 33-36 行存在未初始化漏洞,会导致覆盖原先 slot0slot2 的位置内容
1
2
3
4
5
6
7
-----------------------------------------------------
| idx (32) |
-----------------------------------------------------
| time (32) |
-----------------------------------------------------
| origin (20) | triedPass (12) |
-----------------------------------------------------
  • 同样, SafeBox 也是类似分析,会在 deposit() 中覆盖 slot0slot1
1
2
3
4
5
-----------------------------------------------------
| unused (11) | hash (12) | callback (8) | done (1) |
-----------------------------------------------------
| value (32) |
-----------------------------------------------------
  • 可以看到,如果通过 deposit 修改 slot0slot1 是没用的,即使修改了 owner 也没用,因为还有第 74 行的限制
  • 其实题目考查的是结构体未初始化漏洞+数组存储方式+mapping存储方式+控制程序执行流
  • 因为 33-36 行中如果 origin 足够大,相当于修改了 safeboxes 数组的长度,让数组的长度足够大,如果大到可以修改或者覆盖 failedLogs,那么就可以通过 safeboxes 数组去访问 failedLogs,其实也相当于 failedLogs 是数组 safeboxes 的内容,所以这就对 tx.origin 有一定的要求
  • 假设 safeboxes 可以包含 failedLogs,同时 FailedAttempt 中的 triedPass 可以覆盖 Safeboxcallback : 因为 triedPass 我们可以完全控制,所以我们就可以通过 triedPass 控制 callback ,进一步控制程序执行流(第64行)
  • 如果能够控制程序执行流的话,那么我们只需要找到第 75emit SendFlag(msg.sender);的位置就行了,这个可以通过查看 bytecodes 对应的 opcode 找到,如下图,我们先找到 require(msg.value >= 100000000 ether); 对应的位置,然后再找 emit SendFlag(msg.sender); 对应的位置,因为在EVM中调用一个函数相当于执行 jump 操作,而跳到的地方都是以 jumpdest 开始,所以 0x070f 就是我们想要跳到的位置,这样的话就可以触发 SendFlag 事件了

Solution

  1. 计算 target = keccak256(keccak256(msg.sender||3)) + 2 ,这里 target 就是 FailedAttempt[0] 中的 origin(20) | triedPass(12)
  2. 计算 base = keccak256(2) ,这里 base 就是 safeboxes 数组第一个元素在 slot 的位置
  3. 计算 idx = (target - base) // 2 , 这里 idx 指的是在 safeboxes 数组中的索引,因为一个 Safebox 占据两个 slot ,所以要除以 2
  4. 计算 (target - base) % 2 ,如果等于 0 ,说明 origin(20) | triedPass(12) 刚好可以覆盖 unused (11) | hash (12) | callback (8) | done (1)
  5. 计算 (msg.sender << (12*8)) ,如果 < idx ,说明 safeboxes 数组长度合适,可以包含 failedLogs ; 否则的话从步骤1重新开始
  6. 调用 deposit(0x000000000000000000000000) 并设置 msg.value = 1 ether : 这里是让 callback 指向 sendEther , 目的是下一步调用控制 safeboxes 数组长度
  7. 调用 withdraw(0, 0x111111111111110000070f00) : 这里相当于调用 sendEther(0, 0x111111111111110000070f00) ,将 failedLogs[msg.sender] 中的 triedPass 控制,相当于控制了 unused (11) | hash (12) | callback (8) | done (1) 所在 slot 的低 12 字节,修改了 callback ,其实相当于把 failedLogs[msg.sender] 对应的内容看成是 safeboxes 数组的内容,同时修改了数组长度
  8. 调用 withdraw(idx, 0x000000000000000000000000) : 这里就可以执行到 emit SendFlag(msg.sender); 撒花🎉🎉🎉🎉🎉🎉🎉🎉

题外知识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract C {
address a;
uint r;
uint[] b;
mapping(uint => uint) m;

constructor() public {
a = msg.sender;
r = 777;
b.push(333);
b.push(444);
m[999] = 888;
}
}
// b第一个元素位置在keccak256(2),即slot(keccak256(2)+0)存储333,slot(keccak256(2)+1)存储444
// m[k]存储在slot(keccak256(k||3)),即slot(keccak256(999||3))存储888
  • 可通过下列函数分别获取 slot 内容、mapping 内容对应 slot 、数组第一个元素对应 slot
1
2
3
4
5
6
7
8
9
10
11
function read_slot(uint k) public view returns (bytes32 res) {
assembly { res := sload(k) }
}

function cal_addr(uint k, uint p) public pure returns(bytes32 res) {
res = keccak256(abi.encodePacked(k, p));
}

function cal_addr(uint p) public pure returns(bytes32 res) {
res = keccak256(abi.encodePacked(p));
}
---------------- The End ----------------
谢谢大爷~

Author:pikachu
Link:https://hitcxy.com/2020/balsn2019-bank/
Contact:hitcxy.cn@gmail.com
本文基于 知识共享署名-相同方式共享 4.0 国际许可协议发布
转载请注明出处,谢谢!