首页 » SEO优化 » phpopcode逆向技巧_以太坊智能合约 OPCODE 逆向之理论根本篇

phpopcode逆向技巧_以太坊智能合约 OPCODE 逆向之理论根本篇

访客 2024-11-24 0

扫一扫用手机浏览

文章目录 [+]

在我们对etherscan等平台上合约进行安全审查时,常常会碰着没有公布Solidity源代码的合约,只能获取到合约的OPCODE,以是一个智能合约的反编译器对审计无源码的智能合约起到了非常主要的浸染。

目前在互联网上常见的反编译工具只有porosity[1],其余在Github上还找到其余的反编译工具ethdasm[2],经由测试创造这两个编译器都有许多bug,无法知足我的事情需求。
因此我开始考试测验研究并开拓能知足我们自己需求的反编译工具,在我看来如果要写出一个精良的反汇编工具,首先须要有较强的OPCODE逆向能力,本篇Paper将对以太坊智能合约OPCODE的数据构造进行一次深入剖析。

phpopcode逆向技巧_以太坊智能合约 OPCODE 逆向之理论根本篇

根本

phpopcode逆向技巧_以太坊智能合约 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

内存的存取操作是MSTOREMLOAD

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用来存储智能合约中所有的全局变量

利用SLOADSSTORE进行操作

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 = 0address b; // 1ufixed c; // 2bytes32 d; // 3## a == eth.getStorageAt(contract, 0)d == eth.getStorageAt(contract, 3)

上面举的例子,除了address的长度是160bits,其他变量的长度都是256bits,而storage是256bits对齐的,以是都是一个变量占着一块storage,但是会存在连续两个变量的长度不敷256bits的情形

address a; // slot = 0uint8 b; // 0address c; // 1uint16 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 = 0mapping(address => uint) public b; // slot = 1uint public d; // slot = 1mapping(address => uint) public c; // slot = 3

根据映射变量的储存模型,或许我们真的可以在智能合约中隐蔽私密信息,比如,有一个secret,只有知道key的人才能知道secret的内容,我们可以b[key] = secret, 虽然数据仍旧是储存在storage中,但是在不知道key的情形下却无法获取到secret

不过,storage是存在于区块链之中,目前我预测是通过智能合约可以映射到对应的storage,storage不可能会初始化256256bits的内存空间,那样就太花费硬盘空间了,以是可以通过解析区块链文件,获取到storage全部的数据。

上面这些仅仅是个人猜想,会作为之后研究以太坊源码的一个研究方向。

3. 变长变量

变长变量也便是数组,长度不一定,其储存办法有点像上面两种的结合

uint a; // slot = 0uint b; // 1uint c; // 2

数组任然会占用对应slot的storage,储存数组的长度(b.length == SLOAD(1))

比如我们想获取b[1]的值,会把输入的indexSLOAD(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\"大众)

在变长变量中有两个特例: stringbytes

字符串可以认为是字符数组,bytes是byte数组,当这两种变量的长度在0-31时,值储存在对应slot的storage上,末了一字节为长度2|flag, 当flag = 1,表示长度>31,否则长度

下面进行举例解释

uint i; // slot = 0string a = \公众c\"大众31; // 1SLOAD(1) == \"大众c31\公众 + \公众00\"大众 | 312 == \公众636363636363636363636363636363636363636363636363636363636363633e\"大众

当变量的长度大于31时,SLOAD(slot)储存length2|flag,把值储存到sha3(slot)

uint i; // slot = 0string a = \公众c\"大众36; // 1SLOAD(1) == 362|1 == 0x49SLOAD(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 | NoneCALLVALUE | NoneDUP1 | NoneISZERO | NonePUSH2 | ['0x8e']JUMPI | NonePUSH1 | ['0x0']DUP1 | NoneREVERT | 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 | NonePUSH20 | ['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

智能合约的地址由创建合约的账号和nonce决定,nonce用来记录用户发送的交易个数,在每个交易中都有该字段,现在根据上面的信息来打算下合约地址:

# 创建合约的账号 from: \"大众0x0109dea8b64d87a26e7fe9af6400375099c78fdd\"大众,# nonce: 228 = 0xe4 => 0x7f >> 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逆向之调试器篇》

标签:

相关文章