跳转到主要内容

逆向工程合约

evm
操作码
高级
奥里·波梅兰茨
2021年12月30日
39 分钟阅读

简介

区块链上没有秘密,发生的一切都是一致的、可验证的且公开可用的。理想情况下,合约应该在 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)手动逆向工程并理解合约,以及如何解释反编译器的结果。

为了能够理解本文,你应该已经了解 EVM 的基础知识,并且至少对 EVM 汇编程序有一定的熟悉。你可以在这里阅读有关这些主题的内容 (opens in a new tab)

准备可执行代码

你可以通过在 Etherscan 上访问该合约,点击 Contract 选项卡,然后点击 Switch to Opcodes View 来获取操作码。你将看到每行一个操作码的视图。

Opcode View from Etherscan

然而,为了能够理解跳转(jump),你需要知道每个操作码在代码中的位置。为此,一种方法是打开一个 Google 电子表格,并将操作码粘贴到 C 列。你可以通过复制这份已经准备好的电子表格来跳过以下步骤 (opens in a new tab)

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

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

首先,该函数为操作码本身添加一个字节,然后查找 PUSH。PUSH 操作码比较特殊,因为它们需要额外的字节来存储被压入的值。如果操作码是 PUSH,我们将提取字节数并将其相加。

A1 中输入第一个偏移量,即零。然后,在 A2 中输入此函数,并再次将其复制并粘贴到 A 列的其余部分:

=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. 读取调用数据大小。通常,以太坊合约的调用数据遵循应用程序二进制接口 (ABI) (opens in a new tab),这至少需要四个字节作为函数选择器。如果调用数据大小小于四,则跳转到 0x5E。

Flowchart for this portion

0x5E 处的处理程序(用于非 ABI 调用数据)

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

此代码片段以 JUMPDEST 开始。如果你跳转到一个不是 JUMPDEST 的操作码,EVM(以太坊虚拟机)程序将抛出异常。然后它会检查 CALLDATASIZE,如果为“真”(即不为零),则跳转到 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] 的值。我们还不知道这个值是什么,但我们可以寻找合约在没有调用数据的情况下接收到的交易。仅转账 ETH 而没有任何调用数据(因此也没有方法)的交易在 Etherscan 中的方法为 Transfer。事实上,合约收到的第一笔交易 (opens in a new tab)就是一笔转账。

如果我们查看该交易并点击 Click to see More(点击查看更多),我们会看到调用数据(称为输入数据)确实为空 (0x)。还要注意,该值为 1.559 ETH,这在后面会用到。

The call data is empty

