首页
社区
课程
招聘
[原创][EVM虚拟机]区块链智能合约逆向-合约创建-调用x执行流程分析
2023-12-14 18:48 7338

[原创][EVM虚拟机]区块链智能合约逆向-合约创建-调用x执行流程分析

2023-12-14 18:48
7338

智能合约动态调试与调试器分析

目录

1.工具&链接

http://remix.zhiguxingtu.com/
https://ethereum.org/zh/developers/docs/evm/opcodes/
https://ethervm.io/decompile#google_vignette

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攻击分析

首先依旧是Non Payable Check因为没有参数,所以没有Arguments Copy/Check注意Arguments Copy/Check是合约创建时的操作,发起交易时的参数不会通过CODECOPY来加载,而是通过CallData进行传递和加载

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-JUMPI46-JUMP,此两处跳转分别对应两个处理函数

receive&fallback

CallData为空时,就意味着本次交易连最基本的目标函数都没有被指定,那么进入后面的签名匹配流程也就毫无意义,熟悉合约开发的话就知道,在合约中存在receivefallback两个特殊的函数,我认为将其叫做回调函数并不太恰当,因为它们的每一次调用实际上如其它函数一般,都是“指名道姓”的,只是它们并不存在签名,而作为上述指令段中的存在

即便我们没有为这两个函数声明,在上述指令段中依旧存在第一处跳转,而跳转目的指令是这样的

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

合约来源于:https://ethernaut.openzeppelin.com/

4.写在最后

区块链萌新,关于本文中所列指令内容,我也依然有很多不理解的地方,如有错误,欢迎指出,感谢


[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界

最后于 2023-12-15 11:28 被LeaMov编辑 ,原因: 修改标题
收藏
点赞7
打赏
分享
最新回复 (1)
雪    币: 3632
活跃值: (3798)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
caolinkai 2024-1-17 14:15
2
0
感谢分享。
游客
登录 | 注册 方可回帖
返回