逆向分析以太坊智能合約
譯文聲明
本文是翻譯文章,文章原作者Brandon Arvanaghi,文章來源:http://arvanaghi.com 原文地址:https://arvanaghi.com/blog/reversing-ethereum-smart-contracts/
一、前言
在本文中,我會向大家介紹以太坊虛擬機(Ethereum Virtual Machine,EVM)的工作原理,以及如何對智能合約(smart contract)進行逆向分析。
為了反彙編智能合約,我使用了Trail of Bits開發的適用於Binary Ninja的Ethersplay插件。
二、以太坊虛擬機
以太坊虛擬機(EVM)是一種基於棧的、准圖靈完備(quasi-Turing complete)的虛擬機。
1)基於棧:EVM並不依賴寄存器,任何操作都會在棧中完成。操作數、運算符以及函數調用都置於棧中,並且EVM知道如何處理數據、執行智能合約。
以太坊使用Postfix Notation(後綴表示法)來實現基於棧的運行機制。簡而言之,操作符最後壓入棧,可以作用於先前壓入棧的數據。
舉個例子:來看一下2 + 2
操作,在腦海中,我們知道中間的運算符(+
)表示我們想執行2加2這個操作。將+
放在兩個操作數之間是一種辦法,我們也可以將它放在兩個操作數後面,即2 2 +
,這就是後綴表示法。
2)准圖靈完備:如果一切可計算的問題都能計算,那麼這樣的編程語言或者代碼執行引擎就可以稱為「圖靈完備(Turing complete)」。這個概念並不在意解決問題的時間長短,只要理論上該問題能被解決即可。比特幣腳本語言不能稱為圖靈完備語言,因為該語言的應用場景非常有限。
在EVM中,我們可以解決所有問題。但我們還是將其成為「准圖靈完備」,這主要是因為成本限制問題。gas
是EVM中的一個可計算單位,可以用來衡量操作所需的成本。當某人在區塊鏈上發起交易時,交易代碼以及待執行的任何後續代碼都需要在礦工的主機上執行。由於代碼需要在礦工的內存中執行,這個過程會消耗礦工主機的成本,如電力成本、內存以及CPU計算成本等。
為了激勵礦工來保證交易順利進行,發起交易的那個人需要聲明gas price
,或者他們願意為每個計算單元支付的價格。將這個因素考慮在內後,對於非常複雜的問題,所需的gas量將變得非常龐大,此時由於我們需要為gas定價,因此在以太坊中,從經濟角度來考慮的話複雜的交易並不划算。
三、Bytecode以及Runtime Bytecode
在編譯合約時,我們可以得到contract bytecode(合約位元組碼)或者runtime bytecode(運行時位元組碼)。
contract bytecode是最終存儲在區塊鏈中的位元組碼,也是將位元組碼存放在區塊鏈、初始化智能合約(運行構造函數)時所需的位元組碼。
runtime bytecode只對應於存儲在區塊鏈中的位元組碼,與合約初始化和存放過程無關。
讓我們以Greeter.sol
合約為例來分析兩者的不同點。
contract mortal { /* Define variable owner of the type address */ address owner; /* This function is executed at initialization and sets the owner of the contract */ function mortal() { owner = msg.sender; } /* Function to recover the funds on the contract */ function kill() { if (msg.sender == owner) selfdestruct(owner); }}contract greeter is mortal { /* Define variable greeting of the type string */ string greeting; /* This runs when the contract is executed */ function greeter(string _greeting) public { greeting = _greeting; } /* Main function */ function greet() constant returns (string) { return greeting; }}
使用solc --bin Greeter.sol
命令來編譯合約、獲取合約位元組碼時,我們可以得到如下結果:
6060604052341561000f57600080fd5b6040516103a93803806103a983398101604052808051820191905050336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060019080519060200190610081929190610088565b505061012d565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282601f106100c957805160ff19168380011785556100f7565b828001600101855582156100f7579182015b828111156100f65782518255916020019190600101906100db565b5b5090506101049190610108565b5090565b61012a91905b8082111561012657600081600090555060010161010e565b5090565b90565b61026d8061013c6000396000f30060606040526004361061004c576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806341c0e1b514610051578063cfae321714610066575b600080fd5b341561005c57600080fd5b6100646100f4565b005b341561007157600080fd5b610079610185565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156100b957808201518184015260208101905061009e565b50505050905090810190601f1680156100e65780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610183576000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16ff5b565b61018d61022d565b60018054600181600116156101000203166002900480601f0160208091040260200160405190810160405280929190818152602001828054600181600116156101000203166002900480156102235780601f106101f857610100808354040283529160200191610223565b820191906000526020600020905b81548152906001019060200180831161020657829003601f168201915b5050505050905090565b6020604051908101604052806000815250905600a165627a7a723058204138c228602c9c0426658c0d46685e1d9c157ff1f92cb6e28acb9124230493210029如果使用solc --bin-runtime Greeter.sol命令來編譯時,我們可以得到如下結果:
60606040526004361061004c576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806341c0e1b514610051578063cfae321714610066575b600080fd5b341561005c57600080fd5b6100646100f4565b005b341561007157600080fd5b610079610185565b6040518080602001828103825283818151815260200191508051906020019080838360005b838110156100b957808201518184015260208101905061009e565b50505050905090810190601f1680156100e65780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610183576000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16ff5b565b61018d61022d565b60018054600181600116156101000203166002900480601f0160208091040260200160405190810160405280929190818152602001828054600181600116156101000203166002900480156102235780601f106101f857610100808354040283529160200191610223565b820191906000526020600020905b81548152906001019060200180831161020657829003601f168201915b5050505050905090565b6020604051908101604052806000815250905600a165627a7a723058204138c228602c9c0426658c0d46685e1d9c157ff1f92cb6e28acb9124230493210029
如上所示,我們可知runtime bytecode是contract bytecode的一個子集。
四、逆向分析
在本文中,我們使用Trail of Bits為Binary Ninja開發的Ethersplay插件來反彙編以太坊位元組碼。
我們的操作對象是Ethereum.org提供的Greeter.sol
合約。
首先,我們可以參考教程,將Ethersplay插件加入Binary Ninja中。這裡提醒一下,我們只逆向runtime bytecode,因為這個過程足以告訴我們合約具體做了哪些工作。
工具概覽
Ethersplay插件可以識別runtime bytecode中的所有函數,從邏輯上進行劃分。對於這個合約,Ethersplay發現了兩個函數:kill()
以及greet()
。後面我們會介紹如何提取這些函數。
第一條指令
當我們向智能合約發起交易時,首先碰到的是合約的dispatcher(調度器)。Dispatcher會處理交易數據,確定我們需要交互的具體函數。
我們在dispatcher中看到的第一條指令為:
PUSH1 0x60 // argument 2 of mstore: the value to store in memoryPUSH1 0x40 // argument 1 of mstore: where to store that value in memoryMSTORE // mstore(0x40, 0x60)PUSH1 0x4CALLDATASIZELTPUSH2 0x4cJUMPI
PUSH
指令有16個不同的版本(PUSH1
… PUSH16
)。EVM通過不同編號來了解我們往棧上壓入了多少位元組。
前兩條指令(PUSH1 0x60
以及PUSH1 0x40
)分別代表將0x60
以及 0x40
壓入棧。這些指令執行完畢後,運行時棧的布局如下:
1: 0x400: 0x60
根據Solidity官方文檔,MSTORE
的定義如下:
指令結果mstore(p, v)mem[p..(p+32)) := vEVM會從棧頂到棧底來讀取函數參數,也就是說會執行mstore(0x40, 0x60)
,這條指令與mem[0x40...0x40+32] := 0x60
作用相同。
mstore
會從棧中取出兩個元素,因此棧現在處於清空狀態。接下來的指令為:
PUSH1 0x4CALLDATASIZELTPUSH 0x4cJUMPI
PUSH1 0x4
執行後,棧中只有一個元素:
0: 0x4
CALLDATASIZE
函數會將calldata(相當於msg.data
)的大小壓入棧中。我們可以往任何智能合約發送任意數據,CALLDATASIZE
會檢查數據的大小。
調用CALLDATASIZE
後,棧的布局如下:
1: (however long the msg.data or calldata is)0: 0x4
下一條指令為LT
(即「less than」),指令功能如下:
指令結果mstore(p, v)mem[p..(p+32)) := vlt(x, y)如果x < y則為1,否則為0如果第一個參數小於第二個參數,則lt
會將1壓入棧,否則就壓入0。在我們的例子中,根據此時的棧布局,這條指令為lt((however long the msg.data or calldata is), 0x4)
(判斷msg.data或calldata的大小是否小於0x4位元組)。
為什麼EVM需要檢查我們提供的calldata大小是否至少為4位元組?這裡涉及到函數的識別過程。
EVM會通過函數keccak256
哈希的前4個位元組來識別函數。也就是說,函數原型(函數名以及所需參數)需要交給keccak256
哈希函數處理。在這個合約中,我們可以得到如下結果:
keccak256("greet()") = cfae3217...keccak256("kill()") = 41c0e1b5...
因此,greet()
的函數標識符為cfae3217
,kill()
的函數標識符為41c0e1b5
。Dispatcher會檢查我們發往合約的calldata
(或者消息數據)大小至少為4位元組,以確保我們的確想跟某個函數交互。
函數標識符大小始終為4位元組,因此如果我們發往智能合約的數據小於4位元組,那麼我們無法與任何函數交互。
事實上,我們可以在彙編代碼中看到智能合約如何拒絕不符合規定的行為。如果calldatasize
小於4位元組,那麼bytecode會立即跳到代碼尾部,結束合約執行過程。
來具體看一下判斷過程。
如果lt((however long the msg.data or calldata is), 0x4)
等於1
(為真,即calldata小於4位元組),那麼從棧中取出2個元素後,lt
會往棧中壓入1。
0: 1
接下來的指令為PUSH 0x4c
以及JUMPI
。PUSH 0x4c
指令執行後,棧的布局為:
1: 0x4c0: 1
JUMPI
代表的是「jump if」(條件滿足則跳轉),如果條件滿足,則跳轉到特定的標籤或者位置。
指令結果mstore(p, v)mem[p..(p+32)) := vlt(x, y)如果x < y則為1,否則為0jumpi(label, cond)如果cond非零則跳轉至label在這個例子中,label
為代碼中的0x4c
偏移地址,並且cond
為1,因此程序會跳轉到0x4c
偏移處。
函數調度
來看一下如何從calldata
中提取所需的函數。上一條JUMPI
指令執行完畢後,棧處於清空狀態。
第二個代碼塊中的命令如下:
PUSH1 0x0CALLDATALOADPUSH29 0x100000000....SWAP1DIVPUSH4 0xffffffffANDDUP1PUSH4 0x41c0e1b5EQPUSH2 0x51JUMPI
PUSH1 0x0
會將0壓入棧頂。
0: 0
CALLDATALOAD
指令接受一個參數,該參數可以作為發往智能合約的calldata數據的索引,然後從該索引處再讀取32個位元組,指令說明如下:
指令結果mstore(p, v)mem[p..(p+32)) := vlt(x, y)如果x < y則為1,否則為0jumpi(label, cond)如果cond非零則跳轉至labelcalldataload(p)從calldata的位置p處讀取數據(32位元組)CALLDATALOAD
會將讀取到的32位元組壓入棧頂。由於該指令收到的索引值為0(來自於PUSH1 0x0
命令),因此CALLDATALOAD
會讀取calldata中從0位元組處開始的32個位元組,然後將其壓入棧頂(先彈出棧中的0x0
)。新的棧布局為:
0: 32 bytes of calldata starting at byte 0
下一條指令為PUSH29 0x100000000....
。
1: 0x100000000....0: 32 bytes of calldata starting at byte 0
SWAPi
指令會將棧頂元素與棧中第i
個元素進行交換。在這個例子中,該指令為SWAP1
,因此指令會將棧頂元素與隨後的第一個元素交換。
指令結果mstore(p, v)mem[p..(p+32)) := vlt(x, y)如果x < y則為1,否則為0jumpi(label, cond)如果cond非零則跳轉至labelcalldataload(p)從calldata的位置p處讀取數據(32位元組)swap1 … swap16交換棧頂元素與隨後的第i個元素1: 32 bytes of calldata starting at byte 0
0: PUSH29 0x100000000....下一跳指令為DIV
,即div(x, y)
也就是x/y。在這個例子中,x為32 bytes of calldata starting at byte 0
,y為0x100000000....
。
0x100000000....
的大小為29個位元組,最開頭為1,後面全部都為0。先前我們從calldata中讀取了32個位元組,將32位元組的calldata除以10000...
,結果為calldataload從索引0開始的前4個位元組。這4個位元組其實就是函數標識符。
如果大家還不明白,可以類比一下10進位的除法,123456000 / 100 = 123456
,在16進位中運算過程與之類似。將32位元組的某個值除以29位元組的某個值,結果只保留前4個位元組。
DIV
運算的結果也會壓入棧中。
0: function identifier from calldata
接下來的指令為PUSH4 0xffffffff
以及AND
,這個例子中,對應的是將0xffffffff
與calldata發過來的函數標識符進行AND
操作。這樣就把能棧中函數標識符的後28個位元組清零。
隨後是一條DUP1
指令,可以複製棧中的第一個元素(這裡對應的是函數標識符),然後將其壓入棧頂。
1: function identifier from calldata0: function identifier from calldata
接下來是PUSH4 0x41c0e1b5
指令,這是kill()
的函數標識符。我們將該標識符壓入棧,目的是想將其與calldata的函數標識符進行對比。
2: 0x41c0e1b51: function identifier from calldata0: function identifier from calldata
下一條指令為EQ
(即eq(x,y)
),該指令會將x以及y彈出棧,如果兩者相等則壓入1,否則壓入0。這個過程正是dispatcher的「調度」過程:將calldata函數標識符與智能合約中所有的函數標識符進行對比。
1: (1 if calldata functio identifier matched kill() function identifier, 0 otherwise)0: function identifier from calldata
執行完PUSH2 0x51
後,棧的布局如下:
2: 0x511: (1 if calldata functio identifier matched kill() function identifier, 0 otherwise)0: function identifier from calldata
之所以壓入0x51
,是希望條件滿足時,可以通過JUMPI
指令跳轉到這個偏移處。換句話說,如果calldata發過來的函數標識符與kill()
匹配,那麼執行流程就會跳轉到代碼中的0x51
偏移位置(即kill()
函數所在位置)。
JUMPI
執行之後,我們要麼跳轉到0x51
偏移位置,要麼繼續執行當前流程。
現在棧中只包含一個元素:
0: function identifier from calldata
細心的讀者會注意到,如果我們沒有跳轉到kill()
函數,那麼dispatcher依然會採用相同邏輯,將calldata函數標識符與greet()
函數標識符進行對比。Dispatcher會檢查智能合約中的每個函數,如果不能找到匹配的函數,則會將我們引導至程序退出代碼。
五、總結
以上是對以太坊虛擬機工作原理的簡單介紹,大家如果想了解以太坊或者區塊鏈安全方面內容,歡迎關注我的推特。
本文翻譯自 http://arvanaghi.com, 原文鏈接 。如若轉載請註明出處。
推薦閱讀: