通过逆向和调试深入EVM #5 - EVM如何处理 if/else/for/functions

2023-01-09 17:19:45 浏览数 (2)

译文出自:登链翻译计划[1] 译者:翻译小组[2] 校对:Tiny 熊[3]

在这篇文章中,我们将讨论执行流程。像 if/for 或嵌套函数这样的语句是如何被 EVM 在汇编中处理的?

让我们来了解一下!

这是我们关于通过逆向和调试深入 EVM 的第 5 篇,在这里你可以找到之前文章和接下来文章:

  • 第 1 篇:理解汇编[4]
  • 第 2 篇:部署智能合约[5]
  • 第 3 篇:存储布局是如何工作的?[6]
  • 第 4 篇:结束/中止执行的 5 个指令[7]
  • 第 5 篇:执行流 if/else/for/函数[8]
  • 第 6 篇:完整的智能合约布局[9]
  • 第 7 篇:外部调用和合约部署[10]

1. 汇编中的 IF/ELSE

这是我们第一个关于逆向 if/else 语句的例子,在没有优化器的情况下编译它,并以x=true调用函数flow()

代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {
    uint value = 0;
    function flow(bool x) external {
        if (x) {
            value = 4;
        } else {
            value = 9;
        }
    }
}

下面是该函数的完整反汇编代码:

代码语言:javascript复制
062 JUMPDEST |0x01|stack after arguments discarded|
063 DUP1     |0x01|0x01|
064 ISZERO   |0x00|0x01|
065 PUSH1 4b |0x4b|0x00|0x01|
067 JUMPI    |0x01|
068 PUSH1 04 |0x04|0x01|
070 PUSH1 00 |0x00|0x04|0x01|
072 SSTORE   |0x01|
073 POP
074 JUMP
075 JUMPDEST
076 PUSH1 09
078 PUSH1 00
080 SSTORE
081 POP
082 JUMP

当一个函数被调用时,它的参数每次都被放在堆栈中(我们将“稍后证明”),所以在 EVM 中x=true=1(因此 false=0 ),那么堆栈在Stack(0)包含 1。

在第 63 和 64 字节的指令,堆栈被复制,ISZERO指令被调用。

备注:第 x 字节上的指令,后文简称 :指令 x.

该指令显然是在验证 Stack(0)=0,如果是,那么 1 被推入堆栈,否则 0 被推入堆栈。

由于 Stack(0)=1,那么 0 被推到堆栈中 | 0x00 | 0x01 |

之后 4b 也被推到了堆栈中。堆栈为 | 0x4b | 0x00 | 0x01 | , 然后 JUMPI 被调用

由于 Stack(1)=0,EVM 不会跳到 4b。

因此,我们可以很容易地推断出,如果堆栈中的第一个参数是 0,那么 EVM 将跳到 4b(十进制的 75),否则 EVM 将继续执行流程。

在指令 68 和 74 之间,我们已经知道发生了什么:EVM 将 4 存储在槽号 0 中。在指令 75 和 81 之间的代码相同:EVM 将 9 存储在槽号 0 中。

在这两个 "结果 "之后,EVM 都跳到了 3C,执行结束。

事实上,每次有 JUMPI 指令时,在 solidity 中都有一个对应的 IF 语句(或 WHILE/FOR)。

2. 汇编中的 ELSE IF

如果我们使用一个更复杂的 if 语句呢?这次会有更多的 "else",但汇编代码会不会更复杂呢?

(剧透:其实没有)

编译这段代码(没有优化器)和 solidity 0.8.7,用你想要的任何值调用流程。

代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {
    uint value = 0;
    function flow(uint x) external {
        if (x == 1) {
        	value = 4;
        } else if (x == 2) {
        	value = 9;
        } else if (x == 3) {
            value = 14;
        } else if (x == 4) {
            value = 19;
        } else {
            value = 24;
        }
    }
}

像往常一样,让我们来拆解这个函数 :

