通过逆向和调试理解EVM(#4):结束/中止执行的5种指令

通过调试理解EVM 第 4 篇,了解 结束/中止执行的5种指令

  • 原文链接: https://trustchain.medium.com/reversing-and-debugging-evm-the-end-of-time-part-4-3eafe5b0511a
  • 译文出自:登链翻译计划
  • 译者:翻译小组 校对:Tiny 熊
  • 本文永久链接:learnblockchain.cn/article…

在EVM中,总共有5种方式来结束智能合约的执行。我们将在这篇文章中详细研究它们。让我们现在就开始吧!

这是通过逆向和调试理解EVM系列的第4篇,在这里你可以找到之前和接下来的部分:

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

1. STOP(停止)

我们将使用EVM中最简单的操作码来开始。

这是唯一一个消耗0Gas的操作码,顾名思义,它结束智能合约的执行,不返回任何数据。

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

contract Test {
    function test() external {

    }
}

你可以拆解这个非常简单的智能合约来弄清楚发生了什么。(函数的执行从第45指令开始)

045 JUMPDEST |function signature discarded|
046 PUSH1 33 |0x33|
048 PUSH1 35 |0x35|0x33|
050 JUMP     |0x33|053 JUMPDEST |0x33|
054 JUMP     ||051 JUMPDEST ||
052 STOP     ||

在结束时经过2次跳转。内存中没有任何东西。没有数据被存储,堆栈只包含函数签名,因此没有数据被返回。

就这样简单。

2. RETURN(返回)

RETURN像STOP一样结束智能合约的执行,但与STOP不同,它也可能返回一些数据。我们将编译这个solidity代码:

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

contract Test {
    function test() external returns(uint) {
        return(8)
    }
}

并对该函数进行反汇编:

045 JUMPDEST ||
046 PUSH1 08 |0x08|  the return value of test()
048 PUSH1 40 |0x40|0x08|
050 MLOAD    |0x80|0x08|  mload(0x40) mloads the free memory pointer
051 SWAP1    |0x08|0x80|
052 DUP2     |0x80|0x08|0x80|
053 MSTORE   |0x80|       mstore(0x80,0x08) store the return value in memory[0x80]
054 PUSH1 20 |0x20|0x80|
056 ADD      |0xa0|
057 PUSH1 40 |0x40|0xa0|
059 MLOAD    |0x80|0xa0|
060 DUP1     |0x80|0x80|0xa0|
061 SWAP2    |0xa0|0x80|0x80|
062 SUB      |0x20|0x80|
063 SWAP1    |0x80|0x20|
064 RETURN   ||

在指令45和50之间,EVM mload(0x40),它返回80。

备注:45 、50 表示第几个字节数上的指令,下面都使用这种简写方式。

在指令51和53之间, EVM mstore(0x80,0x08),80是空闲内存地址,8是test函数的返回值。

在指令54到56之间,EVM在之前的结果(80)上加上20,等于a0(20=十进制的32,因为这是一个内存插槽的大小,这里只有一个返回值)。

在指令57和62之间,它在40处重新加载内存(mload(0x40)),并将结果与0xa0(第56行的结果)相乘,即0x20。

这里没有非常有趣的东西。在指令64时,内存0x80槽中有0x08,栈中有80和20。这3个值是什么意思?

根据文档的内容。当被调用时:

Stack(0) = 80应包含返回数据在内存中的偏移量

Stack(1) = 20 应该包含返回数据的偏移后的大小。

这正是这个智能合约的情况,0x80和0xa0之间的内存(=80+20 的十六进制)包含函数测试的返回值(8)。

所以智能合约返回内存[Stack(0):Stack(0)+Stack(1)]

3. REVERT操作码(回退)

现在,我们来修改智能合约。

pragma solidity ^0.8.0;

contract Test {
    function test() external returns(uint) {
        revert("eight");
    }
}

你发现区别了吗?我没有使用return(),而是使用了revert(),参数是一个字符串(我不能在 "revert"中使用数字,solidity编译器不允许我编译)。

如果你调用test(),你应该看到一个错误,但调试仍然是可能的!

下面是test函数的反汇编:

069 JUMPDEST          ||
070 PUSH1 40          |0x40|
072 MLOAD             |0x80|
073 PUSH3 461bcd      |0x461bcd|0x80|
077 PUSH1 e5          |0xe5|0x461bcd|0x80|
079 SHL               |0x08c379a000...000|0x80|  binary shift 197 times (e5 in hex), YES a binary shift can modify hex numbers...
080 DUP2              |0x80|0x08c379a000...000|0x80|
081 MSTORE            |0x80|
082 PUSH1 20          |0x20|0x80|
084 PUSH1 04          |0x04|0x20|0x80|
086 DUP3              |0x80|0x04|0x20|0x80|
087 ADD               |0x84|0x20|0x80|
088 MSTORE            |0x80|
089 PUSH1 05          |0x05|0x80|
091 PUSH1 24          |0x24|0x05|0x80|
093 DUP3              |0x80|0x24|0x05|0x80|
094 ADD               |0xa4|0x05|0x80|
095 MSTORE            |0x80|
096 PUSH5 195a59da1d  |0x195a59da1d|0x80|
102 PUSH1 da          |0xda|0x195a59da1d|0x80|
104 SHL               |0x00..195a59da1d..00|0x80|
105 PUSH1 44          |0x44|0x00..195a59da1d..00|0x80|
107 DUP3              |0x80|0x44|0x00..195a59da1d..00|0x80|
108 ADD               |0xc4|0x00..195a59da1d..00|0x80|
109 MSTORE            |0x80|
110 PUSH1 00          |0x00|0x80|
112 SWAP1             |0x80|0x00|
113 PUSH1 64          |0x64|0x80|0x00|
115 ADD               |0xe4|0x00|
116 PUSH1 40          |0x40|0xe4|0x00|
118 MLOAD             |0x80|0xe4|0x00|
119 DUP1              |0x80|0x80|0xe4|0x00|
120 SWAP2             |0xe4|0x80|0x80|0x00|
121 SUB               |0x64|0x80|0x00|
122 SWAP1             |0x80|0x64|0x00|
123 REVERT            |0x00|

和RETURN差不多,EVM在内存中存储返回值,在堆栈中存储2个偏移。代码较长,但并不像看起来那么复杂。

在第69和72指令之间,空闲内存指针被检索出来,(mload(0x40),返回0x80,所以我们下次可以在0x80处mstore)。

之后在73和81指令之间,EVM在内存中mstore(0x80, 0x08c379a000000000000000000000000000000000000000000000000000000000)

不要忘记,0x08c379a000...是通过二进制0x461bcd 移e5次得到的

因此,内存[0x80:0x84]等于0x08c379a

在82和88指令之间,它也是这样做的。EVM将4加到80=84,然后mstore(0x84 //80+4的结果,0x20)在内存中,它添加了0x04,因为它是内存中最后一个数据的大小,在0x80处,这个数据因此被存储在0x08c379a0.... 后。

内存[0x84:0xa4]现在等于0x20。

在字节89和95指令之间,EVM通过使用与之前相同的方式将0x05存储在内存中 mstore(0xa4,0x05)

因此:内存[0xa4:0xc4] = 0x05。

在96和104 指令之间,0x6569676874被推到堆栈中(并向左移位),所以0x6569676874000....0000在堆栈中。

如果我们把6569676874从十六进制转换为ascii(文本),我们就可以找到 "eight"的字符串,这就是返回值。

在指令105和109之间:

  1. EVM将44加到80(空闲内存指针)= c4(在此内存插之前的槽被占用)
  2. 在内存执行 mstore(0xc4,0x6569676874000.000)

因此,结果是:

最后在指令123,EVM以80作为起始偏移量,64作为大小。(这与RETURN操作码的情况完全相同)。

这意味着返回数据位于0x80和0xe4之间。

不同的是,EVM返回了更多关于回退的信息,不仅仅是我们可能猜到的文本 "eight",还有另外3个参数:

那么这3个未知的值是什么?

  • 0x08c379a0是Error(string)函数的签名。每次有人在他的智能合约中使用带参数的revert,当有一个错误时,其作为错误函数被返回。
  • 20
  • 5
  • 0x5569676874 是eight字符串

基本上,这只是意味着revert向区块链返回函数Error(20,5, "eight")。

4. INVALID 无效操作码

在深入研究这个操作码之前,让我们先回答一个问题。

一个智能合约的大小是多少?

它可以在1字节和24.576Kb之间,

智能合约只由操作码组成(比如我们已经知道的PUSH, POP, DUP, SSTORE),这些操作码被直接翻译成二进制。

每条没有参数的指令需要1个字节的内存。例如:

  • REVERT是0xFD
  • SELFDESTRUCT是0xFF。

一些有参数的指令可以占用2个或更多的字节

  • PUSH1 0x80是6080(PUSH1单独是2个第一字节:0x60和0x80是指令的参数)。
  • DUP1 0x80是8080
  • SWAP4 0xFFFFFFFF 是93FFFFFFFF (仅SWAP4是0x93)

而合约的字节码仅仅是所有指令字节码的连接。

但是一个问题出现了。有16*16=256个不同的操作码(00到FF)的组合,但只有一部分被分配。(大约有145个没有被分配。)

已分配的操作码

这些未分配的操作码被称为:INVALID操作码。

通常情况下,如果你用solidity将你的智能合约编译成EVM的字节码,除非在编译过程中出现错误,否则不应该有可访问的无效操作码。

但是如果EVM(通过任何方式)落入一个无效的操作码,它就会自动回退!这就是EVM。

但实际上,有一种可能性是,一些无效的操作码存在于智能合约中,特别是在最后,但这段代码是不可触及的,这意味着无论向智能合约发送什么交易,EVM都不会读取最后的代码,之前总会有一个JUMP。

在第54指令的JUMP之后,截图中没有代码可以执行。

但为什么在第54指令后有一些代码?我们能不能把第54字节后的所有代码删除?

首先,这是编译后的智能合约的元数据的哈希值,但是哪个元数据?

当Solidity编译智能合约时,它会自动生成一个JSON文件,包含关于智能合约的所有数据。 如果你进入remix的编译标签,点击编译细节和 "METADATA"(通常是列表中的第2个),你应该看到所有的元数据,其中包含:

  • 编译器版本(在我们的例子中是0.8.7)。
  • 包含Abi的 "输出(output)"。
  • 编译设置(版本、优化器...)
  • 智能合约的路径

这意味着,两个完全相同的智能合约,用相同的版本编译,可以有不同的字节码! (区别只存在于最后)

为什么solidity编译后要这样做呢?

根据 solidity 文档,它是用来访问 Swarm 中及 ipfs 中的合约的元数据,你可以在这里了解更多。

第二个问题:你可以删除这块数据以节省Gas吗?

是的,你可以在remix中进行配置。你只需要制作一个交易,并在手动删除智能合约的最后这52个字节。

在合约部署时,每一个字节都要花费200个Gas,因为元数据的IPFS哈希值是52个字节的长度,你可以通过禁用这个选项来节省10400个Gas,这并不小(相比之下,一个简单的转移要花费21000个Gas)。

5. SELFDESTRUCT操作码

你知道吗,可以通过调用一个操作码从区块链上删除一个智能合约?

以下是智能合约代码,我们将进行编译和测试。

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

contract Test {
    function test() external {
        selfdestruct(payable(0x0000000000000000000000000000000000000000));
    }
}

在对test()函数进行反汇编后,我们得到:

53  JUMPDEST 
54  PUSH1 0x00 
56  PUSH20 0xffffffffffffffffffffffffffffffffffffffff 
78  AND 
79  SELFDESTRUCT

(在第79个指令之后,有前一节所讲的元数据的哈希值) (在这个例子中不需要显示堆栈)

0x0和0xffffffffffffffffffffffffffffffffffffffff进行与运算,结果是 0x0,在指令53和78之间的堆栈中出现0x0。

Stack(0)在第78指令后现在含有0x00。在第79指令,SELFDESTRUCT指令被调用,参数为Stack(0)(0地址)。

但什么是SELDESTRUCT,为什么SELFDESTRUCT需要一个参数?

SELDESTRUCT从区块链上删除智能合约。

如果销毁的智能合约包含一些ETH,这些资金不能消失。因此,存储在智能合约中的所有资金将被发送到新的地址。这就是原因。

但是,一个问题出现了:如果新地址是一个智能合约,没有receive和fallback 函数(或者receive功能回退了怎么办?),资金会去哪里?

答案很简单,在此案例中,以太坊会做一个例外:即使函数回退,智能合约仍然会得到资金!这意味着,在此案例中,智能合约有可能获得资金。

这意味着可以向智能合约发送ETH并强迫它接受资金。

如果一个智能合约的逻辑过于依赖ETH的余额,那么就会导致一个未定义的行为。这就是所谓的自毁安全漏洞。

最后一个问题,为什么使用这个操作码很有意思

如果你完成了一个智能合约,并且你不再需要它了。调用selfdestruct(address)比让合约活着并手动转移资金要便宜。(例如使用转移、发送或调用)

这是因为selfdestruct(address)释放了区块链的空间,所以Gas成本比简单的转移要便宜。

6. 结论

这一节相当简单,我想向你展示智能合约执行的所有可能的停止方式,以下是你学到的内容。

  • 5条停止合约的指令。
  • 关于自毁的一些安全性。
  • 合约的元数据哈希值是什么?
  • REVERT和RETURN的返回值

下次见 !


本翻译由 Duet Protocol 赞助支持。

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

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

评论