跳至主要内容

對合約進行逆向工程

evm
opcodes
進階
Ori Pomerantz
2021年12月30日
39 分鐘閱讀

介紹

區塊鏈上沒有秘密,發生的一切都是一致、可驗證且公開的。 理想情況下,合約應在 Etherscan 上發布並驗證其原始碼opens in a new tab。 然而,情況並非總是如此opens in a new tab。 在本文中,您將學習如何透過查看一個沒有原始碼的合約 0x2510c039cc3b061d79e564b38836da87e31b342fopens in a new tab 來對其進行逆向工程。

有反向編譯器,但它們並不總能產生可用的結果opens in a new tab。 在本文中,您將學習如何手動從 opcodesopens in a new tab 逆向工程並理解一個合約,以及如何解釋反編譯器的結果。

要理解本文,您應該已經了解 EVM 的基礎知識,並至少對 EVM 組譯器有些熟悉。 您可以在此處閱讀有關這些主題的資訊opens in a new tab

準備可執行程式碼

您可以前往合約的 Etherscan 頁面,點擊 Contract 標籤,然後點擊 Switch to Opcodes View 來取得 opcodes。 您將得到一個每行一個 opcode 的視圖。

來自 Etherscan 的 Opcode 視圖

然而,為了能夠理解跳轉,您需要知道每個 opcode 在程式碼中的位置。 要做到這一點,一種方法是打開一個 Google 試算表並將 opcodes 貼到 C 欄。您可以透過建立這個已準備好的試算表的副本來跳過以下步驟opens in a new tab

下一步是取得正確的程式碼位置,以便我們能夠理解跳轉。 我們將 opcode 大小放在 B 欄,位置(十六進位)放在 A 欄。在儲存格 B1 中輸入此函數,然後複製並貼到 B 欄的其餘部分,直到程式碼結束。 完成此操作後,您可以隱藏 B 欄。

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

首先,此函數為 opcode 本身增加一個位元組,然後尋找 PUSH。 Push opcodes 很特殊,因為它們需要額外的位元組來儲存被推送的值。 如果 opcode 是 PUSH,我們就提取位元組數並將其加上。

A1 中放入第一個位移量,零。 然後,在 A2 中放入此函數,並再次複製貼到 A 欄的其餘部分:

1=dec2hex(hex2dec(A1)+B1)

我們需要此函數來提供十六進位值,因為在跳轉(JUMPJUMPI)之前推送的值是以十六進位形式給我們的。

進入點 (0x00)

合約總是從第一個位元組開始執行。 這是程式碼的初始部分:

位移Opcode堆疊(在 opcode 之後)
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 大小。 通常,以太坊合約的呼叫資料遵循應用程式二進位介面 (ABI)opens in a new tab,該介面至少需要四個位元組用於函數選擇器。 如果呼叫資料大小小於四,跳轉到 0x5E。

這部分的流程圖

0x5E 的處理常式(用於非 ABI 呼叫資料)

位移Opcode
5EJUMPDEST
5ECALLDATASIZE
60PUSH2 0x007c
63JUMPI

此程式碼片段以 JUMPDEST 開頭。 EVM(以太坊虛擬機)程式如果跳轉到不是 JUMPDEST 的 opcode,將會拋出例外。 然後它會查看 CALLDATASIZE,如果它是「真」(即,非零),則跳轉到 0x7C。 我們稍後會講到這個。

位移Opcode堆疊(在 opcode 之後)
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 中,僅轉帳 ETH 而沒有任何呼叫資料(因此沒有方法)的交易,其方法為 Transfer。 事實上,合約收到的第一筆交易opens in a new tab 就是一筆轉帳。

如果我們查看那筆交易並點擊 Click to see More,我們會看到呼叫資料(稱為輸入資料)確實是空的(0x)。 另請注意,其價值為 1.559 ETH,這在稍後會相關。

呼叫資料為空

接下來,點擊 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 取得帳戶餘額時,沒有必要使用非常昂貴的儲存空間。 第一個 opcode 推送合約自己的地址。 第二個 opcode 讀取堆疊頂部的地址,並將其替換為該地址的餘額。

位移Opcode堆疊
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

我們將在跳轉目的地繼續追蹤這段程式碼。

位移Opcode堆疊
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 是位元運算,所以它會反轉呼叫值中每個位元的值。

位移Opcode堆疊
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

