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

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

  • 原文链接: https://trustchain.medium.com/reversing-and-debugging-evm-the-execution-flow-part-5-2ffc97ef0b77
  • 译文出自:登链翻译计划
  • 译者:翻译小组 校对:Tiny 熊
  • 本文永久链接:learnblockchain.cn/article…

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

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

让我们来了解一下!

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

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

1. 汇编中的IF/ELSE

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

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

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

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,用你想要的任何值调用流程。

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

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

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执行开始时的函数选择器,以选择符合函数签名的代码。(我们在第一篇里看到了)

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

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调用。

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

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

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的值。(别忘了:在函数中参数总是在堆栈中)

| 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:

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)。

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)

152 JUMPDEST 
153 POP 
154 ADD 
155 SWAP1 
156 JUMP

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

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

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)。

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)。

174 JUMPDEST 
175 POP 
176 PUSH1 01 
178 ADD 
179 SWAP1 
180 JUMP

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

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 版本)。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

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

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

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

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()函数。

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. 带参数的函数调用

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

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

让我们来反汇编一下:

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函数不接受参数而返回一个值会发生什么。

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

// 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):

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()函数中增加一个返回值和两个参数,把这三个例子结合起来,会怎么样?

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

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

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智能合约布局和它的不同部分。

本文参与区块链技术网 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 2天前
  • 阅读 ( 91 )
  • 学分 ( 5 )
  • 分类:Solidity

评论