一、基础知识

EVM opcode(GitHub - crytic/evm-opcodes: Ethereum opcodes and instruction reference

三种存储类型

上一次我们稍微提到过智能合约的三种存储方式:stack,memory,storage,想在详细描述一下:

  • stack:
    主要使用push和pop来操作,push后面会带上存入栈数据的长度,最小为1字节,最大为32字节,所以OPCODE从0x60-0x7f分别代表的是push1-push32。
    PUSH1会将OPCODE后面1字节的数据放入栈中,比如字节码是0x6060代表的指令就是PUSH1 0x60。
    除了push和pop指令之外,其他指令获取参数都是从栈中获取,然后直接消耗掉当前栈中的值,指令返回的结果直接存入栈中。(这也是为什么时常会看到dul命令)

  • mem
    内存的存取操作是MSTORE和MLOAD
    MSTORE(arg0, arg1):MEM[arg0:arg0+32] = arg1,表示从栈中获取一个参数存入到memory中。
    MLOAD(arg0):PUSH32(MEM[arg0:arg0+32]),表示从memory中获取一个值压住栈中。
    注:因为PUSH指令,最大只能把32字节的数据存入栈中,所以对内存的操作每次只能操作32字节,但是还有一个指令MSTORE8,只修改内存的1个字节。

内存的作用一般是用来存储返回值,或者某些指令有处理大于32字节数据的需求
eg:SHA3指令
SHA3(arg0, arg1):从栈中获取arg0和arg1,在memory中读取MEM[arg0:arg0+arg1],然后SHA3对memory返回的数据进行计算sha3哈希值,这里arg0和arg1参数只是用来指定内存的范围。

  • storage
    上面的stack和mem都是在EVM执行OPCODE的时候初始化,但是storage是存在于区块链中,我们可以类比为计算机的存储磁盘。所以在storage上存储消耗的gas费用最高。而且就算不执行智能合约,我们也能获取智能合约storage中的数据:
    1
    2
    eth.getStorageAt(合约地址, slot)   
    # 该函数还有第三个参数,默认为"latest",还可以设置为"earliest"或者"pending",具体作用本文不做分析
    storage用来存储智能合约中所有的全局变量,使用SLOAD和SSTORE进行操作
    SSTORE(arg0, arg1):eth.getStorageAt(合约地址, arg0) = arg1,表示将栈栈中的一个参数存入storage指定位子;
    SLOAD(arg0):PUSH32(eth.getStorageAt(合约地址, arg0)),表示将storage中的一个参数取出压入栈中。

变量

智能合约的变量从作用域可以分为三种, 全局公有变量(public), 全局私有变量(private), 局部变量:
– 全局变量储存在storage中
– 局部变量是被编译进OPCODE中,在运行时,被放在stack中,等待后续使用
– 公有变量会被编译成一个constant函数
– 私有变量也储存在storage中,而storage是存在于区块链当中,所以相当于私有变量也是公开的,所以不要想着用私有变量来储存啥不能公开的数据

不同类型变量存储方式

定长变量

第一种我们归类为定长变量,所谓的定长变量,也就是该变量在定义的时候,其长度就已经被限制住了,比如定长整型(int/uint……), 地址(address), 定长浮点型(fixed/ufixed……), 定长字节数组(bytes1-32)。这类的变量在storage中都是按顺序储存
示例:

1
2
3
4
5
6
7
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的情况:

1
2
3
4
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对齐,一个是顺序储存。因此,在写代码时应注意定义变量定义的顺序,尽量减少gas的消耗。

映射变量(mapping)

示例:

1
2
……
mapping(address => uint) a; //假设slot=1

映射变量就没办法像上面的定长变量按顺序储存了,因为这是一个键值对变量,EVM采用的机制是:

SLOAD(sha3(key.rjust(64, “0”)+slot.rjust(64, “0”)))
注:rjust(64, "0")是64位右对齐不足左端填充0。

比如: 计算a[“0xd25ed029c093e56bc8911a07c46545000cbf37c6”]的存储位置:

  • opcode
    此时0xd25ed029c093e56bc8911a07c46545000cbf37c6已经存在栈中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    251 JUMPDEST
    252 PUSH20 ffffffffffffffffffffffffffffffffffffffff
    273 AND //key.rjust(64, "0")
    274 PUSH1 00 //压入要存入memory中的起始位置0x00
    276 SWAP1
    277 DUP2
    278 MSTORE //将key.rjust(64, "0")放入memory的0x00位置
    279 PUSH1 01 //要入slot=1
    281 PUSH1 20 //压入要存入memory中的起始位置0x20
    283 MSTORE 将01存入memory的0x20位置
    284 PUSH1 40
    286 SWAP1 //
    287 SHA3 //sha3(memory[0x00:0x40]))
    288 SLOAD
    289 SWAP1
    290 JUMP
    用python表示该过程:
    1
    2
    3
    4
    5
    6
    7
    8
    >>> from sha3 import keccak_256
    >>> data = "d25ed029c093e56bc8911a07c46545000cbf37c6".rjust(64, "0")

    >>> data += "01".rjust(64, "0")
    >>> keccak_256(data.encode()).hexdigest()
    '739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec'
    #
    a["0xd25ed029c093e56bc8911a07c46545000cbf37c6"] == SLOAD("739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec")
    因此,我们可以用以太坊客户端直接获取:
    1
    > eth.getStorageAt(合约地址, "739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec")
    根据映射变量的储存模型,或许我们真的可以在智能合约中隐藏私密信息,比如,有一个secret,只有知道key的人才能知道secret的内容,我们可以b[key] = secret, 虽然数据仍然是储存在storage中,但是在不知道key的情况下却无法获取到secret。
    一道ctf题就是用了这个思路:
    2、Ethereum Accounts, Addresses and Contracts
    44203c2e.png

给了 ETH 单位,又给了地址,那么题目的意思就可以理解成 : send much money to this contract

变长变量

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

1
2
3
uint a;         // slot = 0
uint[] b; // 1
uint c; // 2

数组仍然会占用对应slot的storage,储存数组的长度(b.length)存入slot=1的位置,

比如我们想获取b[1]的值,会把输入的index=1和SLOAD(1)的值进行比较,防止数组越界访问

然后计算slot的sha3哈希值:

1
2
3
4
5
6
7
8
9
10
11
>>> from sha3 import keccak_256
>>> slot = "01".rjust(64, "0")
>>> keccak_256(slot.encode()).hexdigest()
'20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a4'

#将b[X]存储在slot='20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a4' + X的位置
#
b[X] = SLOAD('20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a4' + X)

# 获取b[2]的值
> eth.getStorageAt(合约地址, "20ec45d096f1fa2aeff1e3da8a84697d90109524958ed4be9f6d69e37a9140a6")

在变长变量中有两个特例: string和bytes

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

下面进行举例说明

1
2
3
4
uint i;                // slot = 0
string a = "c"*31; // 1
slot(1) = "c*31" + "00" | 31*2 = "636363636363636363636363636363636363636363636363636363636363633e"

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

1
2
3
4
uint i;                // slot = 0
string a = "c"*36; // 1
slot(1) = 36*2|1 = 0x49
slot(SHA3("01".rjust(64, "0"))) == "c"*36

结构体

结构体没有单独特殊的储存模型,结构体相当于变量数组,下面进行举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct test {
uint a;
uint b;
uint c;
}
address g;
Test e;

# 上面变量在storage的储存方式等同于
address g;
uint a;
uint b;
uint c;

合约部署

在区块链上,要同步/发布任何信息,都是通过发送交易来进行的,用之前的测试合约来举例,合约地址为: 0xc9fbe313dc1d6a1c542edca21d1104c338676ffd, 创建合约的交易地址为: 0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed

查看下该交易的相关信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
> 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的交易就是创建合约的交易,但是在交易信息中,却不包含合约地址,那么合约地址是怎么得到的呢?

1
2
3
4
5
6
7
8
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用来记录用户发送的交易个数,在每个交易中都有该字段,现在根据上面的信息来计算下合约地址:

1
2
3
4
# 创建合约的账号 from: "0x0109dea8b64d87a26e7fe9af6400375099c78fdd",
# nonce: 228 = 0xe4 => 0x7f < 0xe4 < 0xff
>>> sha3.keccak_256(binascii.unhexlify("d7" + "94" + "0109dea8b64d87a26e7fe9af6400375099c78fdd" + "81e4")).hexdigest()[-40:]
'c9fbe313dc1d6a1c542edca21d1104c338676ffd'

二、反编译合约分析

以上六篇blog十分详细有条理的分析了一个示例合约的编译后的opcode,主要思路围绕这幅图BasicToken.svg
从一个合约的opcode的结构上分析了一个合约:

总体介绍 —>Creation & Runtime —> 函数selector —> 函数 wrapper —> 函数体分析 —>元数据哈希

Creation & Runtime

  • Creation 部分是编译器编译好后的创建合约代码,主要执行了合约的构造函数,并且返回了合约的runtime代码。以及payable部分的检查
    a449ce7e.png

一个智能合约不一样的地方主要是constructor body部分

  • Runtime是真正执行的代码部分,包含了selector、wrapper、function body 和metadata hash 四个部分

函数selector

函数选择器也可以看作是一个路由,内容判断了用户是要调用哪一个函数。
在选择执行函数之前还需要进行内存申请和msg.data的长度检查,是否小于4个字节,若小于,则不继续执行
5f5fa86f.png
而selector中判断路由的部分在上一篇中有提到:
假设一个函数是a(uint256){},则使用web3.sha3("a(uint256)");计算出a(uint256)的签名,计算结果为
“0xf0fdf83467af68171df09204c0b00056c1e4c80e368b3fff732778b858f7966d”,之后取前四字节”f0fdf834”作为a(uint256)函数的签名

根据ABI里的标准,假设用户调用balanceOf(address),参数就会规范成0x70a08231000000000000000000000000ca35b7d915458ef540ade6068dfe2f44e8fa733c这样的格式,前四个字节70a08231就使用路由判断。将selecor中的判断转为伪代码:

1
70a08231 == msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;

如果用户调用的函数不存在,若存在 fallback function, 则执行它,否则revert。

函数wrapper

主要是检查payable部分,解析用户的参数,并调转到真正的函数部分以及通过memory返回函数的return
a6b4d1a1.png

函数body

这个部分才是合约的核心部分,也是各个合约不一样的地方
函数体的开头都有JUMPDEST,对应前面wrapper的jump。
61818cc4.png

元数据哈希

这一部分可参见官方文档合约的元数据 — Solidity develop 文档

最后这一段被解析成字节码,但是其中有很多invalid,实际上这只是一个串0xa1 0x65 'b' 'z' 'z' 'r' '0'开头的hash值。

ea2e30bf.png

三、下一步工作

1.自己写合约,调试分析其合约字节码
2.分析以太坊上没有公布合约源码的智能合约,挖掘漏洞
3.实现自动化工具

参考文章:https://paper.seebug.org/640/