代码语言:javascript复制
062 JUMPDEST
063 DUP1
064 PUSH1 01
066 EQ
067 ISZERO
068 PUSH1 4e
070 JUMPI
071 PUSH1 04
073 PUSH1 00
075 SSTORE
076 POP
077 JUMP
078 JUMPDEST
079 DUP1
080 PUSH1 02
082 EQ
083 ISZERO
084 PUSH1 5e
086 JUMPI
087 PUSH1 09
089 PUSH1 00
091 SSTORE
092 POP
093 JUMP
094 JUMPDEST
095 DUP1
096 PUSH1 03
098 EQ
099 ISZERO
100 PUSH1 6e
102 JUMPI
103 PUSH1 0e
105 PUSH1 00
107 SSTORE
108 POP
109 JUMP
110 JUMPDEST
111 DUP1
112 PUSH1 04
114 EQ
115 ISZERO
116 PUSH1 7e
118 JUMPI
119 PUSH1 13
121 PUSH1 00
123 SSTORE
124 POP
125 JUMP
126 JUMPDEST
127 PUSH1 18
129 PUSH1 00
131 SSTORE
132 POP
133 JUMP

这个结构看起来与我们已经看到的东西相似。尽管相当长,但它非常简单。这只是两个不同模块的重复。

  1. 中间条件块(63-70、79-86、95-102、111-118)。

它验证 stack(0)中的值是否等于一个 if else中间语句。如果不是,它JUMP到下一个中间条件,下一个条件继续相同的处理。。如果是,它不JUMP 并执行SSTORE(模块 2)。

  1. 我们已经非常熟悉了 SSTORE, 保存值到存储槽。

通过将槽和值推入堆栈并调用SSTORE。一旦完成,EVM JUMP 到另一个位置并结束执行。

如果所有的条件都不满足(i 不等于 1 或 2 或 3 或 4),则在指令 127 和 133 之间触发 else 语句,这是最后一个SSTORE块,但没有任何条件。

事实上,整个结构非常类似于 EVM 执行开始时的函数选择器,以选择符合函数签名的代码。(我们在第一篇[11]里看到了)

总结一下这段代码,else if 语句可以翻译成 solidity 中的多个嵌套的 if,这和我们使用 else if 的结果完全一样。

代码语言:javascript复制
if (x == 1) {
	// do something
} else {
	if (x == 2) {
		// do something
	} else {
		if (x == 3) {
			// do something
		} else {
			if (x == 4) {
				// do something
			} else {
				// do something
			}
		}
	}
}

3. 汇编中的 For 循环

与其他编程语言相反,For 语句在 solidity 中没有被广泛使用。

主要原因是,需要 for 语句的功能往往需要大量的 Gas 来执行功能,这使得智能合约无法使用。这与 while 语句的情况大致相同。

下面是我们要研究的代码,编译它,部署并用x=10调用。

代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {
    uint value = 0;
    function flow(uint x) external {
        for (uint i = 0; i < x; i  ) {
            value  = i;
        }
    }
}

这次反汇编是比较难研究的,你需要更加专注 :)

代码语言:javascript复制
062 JUMPDEST
063 PUSH1 00
065 JUMPDEST
066 DUP2
067 DUP2
068 LT
069 ISZERO
070 PUSH1 6c
072 JUMPI

第 62 字节指令是函数 "flow() "的入口点

在第 62 字节的指令,值0xa在堆栈中(十进制的 10),这是 x 的值。(别忘了:在函数中参数总是在堆栈中)

代码语言:javascript复制
| 0xa0 |

在第 63 字节的指令,0 被推到堆栈中。这很可能是我们的变量 i = 0, (for 循环中的初始化) | 0x00 | 0xa0 |

在指令 65,有一条JUMPDEST指令,我们将在后面看到原因。

在指令 66 和 69 之间,x的值与i的值进行比较,(通过使用指令LT,意思是小于)。

如果小于,EVM 就跳到第 72 字节的 6c(十进制的 108),如果不是,EVM 就继续。

很明显,0xa 不小于 0x0,所以在第 73 字节继续执行。

这应该是 for 循环中的i < x:

代码语言:javascript复制
073 DUP1
074 PUSH1 00
076 DUP1
077 DUP3
078 DUP3
079 SLOAD
080 PUSH1 57
082 SWAP2
083 SWAP1
084 PUSH1 88
086 JUMP

这段代码的目的是SLOAD 0 号槽(从 0 号槽读取),并推送 57(十进制的 87)。

是的,这看起来比较复杂,如果你打开优化器,这应该简化为PUSH1 0; SLOAD; PUSH1 88

