跳转至主要内容

对合约进行逆向工程

以太坊虚拟机操作码逆向工程反编译器
高级
Ori Pomerantz
2021年12月30日
38 分钟阅读 minute read

简介

区块链上没有秘密,发生的一切都是持续的、可验证的、公开的。 理想情况下,应将智能合约的源代码发布到 Etherscan 上进行验证(opens in a new tab)。 然而,情况并非总是如此(opens in a new tab)。 在本文中,你将研究一份没有源代码的合约 0x2510c039cc3b061d79e564b38836da87e31b342f(opens in a new tab),从而学习如何对合约进行逆向工程。

有一些反编译器,但它们不一定能提供有用的结果(opens in a new tab)。 在本文中,你将从操作码(opens in a new tab)入手,学习如何对合约手动进行逆向工程并理解合约,以及如何解读反编译器生成的结果。

为了能够理解本文,你应当已经了解以太坊虚拟机基础知识,并至少对以太坊虚拟机汇编器有几分熟悉。 点击此处了解这些主题(opens in a new tab)

准备可执行代码

你可以在 Etherscan 上获得合约的操作码,操作如下:点击 Contract 选项卡,然后切换至 Opcodes 视图。 你将看到每行有一条操作码。

Etherscan 上的 Opcode 视图

但是,为了能够理解跳转,你需要知道每条操作码在代码中的位置。 为此,一种方式是打开 Google Spreadsheet 并把操作码粘贴到 C 列。你可以创建这个已制作好的电子表格的副本,从而跳过以下步骤(opens in a new tab)

下一步是获得正确的操作码位置,以便我们能够理解跳转。 我们将操作码大小放入 B 列,操作码位置(十六进制形式)放入 A 列。在单元格 B1 中输入下面的函数,然后复制并粘贴到 B 列其余单元格中,直到代码结束。 完成后,你就可以隐藏 B 列。

1=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)

首先该函数给操作码增加一个字节,然后查找 PUSH 操作码。 Push 操作码比较特殊,因为它们需要额外的字节表示压入的值。 如果操作码是 PUSH,我们提取该字节的数值并在函数中增加相应的值。

A1 单元格中输入第一个偏移量 0。 然后在 A2 单元格中,输入下面的函数,并再次将它复制粘贴到 A 列其余他单元格中:

1=dec2hex(hex2dec(A1)+B1)

我们需要此函数提供十六进制值,因为跳转(JUMPJUMPI)之前压入的值也是十六进制的。

入口点 (0x00)

智能合约总会从第一个字节开始执行。 下面是代码的开始部分:

偏移量操作码堆栈(在操作码之后)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTORE
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPI

这段代码执行了两项操作:

  1. 将 0x80 作为一个 32 字节的值写入内存地址 0x40-0x5F(0x80 存储在 0x5F,而 0x40-0x5E 全部都是零)。
  2. 读取 calldata 长度。 通常,以太坊合约的调用数据遵循应用程序二进制接口(opens in a new tab),该接口至少需要四个字节用于函数选择器。 如果调用数据长度小于四个字节,将跳转至 0x5E。

这部分代码的流程图

0x5E 处的处理程序(用于非应用程序二进制接口数据调用)

偏移量操作码
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

此代码片段以 JUMPDEST 开头。 如果跳转到的操作码不是 JUMPDEST,以太坊虚拟机程序会抛出异常。 然后它查看 CALLDATASIZE,如果为“true”(即非零),则跳转到 0x7C。 我们将在下面讨论。

偏移量操作码堆栈(在操作码之后)
64CALLVALUE调用提供的 。 在 Solidity 中称为 msg.value
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

因此,当没有调用数据时,我们读取 Storage [6] 中的值。 我们还不知道这个值是什么,但我们可以查找合约收到的没有调用数据的交易。 仅转账以太币而没有任何调用数据(因此没有方法)的交易在 Etherscan 中具有方法 Transfer。 事实上,合约收到的第一笔交易(opens in a new tab)就是转账。

如果我们查看该交易并点击 Click to see More,我们会看到调用数据(称为输入数据)实际上是空的 (0x)。 另请注意,值为 1.559 ETH,稍后将介绍。

调用数据是空的

接下来,点击 State 选项卡并展开我们正在进行逆向工程的合约 (0x2510...)。 可以看到在交易过程中 Storage[6] 确实发生了变化,如果你将十六进制更改为数值,就会看到该值变成了 1,559,000,000,000,000,000(为了清楚起见,这里添加了逗号),即转账数额(以 wei 为单位),对应于下一个合约价值。