接下来,点击 State(状态)选项卡并展开我们正在逆向工程的合约 (0x2510...)。你可以看到 Storage[6] 在交易期间确实发生了变化,如果你将 Hex(十六进制)更改为 Number(数字),你会看到它变成了 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 ETH。在撰写本文时,ETH 的总供应量不到两亿 (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

最后,清空栈(这并非必要),并发出交易成功结束的信号。

总而言之,这是初始代码的流程图。

Entry point flowchart

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

这个跳转是多余的,因为我们要进入下一个操作码。这段代码的 gas 效率远未达到最佳状态。

偏移量操作码
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 调用一个独立的合约,但保留在相同的存储中。这意味着被委托的合约(即我们为其代理的合约)访问相同的存储空间。调用的参数是:

  • Gas:所有剩余的 gas
  • 被调用的地址:Storage[3]-as-address
  • 调用数据:从 0x80 开始的 CALLDATASIZE 个字节,这是我们放置原始调用数据的地方
  • 返回数据:无 (0x00 - 0x00) 我们将通过其他方式获取返回数据(见下文)
偏移量操作码
B0RETURNDATASIZERETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address

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

偏移量操作码
B6DUP2(((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
B7DUP1(((调用成功/失败))) (((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
B8ISZERO(((调用是否失败))) (((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((调用是否失败))) (((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
BCJUMPI(((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
BFRETURN

因此,在调用之后,我们将返回数据复制到缓冲区 0x80 - 0x80+RETURNDATASIZE,如果调用成功,我们随后使用完全相同的缓冲区执行 RETURN

DELEGATECALL 失败

如果我们到达这里,即 0xC0,这意味着我们调用的合约已回退。由于我们只是该合约的代理,我们希望返回相同的数据并同样回退。

偏移量操作码
C0JUMPDEST(((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((调用成功/失败))) RETURNDATASIZE (((调用成功/失败))) 0x80 Storage[3]-as-address
C3REVERT

因此,我们使用之前用于 RETURN 的相同缓冲区执行 REVERT:0x80 - 0x80+RETURNDATASIZE

Call to proxy flowchart

ABI 调用

如果调用数据的大小为 4 个字节或更多,这可能是一个有效的 ABI 调用。

偏移量操作码
DPUSH1 0x000x00
FCALLDATALOAD(((调用数据的第一个字(256 位))))
10PUSH1 0xe00xE0 (((调用数据的第一个字(256 位))))
12SHR(((调用数据的前 32 位(4 字节))))

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

偏移量操作码
13DUP1(((调用数据的前 32 位(4 字节)))) (((调用数据的前 32 位(4 字节))))
14PUSH4 0x3cd8045e0x3CD8045E (((调用数据的前 32 位(4 字节)))) (((调用数据的前 32 位(4 字节))))
19GT0x3CD8045E>调用数据的前32位 (((调用数据的前 32 位(4 字节))))
1APUSH2 0x00430x43 0x3CD8045E>调用数据的前32位 (((调用数据的前 32 位(4 字节))))
1DJUMPI(((调用数据的前 32 位(4 字节))))

像这样将方法签名匹配测试一分为二,平均可以节省一半的测试。紧随其后的代码和 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 处的代理处理程序,希望我们所代理的合约中存在匹配项。

ABI calls flowchart

splitter()

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

此函数要做的第一件事是检查调用是否没有发送任何 ETH。此函数不是 payable (opens in a new tab)。如果有人向我们发送了 ETH,那一定是个错误,我们希望 REVERT 以避免这些 ETH 留在他们无法取回的地方。

偏移量操作码
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] 即我们作为其代理的合约)))
114PUSH1 0x400x40 (((Storage[3] 即我们作为其代理的合约)))
116MLOAD0x80 (((Storage[3] 即我们作为其代理的合约)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] 即我们作为其代理的合约)))
12CSWAP10x80 0xFF...FF (((Storage[3] 即我们作为其代理的合约)))
12DSWAP2(((Storage[3] 即我们作为其代理的合约))) 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),并使合约使用 0x80 - X 的缓冲区执行 RETURN

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 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 0xDA
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],即 Value*

但我们知道任何其他功能都是由 Storage[3] 中的合约提供的。也许如果我们知道那个合约是什么,它会给我们一些线索。值得庆幸的是,这是区块链,一切都是公开透明的,至少在理论上是这样。我们没有看到任何设置 Storage[3] 的方法,因此它一定是由构造函数设置的。

构造函数

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

Click the create transaction

如果我们点击该交易,然后点击状态选项卡,我们就可以看到参数的初始值。具体来说,我们可以看到 Storage[3] 包含 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab)。该合约必定包含缺失的功能。我们可以使用与调查当前合约相同的工具来理解它。

代理合约

使用与上述原始合约相同的技术,我们可以看到该合约在以下情况下会回退:

  • 调用中附带了任何 ETH (0x05-0x0F)
  • 调用数据大小小于 4 (0x10-0x19 和 0xBE-0xC2)

并且它支持的方法有:

我们可以忽略底部的四个方法,因为我们永远不会执行到它们。它们的签名表明我们的原始合约会自行处理它们(你可以点击签名查看上面的详细信息),因此它们必须是被重写的方法 (opens in a new tab)

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

scaleAmountByPercentage

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

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

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

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

最后,该函数返回一个按比例缩放的值。

claim

反编译器生成的代码很复杂,并非所有代码都与我们相关。我将跳过其中一些,重点关注我认为提供有用信息的代码行

def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
  ...
  require _param2 == addr(_param2)
  ...
  if currentWindow <= _param1:
      revert with 0, 'cannot claim for a future window'

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

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

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

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

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

这就是合约将其自身的 ETH 转账到另一个地址(合约或外部拥有账户)的方式。它使用要转账的金额作为值来调用它。所以这看起来像是一次 ETH 空投。

  if not return_data.size:
      if not ext_call.success:
          require ext_code.size(stor2)
          call stor2.deposit() with:
             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)

所以看起来合约试图将 ETH 发送到 _param2。如果能成功,那很好。如果不成功,它会尝试发送 WETH (opens in a new tab)。如果 _param2 是一个外部拥有账户 (EOA),那么它总是可以接收 ETH,但合约可以拒绝接收 ETH。然而,WETH 是 ERC-20 代币,合约不能拒绝接受它。

  ...
  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),我们会发现它确实看起来像是一次申领——该账户向我们正在逆向工程的合约发送了一条消息,作为回报获得了 ETH。

A claim transaction

1e7df9d3

这个函数与上面的 claim 非常相似。它也检查默克尔证明,尝试将 ETH 转账给第一个地址,并生成相同类型的日志条目。

主要区别在于,第一个参数(要提取的窗口)不存在。取而代之的是一个遍历所有可申领窗口的循环。

所以它看起来像是一个申领所有窗口的 claim 变体。

结论

到目前为止,你应该已经知道如何使用操作码或(在可行的情况下)反编译器来理解那些没有源代码的合约。从本文的篇幅可以明显看出,逆向工程一个合约绝非易事,但在一个安全性至关重要的系统中,能够验证合约是否按预期工作是一项重要的技能。

点击此处查看我的更多作品 (opens in a new tab)