智能合约动态调试与调试器分析(调试器开发前学习)
1.工具&链接
2.Thought
先把合约编译部署了一下,然后看了一下remix上的调试器,和我想的一样,所谓合约调试并不是真正的断点调试,而是一次模拟,EVM并没有提供类似系统异常中断的功能,本身并不支持调试,所有的交易都在一瞬间完成
而调试器的原理(猜测)是根据交易Hash所查询到的数据,例如发起者,调用参数,调用函数等信息,再找到对应的合约bin,归结所有程序流程,然后从opcode开始模拟一次执行流程
3.调试分析&静态分析
编译部署完,然后拿着Deletegation
合约部署的交易Hash丢到调试器里,先从合约创建开始分析
可以看到调试器直接跳过了我第一次做智能合约EVM流程实现分析的那部分,[[1.合约安全&漏洞审计#3.2 Delegation静态分析]],而是直接来到了用户代码处
在 编译器->编译详情 中可以看到 函数签名、汇编 等详细信息,功能还是很全面的
补充:关于上面直接跳到用户代码处,可以往回拖动,直接回到0开始,那就从0到构造函数结束进行一次分析吧
3.1函数创建分析
3.1.1 Non Payable Check
1
2
3
|
000
PUSH1
80
002
PUSH1
40
004
MSTORE ;MSTORE(
40h
,
80h
)
|
在04
执行前,调试器中显示的Memory为 No data available ,但EVM实际执行过程中在此处是否真为空暂时无从得知
关于MSTORE
在以太坊官网的虚拟机操作码说明书中有如下定义
操作码 |
指令 |
Gas |
起始堆栈 |
最终堆栈 |
内存&存储 |
描述 |
52 |
MSTORE |
3 |
ost, val |
. |
mem[ost:ost+32] := val |
write a word to memory |
在执行完该指令后调试器所展示的memory和stack如下所示 |
|
|
|
|
|
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
Stack:
No data available
Memory:
0x0
:
00000000000000000000000000000000
0x10
:
00000000000000000000000000000000
0x20
:
00000000000000000000000000000000
0x30
:
00000000000000000000000000000000
0x40
:
00000000000000000000000000000000
0x50
:
00000000000000000000000000000080
|
根据指令判断所传参数应为 (ost=40h,val=80h)
,而80h
最终被写到了50h
的位置,这与说明书中的操作不符,我暂时不知道是调试器的错误还是我的理解错误。使用官方的Remix-ide
进行同样的指令调试,最终看到的Memory
和推测的一样,80h
应该存储在了0x40
处才正确
下面看下一段指令
1
2
3
4
5
6
7
8
|
005
CALLVALUE ;将msg.value压栈
006
DUP1 ;拷贝一份栈顶
007
ISZERO ;弹出当前栈顶,判断是否为
0
,结果再次压栈
008
PUSH2
0010
;
10h
压栈
011
JUMPI ;
if
(栈顶
=
=
1
){jmp
10h
}
012
PUSH1
00
014
DUP1
015
REVERT
|
此处正在进行合约创建的第二步骤,判断msg.value
是否为0,若为0则正常跳转,反之撤销本次交易,以下是构造函数定义
constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
,此时我给构造函数添加payable
关键字修饰,再进行编译,后查看此处汇编指令,此时发现,与预想中的不同,并非将ISZERO
进行NOT
取反,而是直接去除了该判断跳转,于是我进行了一次不转账的合约部署,发现成功了,我之前并不知道合约的构造函数加了payable
修饰是不强制转账的,现在知道了
3.1.2 Arguments Copy
现在看下一指令段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
016
JUMPDEST ;跳转点
017
POP ;弹出上一操作中的遗留数据(不理解为何上一步中专门DUP拷贝一份,但实际上只用一次,此处还需要弹出)
018
PUSH1
40
020
MLOAD ;加载Memory
+
0x40
所存数据并压栈,既上一步中写入的
80h
021
PUSH2
033e
024
CODESIZE ;获得执行合约代码的长度,并压栈
025
SUB ;弹值后计算得到 codesize
-
33eh
026
DUP1 ;计算结果拷贝
027
PUSH2
033e
030
DUP4 ;Memory
+
0x40
值拷贝
031
CODECOPY ;指令拷贝到Memory
-
>CODECOPY(
0x80
,
33eh
,subResult)
032
DUP2 ;拷贝Memory
+
0x40
值
033
DUP2 ;拷贝SUB长度计算结果
034
ADD ;两值相加
035
PUSH1
40
037
MSTORE ;结果存回Memory
+
0x40
038
DUP2 ;原Memory
+
0x40
值拷贝【
80h
】
039
ADD ;再与Sub长度计算结果相加
040
SWAP1
041
PUSH2
0032
044
SWAP2
045
SWAP1
046
PUSH2
00ce
049
JUMP ;调用了某个函数
|
到此,对上面这段指令作一下分析,主要做了一个操作,那就是CODECOPY(80h,33eh,20h)
,操作完成后,最终栈剩余数据 80h、80h+20h(拷贝数据长度)、32h(push)
,随后便进入了下一个函数中
3.1.3 Arguments Check
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
|
284
JUMPDEST
285
PUSH1
00
287
PUSH1
20
289
DUP3 ;push
80h
290
DUP5 ;push a0h
291
SUB ;pop
-
>pop
-
>push a0h
-
80h
292
SLT ;pop
-
>pop
-
>push stack[
0
] < stack[
1
](有符号)
293
ISZERO ;TRUE
294
PUSH2
0132
297
JUMPI
306
JUMPDEST
307
PUSH1
00
309
PUSH2
0140
312
DUP5 ;push a0h
313
DUP3 ;push
0
314
DUP6 ;push
80h
315
ADD ;pop
-
>pop
-
>push
80h
+
0
316
PUSH2
0107
319
JUMP
263
JUMPDEST
264
PUSH1
00
266
DUP2 ;push
80h
267
MLOAD ;push memory[
80h
]
268
SWAP1
269
POP
270
PUSH2
0116
273
DUP2 ;push memory[
80h
]
274
PUSH2
00f0
277
JUMP
240
JUMPDEST
241
PUSH2
00f9
244
DUP2 ;push memory[
80h
]
245
PUSH2
00de
248
JUMP
222
JUMPDEST
223
PUSH1
00
225
PUSH2
00e9
228
DUP3 ;push memory[
80h
]
229
PUSH2
00be
232
JUMP
190
JUMPDEST
191
PUSH1
00
193
PUSH20 ffffffffffffffffffffffffffffffffffffffff
214
DUP3 ;push memory[
80h
]
215
AND ;将memory[
80h
]的值保留
20
字节
216
SWAP1
217
POP
218
SWAP2
219
SWAP1
220
POP
221
JUMP
|
截止到221-JUMP
指令前,我们先看一下此时的栈情况
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
|
0
:
0x00000000000000000000000000000000000000000000000000000000000000e9
1
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
2
:
0x0000000000000000000000000000000000000000000000000000000000000000
3
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
4
:
0x00000000000000000000000000000000000000000000000000000000000000f9
5
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
6
:
0x0000000000000000000000000000000000000000000000000000000000000116
7
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
8
:
0x0000000000000000000000000000000000000000000000000000000000000080
9
:
0x00000000000000000000000000000000000000000000000000000000000000a0
10
:
0x0000000000000000000000000000000000000000000000000000000000000140
11
:
0x0000000000000000000000000000000000000000000000000000000000000000
12
:
0x0000000000000000000000000000000000000000000000000000000000000000
13
:
0x0000000000000000000000000000000000000000000000000000000000000080
14
:
0x00000000000000000000000000000000000000000000000000000000000000a0
15
:
0x0000000000000000000000000000000000000000000000000000000000000032
|
此时我们还不知道这些数据有何作用,继续往后跟,并贴上此类每段的栈
1
2
3
4
5
6
7
|
233
JUMPDEST
234
SWAP1
235
POP
236
SWAP2
237
SWAP1
238
POP
239
JUMP
|
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
|
0
:
0x00000000000000000000000000000000000000000000000000000000000000f9
1
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
2
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
3
:
0x0000000000000000000000000000000000000000000000000000000000000116
4
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
5
:
0x0000000000000000000000000000000000000000000000000000000000000080
6
:
0x00000000000000000000000000000000000000000000000000000000000000a0
7
:
0x0000000000000000000000000000000000000000000000000000000000000140
8
:
0x0000000000000000000000000000000000000000000000000000000000000000
9
:
0x0000000000000000000000000000000000000000000000000000000000000000
10
:
0x0000000000000000000000000000000000000000000000000000000000000080
11
:
0x00000000000000000000000000000000000000000000000000000000000000a0
12
:
0x0000000000000000000000000000000000000000000000000000000000000032
|
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
|
249
JUMPDEST
250
DUP2 ;push Memory[
80h
]
251
EQ
252
PUSH2
0104
255
JUMPI
256
PUSH1
00
258
DUP1
259
REVERT
260
JUMPDEST
261
POP
262
JUMP
278
JUMPDEST
279
SWAP3
280
SWAP2
281
POP
282
POP
283
JUMP
320
JUMPDEST
321
SWAP2
322
POP
323
POP
324
SWAP3
325
SWAP2
326
POP
327
POP
328
JUMP
|
至此,此段就到此结束,截止到328-JUMP
的栈状态如下
1
2
3
4
|
0
:
0x0000000000000000000000000000000000000000000000000000000000000032
1
:
0x000000000000000000000000d9145cce52d386f254917e481eb44e9943f39138
|
而后便正式进入了构造函数中,而上述一大串指令中,仅做了一件事,就是对Delegation
合约的构造函数参数进行了检查,最终stack
中仅剩下参数值
构造函数的内容并没有什么值得过多提起的,但有一点,在所有针对地址的操作中,例如Delegation
合约的构造函数仅将一个地址参数赋值给成员,但依旧对其作了&0xFF(x20)
保留20bytes
的操作,注意,是所有针对地址的操作
与上述 参数检查 中的不同是,并不会对 &
前后结果作对比,也就是不论结果如何,并不会因此而撤销交易
个人感官而言上述如 参数检查 的部分操作是相当繁琐的,或许设计中还有其它功能,但我只看出来了参数检查一个,且仅有一个地址参数的情况下,但可以看到作了非常多次跳转,对参数值也进行了多次拷贝,可是实际参与计算貌似就两次?AND -> EQ
,随后就进行多次 栈交换,清理栈空间最终进入构造函数
3.2 DelegateCall攻击分析
3.2.1 CallData Prepared&Compared()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
016
JUMPDEST
017
POP
018
PUSH1
04
020
CALLDATASIZE ;push
len
(msg.data)
021
LT ;push
len
(msg.data) <
4
022
PUSH2
002f
025
JUMPI ;
if
()jmp
2fh
026
PUSH1
00
028
CALLDATALOAD ;push calldata[
0
]
029
PUSH1 e0
031
SHR ;stack[
0
]
=
stack[
0
] >> (
256
-
32
),位移后获函数签名
032
DUP1 ;push stack[
0
]
033
PUSH4
8da5cb5b
038
EQ
039
PUSH2
00c3
042
JUMPI
043
PUSH2
0030
046
JUMP
|
在EVM中,函数并不像其它语言中存在一个既定的内存地址,用户发起交易并不是靠一个随机的地址来跳转,而是靠一个唯一的签名来判断,该签名为 函数声明 的哈希值
由于当前合约除fallback
之外,对外public
的成员仅有一个owner
,所以上述指令段较短,但当合约中存在多个public
成员时,该指令段便会一一对应存在多个签名匹配
形如SwitchCase
,但却没有SwitchCase
的效率,是纯if-elseif-else
请注意25-JUMPI
和46-JUMP
,此两处跳转分别对应两个处理函数
receive&fallback
当CallData
为空时,就意味着本次交易连最基本的目标函数都没有被指定,那么进入后面的签名匹配流程也就毫无意义,熟悉合约开发的话就知道,在合约中存在receive
和fallback
两个特殊的函数,我认为将其叫做回调函数并不太恰当,因为它们的每一次调用实际上如其它函数一般,都是“指名道姓”的,只是它们并不存在签名,而作为上述指令段中的头
和尾
存在
即便我们没有为这两个函数声明,在上述指令段中依旧存在第一处跳转,而跳转目的指令是这样的
1
2
3
|
041
PUSH1
043
DUP1
044
REVERT
|
而最后一处跳转被替换成了
1
2
3
|
041
PUSH1
043
DUP1
044
REVERT
|
有合约开发经验的话应该知道,当未指定交易函数,且特定情况下不存在fallback
或者receive
的话,交易是会被撤销的,原因在此
根据官方文档可以知道这两个函数被调用的条件,receive
是当calldata
不存在的时候被调用的,fallback
则是殿后的那位,所以当receive
不存在时,匹配签名前的第一个跳转将直接跳转到fallback
的入口点前,从而进入fallback
让我们回到程序,当calldata
存在且无法匹配到函数签名,最终进入fallback
,下面瞅指令
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
|
049
PUSH1
00
051
PUSH1
01
053
PUSH1
00
055
SWAP1
056
SLOAD ;push storage[
0
]
057
SWAP1
058
PUSH2
0100
057
SWAP1
058
PUSH2
0100
061
EXP
062
SWAP1
063
DIV
064
PUSH20 ffffffffffffffffffffffffffffffffffffffff
085
AND
086
PUSH20 ffffffffffffffffffffffffffffffffffffffff
107
AND ;对Delegate合约地址进行了两次保留
20
字节的与操作,意义不明
108
PUSH1
00
110
CALLDATASIZE
111
PUSH1
40
113
MLOAD ;加载预留指针
80h
114
PUSH2
007c
117
SWAP3
118
SWAP2
119
SWAP1
120
PUSH2
0139
123
JUMP
|
至此,看一下栈状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
0
:
0x0000000000000000000000000000000000000000000000000000000000000080
1
:
0x0000000000000000000000000000000000000000000000000000000000000004
2
:
0x0000000000000000000000000000000000000000000000000000000000000000
3
:
0x000000000000000000000000000000000000000000000000000000000000007c
4
:
0x000000000000000000000000358aa13c52544eccef6b0add0f801012adad5ee3
5
:
0x0000000000000000000000000000000000000000000000000000000000000000
6
:
0x00000000000000000000000000000000000000000000000000000000dd365b8b
|
我们已知的数据分别有 预留指针、calldataSize、函数签名
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
|
313
JUMPDEST
314
PUSH1
00
316
PUSH2
0146
319
DUP3
320
DUP5
321
DUP7
322
PUSH2
0114
325
JUMP
276
JUMPDEST
277
PUSH1
00
279
PUSH2
0120
282
DUP4
283
DUP6
284
PUSH2
016d
287
JUMP
365
JUMPDEST
366
PUSH1
00
368
DUP2
369
SWAP1
370
POP
371
SWAP3
372
SWAP2
373
POP
374
POP
375
JUMP
288
JUMPDEST
289
SWAP4
290
POP
291
PUSH2
012d
294
DUP4
295
DUP6
296
DUP5
297
PUSH2
01aa
300
JUMP
|
接着又是一样的,进行了一手操作,大概是在准备参数,贴一下栈
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
|
0
:
0x0000000000000000000000000000000000000000000000000000000000000000
1
:
0x0000000000000000000000000000000000000000000000000000000000000080
2
:
0x0000000000000000000000000000000000000000000000000000000000000004
3
:
0x000000000000000000000000000000000000000000000000000000000000012d
4
:
0x0000000000000000000000000000000000000000000000000000000000000000
5
:
0x0000000000000000000000000000000000000000000000000000000000000000
6
:
0x0000000000000000000000000000000000000000000000000000000000000004
7
:
0x0000000000000000000000000000000000000000000000000000000000000080
8
:
0x0000000000000000000000000000000000000000000000000000000000000146
9
:
0x0000000000000000000000000000000000000000000000000000000000000000
10
:
0x0000000000000000000000000000000000000000000000000000000000000080
11
:
0x0000000000000000000000000000000000000000000000000000000000000004
12
:
0x0000000000000000000000000000000000000000000000000000000000000000
13
:
0x000000000000000000000000000000000000000000000000000000000000007c
14
:
0x000000000000000000000000358aa13c52544eccef6b0add0f801012adad5ee3
15
:
0x0000000000000000000000000000000000000000000000000000000000000000
16
:
0x00000000000000000000000000000000000000000000000000000000dd365b8b
|
接下一段
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
|
426
JUMPDEST
427
DUP3
428
DUP2
429
DUP4
430
CALLDATACOPY ;CALLDATACOPY(
80
,
0
,
4
)
431
PUSH1
00
433
DUP4
434
DUP4
435
ADD ;
80h
+
4
436
MSTORE ;MSTORE(
84h
,
0
)
437
POP
438
POP
439
POP
440
JUMP
301
JUMPDEST
302
DUP3
303
DUP5
304
ADD
305
SWAP1
306
POP
307
SWAP4
308
SWAP3
309
POP
310
POP
311
POP
312
JUMP
326
JUMPDEST
327
SWAP2
328
POP
329
DUP2
330
SWAP1
331
POP
332
SWAP4
333
SWAP3
334
POP
335
POP
336
POP
337
JUMP
|
上述指令段中将calldata
的数据根据长度拷贝至了Memory
中,随后对栈进行了清理,至此,栈终于干净了
1
2
3
4
5
6
7
8
|
0
:
0x0000000000000000000000000000000000000000000000000000000000000084
1
:
0x000000000000000000000000358aa13c52544eccef6b0add0f801012adad5ee3
2
:
0x0000000000000000000000000000000000000000000000000000000000000000
3
:
0x00000000000000000000000000000000000000000000000000000000dd365b8b
|
3.2.2 DelegateCall
1
2
3
4
5
6
7
8
9
10
11
|
124
JUMPDEST
125
PUSH1
00
127
PUSH1
40
129
MLOAD ;MLOAD(
40h
)加载预留指针
130
DUP1 ;push
80h
131
DUP4 ;push
84h
132
SUB ;计算calldata长度
133
DUP2 ;push
80h
134
DUP6 ;push Delegate.address
135
GAS ;push gas
136
DELEGATECALL ;DelegateCall(gas,addr,argOst,argLen,retOst,retLen)
|
至此就正式进入了委托调用流程,参数分别如下
- 剩余Gas:2497
- 合约地址:Delegate.address
- 参数存储位置偏移:80h
- 参数长度:4
- 返回值存储位置偏移:0
- 返回值长度:84h
随后我们继续步入,首先进入到了Non Payable Check
,接着直接来到了Calldata Prepared & Compared
,最后根据我们传入的Calldata
找到pwn()
函数
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
|
135
JUMPDEST LINE
12
136
CALLER ;push msg.sender
137
PUSH1
00
139
DUP1
140
PUSH2
0100
143
EXP
144
DUP2
145
SLOAD ;SLOAD(
0
)加载Delegate的Owner成员
146
DUP2
147
PUSH20 ffffffffffffffffffffffffffffffffffffffff
168
MUL
169
NOT
170
AND
171
SWAP1
172
DUP4
173
PUSH20 ffffffffffffffffffffffffffffffffffffffff
194
AND
195
MUL
196
OR
197
SWAP1
198
SSTORE ;owner
=
msg.sender
199
POP
200
JUMP
097
JUMPDEST
098
STOP
|
在委托函数STOP
后便回到了DelegateCall
下一条指令处
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
137
SWAP2
138
POP
139
POP
140
RETURNDATASIZE
141
DUP1
142
PUSH1
00
144
DUP2
145
EQ
146
PUSH2
00b7
149
JUMPI ;返回值判断,若返回值长度为
0
,跳转
194
JUMPDEST
195
PUSH1
60
197
SWAP2
198
POP
199
JUMPDEST
200
POP
201
POP
202
SWAP1
203
POP
204
POP
205
STOP ;结束
|
可以看到在DelegateCall
之后跟合约源代码一样,就没有其它的操作,直接进入结束流程了,但是结果是,Storage[0]
的位置,被写入了此刻的msg.sender
由此可见,DelegateCall
,虽然叫做委托调用,但实际上只是引用了外部的指令,并不开辟或引用新内存,在此情况下,若被调用函数的签名可被随意操控,也就意味着攻击者可以编写任意的shellcode在你的合约中执行,篡改你的内存数据
最后把合约代码贴一下子,差点忘了
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
|
/
/
SPDX
-
License
-
Identifier: MIT
pragma solidity ^
0.8
.
0
;
contract Delegate {
address public owner;
constructor(address _owner) {
owner
=
_owner;
}
function pwn() public {
owner
=
msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
constructor(address _delegateAddress) {
delegate
=
Delegate(_delegateAddress);
owner
=
msg.sender;
}
fallback() external {
(
bool
result,)
=
address(delegate).delegatecall(msg.data);
}
}
|
合约来源于:
4.写在最后
区块链萌新,关于本文中所列指令内容,我也依然有很多不理解的地方,如有错误,欢迎指出,感谢