Storage[6] 中的变化

如果我们查看同一段时间内其他 Transfer 交易(opens in a new tab)引起的状态变化,就会发现 Storage[6] 跟踪一段时间内的合约价值。 现在,我们将其称为 Value*。 星号 (*) 提醒我们,我们还不知道这个变量的作用,但它不会只是跟踪合约价值,因为当你可以使用 ADDRESS BALANCE 获取帐户余额时,无需使用非常昂贵的存储。 第一个操作码压入合约地址。 第二个操作码读取堆栈顶部的地址并将其替换为该地址的余额。

偏移量操作码堆栈
6CPUSH2 0x00750x75 Value* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Value* 0x75 0 6 CALLVALUE
70SWAP1Value* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

我们将继续在跳转目标处跟踪此代码。

偏移量操作码堆栈
1A7JUMPDESTValue* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE

NOT 是按位运算符,因此它反转调用值中每一位的值。

偏移量操作码堆栈
1ACDUP3Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTValue*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROValue*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

如果 Value* 小于 2^256-CALLVALUE-1 或等于它,则跳转。 这看起来像是防止溢出的逻辑。 事实上,我们看到在偏移量 0x01DE 处进行了一些无意义的操作(例如,写入内存后马上删除)后,如果检测到溢出,合约将回滚,这是正常行为。

请注意,这种溢出极不可能发生,因为它需要调用值加上 Value*,与 2^256 wei 相当,大约 10^59 个以太币。 在撰写本文时,以太币的总供应量不到两亿个(opens in a new tab)

偏移量操作码堆栈
1DFJUMPDEST0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPValue* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDValue*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Value*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

如果执行到此处,获取 Value* + CALLVALUE 并跳转到偏移量 0x75 处。

偏移量操作码堆栈
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

如果执行到此处(要求调用数据为空),我们将调用值添加到 Value*。 这与我们所说的 Transfer 交易的操作是一致的。

偏移量操作码
79POP
7APOP
7BSTOP

最后,清除堆栈(并非必需)并表明交易成功结束。

综上所述,初始代码的流程图如下所示。

入口点流程图

0x7C 的处理程序

我特意没有将此处理程序的作用写入标题中。 重点不是教你这个特定的合约如何运作,而是如何对合约进行逆向工程。 你和我一样,都通过观察代码了解该合约的作用。

我们从多个位置执行到此处:

  • 如果调用数据包含 1、2 或 3 个字节(从偏移量 0x63 处开始)
  • 如果方法签名未知(从偏移量 0x42 和 0x5D 处开始)
偏移量操作码堆栈
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

这是另一个存储单元,我在任何交易中都找不到它,所以很难知道它的含义。 下面的代码将使其更明确。

偏移量操作码堆栈
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

上面的操作码将我们从 Storage[3] 读取的值截断为 160 位,即以太坊地址的长度。

偏移量操作码堆栈
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

上面的跳转是多余的,因为我们要执行下一个操作码。 这个代码远不如它本应该的那样具有燃料效率。

偏移量操作码堆栈
9DJUMPDESTStorage[3]-as-address 0x00
9ESWAP10x00 Storage[3]-as-address
9FPOPStorage[3]-as-address
A0PUSH1 0x400x40 Storage[3]-as-address
A2MLOADMem[0x40] Storage[3]-as-address

在代码的最开始,我们将 Mem[0x40] 设置为 0x80。 如果我们在后面部分查找 0x40,会发现我们没有更改它 - 所以我们可以假设它是 0x80。

偏移量操作码堆栈
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-as-address
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-as-address
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address
A7CALLDATACOPY0x80 Storage[3]-as-address

将所有调用数据复制到内存,从 0x80 处开始。

偏移量操作码堆栈
A8PUSH1 0x000x00 0x80 Storage[3]-as-address
AADUP10x00 0x00 0x80 Storage[3]-as-address
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ADDUP6Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AEGASGAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AFDELEGATE_CALL

