通过逆向和调试深入EVM #6 - 完整的智能合约布局

2023-01-09 17:20:25 浏览数 (1)

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

在这个合约中,我们将逆向一个完整的智能合约。这一部分的目标是全面了解智能合约布局,全面了解智能合约的布局,并通过手动的方式对其进行反编译。

这是我们通过逆向和调试深入 EVM的第 6 篇,在这里你可以找到之前和接下来的部分。

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

下面的代码就是要分析的智能合约,用以下设置编译它。

  • solidity 版本:0.8.7
  • 优化器:200 runs
代码语言:javascript复制
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^ 0.8 .0;

contract Test {
  address owner;

  uint data;

  function setOwner(address _addr) external {
    owner = _addr;
  }

  function returnAdd(uint x, uint y) internal view returns(uint) {
    return x   y;
  }

  function setBalance(uint x) external {
    uint var1 = 10;
    data = returnAdd(x, var1);
  }
}

这个智能合约比之前的合约要长一些,但不用担心,难度不高。

以下是该智能合约的完整拆解:https://ethervm.io/decompile/ropsten/0xd3ac4c6028484a0f101f835e9e5dab72a2fe1b97

(不要相信这个反编译。有一些错误将在这篇文章中强调,请使用文章末尾的反汇编)

1. 反汇编函数 main

在每一个程序中(不仅仅是在 EVM 上)都有一个所谓的入口点,这就是被执行的第一行代码。

例如,当你在 C 或 C 中创建一个程序时,入口点是函数main()

但在 solidity 中有些不同,入口点是智能合约的开始。 每当你在区块链上调用一个智能合约时,这个入口点就会被首先执行。我们将其称为函数 main,它的位置当然是在字节 0 处。

通过查看指令 0 和 17 字节之间的反汇编,我们可以很容易地推断出函数 main 由这段代码开始:

代码语言:javascript复制
function main() {
  mstore(0x40, 0x80)
  if (msg.value > 0) {
    revert();
  }
  if (msg.data.size < 4) {
    revert();
  }
}

(我们已经在本系列的第一篇分析了智能合约的开始,如果你不记得了,请随时刷新你的知识,如果有什么遗漏的话)

通过查看第 18 和 36 字节之间指令,我们可以很容易地看到一个 "else if "语句,这是一个函数选择器:

代码语言:javascript复制
function main() {
  mstore(0x40, 0x80)
  if (msg.value > 0) {
    revert();
  }
  if (msg.data.size < 4) {
    revert();
  }
  byte4 selector = msg.data[0:4]
  switch (selector) {
    case 0x13af4035:
      // JUMP to 37

    case 0xfb1669ca:
      // JUMP to 66
    default:
      revert(0);
  }

值得注意的是,"switch" 语句在 solidity 中并不存在,而是由 "else if " 组成。

我们将在后面的文章中倒转 else if 函数中的内容。我们需要首先 "映射 "智能合约的所有函数,看看它们在智能合约中的位置。

2. 函数布局

一个智能合约,仅仅是由不同的函数构成的。每一块汇编代码都在一个函数中,而且每个函数在智能合约代码中都是并排的。

这意味着,如果有一些函数 A 的代码位于字节 1 和 5 之间,一些函数 B 的代码位于字节 6 和 9 之间,函数 A 不能在字节 10 之后继续,因为这些代码不是 "并排的",它们被 B 分开了。

鉴于这些信息,我们将尝试重建智能合约的所有函数,注意有 "用户创建" 的函数,也有 "编译器" 创建的函数。

请注意,在这篇文章中,我们将使用十六进制偏移量而不是十进制

要做到这一点,我们现在只看 3 条指令:JUMP、JUMPDEST、JUMPI(有时也看 PUSH)。

我们知道,在第 0 字节和第 66 字节之间(至少),我们是在函数main()中,现在我们不知道函数main()在哪里结束。

但是在第 70 字节...

代码语言:javascript复制
67 PUSH1 0x64
69 PUSH1 0x71
6B CALLDATASIZE
6C PUSH1 0x04
6E PUSH1 0xba
70 JUMP
71 JUMPDEST

我们可以很容易地看到对 0xBA 的函数调用,参数 0x04 和 CALLDATASIZE(这是一个将 msg.data 的大小推入堆栈的函数)被推入堆栈中。

0x71 是调用完成后继续执行流程的保存地址(这就是为什么在 0x71 字节有一个JUMPDEST)。

这样看来,0xba 是开始一个新函数的地址,让我们去看看 0xba 吧

代码语言:javascript复制
00BA JUMPDEST
00BB PUSH1 0x00
00BD PUSH1 0x20
00BF DUP3
00C0 DUP5
00C1 SUB
00C2 SLT
00C3 ISZERO
00C4 PUSH1 0xcb
00C6 *JUMPI

在 0xC6 处有一些条件,如果条件得到满足,EVM 就会跳到 CB,否则代码就会继续进行,不久就会返回。

代码语言:javascript复制
00C7 PUSH1 0x00
00C9 DUP1
00CA *REVERT00CB JUMPDEST
00CC POP
00CD CALLDATALOAD
00CE SWAP2
00CF SWAP1
00D0 POP
00D1 *JUMP

在 0xD1 函数 JUMP 到一个未知的目的地,很可能是调用之前保存的 0x71 地址。(可以通过计算 0xba 和 0xd1 之间每条指令的堆栈元素数量来验证)所以这是一个以 0xba 开始的函数的结束。

这样看来,我们发现的第一个函数位于 BA 和 D1 之间,而且我们知道它需要两个参数。

我们将它命名为 "function_0BA(a,b)",因为我们不知道这个函数的 "真实 "名称。

让我们深入研究一下,因为智能合约只是由函数组成。还有一个函数从 0xD2 开始(在 0xD1 之后),让我们把它拆解一下。

代码语言:javascript复制
D2 JUMPDEST  | D3 PUSH1 0x00 | D5  DUP3 | D6 NOT | D7 DUP3 | 00D8 GT | D9 ISZERO | DA PUSH1 0xf2 | DC *JUMPI

在 0xDC,那里的函数有一个条件,如果条件得到满足,EVM 就会跳转到 0xF2,否则就会执行 0xDD 和 0xF1(该代码会被回退)之间的代码。

代码语言:javascript复制
00DD    63  PUSH4 0x4e487b71
00E2    60  PUSH1 0xe0
00E4    1B  SHL
00E5    60  PUSH1 0x00
00E7    52  MSTORE
00E8    60  PUSH1 0x11
00EA    60  PUSH1 0x04
00EC    52  MSTORE
00ED    60  PUSH1 0x24
00EF    60  PUSH1 0x00
00F1    FD  *REVERT

00F2    5B  JUMPDEST
00F3    50  POP
00F4    01  ADD
00F5    90  SWAP1
00F6    56  *JUMP

在 0xF2,函数 JUMP 到一个未知的位置。这很可能是保存的返回地址。此外,在这之后没有任何代码(只有哈希元数据,我们在第四篇[11]谈到),所以这肯定是函数的结束。

我们将它命名为 func_0D2(),但我们不知道(至少现在)它需要多少参数。我们只知道它位于代码中的 0xD2 到 0xF6 之间。

还没有完成,在 0x71 和 0xBA 之间还有空间可以分析。

如果我们回到 0x71,在调用函数 0xBA 后继续。

代码语言:javascript复制
0071    5B  JUMPDEST
0072    60  PUSH1 0x0a
0074    60  PUSH1 0x7b
0076    82  DUP3
0077    82  DUP3
0078    60  PUSH1 0x82
007A    56  *JUMP
007B    5B  JUMPDEST

在 0x7A 处又有一次对函数 0x82 的调用,有 2 个参数(因为 DUP3 推 1 个值到堆栈)。所以我们来检查一下函数 0x82。

代码语言:javascript复制
0082    5B  JUMPDEST
0083    60  PUSH1 0x00
0085    60  PUSH1 0x8c
0087    82  DUP3
0088    84  DUP5
0089    60  PUSH1 0xd2
008B    56  *JUMP
008C    5B  JUMPDEST
..
0092    56  *JUMP

在 0x8B 处有一个对 0xd2 的调用,有 2 个参数(DUP3 和 DUP5 向堆栈推 1 个值)。之后在字节 0x92 函数 JUMP 到一个未知的位置。

这是智能合约的第三个函数,我们将称之为 "func_082(a,b)"。 它位于 0x82 和 0x92 字节之间。

如前所述,92 是新函数的开始(我们将称之为func_092())。

代码语言:javascript复制
...
009D    60  PUSH1 0xa4
009F    57  *JUMPI
00A0    60  PUSH1 0x00
00A2    80  DUP1
00A3    FD  *REVERT
00A4    5B  JUMPDEST
00A5    81  DUP2
...

在 0x9F 和 0xA4 之间有一个条件,使用与之前相同的布局。 但是如果我们继续下去,在0xB5有一个奇怪的东西,有一个判断条件。

代码语言:javascript复制
00B3    60  PUSH1 0x8c
00B5    57  *JUMPI
00B6    60  PUSH1 0x00
00B8    80  DUP1
00B9    FD  *REVERT

如果条件得到满足,EVMJUMP到 0x8C。但是 0x8c 不是一个函数,它在函数 function_082(a,b)中(0x82 和 0x92 之间)。

我们的假设是否有错误?这些函数可以交错使用吗?

幸运的是,答案是,这是他优化器的 "错误"。

优化器看到在 0xBA(0xB9 之后)和 0x8C 可能有 2 个相同的汇编块,所以优化器宁愿 JUMP 到 0x8C,而不是把这个块复制到 0xBA,这在部署时成本较低。这样,0x92 的函数就结束了。

我们知道了智能合约的 5 个函数:

  1. func_082() 0x82 => 0x91
  2. func_092() 0x92 => 0xB9
  3. func_0BA(a,b) 0xBA => 0xD1
  4. func_0D2() 0xD2 => 0xF6

我们也可以推断出函数 main()的布局是 0x00 => 0x81。因为从 0x7B(仍在函数 main()中)和 0x81 开始,代码继续进行,并在 0x81 处 JUMP。

3. 了解代码中的函数

一旦,我们知道了所有函数的位置,我们就可以试着去理解这些函数的代码。

3.1 最简单的函数:函数 func_0BA(a,b)

我们知道,它在堆栈中需要 2 个参数。|a|b|RET|(RET 是函数调用后保存的字节地址,我们将不显示它)

我不会在这里显示汇编(你可以在:https://ethervm.io/decompile/ropsten/0x13e566acef92c2ff26688c08cd25ab13f045b195 找到)。

  1. 它推送 0x00 和 0x20 |0x20|0x00|a|b| 和 DUP3、DUP5 |b|a|0x20|0x00|a|b|
  2. 将 Sub(减法)的结果入栈 |b-a|0x20|0x00|a|b|
  3. 通过 SLT 比较 Stack(0)和 0x20,如果小于 0x20,就把 1 推到堆栈,如果大于或等于 20,就把 0 推到堆栈, 堆栈 为 |b-a < 0x20|0x00|a|b| .
  4. ISZERO操作码被使用,如果结果是零(所以如果它大于或等于),函数 JUMP 到 0xcB,否则它不 JUMP,并很快回退。然后堆栈是|!(b-a < 0x20)|0x00|a|b|。 我们将假设 YES (等于/大于),所以堆栈是|1|0x00|a|b| , 在 0xCB JUMP 后为 |0x00|a|b|
  5. EVM 在 0xCC POP Stack(0)后,所以堆栈是|a|b|
  6. 在 0xCD 处的 CALLDATALOAD 加载 msg.data 中 Stack(0)(偏移量) 之后的 32 个字节 |msg.data[a:a 0x20]|b|RET|
  7. 在 0xCE 和 0xCF 处的 SWAP2 和 SWAP1 : |b|msg.data[a:a 0x20]|RET|
  8. 并在 POP 后的 0xD1 处返回数据 : |RET|msg.data[a:a 0x20]|

这段代码不是很复杂,而且在很多智能合约中都有。一旦你看到这个模式,你就可以在任何地方识别它。你只需要练习,不要担心 !

总结一下。函数 BA,接收 2 个参数,查看差值并将其与 0x20 比较 如果差值低于十六进制的 0x20(十进制为 32),它就会回退。我们将在后面看到原因。

因此我们可以 "重新组合(反汇编)" 出函数func_0BA(a,b)

代码语言:javascript复制
function func_0BA(a,b) {
    if (a - b < 20) { reverts(); } else return msg.data[b:b 0x20]
}

3.2 func_082(a,b).

这是迄今为止这段代码中最短的函数,它只需要两个参数:

  1. 在 0x82 处的堆栈是|a|b|RET|
  2. 在 DUP5 之后是:|a|b|0x8c|0x00|a|b|RET|
  3. 在 func_D2 被调用后(它返回一个值,我们将其命名为 x),堆栈是:|x|0x00|a|b|RET|
  4. 在 SWAP4 和 SWAP3 之后,堆栈是|a|0x00|b|RET|x|,最后 3 个值弹出了:|RET|x|

_x_,这是 func_D2 的返回值,在 func_82 函数内返回的。

func_082 的目的只是用 func_082 的参数来调用 func_D2,没有别的意思,下面是反汇编的代码:

代码语言:javascript复制
function func_082(a,b) {
    return func_D2(a,b) {
}

3.3 func_D2(a,b)

如前所述,func_D2被调用时有两个参数: |a|b|RET|

  1. 0x00 被推入,DUP3 被调用: |b|0|a|b|RET|
  2. 在 0xb 上调用 NOT: |0xfffffff....ffff5|0|a|b|RET|
  3. DUP3 被调用: | a |0xfffffff....ffff5|0|a|b|RET|
  4. GT 被调用:|a > 0xfffffff....ffff5|0|a|b|RET|
  5. 如果 stack(0)为真(等于 1),那么 ISZERO 返回 0,因此 JUMPI 不会被执行,函数继续它的流程并返回。
  6. 我们将假设 a 小于 0xffff....ff5,所以函数 JUMP 到 F2,此时堆栈是|0|a|b|RET|
  7. 进行 POP 和 ADD:|a b|RET|,最后。SWAP1 |RET|a b|, 该函数返回 a b,但步骤 1 和 5 之间的代码的目的是什么?

首先这里是 "反编译" 代码:

代码语言:javascript复制
function func_D2(a,b) {
    if ( ~(a) > b) { revert } else return a b
}

这个函数 对第一个参数取反(NOT),并将其与 b 进行比较,这是什么意思?

这是为了防止溢出。

我们知道,NOT(a) = 2²⁵⁶- a 例如,NOT(0x1)=0xffffffffffff...fffff(64 个 f,因为 EVM 按 32 字节的槽工作)

如果 a 大于 NOT(b),那么总和(a b)大于 0xffffffff...fffff,因此不能包含在一个 256 字节的 uint 中,所以会有溢出。

所以 func_D2 的目标是将两个参数相加,并验证是否有溢出。

3.4 函数 func_093

开始处在 0x93 0xA4 之间,与第一个函数中的 0xBA 和 0xCB 之间相同,因此堆栈是相同的|0x00|a|b|(func_093 也需要 2 个参数)

  1. 在字节 0xA5 和 0xA6:DUP2 和 CALLDATALOAD 被调用 |msg.data[a:a 20]|0x00|a|b|
  2. 在字节 0xA7 到 0xAB : 在 3 个 PUSHs 之后|0xa0|0x01|0x01|msg.data[a:a 20]|0x00|a|b|
  3. 在字节 0xAD:SHL 将 0x01 的所有字节向左移动 0xa0(160 的十进制)二进制数字,因此移动 40 个十六进制数字。结果是|0x0000...00100......00|0x01|msg.data[a:a 0x20]|0x00|a|b|
  4. 在字节 0xAE:EVM 的子调用 SUB 操作码,结果是|0x0000...000ffffff..ffffff|msg.data[a:a 0x20]|0x00|a|b|
  5. 在字节 0xAF 到 0xB1。经过 DUP2,AND 和再次 DUP2 操作码,结果是:|msg.data[a:a 0x20]|msg.data[a:a 0x20]|msg.data[a:a 0x20]|0x00|a|b|
  6. 在字节 0xB2:调用 EQ,由于 Stack(0)和 Stack(1)相等,结果是:|1|msg.data[a:a 20]|0x00|a|b|
  7. 之后 EVM 跳转到 0x8c 并结束函数。

这段代码的目的与 func_0BA 相同,但有区别。

第 1 步和第 7 步之间的代码验证msg.data[a:a 0x20]是一个有效的以太坊地址,其格式为:

0x0000000000000000abcdef....124,而不是类似这样的:0x100000000000000000000000abcdef….124.

下面是该函数的反编译:

代码语言:javascript复制
func_093(a,b) {

if (a - b < 20) { revert(); }
else {
    if (msg.data[b:b 0x20] & 0x0000...000ffffff..ffffff == msg.data[b:b 0x20]) {
          return msg.data[b:b 0x20]
    } else {
       reverts();
    }
}

我们已经完成了 4 个函数的编译! 现在只剩下 main()函数了...

4. main()里面是什么?

我们不分析 0x37-0x82 之间的偏移量。所以让我们开始吧!!!。

前面我们没有分析 2 个 "else if "是什么,它代表了函数选择器,但这是最重要的,可以看到 2 个外部函数做了什么。

4.1 我们将从 0x37 处的第一个 "else if "开始(来自签名 0xfb1669ca)。

代码语言:javascript复制
0037    5B  JUMPDEST
0038    60  PUSH1 0x64
003A    60  PUSH1 0x42
003C    36  CALLDATASIZE
003D    60  PUSH1 0x04
003F    60  PUSH1 0x93
0041    56  *JUMP

首先,它用 0x04 和 CALLDATASIZE 调用 func_093。 CALLDATASIZE 是 msg.data 的大小。

这个函数计算 2 个数字的差值,如果结果小于 32,则返回。

由于 1 个参数被编码为 0x20 的大小(十进制为 32),其目的是为了验证在调用该函数时至少有 1 个参数。之后func_092返回**msg.data[0x04:0x24]**,这是区块链函数调用的第一个参数。

在 0x42 和 0x63 之间,智能合约将结果存储在槽 0 中。(由于篇幅,我们在此不做详细介绍......)

所以这是 setOwner(_addr) 函数。

在 0x63 处,它跳转到一个未知的目的地(事实上是 0x64,这是 else if 的结束)在 0x65 处,智能合约 STOP。

在 0x66 处的第二个 else if(来自签名:0xfb1669ca)。

代码语言:javascript复制
0066    5B  JUMPDEST
0067    60  PUSH1 0x64
0069    60  PUSH1 0x71
006B    36  CALLDATASIZE
006C    60  PUSH1 0x04
006E    60  PUSH1 0xba
0070    56  *JUMP

它用 04 和 CALLDATASIZE 调用函数 func_0BA 来验证 msg.data 中是否至少有 1 个参数,如果有,它返回 msg.data[0x04:0x24] 这是区块链调用的第一个参数。

[0x00:0x03]仍然是 4 字节的签名。

在这之后是调用 0x82,其调用 DA。

代码语言:javascript复制
0071    5B  JUMPDEST
0072    60  PUSH1 0x0a
0074    60  PUSH1 0x7b
0076    82  DUP3
0077    82  DUP3
0078    60  PUSH1 0x82
007A    56  *JUMP

参数是 msg.data[0x04:0x24] (第一个 DUP3)和 10(第二个 DUP3)。 结果是 msg.data[0x04:0x24] 40。

代码语言:javascript复制
007B    5B    JUMPDEST
007C    60    PUSH1 0x01
007E    55    SSTORE
007F    50    POP
0080    50    POP
0081    56    *JUMP

在函数结束后,它在槽 0x01 处 SSTORE 了结果。在 0x81 处,它跳转到一个未知的地址(也就是 0x64 处)。在 0x65 处,智能合约 STOP。

第二个 else if 显然是**setBalance(uint x)**。

我们还可以推断出 func_0DA(a,b) 是内部 returnAdd()函数。

5. 结论

我们成功地对这个智能合约进行了逆向工程,我希望你在这篇文章中学到了很多东西,这比前面 5 篇文章要实用得多!

我们还可以注意到,智能合约并没有真正优化,有一些代码块是重复的,比如在 0x93 0xA4 和 0xBA 0xCB 之间。

我们可以从智能合约中删除一些字节代码来压缩它,如果你有时间,你可以试试。

原文链接: https://trustchain.medium.com/reversing-and-debugging-part-6-full-smart-contract-layout-f236c3121bd1

参考资料

[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://learnblockchain.cn/article/5019

[10]

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

[11]

第四篇: https://learnblockchain.cn/article/4965

0 人点赞