位移Opcode堆疊
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。

位移Opcode堆疊
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

如果我們到達這裡(這要求呼叫資料為空),我們會將呼叫值加到 Value* 上。 這與我們所說的 Transfer 交易的作用一致。

位移Opcode
79POP
7APOP
7BSTOP

最後,清除堆疊(這不是必要的)並標示交易成功結束。

總結一下,這是初始程式碼的流程圖。

進入點流程圖

0x7C 的處理常式

我故意不在標題中說明這個處理常式的作用。 重點不是教您這個特定合約如何運作,而是如何對合約進行逆向工程。 您將透過追蹤程式碼,以與我相同的方式了解它的作用。

我們從幾個地方來到這裡:

  • 如果呼叫資料為 1、2 或 3 個位元組(從位移 0x63 開始)
  • 如果方法簽名未知(從位移 0x42 和 0x5D 開始)
位移Opcode堆疊
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

這是另一個儲存單元,我在任何交易中都找不到它,所以很難知道它的意思。 下面的程式碼將使其更清晰。

位移Opcode堆疊
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

這些 opcodes 將我們從 Storage[3] 讀取的值截斷為 160 位元,即以太坊地址的長度。

位移Opcode堆疊
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

這個跳轉是多餘的,因為我們要去下一個 opcode。 這段程式碼的 gas 效率遠不及應有的水平。

位移Opcode堆疊
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。

位移Opcode堆疊
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 開始。

位移Opcode堆疊
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) 我們將透過其他方式取得返回資料(見下文)
位移Opcode堆疊
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 開始的記憶體緩衝區。

位移Opcode堆疊
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,這意味著我們呼叫的合約已還原。 由於我們只是該合約的代理,我們希望返回相同的資料並也進行還原。

位移Opcode堆疊
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

代理呼叫流程圖

ABI 呼叫

如果呼叫資料大小為四個位元組或更多,這可能是一個有效的 ABI 呼叫。

位移Opcode堆疊
DPUSH1 0x000x00
FCALLDATALOAD(((呼叫資料的第一個字 (256 位元)))
10PUSH1 0xe00xE0 (((呼叫資料的第一個字 (256 位元)))
12SHR(((呼叫資料的前 32 位元 (4 位元組)))

Etherscan 告訴我們 1C 是一個未知的 opcode,因為它是在 Etherscan 編寫此功能後添加的opens in a new tab,而且他們尚未更新。 一份最新的 opcode 表格opens in a new tab顯示這是向右移位

位移Opcode堆疊
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 (((方法簽名))),執行 EQ 檢查是否相等,然後如果方法簽名匹配則 JUMPI。 以下是方法簽名、它們的地址,以及(如果已知)相應的方法定義opens in a new tab

方法方法簽名跳轉到的位移
splitter()opens in a new tab0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()opens in a new tab0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()opens in a new tab0x2eb4a7ab0x00ED

如果找不到匹配項,程式碼會跳轉到 0x7C 的代理處理常式,希望我們作為代理的合約有匹配項。

ABI 呼叫流程圖

splitter()

位移Opcode堆疊
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

這個函數首先檢查呼叫是否發送了任何 ETH。 這個函數不是 payableopens in a new tab。 如果有人向我們發送 ETH,那一定是個錯誤,我們希望 REVERT 以避免那些 ETH 留在他們無法取回的地方。

位移Opcode堆疊
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 現在包含代理地址

位移Opcode堆疊
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

E4 程式碼

這是我們第一次看到這些行,但它們與其他方法共用(見下文)。 所以我們將堆疊中的值稱為 X,並記住,在 splitter() 中,這個 X 的值是 0xA0。

位移Opcode堆疊
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

位移Opcode堆疊
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

DA 程式碼

這段程式碼也與其他方法共用。 所以我們將堆疊中的值稱為 Y,並記住,在 currentWindow() 中,這個 Y 的值是 Storage[1]。

位移Opcode堆疊
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

將 Y 寫入 0x80-0x9F。

位移Opcode堆疊
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

其餘的已在上面解釋過了。 所以跳轉到 0xDA 會將堆疊頂部 (Y) 寫入 0x80-0x9F,並返回該值。 在 currentWindow() 的情況下,它返回 Storage[1]。

merkleRoot()

位移 0xED-0xF8 中的程式碼與我們在 splitter() 的 0x103-0x10E 中看到的相同(除了 JUMPI 目的地),所以我們知道 merkleRoot() 也不是 payable

位移Opcode堆疊
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

位移Opcode堆疊
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 個位元組(一個字)的呼叫資料。

位移Opcode堆疊
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

如果它沒有得到呼叫資料,交易會被還原而沒有任何返回資料。

讓我們看看如果函數_確實_得到了它需要的呼叫資料會發生什麼。

位移Opcode堆疊
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) 是呼叫資料中方法簽名_之後_的第一個字

位移Opcode堆疊
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],函數將失敗。 它會還原而不返回任何值:

位移Opcode堆疊
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

如果 calldataload(4) 小於 Storage[4],我們得到這段程式碼:

位移Opcode堆疊
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 為四)

