逆向分析以太坊智能合約

譯文聲明

本文是翻譯文章,文章原作者Brandon Arvanaghi,文章來源:arvanaghi.com

原文地址:arvanaghi.com/blog/reve

一、前言

在本文中,我會向大家介紹以太坊虛擬機(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個不同的版本(PUSH1PUSH16)。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()的函數標識符為cfae3217kill()的函數標識符為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 以及JUMPIPUSH 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會檢查智能合約中的每個函數,如果不能找到匹配的函數,則會將我們引導至程序退出代碼。

五、總結

以上是對以太坊虛擬機工作原理的簡單介紹,大家如果想了解以太坊或者區塊鏈安全方面內容,歡迎關注我的推特。

本文翻譯自 arvanaghi.com, 原文鏈接 。如若轉載請註明出處。

推薦閱讀:

如何通過以太坊智能合約來進行眾籌(ICO)
智能合約是怎樣運作的?三分鐘讀懂智能合約
教你執行以太坊智能合約或轉賬

TAG:智能合約 | 以太坊 |