一、基础知识
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中的数据:storage用来存储智能合约中所有的全局变量,使用SLOAD和SSTORE进行操作1
2eth.getStorageAt(合约地址, slot)
# 该函数还有第三个参数,默认为"latest",还可以设置为"earliest"或者"pending",具体作用本文不做分析
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 | uint a; // slot = 0 |
上面举的例子,除了address的长度是160bits,其他变量的长度都是256bits,而storage是256bits对齐的,所以都是一个变量占着一块storage,但是会存在连续两个变量的长度不足256bits的情况:
1 | address a; // slot = 0 |
在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 | …… |
映射变量就没办法像上面的定长变量按顺序储存了,因为这是一个键值对变量,EVM采用的机制是:
SLOAD(sha3(key.rjust(64, “0”)+slot.rjust(64, “0”)))
注:rjust(64, "0")
是64位右对齐不足左端填充0。
比如: 计算a[“0xd25ed029c093e56bc8911a07c46545000cbf37c6”]的存储位置:
- opcode
此时0xd25ed029c093e56bc8911a07c46545000cbf37c6
已经存在栈中用python表示该过程:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16251 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因此,我们可以用以太坊客户端直接获取: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")根据映射变量的储存模型,或许我们真的可以在智能合约中隐藏私密信息,比如,有一个secret,只有知道key的人才能知道secret的内容,我们可以b[key] = secret, 虽然数据仍然是储存在storage中,但是在不知道key的情况下却无法获取到secret。1
> eth.getStorageAt(合约地址, "739cc24910ff41b372fbcb2294933bdc3108bd86ffd915d64d569c68a85121ec")
一道ctf题就是用了这个思路:
2、Ethereum Accounts, Addresses and Contracts
给了 ETH 单位,又给了地址,那么题目的意思就可以理解成 : send much money to this contract
变长变量
变长变量也就是数组,长度不一定,其储存方式有点像上面两种的结合
1 | uint a; // slot = 0 |
数组仍然会占用对应slot的storage,储存数组的长度(b.length)存入slot=1的位置,
比如我们想获取b[1]的值,会把输入的index=1和SLOAD(1)的值进行比较,防止数组越界访问
然后计算slot的sha3哈希值:
1 | >>> from sha3 import keccak_256 |
在变长变量中有两个特例: string和bytes
字符串可以认为是字符数组,bytes是byte数组,当这两种变量的长度在0-31时,值储存在对应slot的storage上,最后一字节为长度*2|flag, 当flag = 1,表示长度>31,否则长度<=31
下面进行举例说明
1 | uint i; // slot = 0 |
当变量的长度大于31时,SLOAD(slot)储存length*2|flag,把值储存到sha3(slot)
1 | uint i; // slot = 0 |
结构体
结构体没有单独特殊的储存模型,结构体相当于变量数组,下面进行举例说明:
1 | struct test { |
合约部署
在区块链上,要同步/发布任何信息,都是通过发送交易来进行的,用之前的测试合约来举例,合约地址为: 0xc9fbe313dc1d6a1c542edca21d1104c338676ffd, 创建合约的交易地址为: 0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed
查看下该交易的相关信息:
1 | > eth.getTransaction("0x6cf9d5fe298c7e1b84f4805adddba43e7ffc8d8ffe658b4c3708f42ed94d90ed") |
我们可以看出来,想一个空目标发送OPCODE的交易就是创建合约的交易,但是在交易信息中,却不包含合约地址,那么合约地址是怎么得到的呢?
1 | function addressFrom(address _origin, uint _nonce) public pure returns (address) { |
智能合约的地址由创建合约的账号和nonce决定,nonce用来记录用户发送的交易个数,在每个交易中都有该字段,现在根据上面的信息来计算下合约地址:
1 | # 创建合约的账号 from: "0x0109dea8b64d87a26e7fe9af6400375099c78fdd", |
二、反编译合约分析
- 系列文章
Deconstructing a Solidity Contract —Part I: Introduction – OpenZeppelin blog
Deconstructing a Solidity Contract — Part II: Creation vs. Runtime – OpenZeppelin blog
Deconstructing a Solidity Contract — Part III: The Function Selector – OpenZeppelin blog
Deconstructing a Solidity Contract — Part IV: Function Wrappers – OpenZeppelin blog
Deconstructing a Solidity Contract — Part V: Function Bodies – OpenZeppelin blog
Deconstructing a Solidity Contract — Part VI: The Metadata Hash – OpenZeppelin blog
以上六篇blog十分详细有条理的分析了一个示例合约的编译后的opcode,主要思路围绕这幅图
从一个合约的opcode的结构上分析了一个合约:
总体介绍 —>Creation & Runtime —> 函数selector —> 函数 wrapper —> 函数体分析 —>元数据哈希
Creation & Runtime
- Creation 部分是编译器编译好后的创建合约代码,主要执行了合约的构造函数,并且返回了合约的runtime代码。以及payable部分的检查
一个智能合约不一样的地方主要是constructor body部分
- Runtime是真正执行的代码部分,包含了selector、wrapper、function body 和metadata hash 四个部分
函数selector
函数选择器也可以看作是一个路由,内容判断了用户是要调用哪一个函数。
在选择执行函数之前还需要进行内存申请和msg.data的长度检查,是否小于4个字节,若小于,则不继续执行
而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
函数body
这个部分才是合约的核心部分,也是各个合约不一样的地方
函数体的开头都有JUMPDEST,对应前面wrapper的jump。
元数据哈希
这一部分可参见官方文档合约的元数据 — Solidity develop 文档
最后这一段被解析成字节码,但是其中有很多invalid,实际上这只是一个串0xa1 0x65 'b' 'z' 'z' 'r' '0'
开头的hash值。
三、下一步工作
1.自己写合约,调试分析其合约字节码
2.分析以太坊上没有公布合约源码的智能合约,挖掘漏洞
3.实现自动化工具