作者:Hcamael@知道創宇404區塊鏈安全研究團隊
在我們對etherscan等平臺上合約進行安全審查時,常常會遇到沒有公布Solidity源代碼的合約,只能獲取到合約的OPCODE,所以一個智能合約的反編譯器對審計無源碼的智能合約起到了非常重要的作用。
目前在互聯網上常見的反編譯工具只有porosity[1],另外在Github上還找到另外的反編譯工具ethdasm[2],經過測試發現這兩個編譯器都有許多bug,無法滿足我的工作需求。因此我開始嘗試研究并開發能滿足我們自己需求的反編譯工具,在我看來如果要寫出一個優秀的反匯編工具,首先需要有較強的OPCODE逆向能力,本篇Paper將對以太坊智能合約OPCODE的數據結構進行一次深入分析。
基礎
智能合約的OPCODE是在EVM(Ethereum Virtual Machine)中進行解釋執行,OPCODE為1字節,從0x00 - 0xff代表了相對應的指令,但實際有用的指令并沒有0xff個,還有一部分未被使用,以便將來的擴展
具體指令可參考Github[3]上的OPCODE指令集,每個指令具體含義可以參考相關文檔[4]
IO
在EVM中不存在寄存器,也沒有網絡IO相關的指令,只存在對棧(stack),內存(mem), 存儲(storage)的讀寫操作
- stack
使用的push和pop對棧進行存取操作,push后面會帶上存入棧數據的長度,最小為1字節,最大為32字節,所以OPCODE從0x60-0x7f分別代表的是push1-push32
PUSH1會將OPCODE后面1字節的數據放入棧中,比如字節碼是0x6060代表的指令就是PUSH1 0x60
除了PUSH指令,其他指令獲取參數都是從棧中獲取,指令返回的結果也是直接存入棧中
- mem
內存的存取操作是MSTORE和MLOAD
MSTORE(arg0, arg1)從棧中獲取兩個參數,表示MEM[arg0:arg0+32] = arg1
MLOAD(arg0)從棧中獲取一個參數,表示PUSH32(MEM[arg0:arg0+32])
因為PUSH指令,最大只能把32字節的數據存入棧中,所以對內存的操作每次只能操作32字節
但是還有一個指令MSTORE8,只修改內存的1個字節
MSTORE(arg0, arg1)從棧中獲取兩個參數,表示MEM[arg0] = arg1
內存的作用一般是用來存儲返回值,或者某些指令有處理大于32字節數據的需求
比如: SHA3(arg0, arg1)從棧中獲取兩個參數,表示SHA3(MEM[arg0:arg0+arg1]),SHA3對內存中的數據進行計算sha3哈希值,參數只是用來指定內存的范圍
- storage
上面的stack和mem都是在EVM執行OPCODE的時候初始化,但是storage是存在于區塊鏈中,我們可以類比為計算機的存儲磁盤。
所以,就算不執行智能合約,我們也能獲取智能合約storage中的數據:
eth.getStorageAt(合約地址, slot)
# 該函數還有第三個參數,默認為"latest",還可以設置為"earliest"或者"pending",具體作用本文不做分析
storage用來存儲智能合約中所有的全局變量
使用SLOAD和SSTORE進行操作
SSTORE(arg0, arg1)從棧中獲取兩個參數,表示eth.getStorageAt(合約地址, arg0) = arg1
SLOAD(arg0)從棧中獲取一個參數,表示PUSH32(eth.getStorageAt(合約地址, arg0))
變量
智能合約的變量從作用域可以分為三種, 全局公有變量(public), 全局私有變量(private), 局部變量
全局變量和局部變量的區別是,全局變量儲存在storage中,而局部變量是被編譯進OPCODE中,在運行時,被放在stack中,等待后續使用
公有變量和私有變量的區別是,公有變量會被編譯成一個constant函數,后面會分析函數之前的區別
因為私有變量也是儲存在storage中,而storage是存在于區塊鏈當中,所以相當于私有變量也是公開的,所以不要想著用私有變量來儲存啥不能公開的數據。
全局變量的儲存模型
不同類型的變量在storage中儲存的方式也是有區別的,下面對各種類型的變量的儲存模型進行分析
1. 定長變量
第一種我們歸類為定長變量,所謂的定長變量,也就是該變量在定義的時候,其長度就已經被限制住了
比如定長整型(int/uint......), 地址(address), 定長浮點型(fixed/ufixed......), 定長字節數組(bytes1-32)
這類的變量在storage中都是按順序儲存
uint a; // slot = 0
address b; // 1
ufixed c; // 2
bytes32 d; // 3
##
a == eth.getStorageAt(contract, 0)
d == eth.getStorageAt(contract, 3)
上面舉的例子,除了address的長度是160bits,其他變量的長度都是256bits,而storage是256bits對齊的,所以都是一個變量占著一塊storage,但是會存在連續兩個變量的長度不足256bits的情況
address a; // slot = 0
uint8 b; // 0
address c; // 1
uint16 d; // 1
在opcode層面,獲取a的值得操作是: SLOAD(0) & 0xffffffffffffffffffffffffffffffffffffffff
獲取b值得操作是: SLOAD(0) // 0x10000000000000000000000000000000000000000 & 0xff
獲取d值得操作是: SLOAD(1) // 0x10000000000000000000000000000000000000000 & 0xffff
因為b的長度+a的長度不足256bits,變量a和b是連續的,所以他們在同一塊storage中,然后在編譯的過程中進行區分變量a和變量b,但是后續在加上變量c,長度就超過了256bits,因此把變量c放到下一塊storage中,然后變量d跟在c之后
從上面我們可以看出,storage的儲存策略一個是256bits對齊,一個是順序儲存。(并沒有考慮到充分利用每一字節的儲存空間,我覺得可以考慮把d變量放到b變量之后)
2. 映射變量
mapping(address => uint) a;
映射變量就沒辦法想上面的定長變量按順序儲存了,因為這是一個鍵值對變量,EVM采用的機制是:
SLOAD(sha3(key.rjust(64, "0")+slot.rjust(64, "0")))
比如: a["0xd25ed029c093e56bc8911a07c46545000cbf37c6"]首先計算sha3哈希值:
>>> from sha3 import keccak_256
>>> data = "d25ed029c093e56bc8911a07c46545000cbf37c6".rjust(64, "0")
>>> data += "00".rjust(64, "0")
>>> keccak_256(data.encode()).hexdigest()
'739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec'
#
a["0xd25ed029c093e56bc8911a07c46545000cbf37c6"] == SLOAD("739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec")
我們也可以使用以太坊客戶端直接獲取:
> eth.getStorageAt(合約地址, "739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec")
還有slot需要注意一下:
address public a; // slot = 0
mapping(address => uint) public b; // slot = 1
uint public d; // slot = 1
mapping(address => uint) public c; // slot = 3
根據映射變量的儲存模型,或許我們真的可以在智能合約中隱藏私密信息,比如,有一個secret,只有知道key的人才能知道secret的內容,我們可以b[key] = secret, 雖然數據仍然是儲存在storage中,但是在不知道key的情況下卻無法獲取到secret。
不過,storage是存在于區塊鏈之中,目前我猜測是通過智能合約可以映射到對應的storage,storage不可能會初始化256*256bits的內存空間,那樣就太消耗硬盤空間了,所以可以通過解析區塊鏈文件,獲取到storage全部的數據。
上面這些僅僅是個人猜想,會作為之后研究以太坊源碼的一個研究方向。
3. 變長變量
變長變量也就是數組,長度不一定,其儲存方式有點像上面兩種的結合
uint a; // slot = 0
uint[] b; // 1
uint c; // 2
數組任然會占用對應slot的storage,儲存數組的長度(b.length == SLOAD(1))
比如我們想獲取b[1]的值,會把輸入的index和SLOAD(1)的值進行比較,防止數組越界訪問
然后計算slot的sha3哈希值:
>>> from sha3 import keccak_256
>>> slot = "01".rjust(64, "0")
>>> keccak_256(slot.encode()).hexdigest()
'20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a4'
#
b[X] == SLOAD('20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a4' + X)
# 獲取b[2]的值
> eth.getStorageAt(合約地址, "20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a6")
在變長變量中有兩個特例: string和bytes
字符串可以認為是字符數組,bytes是byte數組,當這兩種變量的長度在0-31時,值儲存在對應slot的storage上,最后一字節為長度*2|flag, 當flag = 1,表示長度>31,否則長度<=31
下面進行舉例說明
uint i; // slot = 0
string a = "c"*31; // 1
SLOAD(1) == "c*31" + "00" | 31*2 == "636363636363636363636363636363636363636363636363636363636363633e"
當變量的長度大于31時,SLOAD(slot)儲存length*2|flag,把值儲存到sha3(slot)
uint i; // slot = 0
string a = "c"*36; // 1
SLOAD(1) == 36*2|1 == 0x49
SLOAD(SHA3("01".rjust(64, "0"))) == "c"*36
4. 結構體
結構體沒有單獨特殊的儲存模型,結構體相當于變量數組,下面進行舉例說明:
struct test {
uint a;
uint b;
uint c;
}
address g;
Test e;
# 上面變量在storage的儲存方式等同于
address g;
uint a;
uint b;
uint c;
函數
兩種調用函數的方式
下面是針對兩種函數調用方式說明的測試代碼,發布在測試網絡上: https://ropsten.etherscan.io/address/0xc9fbe313dc1d6a1c542edca21d1104c338676ffd#code
pragma solidity ^0.4.18;
contract Test {
address public owner;
uint public prize;
function Test() {
owner = msg.sender;
}
function test1() constant public returns (address) {
return owner;
}
function test2(uint p) public {
prize += p;
}
}
整個OPCODE都是在EVM中執行,所以第一個調用函數的方式就是使用EVM進行執行OPCODE:
# 調用test1
> eth.call({to: "0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", data: "0x6b59084d"})
"0x0000000000000000000000000109dea8b64d87a26e7fe9af6400375099c78fdd"
> eth.getStorageAt("0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", 0)
"0x0000000000000000000000000109dea8b64d87a26e7fe9af6400375099c78fdd"
第二種方式就是通過發送交易:
# 調用test2
> eth.getStorageAt("0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", 1)
"0x0000000000000000000000000000000000000000000000000000000000000005"
> eth.sendTransaction({from: eth.accounts[0], to: "0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", data: "0xcaf446830000000000000000000000000000000000000000000000000000000000000005"})
> eth.getStorageAt("0xc9fbe313dc1d6a1c542edca21d1104c338676ffd", 1)
"0x000000000000000000000000000000000000000000000000000000000000000a"
這兩種調用方式的區別有兩個:
- 使用call調用函數是在本地使用EVM執行合約的OPCODE,所以可以獲得返回值
- 通過交易調用的函數,能修改區塊鏈上的storage
一個調用合約函數的交易(比如
https://ropsten.etherscan.io/tx/0xab1040ff9b04f8fc13b12057f9c090e0a9348b7d3e7b4bb09523819e575cf651)的信息中,是不存在返回值的信息,但是卻可以修改storage的信息(一個交易是怎么修改對應的storage信息,是之后的一個研究方向)
而通過call調用,是在本地使用EVM執行OPCODE,返回值是存在MEM中return,所以可以獲取到返回值,雖然也可以修改storage的數據,不過只是修改你本地數據,不通過發起交易,其他節點將不會接受你的更改,所以是一個無效的修改,同時,本地調用函數也不需要消耗gas,所以上面舉例中,在調用信息的字典里,不需要from字段,而交易卻需要指定(設置from)從哪個賬號消耗gas。
調用函數
EVM是怎么判斷調用哪個函數的呢?下面使用OPCODE來進行說明
每一個智能合約入口代碼是有固定模式的,我們可以稱為智能合約的主函數,上面測試合約的主函數如下:
PS: Github[5]上面有一個EVM反匯編的IDA插件
[ 0x0] | PUSH1 | ['0x80']
[ 0x2] | PUSH1 | ['0x40']
[ 0x4] | MSTORE | None
[ 0x5] | PUSH1 | ['0x4']
[ 0x7] | CALLDATASIZE | None
[ 0x8] | LT | None
[ 0x9] | PUSH2 | ['0x61']
[ 0xc] | JUMPI | None
[ 0xd] | PUSH4 | ['0xffffffff']
[ 0x12] | PUSH29 | ['0x100000000000000000000000000000000000000000000000000000000']
[ 0x30] | PUSH1 | ['0x0']
[ 0x32] | CALLDATALOAD | None
[ 0x33] | DIV | None
[ 0x34] | AND | None
[ 0x35] | PUSH4 | ['0x6b59084d']
[ 0x3a] | DUP2 | None
[ 0x3b] | EQ | None
[ 0x3c] | PUSH2 | ['0x66']
[ 0x3f] | JUMPI | None
[ 0x40] | DUP1 | None
[ 0x41] | PUSH4 | ['0x8da5cb5b']
[ 0x46] | EQ | None
[ 0x47] | PUSH2 | ['0xa4']
[ 0x4a] | JUMPI | None
[ 0x4b] | DUP1 | None
[ 0x4c] | PUSH4 | ['0xcaf44683']
[ 0x51] | EQ | None
[ 0x52] | PUSH2 | ['0xb9']
[ 0x55] | JUMPI | None
[ 0x56] | DUP1 | None
[ 0x57] | PUSH4 | ['0xe3ac5d26']
[ 0x5c] | EQ | None
[ 0x5d] | PUSH2 | ['0xd3']
[ 0x60] | JUMPI | None
[ 0x61] | JUMPDEST | None
[ 0x62] | PUSH1 | ['0x0']
[ 0x64] | DUP1 | None
[ 0x65] | REVERT | None
反編譯出來的代碼就是:
def main():
if CALLDATASIZE >= 4:
data = CALLDATA[:4]
if data == 0x6b59084d:
test1()
elif data == 0x8da5cb5b:
owner()
elif data == 0xcaf44683:
test2()
elif data == 0xe3ac5d26:
prize()
else:
pass
raise
PS:因為個人習慣問題,反編譯最終輸出沒有選擇對應的Solidity代碼,而是使用Python。
從上面的代碼我們就能看出來,EVM是根據CALLDATA的前4字節來確定調用的函數的,這4個字節表示的是函數的sha3哈希值的前4字節:
> web3.sha3("test1()")
"0x6b59084dfb7dcf1c687dd12ad5778be120c9121b21ef90a32ff73565a36c9cd3"
> web3.sha3("owner()")
"0x8da5cb5b36e7f68c1d2e56001220cdbdd3ba2616072f718acfda4a06441a807d"
> web3.sha3("prize()")
"0xe3ac5d2656091dd8f25e87b604175717f3442b1e2af8ecd1b1f708bab76d9a91"
# 如果該函數有參數,則需要加上各個參數的類型
> web3.sha3("test2(uint256)")
"0xcaf446833eef44593b83316414b79e98fec092b78e4c1287e6968774e0283444"
所以可以去網上找個哈希表映射[6],這樣有概率可以通過hash值,得到函數名和參數信息,減小逆向的難度
主函數中的函數
上面給出的測試智能合約中只有兩個函數,但是反編譯出來的主函數中,卻有4個函數調用,其中兩個是公有函數,另兩個是公有變量
智能合約變量/函數類型只有兩種,公有和私有,公有和私有的區別很簡單,公有的是能別外部調用訪問,私有的只能被本身調用訪問
對于變量,不管是公有還是私有都能通過getStorageAt訪問,但是這是屬于以太坊層面的,在智能合約層面,把公有變量給編譯成了一個公有函數,在這公有函數中返回SLOAD(slot),而私有函數只能在其他函數中特定的地方調用SLOAD(slot)來訪問
在上面測試的智能合約中, test1()函數等同于owner(),我們可以來看看各自的OPCODE:
; test1()
; 0x66: loc_66
[ 0x66] | JUMPDEST | None
[ 0x67] | CALLVALUE | None
[ 0x68] | DUP1 | None
[ 0x69] | ISZERO | None
[ 0x6a] | PUSH2 | ['0x72']
[ 0x6d] | JUMPI | None
[ 0x6e] | PUSH1 | ['0x0']
[ 0x70] | DUP1 | None
[ 0x71] | REVERT | None
; 0x72: loc_72
[ 0x72] | JUMPDEST | None
[ 0x73] | POP | None
[ 0x74] | PUSH2 | ['0x7b']
[ 0x77] | PUSH2 | ['0xfa']
[ 0x7a] | JUMP | None
; 0xFA: loc_fa
[ 0xfa] | JUMPDEST | None
[ 0xfb] | PUSH1 | ['0x0']
[ 0xfd] | SLOAD | None
[ 0xfe] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff']
[ 0x113] | AND | None
[ 0x114] | SWAP1 | None
[ 0x115] | JUMP | None
; 0x7B: loc_7b
[ 0x7b] | JUMPDEST | None
[ 0x7c] | PUSH1 | ['0x40']
[ 0x7e] | DUP1 | None
[ 0x7f] | MLOAD | None
[ 0x80] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff']
[ 0x95] | SWAP1 | None
[ 0x96] | SWAP3 | None
[ 0x97] | AND | None
[ 0x98] | DUP3 | None
[ 0x99] | MSTORE | None
[ 0x9a] | MLOAD | None
[ 0x9b] | SWAP1 | None
[ 0x9c] | DUP2 | None
[ 0x9d] | SWAP1 | None
[ 0x9e] | SUB | None
[ 0x9f] | PUSH1 | ['0x20']
[ 0xa1] | ADD | None
[ 0xa2] | SWAP1 | None
[ 0xa3] | RETURN | None
和owner()函數進行對比:
; owner()
; 0xA4: loc_a4
[ 0xa4] | JUMPDEST | None
[ 0xa5] | CALLVALUE | None
[ 0xa6] | DUP1 | None
[ 0xa7] | ISZERO | None
[ 0xa8] | PUSH2 | ['0xb0']
[ 0xab] | JUMPI | None
[ 0xac] | PUSH1 | ['0x0']
[ 0xae] | DUP1 | None
[ 0xaf] | REVERT | None
; 0xB0: loc_b0
[ 0xb0] | JUMPDEST | None
[ 0xb1] | POP | None
[ 0xb2] | PUSH2 | ['0x7b']
[ 0xb5] | PUSH2 | ['0x116']
[ 0xb8] | JUMP | None
; 0x116: loc_116
[ 0x116] | JUMPDEST | None
[ 0x117] | PUSH1 | ['0x0']
[ 0x119] | SLOAD | None
[ 0x11a] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff']
[ 0x12f] | AND | None
[ 0x130] | DUP2 | None
[ 0x131] | JUMP | None
; 0x7B: loc_7b
[ 0x7b] | JUMPDEST | None
[ 0x7c] | PUSH1 | ['0x40']
[ 0x7e] | DUP1 | None
[ 0x7f] | MLOAD | None
[ 0x80] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff']
[ 0x95] | SWAP1 | None
[ 0x96] | SWAP3 | None
[ 0x97] | AND | None
[ 0x98] | DUP3 | None
[ 0x99] | MSTORE | None
[ 0x9a] | MLOAD | None
[ 0x9b] | SWAP1 | None
[ 0x9c] | DUP2 | None
[ 0x9d] | SWAP1 | None
[ 0x9e] | SUB | None
[ 0x9f] | PUSH1 | ['0x20']
[ 0xa1] | ADD | None
[ 0xa2] | SWAP1 | None
[ 0xa3] | RETURN | None
所以我們可以得出結論:
address public a;
會被編譯成(==)
function a() public returns (address) {
return a;
}
#
address private a;
function c() public returns (address) {
return a;
}
等同于下面的變量定義(≈)
address public c;
公有函數和私有函數的區別也很簡單,公有函數會被編譯進主函數中,能通過CALLDATA進行調用,而私有函數則只能在其他公有函數中進行調用,無法直接通過設置CALLDATA來調用私有函數
回退函數和payable
在智能合約中,函數都能設置一個payable,還有一個特殊的回退函數,下面用實例來介紹回退函數
比如之前的測試合約加上了回退函數:
function() {
prize += 1;
}
則主函數的反編譯代碼就變成了:
def main():
if CALLDATASIZE >= 4:
data = CALLDATA[:4]
if data == 0x6b59084d:
return test1()
elif data == 0x8da5cb5b:
return owner()
elif data == 0xcaf44683:
return test2()
elif data == 0xe3ac5d26:
return prize()
assert msg.value == 0
prize += 1
exit()
當CALLDATA和該合約中的函數匹配失敗時,將會從拋異常,表示執行失敗退出,變成調用回退函數
每一個函數,包括回退函數都可以加一個關鍵字: payable,表示可以給該函數轉帳,從OPCODE層面講,沒有payable關鍵字的函數比有payable的函數多了一段代碼:
JUMPDEST | None
CALLVALUE | None
DUP1 | None
ISZERO | None
PUSH2 | ['0x8e']
JUMPI | None
PUSH1 | ['0x0']
DUP1 | None
REVERT | None
反編譯成python,就是:
assert msg.value == 0
REVERT是異常退出指令,當交易的金額大于0時,則異常退出,交易失敗
函數參數
函數獲取數據的方式只有兩種,一個是從storage中獲取數據,另一個就是接受用戶傳參,當函數hash表匹配成功時,我們可以知道該函數的參數個數,和各個參數的類型,但是當hash表匹配失敗時,我們仍然可以獲取該函數參數的個數,因為獲取參數和主函數、payable檢查一樣,在OPCODE層面也有固定模型:
比如上面的測試合約,調動test2函數的固定模型就是: main -> payable check -> get args -> 執行函數代碼
獲取參數的OPCODE如下
; 0xAF: loc_af
[ 0xaf] | JUMPDEST | None
[ 0xb0] | POP | None
[ 0xb1] | PUSH2 | ['0xd1']
[ 0xb4] | PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff']
[ 0xc9] | PUSH1 | ['0x4']
[ 0xcb] | CALLDATALOAD | None
[ 0xcc] | AND | None
[ 0xcd] | PUSH2 | ['0x18f']
[ 0xd0] | JUMP | None
函數test2的參數p = CALLDATA[4:4+0x20]
如果有第二個參數,則是arg2 = CALLDATA[4+0x20:4+0x40],以此類推
所以智能合約中,調用函數的規則就是data = sha3(func_name)[:4] + *args
但是,上面的規則僅限于定長類型的參數,如果參數是string這種不定長的變量類型時,固定模型仍然不變,但是在從calldata獲取數據的方法,變得不同了,定長的變量是通過調用CALLDATALOAD,把值存入棧中,而string類型的變量,因為長度不定,會超過256bits的原因,使用的是calldatacopy把參數存入MEM
可以看看function test3(string a) public {}函數獲取參數的代碼:
; 0xB2: loc_b2
[ 0xb2] | JUMPDEST | None
[ 0xb3] | POP | None
[ 0xb4] | PUSH1 | ['0x40']
[ 0xb6] | DUP1 | None
[ 0xb7] | MLOAD | None
[ 0xb8] | PUSH1 | ['0x20']
[ 0xba] | PUSH1 | ['0x4']
[ 0xbc] | DUP1 | None
[ 0xbd] | CALLDATALOAD | None
[ 0xbe] | DUP1 | None
[ 0xbf] | DUP3 | None
[ 0xc0] | ADD | None
[ 0xc1] | CALLDATALOAD | None
[ 0xc2] | PUSH1 | ['0x1f']
[ 0xc4] | DUP2 | None
[ 0xc5] | ADD | None
[ 0xc6] | DUP5 | None
[ 0xc7] | SWAP1 | None
[ 0xc8] | DIV | None
[ 0xc9] | DUP5 | None
[ 0xca] | MUL | None
[ 0xcb] | DUP6 | None
[ 0xcc] | ADD | None
[ 0xcd] | DUP5 | None
[ 0xce] | ADD | None
[ 0xcf] | SWAP1 | None
[ 0xd0] | SWAP6 | None
[ 0xd1] | MSTORE | None
[ 0xd2] | DUP5 | None
[ 0xd3] | DUP5 | None
[ 0xd4] | MSTORE | None
[ 0xd5] | PUSH2 | ['0xff']
[ 0xd8] | SWAP5 | None
[ 0xd9] | CALLDATASIZE | None
[ 0xda] | SWAP5 | None
[ 0xdb] | SWAP3 | None
[ 0xdc] | SWAP4 | None
[ 0xdd] | PUSH1 | ['0x24']
[ 0xdf] | SWAP4 | None
[ 0xe0] | SWAP3 | None
[ 0xe1] | DUP5 | None
[ 0xe2] | ADD | None
[ 0xe3] | SWAP2 | None
[ 0xe4] | SWAP1 | None
[ 0xe5] | DUP2 | None
[ 0xe6] | SWAP1 | None
[ 0xe7] | DUP5 | None
[ 0xe8] | ADD | None
[ 0xe9] | DUP4 | None
[ 0xea] | DUP3 | None
[ 0xeb] | DUP1 | None
[ 0xec] | DUP3 | None
[ 0xed] | DUP5 | None
[ 0xee] | CALLDATACOPY | None
[ 0xef] | POP | None
[ 0xf0] | SWAP5 | None
[ 0xf1] | SWAP8 | None
[ 0xf2] | POP | None
[ 0xf3] | PUSH2 | ['0x166']
[ 0xf6] | SWAP7 | None
[ 0xf7] | POP | None
[ 0xf8] | POP | None
[ 0xf9] | POP | None
[ 0xfa] | POP | None
[ 0xfb] | POP | None
[ 0xfc] | POP | None
[ 0xfd] | POP | None
[ 0xfe] | JUMP | None
傳入的變長參數是一個結構體:
struct string_arg {
uint offset;
uint length;
string data;
}
offset+4表示的是當前參數的length的偏移,length為data的長度,data就是用戶輸入的字符串數據
當有多個變長參數時: function test3(string a, string b) public {}
calldata的格式如下: sha3(func)[:4] + a.offset + b.offset + a.length + a.data + b.length + b.data
翻譯成py代碼如下:
def test3():
offset = data[4:0x24]
length = data[offset+4:offset+4+0x20]
a = data[offset+4+0x20:length]
offset = data[0x24:0x24+0x20]
length = data[offset+4:offset+4+0x20]
b = data[offset+4+0x20:length]
因為參數有固定的模型,因此就算沒有從hash表中匹配到函數名,也可以判斷出函數參數的個數,但是要想知道變量類型,只能區分出定長、變長變量,具體是uint還是address,則需要從函數代碼,變量的使用中進行判斷
變量類型的分辨
在智能合約的OPCDOE中,變量也是有特征的
比如一個address變量總會 & 0xffffffffffffffffffffffffffffffffffffffff:
PUSH1 | ['0x0']
SLOAD | None
PUSH20 | ['0xffffffffffffffffffffffffffffffffffffffff']
AND | None
上一篇說的mapping和array的儲存模型,可以根據SHA3的計算方式知道是映射變量還是數組變量
再比如,uint變量因為等同于uint256,所以使用SLOAD獲取以后不會再進行AND計算,但是uint8卻會計算& 0xff
所以我們可以SLOAD指令的參數和后面緊跟的計算,來判斷出變量類型
智能合約代碼結構
部署合約
在區塊鏈上,要同步/發布任何信息,都是通過發送交易來進行的,用之前的測試合約來舉例,合約地址為: 0xc9fbe313dc1d6a1c542edca21d1104c338676ffd, 創建合約的交易地址為: 0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed
查看下該交易的相關信息:
> eth.getTransaction("0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed")
{
blockHash: "0x7f684a294f39e16ba1e82a3b6d2fc3a1e82ef023b5fb52261f9a89d831a24ed5",
blockNumber: 3607048,
from: "0x0109dea8b64d87a26e7fe9af6400375099c78fdd",
gas: 171331,
gasPrice: 1000000000,
hash: "0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed",
input: "0x608060405234801561001057600080fd5b5060008054600160a060020a0319163317905561016f806100326000396000f3006080604052600436106100615763ffffffff7c01000000000000000000000000000000000000000000000000000000006000350416636b59084d81146100665780638da5cb5b146100a4578063caf44683146100b9578063e3ac5d26146100d3575b600080fd5b34801561007257600080fd5b5061007b6100fa565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b3480156100b057600080fd5b5061007b610116565b3480156100c557600080fd5b506100d1600435610132565b005b3480156100df57600080fd5b506100e861013d565b60408051918252519081900360200190f35b60005473ffffffffffffffffffffffffffffffffffffffff1690565b60005473ffffffffffffffffffffffffffffffffffffffff1681565b600180549091019055565b600154815600a165627a7a7230582040d052fef9322403cb3c1de27683a42a845e091972de4c264134dd575b14ee4e0029",
nonce: 228,
r: "0xa08f0cd907207af4de54f9f63f3c9a959c3e960ef56f7900d205648edbd848c6",
s: "0x5bb99e4ab9fe76371e4d67a30208aeac558b2989a6c783d08b979239c8221a88",
to: null,
transactionIndex: 4,
v: "0x2a",
value: 0
}
我們可以看出來,想一個空目標發送OPCODE的交易就是創建合約的交易,但是在交易信息中,卻不包含合約地址,那么合約地址是怎么得到的呢?
function addressFrom(address _origin, uint _nonce) public pure returns (address) {
if(_nonce == 0x00) return address(keccak256(byte(0xd6), byte(0x94), _origin, byte(0x80)));
if(_nonce <= 0x7f) return address(keccak256(byte(0xd6), byte(0x94), _origin, byte(_nonce)));
if(_nonce <= 0xff) return address(keccak256(byte(0xd7), byte(0x94), _origin, byte(0x81), uint8(_nonce)));
if(_nonce <= 0xffff) return address(keccak256(byte(0xd8), byte(0x94), _origin, byte(0x82), uint16(_nonce)));
if(_nonce <= 0xffffff) return address(keccak256(byte(0xd9), byte(0x94), _origin, byte(0x83), uint24(_nonce)));
return address(keccak256(byte(0xda), byte(0x94), _origin, byte(0x84), uint32(_nonce))); // more than 2^32 nonces not realistic
}
智能合約的地址由創建合約的賬號和nonce決定,nonce用來記錄用戶發送的交易個數,在每個交易中都有該字段,現在根據上面的信息來計算下合約地址:
# 創建合約的賬號 from: "0x0109dea8b64d87a26e7fe9af6400375099c78fdd",
# nonce: 228 = 0xe4 => 0x7f < 0xe4 < 0xff
>>> sha3.keccak_256(binascii.unhexlify("d7" + "94" + "0109dea8b64d87a26e7fe9af6400375099c78fdd" + "81e4")).hexdigest()[-40:]
'c9fbe313dc1d6a1c542edca21d1104c338676ffd'
創建合約代碼
一個智能合約的OPCODE分為兩種,一個是編譯器編譯好后的創建合約代碼,還是合約部署好以后runtime代碼,之前我們看的,研究的都是runtime代碼,現在來看看創建合約代碼,創建合約代碼可以在創建合約交易的input數據總獲取,上面已經把數據粘貼出來了,反匯編出指令如下:
; 0x0: main
[ 0x0] | PUSH1 | ['0x80']
[ 0x2] | PUSH1 | ['0x40']
[ 0x4] | MSTORE | None
[ 0x5] | CALLVALUE | None
[ 0x6] | DUP1 | None
[ 0x7] | ISZERO | None
[ 0x8] | PUSH2 | ['0x10']
[ 0xb] | JUMPI | None
[ 0xc] | PUSH1 | ['0x0']
[ 0xe] | DUP1 | None
[ 0xf] | REVERT | None
----------------------------------------------------------------
; 0x10: loc_10
[ 0x10] | JUMPDEST | None
[ 0x11] | POP | None
[ 0x12] | PUSH1 | ['0x0']
[ 0x14] | DUP1 | None
[ 0x15] | SLOAD | None
[ 0x16] | PUSH1 | ['0x1']
[ 0x18] | PUSH1 | ['0xa0']
[ 0x1a] | PUSH1 | ['0x2']
[ 0x1c] | EXP | None
[ 0x1d] | SUB | None
[ 0x1e] | NOT | None
[ 0x1f] | AND | None
[ 0x20] | CALLER | None
[ 0x21] | OR | None
[ 0x22] | SWAP1 | None
[ 0x23] | SSTORE | None
[ 0x24] | PUSH2 | ['0x24f']
[ 0x27] | DUP1 | None
[ 0x28] | PUSH2 | ['0x32']
[ 0x2b] | PUSH1 | ['0x0']
[ 0x2d] | CODECOPY | None
[ 0x2e] | PUSH1 | ['0x0']
[ 0x30] | RETURN | None
代碼邏輯很簡單,就是執行了合約的構造函數,并且返回了合約的runtime代碼,該合約的構造函數為:
function Test() {
owner = msg.sender;
}
因為沒有payable關鍵字,所以開頭是一個check代碼assert msg.value == 0
然后就是對owner變量的賦值,當執行完構造函數后,就是把runtime代碼復制到內存中:
CODECOPY(0, 0x32, 0x24f) # mem[0:0+0x24f] = CODE[0x32:0x32+0x24f]
最后在把runtime代碼返回: return mem[0:0x24f]
在完全了解合約是如何部署的之后,也許可以寫一個OPCODE混淆的CTF逆向題
總結
通過了解EVM的數據結構模型,不僅可以加快對OPCODE的逆向速度,對于編寫反編譯腳本也有非常大的幫助,可以對反編譯出來的代碼進行優化,使得更加接近源碼。
在對智能合約的OPCODE有了一定的了解后,后續準備先寫一個EVM的調試器,雖然Remix已經有了一個非常優秀的調試器了,但是卻需要有Solidity源代碼,這無法滿足我測試無源碼的OPCODE的工作需求。所以請期待下篇《以太坊智能合約OPCODE逆向之調試器篇》
針對目前主流的以太坊應用,知道創宇提供專業權威的智能合約審計服務,規避因合約安全問題導致的財產損失,為各類以太坊應用安全保駕護航。
知道創宇404智能合約安全審計團隊: https://www.scanv.com/lca/index.html
聯系電話:(086) 136 8133 5016(沈經理,工作日:10:00-18:00)
引用
- https://github.com/comaeio/porosity
- https://github.com/meyer9/ethdasm
- https://github.com/trailofbits/evm-opcodes
- http://solidity.readthedocs.io/en/v0.4.21/assembly.html
- https://github.com/trailofbits/ida-evm
- https://github.com/trailofbits/ida-evm/blob/master/known_hashes.py
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/640/