之后,代码无条件地 JUMP 到 88(十进制的 136)。

代码语言:javascript复制
136 JUMPDEST
137 PUSH1 00
139 DUP3
140 NOT
141 DUP3
142 GT
143 ISZERO
144 PUSH1 98
146 JUMPI
147 PUSH1 98
149 PUSH1 b5
151 JUMP

由于这里篇幅会很长,我不会在这里解释一切。你只需要知道,在 solidity 0.8.0 及以后的版本中,编译器会注入代码来防止我们在加法时的溢出。

例如对于 uint256 类型:2²⁵⁶- 1 是最大的可能数字,如果我在这个数字上加 1,结果将是 0,因为 2²⁵⁶ 不能放在 256 位的槽中。

这段代码的目的是测试在进行算术操作之前是否会有溢出。

  • 如果是,那么代码就会跳到 B5,然后回退。(你可以在 181 处检查反汇编)。
  • 如果不是,那么代码就会在 98 处继续执行 (十进制的 152)
代码语言:javascript复制
152 JUMPDEST
153 POP
154 ADD
155 SWAP1
156 JUMP

一旦溢出验证完成。这段代码将之前 SLOAD 槽 0 的结果加上 i(递增变量)。

之后,EVM 跳转到 57(十进制的 87),57 是在指令 80 推入到堆栈中。在下一节中你会明白为什么 57 被保存。

代码语言:javascript复制
087 JUMPDEST
088 SWAP1
089 SWAP2
090 SSTORE
091 POP
092 DUP2
093 SWAP1
094 POP
095 PUSH1 65
097 DUP2
098 PUSH1 9d
100 JUMP

这段代码存储(SSTORE)之前加法的结果到槽 0 中,并直接跳转到 9d(十进制的 157)。

代码语言:javascript复制
157 JUMPDEST
158 PUSH1 00
160 PUSH1 00
162 NOT
163 DUP3
164 EQ
165 ISZERO
166 PUSH1 ae
168 JUMPI
169 PUSH1 ae
171 PUSH1 b5
173 JUMP

这段代码与 136 和 151 之间的代码完全相同,它验证未来算术运算的结果是否处于溢出状态。

如果一切正常,它将跳转到 ae(十进制的 174)。

代码语言:javascript复制
174 JUMPDEST
175 POP
176 PUSH1 01
178 ADD
179 SWAP1
180 JUMP

它把 1 加到增量变量 i 上,然后 JUMP 到 65(十进制的 101,这是在刚才的第 90 字节处推入的。)

代码语言:javascript复制
101 JUMPDEST
102 SWAP2
103 POP
104 POP
105 PUSH1 41
107 JUMP

这段代码的目的只是为了 "清理 "堆栈并跳转到 41(十进制的的 65)。

但是你还记得 65 是什么吗?

这是循环的开始,有了这些信息,就可以还原执行流程了。

  1. 声明i = 0
  2. 测试是否i < x,如果是则直接跳到最后(8)。
  3. 加载 Slot 0 ( value变量)。
  4. 验证将 i 添加到 Slot 到value时,会不会有溢出。如果测试失败,函数回退,转到 181。
  5. 把 i 加到value,然后 SSTORE 到槽 0。
  6. 验证当 EVM 将添加 1 到 i(递增量)时,会不会有溢出,如果测试失败,函数回退,转到 181。
  7. 给 i 加 1 并返回到 第 2 步。
  8. 结束执行。

在 i<x 时循环位于 2 和 8 之间(在这个例子中,x=10)。

这是本文最长的部分,但我们已经完成了 for 循环,现在我们来谈谈函数。

4. 无参数的函数调用

这一部分是本文最重要的部分,它将帮助我们理解下一篇文章。不要跳过它。

汇编中的函数行为是什么?

以下是我们要分析的代码,在没有优化器的情况下编译它(但仍使用 solidity 0.8.7 版本)。

代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {
  uint value = 0;
  function flow() external {
		flow2();
  }

	function flow2() public {
		value = 5;
	}
}

当然,还需要对它进行反汇编:

代码语言:javascript复制
071 JUMPDEST
072 PUSH1 4d
074 PUSH1 4f
076 JUMP
077 JUMPDEST
078 JUMP

