Ethereum Storage(3)-XCTF_final 2019 Happy_DOuble_Eleven
XCTF_final 2019 Happy_DOuble_Eleven
[toc]
wiki上推荐的题目。但感觉放在初学的那一节内容得看很久,尤其是关于evm逆向的相关知识,感觉这题复现完也有点蒙蒙的。
本次复现得合约地址为0xcCaECd49e4Ea39C536291193E9301dF4d5E0A654
攻击账户0x90641D6c0691829Dd70C39EE10EA44B26ac8C5AE
前置知识
照着wiki上Ethereum Storage推荐的题目做的,但感觉梯度有点直接太高了,涉及了很多其它攻击,一共学合约就没几天哈哈哈逆向给看麻了,但还是学到了挺多。
关于重入攻击
关于随机数预测
这题中涉及到了利用区块号生成的随机数是可预测的。
参考文章1
参考文章2
wiki部分
关于msg.data
msg.data是什么,就是完整的calldata,那calldata又是什么呢,我理解就是调用函数时包含的参数函数签名等等所有的数据。
可以参考这个例子,这个函数需要5个参数,然后返回msg.data
1 |
|
然后使用以下方式调用函数
1 |
|
最后返回的msg.data是这样的,可以看到第一行是调用的函数签名,后面一次是函数需要的参数值。其实这样来看的话就是理解abi了就行了。
1 |
|
逆向分析
合约地址0x7D43878EFBF99C6B5B0eb288026B1d48588C1793
合约反编译出来有这些函数。
反汇编出来最开始的那一段是这样的。
1 |
|
0x6bc344bc payforflag(string)
因为第一次逆向合约,是对着源码一起看的,反编译代码和注释分析如下。
首先看调用payforflag的地方,这里我看了挺久的也问了一些大佬,2333evm逆向真不比re简单啊。
这里就涉及到了msg.data我们看看直接调用这个函数的完整calldata。很容易看懂,string类型是边长的,所以第一个byte32就是string的offset 0x20(函数就一个参数)。然后第二个就是string的长度0x06,最后一个就是string值了。
所以这里temp2就是offset了然后还得加上函数签名的四字节,temp3获取到string的长度,但是temp4不是从msg.data里面取值了,temp4是空闲内存指针的值
然后对于中间那三个memory的操作,首先可以看懂这篇文章,sodility文档内联汇编部分也讲了。
所以
memory[0x40:0x60] = temp4 + (temp3 + 0x1f) / 0x20 * 0x20 + 0x20;
就是在计算空闲内存指针实际指向的位置,然后放入,memory[0x40:0x60]中,memory[0x40:0x60]就类似于一个入口点的样子。计算出空闲内存指针实际位置之后,就开始从这个新地址分配内存来存放一些函数需要使用的参数临时数据。例如这里最先存储的是temp3即string长度
然后将string参数的值放到了memory[temp4 + 0x20:temp4 + 0x20 + temp3]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16else if (var0 == 0x6bc344bc) {
// Dispatch table entry for payforflag(string)
var1 = msg.value;
if (var1) { revert(memory[0x00:0x00]); }
var1 = 0x0282;
var temp2 = msg.data[0x04:0x24] + 0x04;
var temp3 = msg.data[temp2:temp2 + 0x20];
var temp4 = memory[0x40:0x60];
memory[0x40:0x60] = temp4 + (temp3 + 0x1f) / 0x20 * 0x20 + 0x20;
memory[temp4:temp4 + 0x20] = temp3;
memory[temp4 + 0x20:temp4 + 0x20 + temp3] = msg.data[temp2 + 0x20:temp2 + 0x20 + temp3];
var2 = temp4;
payforflag(var2);
stop();函数里面有几个要求
要求 msg.sender == storage[0x00](所以最开始肯定定义了address public owner这种,并且这里大概率使用了onlyOwner来修饰这个函数)
要求 msg.sender 后三12位为 0x111
要求 storage[0x06] == 0x03
要求 storage[0x05] > 0x8ac7230489e80000 (感觉这种就能判断为mapping类型并且大概率类似mapping (address => uint))
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
83
84
85
86
87
88
89
90
91
92
93function payforflag(var arg0) {
//其实就是用了onlyOwner来修饰函数,等价于语句require(msg.sender == owner)
if (msg.sender != storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff) { revert(memory[0x00:0x00]); }
//需要调用合约的账户后三位是0x111
if (msg.sender & 0x0fff != 0x0111) { revert(memory[0x00:0x00]); }
//0x06即slot7 ,说明前面还定义了一些变量
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;
//类似于keccak256(abi.encodePacked(k, p)),这里很明显是一个mapping类型变量
//读取值然后要求 level[msg.sender] == 3(level是变量名这里是看了源码写的,下面的变量名同)
if (storage[keccak256(memory[0x00:0x40])] != 0x03) { revert(memory[0x00:0x00]); }
//0x05 即 slot6 ,也是一个mapping类型
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;
//要求 mycart[msg.sender] > 10000000000000000000
if (storage[keccak256(memory[0x00:0x40])] <= 0x8ac7230489e80000) { revert(memory[0x00:0x00]); }
//将storage[0x04]赋值为0x00,
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x04;
storage[keccak256(memory[0x00:0x40])] = 0x00;
//将storage[0x06]赋值为0x00,即level[msg.sender] = 0
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;
storage[keccak256(memory[0x00:0x40])] = 0x00;
//storage[0x02] 最后个字节赋值为0,这里也能看出这是一个单字节变量,这里有可能是个bool型,
//但是也可能是个变长型变量例如动态数组等,这里存储的是长度,从源码可以看到这里是对应一个bool型变量。
storage[0x02] = (storage[0x02] & ~0xff) | 0x00;
//0xff * 0x0100 ** 0x14)=0xff0000000000000000000000000000000000000000
//算得的结果是21字节,所以大概率第一个字节是个bool型,后面二十字节是一个address类型值
//所以这里就是将storage[0x00]第一个字节赋值为0x00
storage[0x00] = (storage[0x00] & ~(0xff * 0x0100 ** 0x14)) | 0x00;
var var0 = 0x00;
var var1 = 0x0eed;
var var2 = 0x01;
var var3 = var0;
func_1489(var2, var3);
var0 = 0x296b9274d26b7baffb5cc93e1af19012c35ace27ba9acf1badff99d1f76dfa69;
var temp0 = arg0;
var1 = temp0;
var temp1 = memory[0x40:0x60];
var2 = temp1;
var3 = var2;
var temp2 = var3 + 0x20;
memory[var3:var3 + 0x20] = temp2 - var3;
memory[temp2:temp2 + 0x20] = memory[var1:var1 + 0x20];
var var4 = temp2 + 0x20;
var var6 = memory[var1:var1 + 0x20];
var var5 = var1 + 0x20;
var var7 = var6;
var var8 = var4;
var var9 = var5;
var var10 = 0x00;
if (var10 >= var7) {
label_0F50:
var temp3 = var6;
var4 = temp3 + var4;
var5 = temp3 & 0x1f;
if (!var5) {
var temp4 = memory[0x40:0x60];
log(memory[temp4:temp4 + var4 - temp4], [stack[-6]]);
return;
} else {
var temp5 = var5;
var temp6 = var4 - temp5;
memory[temp6:temp6 + 0x20] = ~(0x0100 ** (0x20 - temp5) - 0x01) & memory[temp6:temp6 + 0x20];
var temp7 = memory[0x40:0x60];
log(memory[temp7:temp7 + (temp6 + 0x20) - temp7], [stack[-6]]);
return;
}
} else {
label_0F3E:
var temp8 = var10;
memory[var8 + temp8:var8 + temp8 + 0x20] = memory[var9 + temp8:var9 + temp8 + 0x20];
var10 = temp8 + 0x20;
if (var10 >= var7) { goto label_0F50; }
else { goto label_0F3E; }
}
}
0xed21248c Deposit()
调用Deposit函数倒没啥,因为没有传参,所以直接调用即可,
1 |
|
函数分析。
首先需要msg.value >= 500000000000000000000 wei 即 500eth,然后slot[0x05] + 0x01.
结合 payforflag 来看,这个操作不现实,因为 payforflag 中要求 storage[0x05] > 0x8ac7230489e80000 ,即要将 msg.value >= 500 eth 进行 0x8ac7230489e80000+1 次
1
2
3
4
5
6
7
8
9
10function Deposit() {
//要求msg.value >= 500000000000000000000
if (msg.value < 0x1b1ae4d6e2ef500000) { return; }
//slot[0x05] = slot[0x05] + 0x01
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;
var temp0 = keccak256(memory[0x00:0x40]);
storage[temp0] = storage[temp0] + 0x01;
}
0x24b04905 gift()
函数调用,没啥说的。
1 |
|
函数分析
- 要求 address(msg.sender).code.length == 0 ,即在合约 constructor 中运行即可
- 要求 msg.sender 后三个数为 0x111
- 满足上述条件后,storage[0x04] = 100 , storage[0x05] += 1 , storage[0x06] += 1
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
32function gift() {
//要求address(msg.sender).code.length == 0 就是要求在construct构造函数中使用,因为此时没有函数签名了。
var var0 = address(msg.sender).code.length;
if (var0 != 0x00) { revert(memory[0x00:0x00]); }
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;
//要求 slot[0x05] == 0
if (storage[keccak256(memory[0x00:0x40])] != 0x00) { revert(memory[0x00:0x00]); }
//要求msg.sender账户后三位是0x111
if (msg.sender & 0x0fff != 0x0111) { revert(memory[0x00:0x00]); }
//slot[0x04] = 0x64
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x04;
storage[keccak256(memory[0x00:0x40])] = 0x64;
//slot[0x05]=slot[0x05] + 1
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;
var temp0 = keccak256(memory[0x00:0x40]);
storage[temp0] = storage[temp0] + 0x01;
//slot[0x06] = slot[0x06] + 1
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;
var temp1 = keccak256(memory[0x00:0x40]);
storage[temp1] = storage[temp1] + 0x01;
}
0x23de8635 func_06CE(arg0)
这个函数逆向稍显复杂,但相应源码其实很短
1 |
|
反编译如下,分析都写在注释里面了。所以这个函数最终干了什么呢。
- 调用了两次同一个函数0xa8286aca
- 第一次调用仅仅就判断了函数的返回值取反之后是满足true还是false,如果通过if判断则会将storage[0x03]赋值为第一次调用0xa8286aca的函数参数,然后第二次调用0xa8286aca函数,并将结果赋值给storage[0x02].
而在大佬wp里面明显看出来了更多东西。这里有个疑问,是如何确定两次调用函数返回结果不一样的。
总体来看,这里调用了 0xa8286aca 两次,输入同样的参数 arg0 一次, 0xa8286aca 第一次和第二次返回的结果不一样,但是一个 function 当它的参数确定时,他的返回结果也应该是确定的,而不会两次不一样,所以 0xa8286aca 这里应该是一个接口函数,我们是可以改写的,最后改变了 storage[0x02] 的值
1 |
|
0x9189fec1 guess(uint256)
- 首先要求 arg0 == block.blockHash(block.number - 0x01) % 3 ,这个很容易满足,因为利用区块号生成的随机数是可预测的
- 满足要求后,
storage[0x00] = (storage[0x00] & ~(0xff * 0x0100 ** 0x14)) | 0x0100 ** 0x14
,即 storage[0x00] 的address地址的前一个字节设为1,其实就是bool和address放在一个slot下的情况,这里只改了那个bool值。1
2
3
4
5
6
7
8
9
10
11
12
13
14function guess(var arg0) {
var var1 = 0x00;
var var0 = block.blockHash(block.number - 0x01);
var var2 = 0x03;
var var3 = var0;
if (!var2) { assert(); }
var1 = var3 % var2;
if (var1 != arg0) { return; }
storage[0x00] = (storage[0x00] & ~(0xff * 0x0100 ** 0x14)) | 0x0100 ** 0x14;
}0xa6f2ae3a buy()
函数分析,这个函数较容易分析。要求 storage[0x06] == 1 ,这些调用 gift() 空投可以完成
要求 storage[0x05] == 1 ,这些调用 gift() 空投可以完成
要求 storage[0x02] == 1 ,结合 func_06CE 来看,只需使得 0xa8286aca 第二次调用返回 1 即可
要求 storage[0x00] / 0x0100 ** 0x14 & 0xff == 1 , 要求storage[0x00]的从低位数第21字节 == 0x01 ,这个满足 guess 即可
满足上述要求后,storage[0x05] += 1 ,storage[0x06] += 1
1 |
|
0x47f57b32 retract()
要求 storage[0x01] == 0 (这里的storage[0x01]就是codex.length)
要求 storage[0x05] == 0x02 ,调用 gift 后,再调用 buy 即可
要求 storage[0x06] == 0x02 ,调用 gift 后,再调用 buy 即可
要求 storage[0x00] / 0x0100 ** 0x14 & 0xff == 0x01 ,即 storage[0x00] 的高 96 位数值要求为 1 其实就是从低位开始第21字节前二十字节应该是一个address类型变量,这个满足 guess 即可
满足上述要求之后,storage[0x01] -= 0x1 ,这里应该是修改数组的长度
1 |
|
0x0339f300 revise(uint256,bytes32)
剩下得几个函数分析和上面的差不多。
这个函数有一个最主要的漏洞就是能够覆盖任意的storage值,payforflag那个函数是我们最终需要调用的,但那个函数要求msg.sender == storage[0x00]
,所以这里就能利用这个函数覆盖掉storage[0x00]为msg.sender。
要求 storage[0x05] == 0x02 ,
要求 storage[0x06] == 0x02 ,
要求 storage[0x00] / 0x0100 ** 0x14 & 0xff == 0x01 ,即 storage[0x00] 的高 96 位数值要求为 1 ,这个满足 guess 即可
要求 arg0 >= storage[0x01]
满足上述要求后,后面进行了 storage 写操作,这里是任意写操作,可以看代码的注释,因为写入storage的位置可控了
然后判断 storage[0x01] >= 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000 ,经过 retract() 后即可满足,如果不满足则storage[0x06] = storage[0x06] + 0x01;
1 |
|
0xa9059cbb transfer(address,uint256)
这里是进行 storage[0x04] 之间的转账操作,一般这种转账涉及变量就类似 mapping (address => uint) public balanceOf;
这种
1 |
|
func_126F中是对于转账两账户之间的余额操作,其实就是一个账户余额加上value,另一个账户减去value。
1 |
|
0x2e1a7d4d withdraw(uint256)
要求 storage[0x05] == 0x02
要求 storage[0x06] == 0x03
要求退款每次 >= 100
要求 storage[0x04] < arg0,即余额比每次退款要多 即对应 require(balanceOf[msg.sender] >= _amount);
要求合约余额比退款要多 即require(address(this).balance >= _amount);
满足条件后,storage[0x04] -= arg0 ,然后调用 call 函数进行转账(这里存在重入攻击,因为没有对 gas 做控制),最后 storage[0x05] -= 0x01
1 |
|
链子
分析完函数,太多信息了。。无法连起来看,这里总结一下。
payforflag函数是我们最终需要调用的函数,但是有三个条件
- 一个账户尾数需要0x111,这个有相应网站直接生成
- 要求 storage[0x06] == 0x03 ,可以依次调用gift(),buy(),revise()即可
- 要求 storage[0x05] > 0x8ac7230489e80000 这个可以重入攻击
- 要求 msg.sender == storaget[0x00] 即onlyowner修饰 这个可以覆盖storage[0x00]为msg.sender
gift函数
- 要求 address(msg.sender).code.length == 0,即需要在合约的constructor中调用。 所以说无法重复调用,不然只用这个函数就能随意storage[0x05],storage[0x06]的值。
- 要求 msg.sender 后三个数为 0x111 这个也可以直接生成相应账户
- 满足上述条件后,storage[0x04] = 100 , storage[0x05] += 1 , storage[0x06] += 1
guess函数
- 首先要求 arg0 == block.blockHash(block.number - 0x01) % 3 ,这个很容易满足,因为利用区块号生成的随机数是可预测的
- 满足要求后,将storage[0x00] 的address地址的前一个字节设为1,其实就是bool和address放在一个slot下的情况,这里只改了那个bool值。
buy函数
- 要求 storage[0x06] == 1 ,这些在攻击合约构造函数中调用 gift() 空投可以完成
- 要求 storage[0x05] == 1 ,这里同上。
- 要求 storage[0x02] == 1 ,结合 func_06CE 来看,只需使得 0xa8286aca 第二次调用返回 1 即可
- 要求storage[0x00]的从低位数第21字节 == 0x01(前二十即为一个address),这个同guess函数要求
满足上述要求后,storage[0x05] += 1 ,storage[0x06] += 1
revise函数
存在漏洞可以覆盖任意storage的值。
- 要求 storage[0x05] == 0x02 ,
- 要求 storage[0x06] == 0x02 ,
- 要求 storage[0x00] / 0x0100 ** 0x14 & 0xff == 0x01 ,即 storage[0x00] 的高 96 位数值要求为 1 ,这个满足 guess 即可
- 要求 arg0 >= storage[0x01]
满足上述要求后,后面进行了 storage 写操作,这里是任意写操作所以可以覆盖任意storage的值,所以就解决了payforflag的一个要求,可以将storage[0x00]修改为 msg.sender
func_06CE(arg0)函数
- 调用了两次同一个函数0xa8286aca
- 第一次调用仅仅就判断了函数的返回值取反之后是满足true还是false,如果通过if判断则会进行第二次调用0xa8286aca函数,并将结果赋值给storage[0x02].
这里解答一下上面自己的疑问,就是wp里面说到两次返回结果不一样并不是一定不一样的,这是我们自己根据其它函数和攻击链子需要判断出来的这里两次调用函数返回结果需要不一样。首先第一次需要让它返回false,取反之后就是true,然后第二次需要返回true,然后storage[0x02] = true,才能满足调用buy函数的要求。
所以最终链子就是wp里的
生成符合要求的外部账户,在 constructor 中调用 gift()
调用 0x23de8635 func_06CE ,这里要利用 bytecode 的方式部署,因为我们不知道 func_06CE 中调用的接口函数 0xa8286aca 的函数名,所以利用 bytecode 的方式部署第三方合约,将 fake(uint256) 对应的函数选择 id 改为 0xa8286aca 即可,这样调用 0xa8286aca 就是调用我们重写之后的 0xa8286aca 了,用 bytecode 部署可以用在线的 myetherwallet
调用 guess() ,然后调用 buy()
调用 retract() 和 revise() 修改 owner
部署第三方子合约,第三方子合约调用 gift() 和 transfer() 给攻击合约转账,然后调用 withdraw() 进行重入攻击
最后调用 payforflag 即可
攻击
首先生成一个符合要求的用户。
部署第三方合约,在构造函数中调用gift函数
然后利用bytecode部署第三方合约,将bytecode中的fake(uint256)
函数签名0xc7375737改为0xa8286aca
1 |
|
部署完可以看到合约地址最后十二位为0x111,并且按照上述分析,部署完第三方合约在构造函数会调用gift所以此时storage[0x04]应该为100,storage[0x05]和storage[0x06]都为1
利用如下合约验证
1 |
|
step1调用到重写的接口函数
在上述分析可以知道,接口函数重写需要使两次返回结果不一样,我们让第二次返回true,所以sotrage[0x02]=true
step2调用guess和buy函数
可以看到调用完,storage[0x00]相比调用前高位字节多了个0x01,即一个bool型变量被设置成了true。
而在buy函数中会使storage[0x05]和storage[0x06]都+1
step3调用retract函数
在retract函数中会进行storage[0x01]-1,而在上面可以看到storage[0x01]为0,所以会下溢出。
此时storage[0x01]为0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
step4调用 retract() 和 revise() 修改 owner
调用完storage[0x00]已经被修改
部署第三方子合约,第三方子合约调用 gift() 和 transfer() 给攻击合约转账
1 |
|
转账一次+100,所以变成了0xc8即200
step5调用withdraw进行重入攻击
在这一步时我发现我在本地部署题目合约的时候没给合约里面转eth导致合约的balance为0。。。所以导致第三方合约无法调用withdraw函数。。最终换了个合约地址继续做题0x168892cb672A747F193eb4acA7b964bfb0aA6476,攻击合约为0x6959f5E401E881A35563599397E0345860742111
这里需要注意就是最开始storage[0x05]为0x02,而我们需要让它减4之后才能下溢出,所以需要将攻击账户的balance增加到400,所以总共需要部署第三方转账合约转三次,一次+100。因为我们调用witudraw进行重入攻击会改变storage[0x05],所以只有一次机会,不然得重新来一遍。
转账给攻击合约一直到balance为400
进行重入攻击
此时storage[0x05]下溢出,就能满足调用payforflag得条件了
调用payforflag
总结
这题前前后后做了很久很久,涉及到的知识太多了,虽然在wiki里面把这题推荐放在了介绍Ethereum Storage那一张,也是考察到了相关覆盖Storage的知识,但其它的考点也非常多,融合在一起对于入门一个月的新手讲确实很难了,而且还是个xctf决赛的题。但不得不说还是学到很多。对于重入攻击,溢出,随机数预测,以及eth下的区块链攻击模式有了更深的理解、
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!