# 以太坊智能合约逆向分析与实战：（5）深入EVM之合约的部署与调用

By [Hackit](https://paragraph.com/@hackbot) · 2022-09-29

---

当我们部署和调用合约的时候，EVM 都在做些什么？

如果你开发过以太坊智能合约，想必你应该熟悉这样的操作 (此处以remix为例) ：

编写solidity代码 -> 编译 -> 部署-> 交互 。合约的编写与部署似乎并不是一件很麻烦的操作：编写阶段就不说了，Solidity语言大家都应该会；到了编译阶段，本地的 solc 编译器会把 Solidity 代码编译成字节码（bytecodes）；而在部署阶段，部署者通过发起一笔特殊交易（to的地址为空）calldata 带上编译后的字节码，等交易上链之后，就完成了合约的部署；而合约交互，就是call合约里的某个函数，等待函数的响应和返回，一切就是这样的简单。

但是正如开车一样，当你踩住油门后，车辆开始前进。然而这看似简单的操作背后是汽油爆燃、活塞往复、数百个齿轮啮合传动、轮胎与地面滚动摩擦的复杂行为。部署和调用合约也是如此，它涉及到 EVM 的堆栈操作，内存读写，存储访问等一系列底层操作。当部署合约时， EVM 把收到的 calldata 翻译成操作指令，把它们按照给定的长度和参数读入内存；当调用合约时，EVM 又根据收到的 calldata ，通过函数选择器来确定调用哪一段代码，并返回数值。如果只讲理论未免过于枯燥，为了便于讲解，我们这次用 ethernaut 的一道题目作为例子，详细了解 EVM 是如何部署和运行合约的，以及如何充当人肉编译器，徒手编写智能合约。

[这个题目](https://ethernaut.openzeppelin.com/level/0x200d3d9Ac7bFd556057224e7aEB4161fED5608D0)是这样的：我们需要部署一个合约，当我们调用合约 \*\*whatIsTheMeaningOfLife()\*\*函数的时候，它需要返回一个数字 “42”。看起来很简单对吧？我们分分钟编写完毕：

![图1](https://storage.googleapis.com/papyrus_images/a5a1f8e031509f555b2ef758c77ebc0e8b0ac3cabfb16f73ea0e7d0ad6242e44.png)

图1

慢着，题目后面还有个小小的附加要求：“所部署的合约大小不超过10个操作码”。好吧，这个要求的确够“小”，要知道连合约头部的 “函数选择器” 都不止 10 个操作码好吧？可是“函数选择器” 是什么，为什么会出现在合约里面呢？带着你的疑问，继续向下看。

我们通过 `./solc --asm --bin target.sol` 来看看这个合约的最终编译结果：

608060405234801561001057600080fd5b5060b68061001f6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c8063650500c114602d575b600080fd5b60336047565b604051603e91906067565b60405180910390f35b6000602a905090565b6000819050919050565b6061816050565b82525050565b6000602082019050607a6000830184605a565b9291505056fea26469706673582212206ef8c7b5177952a701b3b46b69cb3ec296f4c54c946692e8ec901f5e43c1e78a64736f6c63430008110033

这么一大坨十六进制数据，就是上述 Solidity 程序编译之后的字节码。当我们部署合约时，把这一堆 data 发给以太坊节点，等广播完成后，合约就部署完毕了。这是 solc 编译器编译 Solidity程序得到的代码，看似杂乱无章的的数据，其实都是和 [opcodes](https://www.ethervm.io/) 一一对应的。我们来一段一段地看这些代码：

**合约部署代码:**

608060405234801561001057600080fd5b5060b68061001f6000396000f3fe

**合约运行代码:**

6080604052348015600f57600080fd5b506004361060285760003560e01c8063650500c114602d575b600080fd5b60336047565b604051603e91906067565b60405180910390f35b6000602a905090565b6000819050919050565b6061816050565b82525050565b6000602082019050607a6000830184605a565b9291505056fe

**auxdata:**

a26469706673582212206ef8c7b5177952a701b3b46b69cb3ec296f4c54c946692e8ec901f5e43c1e78a64736f6c63430008110033

我们先简单地把这堆代码分为合约的部署代码、运行代码、auxdata 三部分，如何理解这三种代码呢？我觉得可以理解为向太空发射卫星：“**部署代码**” 就是运载火箭，而“**运行代码**”就是卫星。运载火箭只在发射卫星时才起到作用，一旦卫星进入轨道，火箭就废弃了，只留下卫星在太空中与地球通信。 部署合约也是如此，在部署合约时，部署代码把一些初始化工作作完之后，就把合约的运行代码送入EVM，只留下运行代码在链上与用户进行交互。 （至于**auxdata**，它是紧跟在runtime代码后面的43个字节，相当于源码的指纹，可以用来验证。这只是数据，并不会被EVM执行。）

那么言归正传，我们题目要求我们合约运行代码的 opcedes 不超过 10 条，那么，这段代码对应的 opcodes 是多少条呢？答：71 条。（通过查看 Remix : ./artifacts/MagicNum.json 中的 bytecode 里的 opcodes 可以看到。而 deployedBytecode 里的 opcodes 却是 92 条，因为它的长度是 部署代码 + 运行代码 ）

那么问题来了，如何把 71 条 opcodes 精简到 10 条以内呢? 这就需要我们对 EVM 运行智能合约的方式有着一定的了解。如果不了解也没关系，拿起你手边的 [**EVM 指令集**](https://www.ethervm.io/) ，我们一起来看看吧：

首先我们要知道，EVM 执行代码时是按照**自上而下**的顺序执行的，代码中没有其他入口点，始终从**顶部** (也就是第一行 opcode ) 开始执行。（这点和 Windows 软件不一样，PE文件是有固定的入口点的,而且不同的 Windows 版本或不同的 PE 文件 入口点也会有所不同）。也就是说，当我们部署合约时， EVM 会从第一个bytecode开始读起。

所以我们看字节码最前面的部分，也就是它的部署代码：608060405234801561001057600080fd5b5060b68061001f6000396000f3fe

对照 EVM 指令，我们可以识别出这段代码的含义：

![图2](https://storage.googleapis.com/papyrus_images/b8423342b92287b217bc3267e34f6c5b857d794072322fe20f56484c9b552be7.png)

图2

然后我们看合约的运行代码：

6080604052348015600f57600080fd5b506004361060285760003560e01c8063650500c114602d575b600080fd5b60336047565b604051603e91906067565b60405180910390f35b6000602a905090565b6000819050919050565b6061816050565b82525050565b6000602082019050607a6000830184605a565b9291505056fe

![图3](https://storage.googleapis.com/papyrus_images/c29642acdf5f926decb7b8929c9854ac494cda329a4e63c996a823f546d11099.png)

图3

![图4](https://storage.googleapis.com/papyrus_images/e5f90e162ebbedf4fed145cc7d946efb5fa75b8ccdd5e205dc6bc27dcfb6cc9e.png)

图4

综合以上可以发现，合约的运行代码的架构是这样的：

![图5](https://storage.googleapis.com/papyrus_images/9fb1b0cdd5e61a5b343d38eb2c8272f6f41fc9d491801bc237a16523ef360e28.png)

图5

初始化操作、函数选择器这些，是 solc 在编译 Solidity 程序的时候自动生成的。如果我们砍掉这些复杂的东西，直接把我们想要的核心功能编码上去，不就可以在 10 条以内opcodes 实现既定功能了吗？

通过分析 图4 的 whatIsTheMeaningOfLife() 函数调用栈可以得知，让智能合约返回 “42” ( 十六进制 0x2a) 的关键在于 先用 **mstore** 指令将 0x2a 放入 Memory , 再用 **return** 指令将内存里的 0x2a 返回即可。至于那些函数名称和函数签名，只是高级语言的编译产物，直接用汇编实现的话，我们直接用这段代码读写内存，完全没有必要搞那些花里胡哨：

![图6](https://storage.googleapis.com/papyrus_images/d92ecdbd27a1aacbe1a99bcf1996279d29518cf4869713e775a40fb699ad1144.png)

图6

以上代码相当于构造了一个十分小的合约“运行代码”。前面我们说过，EVM 执行代码时是按照**自上而下**的顺序执行的，代码中没有其他入口点，始终从**顶部** (也就是第一行 opcode ) 开始执行。而且我们编写的代码并没有函数选择器，也就是说，当外部账户调用该它时，无论传递给它什么样的参数、什么样的函数签名， EVM 都只会从它的 \[00\] 处开始执行，老老实实地走到 \[09\]，然后 return 给我们一个 0x20.

但这只是运行代码，还记得本文开头说的那三段字节码吗？是的，我们还差一个“运载火箭”（部署代码），把这段运行代码给发射出去：

部署代码的结构基本没怎么变，之前已有解析，此处就不罗嗦了，唯一的区别是把复制到内存的长度由 b6 改为 0a : 608060405234801561001057600080fd5b5060**0a**8061001f6000396000f3fe

然后把他们拼接到一起，记得部署代码在前、运行代码在后，最后我们把这段代码发射出去就 OK了：

你将得到一个超级小巧、只有 10 个字节、无论传递什么参数都 **只 会 返 回 42** 的 “智能合约” （这么说看起来并不智能的样子……）

![图7](https://storage.googleapis.com/papyrus_images/29be5c5754c815e603e2267bdb6cdde0396c19f6186197604732b2d6315e742d.png)

图7

全文完。

* * *

关于作者：

---

*Originally published on [Hackit](https://paragraph.com/@hackbot/5-evm)*
