跳至主要內容

逆向工程合約

evm
操作碼
進階
奧里·波梅蘭茨
2021年12月30日
40 分鐘閱讀

簡介

區塊鏈上沒有秘密,發生的每一件事都是一致、可驗證且公開透明的。理想情況下,合約應該在 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 呼叫

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

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

Etherscan 告訴我們 1C 是一個未知的操作碼,因為它是在 Etherscan 編寫此功能之後才加入的 (opens in a new tab),而他們尚未更新。一份最新的操作碼表 (opens in a new tab)顯示這是向右位移 (shift right)。

偏移量操作碼堆疊
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 是正在被申領的視窗 (window),它必須是 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),這是一個包裝以太幣 (wETH) 合約,其原始碼已上傳至 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)