位移Opcode堆疊
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])提供一個條目。

位移Opcode堆疊
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

位移Opcode堆疊
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

我們已經知道位移 0xDA 的程式碼 的作用,它將堆疊頂部的值返回給呼叫者。 所以這個函數返回 Value*

方法摘要

此時您是否覺得您已經理解了合約? 我不覺得。 到目前為止,我們有這些方法:

方法意義
轉帳接受呼叫提供的值,並將 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時,我們也可以看到創建它的交易。

點擊創建交易

如果我們點擊那筆交易,然後點擊 State 標籤,我們可以看到參數的初始值。 具體來說,我們可以看到 Storage[3] 包含 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761opens in a new tab。 該合約必須包含缺失的功能。 我們可以使用用於我們正在調查的合約的相同工具來理解它。

代理合約

使用我們用於上面原始合約的相同技術,我們可以看到合約在以下情況下會還原:

  • 呼叫附加了任何 ETH (0x05-0x0F)
  • 呼叫資料大小小於四 (0x10-0x19 和 0xBE-0xC2)

它支援的方法是:

我們可以忽略最下面的四個方法,因為我們永遠不會到達它們。 它們的簽名使得我們的原始合約會自行處理它們(您可以點擊簽名以查看上面的詳細資訊),所以它們必須是被覆蓋的方法opens in a new tab

剩下的方法之一是 claim(<params>),另一個是 isClaimed(<params>),所以它看起來像一個空投合約。 與其逐個 opcode 瀏覽其餘部分,我們可以嘗試反編譯器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 測試呼叫資料除了函數簽名的四個位元組外,至少還有 64 個位元組,足以容納兩個參數。 如果不是,顯然有問題。

if 語句似乎檢查 _param1 是否不為零,以及 _param1 * _param2 是否不為負。 這可能是為了防止環繞的情況。

最後,函數返回一個縮放後的值。

申領

反編譯器創建的程式碼很複雜,並非所有程式碼都與我們相關。 我將跳過其中一部分,專注於我認為提供有用資訊的行

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() 函數,所以這段程式碼看起來像是在驗證一個 merkle 證明opens in a new tab。 這意味著 _param4 是一個 merkle 證明。

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

這是一個合約將自己的 ETH 轉移到另一個地址(合約或外部擁有)的方式。 它以要轉移的金額作為值來呼叫它。 所以看起來這是一次 ETH 空投。

1if 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,我們會看到這個合約是 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2opens in a new tab,一個 其原始碼已上傳到 Etherscan 的opens in a new tab 包裝以太幣合約。

所以看起來合約試圖將 ETH 發送給 _param2。 如果能成功,那就太好了。 如果不行,它會嘗試發送 WETHopens in a new tab。 如果 _param2 是一個外部擁有的帳戶 (EOA),那麼它總是可以接收 ETH,但合約可以拒絕接收 ETH。 然而,WETH 是 ERC-20,合約不能拒絕接受它。

1log 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。

一次申領交易

1e7df9d3

這個函數與上面的 claim 非常相似。 它還會檢查 merkle 證明,嘗試將 ETH 轉移給第一個,並產生相同類型的日誌條目。

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 變體。

結論

到目前為止,您應該知道如何使用 opcodes 或(當它起作用時)反編譯器來理解沒有原始碼的合約。 從本文的篇幅可以明顯看出,對合約進行逆向工程並非易事,但在一個安全至關重要的系統中,能夠驗證合約是否如承諾般運作是一項重要的技能。

在此查看我的更多作品opens in a new tab

頁面最後更新時間: 2025年8月22日

這個使用教學對你有幫助嗎?