现在事情清楚了很多。 此合约可以作为代理(opens in a new tab),调用 Storage[3] 中的地址来完成真实的工作。 DELEGATE_CALL 调用另一个合约,但它保存在同一个存储中。 这意味着我们代理的受委托合约访问相同的存储空间。 调用的参数如下:

  • 燃料:所有剩余的燃料
  • 调用地址:Storage[3] 做为地址
  • 调用数据:从 0x80 开始的 CALLDATASIZE 字节数,0x80 是我们存入初始调用数据的位置
  • 返回数据:None (0x00 - 0x00) 我们将通过其他方式获取返回数据(见下文)
偏移量操作码堆栈
B0RETURNDATASIZERETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address

此处,我们将所有返回数据复制到从 0x80 开始的内存缓冲区。

偏移量操作码堆栈
B6DUP2(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B7DUP1(((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B8ISZERO(((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BCJUMPI(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BFRETURN

因此在调用之后,我们将返回数据复制到缓冲区 0x80 - 0x80+RETURNDATASIZE,如果调用成功,我们将准确地 RETURN 该缓冲区。

DELEGATECALL 失败

如果执行到此处,即到达 0xC0,意味着我们调用的合约已回滚。 由于我们只是该合约的代理,我们希望返回相同的数据并且也回滚。

偏移量操作码堆栈
C0JUMPDEST(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C3REVERT

所以我们 REVERT 至我们之前用于 RETURN 的相同缓冲区:0x80 - 0x80+RETURNDATASIZE

调用代理流程图

应用程序二进制接口调用

如果调用数据长度为四个字节或更多,这可能是一个有效的应用程序二进制接口调用。

偏移量操作码堆栈
DPUSH1 0x000x00
FCALLDATALOAD(((First word (256 bits) of the call data)))
10PUSH1 0xe00xE0 (((First word (256 bits) of the call data)))
12SHR(((first 32 bits (4 bytes) of the call data)))

Etherscan 指出 1C 是一个未知操作码,因为它是在 Etherscan 编写此功能后添加的(opens in a new tab)并且还没有更新。 最新操作码表(opens in a new tab)告诉我们这是右移操作。

偏移量操作码堆栈
13DUP1(((呼叫数据的前 32 位(4 字节))))(((呼叫数据的前 32 位(4 字节))))
14PUSH4 0x3cd8045e0x3CD8045E (((first 32 bits (4 bytes) of the call data))) (((first 32 bits (4 bytes) of the call data)))
19GT0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1APUSH2 0x00430x43 0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1DJUMPI(((first 32 bits (4 bytes) of the call data)))

通过像这样将方法签名匹配测试一分为二,平均可以省去一半的测试。 紧随其后的代码和 0x43 处的代码遵循相同的模式:DUP1 调用数据的前 32 位,PUSH4 (((method signature> ,运行 EQ 检查是否相等,然后如果方法签名匹配,JUMPI。 以下是方法签名、它们的地址以及相应的方法定义(opens in a new tab)(如果已知):

方法方法签名跳转到的偏移量
splitter()(opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()(opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()(opens in a new tab)0x2eb4a7ab0x00ED

如果没有找到匹配项,代码跳转到 0x7C 处的代理处理程序,指望我们作为代理的合约有匹配项。

应用程序二进制接口调用流程图

splitter()

偏移量操作码堆栈
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE 0 CALLVALUE
107PUSH2 0x01df0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLValUE
10EREVERT

此函数首先检查调用没有发送任何以太币。 此函数不是 payable(opens in a new tab)。 如果有人向我们发送了以太币,而那肯定是误发,我们希望 REVERT 以避免将此以太币放入他们无法取回的位置。

偏移量操作码堆栈
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] a.k.a the contract for which we are a proxy)))
114PUSH1 0x400x40 (((Storage[3] a.k.a the contract for which we are a proxy)))
116MLOAD0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] a.k.a the contract for which we are a proxy)))
12DSWAP2(((Storage[3] a.k.a the contract for which we are a proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

0x80 现在包含代理地址

偏移量操作码堆栈
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

E4 代码

这是我们第一次看到这些行,但它们与其他方法是共享的(见下文)。 所以我们将调用堆栈 X 中的值,记住在 splitter() 中此 X 的值是 0xA0。

偏移量操作码堆栈
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

因此,此代码接收堆栈 (X) 中一个内存指针,并导致合约 RETURN 缓冲区 0x80 - X。

对于 splitter() 方法,将返回我们作为代理的地址。 RETURN 返回 0x80-0x9F 之间的缓冲区,这是我们写入此数据的位置(上面的偏移量 0x130)。

currentWindow()

偏移量 0x158-0x163 中的代码与我们在 splitter() 中看到的 0x103-0x10E 中的代码相同(除 JUMPI 目标地址外),因此我们知道 currentWindow () 也不是 payable

偏移量操作码堆栈
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

DA 代码

此代码也与其他方法共享。 所以我们将调用堆栈 Y 中的值,并且记住在 currentWindow() 中这个 Y 的值是 Storage[1]

偏移量操作码堆栈
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 Y 0xDA

将 Y 写入 0x80-0x9F。

偏移量操作码堆栈
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

其余部分已在上面解释过。 所以跳转到 0xDA,将栈顶 (Y) 的值写入 0x80-0x9F,并返回该值。 对于 currentWindow() 方法,返回 Storage[1]

merkleRoot()

偏移量 0xED-0xF8 中的代码与我们在 splitter() 中看到的 0x103-0x10E 中的代码相同(除 JUMPI 目标地址外),因此我们知道 merkleRoot () 也不是 payable

偏移量操作码堆栈
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

我们已经弄清楚了跳转后会发生什么。 嗯,merkleRoot() 返回 Storage[0]

0x81e580d3

偏移量 0x138-0x143 中的代码与我们在 splitter() 中看到的 0x103-0x10E 中的代码相同(除 JUMPI 目标地址外),因此我们知道此函数也不是 payable

偏移量操作码堆栈
144JUMPDEST
145POP
146PUSH2 0x00da0xDA
149PUSH2 0x01530x0153 0xDA
14CCALLDATASIZECALLDATASIZE 0x0153 0xDA
14DPUSH1 0x040x04 CALLDATASIZE 0x0153 0xDA
14FPUSH2 0x018f0x018F 0x04 CALLDATASIZE 0x0153 0xDA
152JUMP0x04 CALLDATASIZE 0x0153 0xDA
18FJUMPDEST0x04 CALLDATASIZE 0x0153 0xDA
190PUSH1 0x000x00 0x04 CALLDATASIZE 0x0153 0xDA
192PUSH1 0x200x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
194DUP30x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
195DUP5CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
196SUBCALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
197SLTCALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
198ISZEROCALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
199PUSH2 0x01a00x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19CJUMPI0x00 0x04 CALLDATASIZE 0x0153 0xDA

看起来此函数至少使用调用数据的 32 个字节(一个字)。

偏移量操作码堆栈
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

如果此函数没有获得调用数据,则交易将回滚且不会任何返回数据。

我们来看看,如果此函数确实获得了它需要的调用数据,会出现什么情况。

偏移量操作码堆栈
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xD
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) 是在方法签名之后调用数据的第一个字

偏移量操作码堆栈
1A3SWAP20x0153 CALLDATASIZE calldataload(4) 0xDA
1A4SWAP1CALLDATASIZE 0x0153 calldataload(4) 0xDA
1A5POP0x0153 calldataload(4) 0xDA
1A6JUMPcalldataload(4) 0xDA
153JUMPDESTcalldataload(4) 0xDA
154PUSH2 0x016e0x016E calldataload(4) 0xDA
157JUMPcalldataload(4) 0xDA
16EJUMPDESTcalldataload(4) 0xDA
16FPUSH1 0x040x04 calldataload(4) 0xDA
171DUP2calldataload(4) 0x04 calldataload(4) 0xDA
172DUP20x04 calldataload(4) 0x04 calldataload(4) 0xDA
173SLOADStorage[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(4) 0x04 calldataload(4) 0xDA

如果第一个字不小于 Storage[4],则函数失败。 此函数回滚且没有任何返回值:

偏移量操作码堆栈
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

如果 calldataload(4) 小于 Storage[4],我们得到以下代码:

偏移量操作码堆栈
17EJUMPDESTcalldataload(4) 0x04 calldataload(4) 0xDA
17FPUSH1 0x000x00 calldataload(4) 0x04 calldataload(4) 0xDA
181SWAP20x04 calldataload(4) 0x00 calldataload(4) 0xDA
182DUP30x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA
183MSTOREcalldataload(4) 0x00 calldataload(4) 0xDA

并且内存位置 0x00-0x1F 现在包含数据 0x04(0x00-0x1E 中都是零,0x1F 中是 4)

偏移量操作码堆栈
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 of 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 of 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

所以存储中有一个查找表,它从 0x000...0004 的 SHA3 开始,并且为每个合法调用数据值(低于 Storage[4] 位置的值)提供一个数据项。

偏移量操作码堆栈
18BSWAP1calldataload(4) Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA

我们已经知道偏移量 0xDA 处的代码执行的操作,它将栈顶值返回给调用者。 所以此函数将查找表中的值返回给调用者。

0x1f135823

偏移量 0xC4-0xCF 中的代码与我们在 splitter() 中看到的 0x103-0x10E 中的代码相同(除 JUMPI 目标地址外),因此我们知道此函数也不是 payable

偏移量操作码堆栈
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

我们已经知道偏移量 0xDA 处的代码执行的操作,它将栈顶值返回给调用者。 所以此函数返回 Value*

方法摘要

你觉得自己现在理解合约了? 我认为没有。 目前为止,我们使用了以下方法:

方法意义
Transfer接受调用提供的值并将 Value* 增加该数量
splitter()返回 Storage[3],代理地址
currentWindow()返回 Storage[1]
merkleRoot()返回 Storage[0]
0x81e580d3返回查找表中的值,前提是参数小于 Storage[4]
0x1f135823返回 Storage[6], a.k.a. 值*

但是我们知道 Storage[3] 中的合约还提供了其他功能。 也许如果我们知道该合约是什么,它就会给我们一条线索。 值得庆幸的是,这是区块链,一切都是已知的,至少理论上是这样。 我们没有看到任何设置 Storage[3] 的方法,所以它一定是由构造函数设置的。

构造函数

当我们查看合约(opens in a new tab)时,我们还可以看到创建它的交易。

点击创建的交易

如果我们点击该交易,然后点击 State 选项卡,我们可以看到参数的初始值。 具体来讲,我们可以看到 Storage[3] 包含 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761(opens in a new tab)。 该合约必须包含缺少的功能。 我们可以借助我们正在研究的合约中使用的相同工具来理解它。

代理合约

借助我们用于上述初始合约的相同技术,我们可以看到该合约在以下情况回滚:

  • 调用 (0x05-0x0F) 附加了任何以太币
  • 调用数据长度小于 4(0x10-0x19 和 0xBE-0xC2)

它支持以下方法:

方法方法签名跳转到的偏移量
scaleAmountByPercentage(uint256,uint256)(opens in a new tab)0x8ffb5c970x0135
isClaimed(uint256,address)(opens in a new tab)0xd2ef07950x0151
claim(uint256,address,uint256,bytes32[])(opens in a new tab)0x2e7ba6ef0x00F4
incrementWindow()(opens in a new tab)0x338b1d310x0110
???0x3f26479e0x0118
???0x1e7df9d30x00C3
currentWindow()(opens in a new tab)0xba0bafb40x0148
merkleRoot()(opens in a new tab)0x2eb4a7ab0x0107
???0x81e580d30x0122
???0x1f1358230x00D8

我们可以忽略最下面的四种方法,因为我们永远都不会遇到它们。 由于这些方法的签名,我们的初始合约将自行处理它们(可以点击上面的签名查看详细信息),因此它们必须是被重写的方法(opens in a new tab)

在剩下方法中,其中一个是 claim(<params>),另一个是isClaimed(<params>),所以此合约看起来像一个空投合约。 我们可以尝试使用反编译器(opens in a new tab),而不是逐个操作码查看其余代码,反编译器会为此合约中的以下三个函数生成有用的结果。 其他合约的逆向工程留给读者作为练习。

scaleAmountByPercentage

这是反编译器为此函数提供的结果:

1def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
2 require calldata.size - 4 >=64
3 if _param1 and _param2 > -1 / _param1:
4 revert with 0, 17
5 return (_param1 * _param2 / 100 * 10^6)
复制

第一个 require 测试调用数据除了有函数签名的 4 个字节外,至少还要有 64 个字节,方可容纳两个参数。 如果不是,那么显然有问题。

if 语句似乎检查 _param1 不为零,并且 _param1 * _param2 不是负数。 这可能是为了防止发生回绕的情况。

最后,此函数返回一个调整后的值。

claim

反编译器创建的代码很复杂,并不是所有代码都与我们相关。 这里将跳过其中一些内容,专注于我认为提供有用信息的行

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
2 ...
3 require _param2 == addr(_param2)
4 ...
5 if currentWindow <= _param1:
6 revert with 0, 'cannot claim for a future window'
复制

我们在这里看到两件重要的事情:

  • _param2,虽然被声明为 uint256,但实际上是一个地址
  • _param1 是被声明的窗口,它必须是 currentWindow 或更早。
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'
复制

所以现在我们知道 Storage[5] 是一个窗口和地址数组,并知道该地址是否申领了此窗口内奖励。

1 ...
2 idx = 0
3 s = 0
4 while idx < _param4.length:
5 ...
6 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
7 mem[mem[64] + 32] = mem[(32 * idx) + 296]
8 ...
9 s = sha3(mem[_62 + 32 len mem[_62]])
10 continue
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 continue
14 if unknown2eb4a7ab != s:
15 revert with 0, 'Invalid proof'
显示全部
复制

我们知道 unknown2eb4a7ab 实际上是函数 merkleRoot(),所以此代码看起来像是在验证一个默克尔证明(opens in a new tab)。 这意味着 _param4 是默克尔证明。

1 call addr(_param2) with:
2 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
3 gas 30000 wei
复制

这就是合约将自己的以太币转账到另一个地址(合约地址或外部地址)的方式。 它使用一个值来调用该函数,该值是要转账的金额。 因此,该合约看起来像是一次以太币空投。

1 if not return_data.size:
2 if not ext_call.success:
3 require ext_code.size(stor2)
4 call stor2.deposit() with:
5 value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
复制

下面两行表明 Storage[2] 也是我们调用的合约。 如果我们查阅构造函数交易(opens in a new tab),就会看到此合约是 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2(opens in a new tab),即一个封装的以太币合约,其源代码已经上传到 Etherscan(opens in a new tab)

是的,看起来这些合约试图将以太币发送到 _param2。 如果它可以做到,那就太好了。 如果做不到,它会尝试发送 WETH(opens in a new tab)。 如果 _param2 是外部帐户(EOA),那么它总是可以收到以太币,但合约帐户可以拒绝接收以太币。 但是,WETH 是 ERC-20,合约不能拒绝接受。

1 ...
2 log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)
复制

在此函数结束时,我们看到正在生成一个日志项。 查看生成的日志项(opens in a new tab)并筛选以 0xdbd5... 开头的主题。 如果点击生成此类项的其中一个交易(opens in a new tab),我们会看到它确实看起来像一个声明 - 该帐户向我们进行逆向工程的合约发送一条消息并获得了以太币。

声明交易

1e7df9d3

此函数和上面的 claim 非常相似。 它也检查默克尔证明,尝试将以太币转账到第一个参数,并生成相同类型的日志项。

1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
2 ...
3 idx = 0
4 s = 0
5 while idx < _param3.length:
6 if idx >= mem[96]:
7 revert with 0, 50
8 _55 = mem[(32 * idx) + 128]
9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
10 ...
11 s = sha3(mem[_58 + 32 len mem[_58]])
12 continue
13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
14 ...
15 if unknown2eb4a7ab != s:
16 revert with 0, 'Invalid proof'
17 ...
18 call addr(_param1) with:
19 value s wei
20 gas 30000 wei
21 if not return_data.size:
22 if not ext_call.success:
23 require ext_code.size(stor2)
24 call stor2.deposit() with:
25 value s wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
显示全部
复制

主要区别在于第一个参数,即要提款的窗口,不存在。 相反,所有可以声明的窗口都有一个循环。

1 idx = 0
2 s = 0
3 while idx < currentWindow:
4 ...
5 if stor5[mem[0]]:
6 if idx == -1:
7 revert with 0, 17
8 idx = idx + 1
9 s = s
10 continue
11 ...
12 stor5[idx][addr(_param1)] = 1
13 if idx >= unknown81e580d3.length:
14 revert with 0, 50
15 mem[0] = 4
16 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
17 revert with 0, 17
18 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
19 revert with 0, 17
20 if idx == -1:
21 revert with 0, 17
22 idx = idx + 1
23 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
24 continue
显示全部
复制

所以它看起来像一个声明所有窗口的 claim 变体。

总结

现在,你应该知道如何通过操作码或反编译器(当它有效时)理解没有源代码的智能合约。 从本文的篇幅明显可以看出,对合约进行逆向工程并非易事,但在安全性至关重要的系统中,能够验证合约是否按承诺运作是一项重要技能。

上次修改时间: @wackerow(opens in a new tab), 2024年4月2日

本教程对你有帮助吗?