在第 72 字节指令,4d 被推送(十进制 77)。在第 74 字节指令,4f 被推入。在第 76 字节指令,EVM 跳转到 Stack(0),在我们的例子中是 4f(十进制 79)。

在第 79 字节指令,函数代码非常明显,是 flow2()函数。

代码语言:javascript复制
079 JUMPDEST
080 PUSH1 05
082 PUSH1 00
084 DUP2
085 SWAP1
086 SSTORE
087 POP
088 JUMP

它在槽 0 中存储值 5,仅此而已。

在存储值之后,在第 88 字节操作码 JUMP 被执行,但是 JUMP 去了哪里?这时 Stack(0)的值是多少?

你是否记得 4d 是在第 72 字节指令被推入的?

函数 flow2(定义在指令 79 和 88 之间,通过使用 PUSH、PUSH 和 DUP,3 个值被添加到堆栈,通过使用消耗 2 个值的 SSTORE 和 POP, 3 个值被移除。

所以在第 74 字节,其于调用 PUSH 4f 之前的堆栈与第 88 字节相同。

结果在 flow2()开始之前,Stack(0)=4d。因此在 88 字节的跳转到 Stack(0) = 4d (=十进制 77 )

solidity 中的所有函数一旦执行就会使用堆栈,并在执行后清理它。因此,堆栈在执行前后将完全相同!

我们可以注意到,在函数 flow2 结束后,EVM 在调用 flow2()的第 75 字节后的 77 字节处出现了 JUMP。为什么会出现这种情况?

在函数 flow2()结束后,函数 flow()继续。这就是为什么 4d 被 PUSH 了:以保存函数执行的状态。

由于 flow2()被嵌套在 flow()中,在执行完 flow2()后,EVM 需要继续执行 flow()的流程。为了做到这一点,在调用 flow2()之前,EVM 在堆栈中保存了 JUMP 之后的下一条指令(JUMPDEST)以恢复执行。

在 solidity 中每次函数被调用时(或其他汇编如 x86 或 ARM 也是类似)。当前函数的字节/地址被保存在堆栈中,以便在被调用的函数完成后继续执行。

如果一切正常,让我们在了解更复杂的函数,如果我们在 flow2()函数中加入参数(比如一个 uint)会怎样?

5. 带参数的函数调用

这是我们关于逆向函数调用的第二个例子,在没有优化器的情况下进行编译。

代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {
    	uint value = 0;
   	function flow() external {
		flow2(5);
    	}

	function flow2(uint y) public {
		value = y;
	}
}

让我们来反汇编一下:

代码语言:javascript复制
087 JUMPDEST
088 DUP1
089 PUSH1 00
091 DUP2
092 SWAP1
093 SSTORE
094 POP
095 POP
096 JUMP
097 JUMPDEST
098 PUSH1 69
100 PUSH1 05
102 PUSH1 57
104 JUMP
105 JUMPDEST
106 JUMP

函数 flow()的入口点开始于指令 97,就在它把 69、05 和 57 推入堆栈之后。

正如你可能猜到的那样:

69(十进制的 105)保存了函数调用后的字节位置。

05 是该函数的参数

57(十进制的 87)是函数 flow2 的地址,现在将通过 JUMP 在 104 字节指令调用。

在指令 87 和 96 之间,这是函数 "flow2",它 SSTORE 了 Stack(0)的内容,在这里是 05(提供给函数 flow2 的参数)。

在这之后是跳转,因为这是该函数的结束

反汇编几乎是完全一样的(除了函数在代码的其他区域),但唯一真正的区别是,参数 5 被推到了堆栈中。

和第一段代码一样,这个函数每次都会清理堆栈

6. 带返回值的函数调用

现在,让我们看看如果 flow2 函数不接受参数而返回一个值会发生什么。

剧透:想法是一样的,区别也是很小的。

代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {
   uint value = 0;
   function flow() external {
	   uint n = flow2();
		 value = n;
   }

	 function flow2() public returns(uint) {
		return 5;
	 }
}

完整的反汇编(函数 flow()的入口点是 90):

代码语言:javascript复制
090 PUSH1 00
092 PUSH1 61
094 PUSH1 6d
096 JUMP
097 JUMPDEST
098 SWAP1
099 POP
100 DUP1
101 PUSH1 00
103 DUP2
104 SWAP1
105 SSTORE
106 POP
107 POP
108 JUMP
109 JUMPDEST
110 PUSH1 00
112 PUSH1 05
114 SWAP1
115 POP
116 SWAP1
117 JUMP

在指令 90 和 94 之间,0x0 0x61 和 0x6d 被推到堆栈中。

然后函数 JUMP 到 6d(109 的十进制)。

在指令 109 和 117 之间的函数 flow2()把 5 推到堆栈(所有的 5 条指令都简化为 PUSH 5,优化器应该被启用,以便在代码中看到它)。

在 117 直接处的堆栈是(61 和 5)。

61,我们已经知道了其作用,但是 5 是什么?你可能猜到了。这是该函数的返回值

你可能已经注意到了,返回值也被推到了堆栈中。

在执行 flow2()之后,flow 函数仍将继续,堆栈是相同的(如前所述),但值是 5 !

7. 让我们把它放在一起

最后,这是这篇文章的最后一个例子。

如果我们通过在 flow2()函数中增加一个返回值和两个参数,把这三个例子结合起来,会怎么样?

代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {
    uint value = 0;
    function flow() external {
			  uint n = flow2(5,7);
				value = n;
    }

		function flow2(uint x,uint y) public returns(uint) {
				return x;
		}
}

让我们分析一下! (剧透:也没有那么大的区别)

代码语言:javascript复制
117 JUMPDEST
118 PUSH1 00
120 PUSH2 0083
123 PUSH1 05
125 PUSH1 07
127 PUSH2 008f
130 JUMP
131 JUMPDEST
132 SWAP1
133 POP
134 DUP1
135 PUSH1 00
137 DUP2
138 SWAP1
139 SSTORE
140 POP
141 POP
142 JUMP
143 JUMPDEST
144 PUSH1 00
146 DUP3
147 SWAP1
148 POP
149 SWAP3
150 SWAP2
151 POP
152 POP
153 JUMP

flow()函数的入口点在指令 118 字节。

83 ( 十进制 131) 是保存调用后位置,05 和 07 是参数,8f (143 十进制)是函数的地址。

在指令 143 和 153 之间,函数 flow2()删除了 y(7),因为它不需要这个值,把 x(5)放在堆栈中并返回。

函数 JUMP 到保存的字节(83,十进制的 131),函数 flow()的执行通过存储返回值 5 而继续进行。

一旦完成,它就跳到 STOP 点,执行在此结束。

关于这个函数没有什么可说的,这种行为是预期的。参数、保存的字节和返回值都存储在堆栈中,该函数已经正确完成了工作。

那么,你需要记住什么

当你在 solidity 中调用一个函数时(在汇编中)。

  1. EVM 在调用前将所有的参数推到堆栈中
  2. 该函数被执行
  3. 所有的返回值都被推送到堆栈中

8. 总结

这是系列的第 5 篇。这是这个系列中最难的部分,但这是必要的。现在我们已经对 solidity 中汇编的执行流程有了更好的理解。

接下来,我们将学习完整的 solidity 智能合约布局和它的不同部分。

原文链接:https://trustchain.medium.com/reversing-and-debugging-evm-the-execution-flow-part-5-2ffc97ef0b77

参考资料

[1]

登链翻译计划: https://github.com/lbc-team/Pioneer

[2]

翻译小组: https://learnblockchain.cn/people/412

[3]

Tiny 熊: https://learnblockchain.cn/people/15

[4]

第1篇:理解汇编: https://learnblockchain.cn/article/4913

[5]

第2篇:部署智能合约: https://learnblockchain.cn/article/4927

[6]

第3篇:存储布局是如何工作的?: https://learnblockchain.cn/article/4943

[7]

第4篇:结束/中止执行的5个指令: https://learnblockchain.cn/article/4965

[8]

第5篇:执行流 if/else/for/函数: https://learnblockchain.cn/article/4987

[9]

第6篇:完整的智能合约布局: https://medium.com/@TrustChain/reversing-and-debugging-part-6-full-smart-contract-layout-f236c3121bd1

[10]

第7篇:外部调用和合约部署: https://medium.com/@TrustChain/reversing-and-debugging-theevm-part-7-2a20a44a555e

[11]

第一篇: https://learnblockchain.cn/article/4913#8. 函数选择器

0 人点赞