前言
在开始之前,我们默认读者已经初步理解 EVM, 包括字节码、操作码、堆栈、内存、存储、calldata。理解 ABI,能够根据文档计算对应变量的 ABI,并且具有一定的编译基础。因此,如果阅读过程中觉得困难,请先阅读 EVM 的其他文章,如 初步理解以太坊虚拟机。
本文编译器版本采用 0.8.10
,EVM 版本是 London
。没有特殊说明的条件下,默认关闭编译优化。建议读者复现时采用相同的编译器和 EVM,避免不一样的结果,虽然一般而言小版本的变化区别不大。
合约创建
字节码和运行时字节码
我们首先部署一个空的合约 Empty.sol
,观察合约部署的字节码。这里需要注意区分运行时代码和部署时代码,运行时代码在部署后被舍弃,RETURN 的运行时字节码写入区块链。
1 2 3 4 5 6 |
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.7.0 <0.9.0; contract Empty { } |
在 Remix 的部署页面的最下面有编译细节,里面有辅助的部署函数和部署时、运行时的字节码。
部署时的字节码常直接称作 bytecode
:
1 2 |
"object": "6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220b3cdd68a9a1040f3ba42bb4f6ac7a5ea4dd3119af7649144cd611e3fef9a611564736f6c634300080d0033", "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x3F DUP1 PUSH1 0x1D PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN INVALID PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 0xB3 0xCD 0xD6 DUP11 SWAP11 LT BLOCKHASH RETURN 0xBA TIMESTAMP 0xBB 0x4F PUSH11 0xC7A5EA4DD3119AF7649144 0xCD PUSH2 0x1E3F 0xEF SWAP11 PUSH2 0x1564 PUSH20 0x6F6C634300080D00330000000000000000000000 ", |
我们开始单步调试,具体过程如果不熟悉的话,请阅读 Remix-ide doc,操作码详解可以见 https://www.evm.codes/
PUSH1 0x80 PUSH1 0x40 MSTORE
偏移 40 字节,0x80 拓展成 256 位,然后写入内存。
按照内存的布局:
0x00
–0x3f
(前面 64 字节,占用 2 个 slot): 计算哈希时临时存储数据的空间,在语句之间使用。0x40
–0x5f
(32 字节,占用 1 个 slot): 当前分配的内存大小 ,或者说是内存指针所在位置(因为可以通过内存空间大小计算内存指针位置)。0x60
–0x7f
(32 字节,占用 1 个 slot): slot[0],正式内存,用于保存动态 memory 数组的初始值,而且只读。然后下一个位置0x80
是开始写入的位置。
我们可以知道,0x80 作为了初始的内存指针。
CALLVALUE DUP1 ISZERO
判断部署合约时给合约的转账金额是否为 0
PUSH1 0xF JUMPI JUMPDEST
0xF
是跳转的位置,如果部署合约的 callvalue 为 0,那么 pc 条件跳转到栈 0xF
的位置,也就是下一个最近的 JUMPDEST 的位置。
POP PUSH1 0x3F DUP1 PUSH1 0x1D PUSH1 0x0 CODECOPY
POP 后清空了栈里最后一个元素,然后栈的内容如下:
1
|
[ "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000000000000000000000000000000000000000001d", "0x000000000000000000000000000000000000000000000000000000000000003f", "0x000000000000000000000000000000000000000000000000000000000000003f" ]
|
最后的 CODECOPY 将会在内存偏移 0 的位置,将字节码偏移 0x1d
后的 3f
个字节复制到内存,直接覆盖原来的值。这里可以知道,存放在临时存储空间。
PUSH1 0x0 RETURN
RETURN 前的栈,最后在内存中偏移量为 0x0
处开始的 3f
个字节写入区块链。
1
|
[ "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000000000000000000000000000000000000000003f" ]
|
所以 RETURN 的值是,这就是运行时字节码
1
|
6080604052600080fdfea26469706673582212201249c699c4827fdd0ee29a1e00afff56e54b23a7995fd367cf89d5f34b9922df64736f6c634300080a0033
|
我们再对照之前的部署时字节码,
1
|
6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220b3cdd68a9a1040f3ba42bb4f6ac7a5ea4dd3119af7649144cd611e3fef9a611564736f6c634300080d0033
|
可以发现,有许多的差别,省去了不必要的情况,如部署时的转账不为 0.
EVM 汇编
Remix 产生的 EVM 汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
.code PUSH 80 contract Empty{\r\n \r\n} PUSH 40 contract Empty{\r\n \r\n} MSTORE contract Empty{\r\n \r\n} CALLVALUE contract Empty{\r\n \r\n} DUP1 contract Empty{\r\n \r\n} ISZERO contract Empty{\r\n \r\n} PUSH [tag] 1 contract Empty{\r\n \r\n} JUMPI contract Empty{\r\n \r\n} PUSH 0 contract Empty{\r\n \r\n} DUP1 contract Empty{\r\n \r\n} REVERT contract Empty{\r\n \r\n} tag 1 contract Empty{\r\n \r\n} JUMPDEST contract Empty{\r\n \r\n} POP contract Empty{\r\n \r\n} PUSH #[$] 0000000000000000000000000000000000000000000000000000000000000000 contract Empty{\r\n \r\n} DUP1 contract Empty{\r\n \r\n} PUSH [$] 0000000000000000000000000000000000000000000000000000000000000000 contract Empty{\r\n \r\n} PUSH 0 contract Empty{\r\n \r\n} CODECOPY contract Empty{\r\n \r\n} PUSH 0 contract Empty{\r\n \r\n} RETURN contract Empty{\r\n \r\n} .data 0: .code PUSH 80 contract Empty{\r\n \r\n} PUSH 40 contract Empty{\r\n \r\n} MSTORE contract Empty{\r\n \r\n} PUSH 0 contract Empty{\r\n \r\n} DUP1 contract Empty{\r\n \r\n} REVERT contract Empty{\r\n \r\n} .data |
简单介绍,.code
包括了合约初始化的字节码,执行完之后就会舍弃。.data
开始是运行时的字节码,每个 tag
是基本块,里面是连续执行的指令,通常顺序执行或者跳转到不同的 tag
,例如 PUSH [tag] 1
表示跳转到 tag1
的部分。
solc 产生的 EVM 汇编和 solcjs 的 (如 remix 应该采用 solcjs) 汇编有些差异
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
======= Empty.sol:Empty ======= EVM assembly: /* "Empty.sol":69:91 contract Empty{... */ mstore(0x40, 0x80) callvalue dup1 iszero tag_1 jumpi 0x00 dup1 revert tag_1: pop dataSize(sub_0) dup1 dataOffset(sub_0) 0x00 codecopy 0x00 return stop sub_0: assembly { /* "Empty.sol":69:91 contract Empty{... */ mstore(0x40, 0x80) 0x00 dup1 revert auxdata: 0xa2646970667358221220b4acf947b85370aec1c3e21a1f682830785f96fd0cbcd09512abfffcd7f9e7be64736f6c634300080a0033 } |
auxdata
是元数据,是字节码的标识,用的很少。对于压栈操作,直接省略了 push
操作码,给出了数值。
之前提到的都是 callvalue 的值为 0 的情况,现在从汇编看跳转关系可以知道,如果 callvalue 不为 0,那么将会在 sub_0
中 REVERT
,说明异常终止,这是因为合约中默认的构造函数是 constructor(){}
,因此我们需要指定 payable
。
我们来看构造函数具有 payable
的情况
1 2 3 4 5 6 7 |
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.7.0 <0.9.0; contract Empty{ constructor() payable{ } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
======= Empty.sol:Empty ======= EVM assembly: /* "Empty.sol":69:119 contract Empty{... */ mstore(0x40, 0x80) dataSize(sub_0) dup1 dataOffset(sub_0) 0x00 codecopy 0x00 return stop sub_0: assembly { /* "Empty.sol":69:119 contract Empty{... */ mstore(0x40, 0x80) 0x00 dup1 revert auxdata: 0xa26469706673582212207c6dad3e26954a2823e1b580d4e9cb6c0365802bbf4dbd146e093af53efe9beb64736f6c634300080a0033 } |
可以看到,汇编中去除了 ISZERO 的判断,因为 callvalue 可以不为 0。
calldata
部署合约时的 calldata
即合约的字节码。
案例分析
以一个很简单的合约代码为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.9; contract Foo { uint x; constructor(uint _x) { x = _x; } function foo() public view returns (uint) { return x; } } |
使用 solc 编译,并打印 evm 汇编
1
|
solc --asm --optimize test.sol > test.asm
|
注:--optimize
表示对 evm bytecode 进行优化,从而可以生成较为精简的汇编代码。
生成如下,代码的作用见注释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
======= test.sol:Foo ======= EVM assembly: /* "test.sol":62:656 contract Foo {... */ // solidity 将前 0x80 字节的内存用作特殊用途。 // 普通的临时变量等将分配在 0x80 后。 // 0x40 位置为 free memory pointer // 一般智能合约前几条指令都会进行此设置 // 参考:https://docs.soliditylang.org/en/v0.8.10/internals/layout_in_memory.html mstore(0x40, 0x80) // 这里汇编代码实际有一定的简写 // EVM 是栈式虚拟机,实际的指令应该是 // 00000: PUSH1 0x80 // 00002: PUSH1 0x40 // 00004: MSTORE // 这也是智能合约开头字节大都是 0x6080604052 的原因 // 以下是 constructor 代码。 // 如果没有 constructor 将直接跳到最后的 codecopy 代码。 /* "test.sol":152:259 constructor(uint _x) {... */ callvalue // 交易的 ether 数量 dup1 iszero // 判定是否为 0 tag_1 jumpi // 如果是则跳转 0x00 dup1 revert // 不是则这里会触发 revert,因为合约中的 constructor 没有标识 payable tag_1: pop mload(0x40) sub(codesize, bytecodeSize) // codesize 就是 codesize 指令,会取到执行时代码的总体长度。 // bytecodeSize 实际是编译器生成的立刻数,编译生成出来的代码总长度。 // 二者相减,就是构造函数 ABI 编码后数据的长度。 dup1 bytecodeSize dup4 codecopy // 从代码的 bytecodeSize 偏移处,copy 长度 codesize-bytecodeSize 的数据到内存中。 // 实际就是将 constructor 的参数数据放到内存中。 dup2 add 0x40 dup2 swap1 mstore tag_2 swap2 tag_3 jump // in // 这里连续压了两个 tag,先跳 tag_3,在 tag_3 执行结束后跳到 tag_2 // 可以理解成函数调用。可将 tag_3 当成一个函数,执行完返回这里继续执行。 tag_2: // tag_3 已经将 constructor 的参数压在了栈顶 // 这里可以开始执行 constructor 的代码了。 /* "test.sol":183:184 x */ 0x00 /* "test.sol":183:189 x = _x */ sstore /* "test.sol":62:656 contract Foo {... */ jump(tag_7) // constructor 执行完成。跳去 tag_7。 /* "#utility.yul":14:198 */ tag_3: // 这段代码的作用就是解析 ABI 编译的 constructor 的参数 /* "#utility.yul":84:90 */ 0x00 /* "#utility.yul":137:139 */ 0x20 /* "#utility.yul":125:134 */ dup3 /* "#utility.yul":116:123 */ dup5 /* "#utility.yul":112:135 */ sub /* "#utility.yul":108:140 */ slt /* "#utility.yul":105:157 */ iszero // 检查参数数据的长度是否小于 0x20 // 例子的构造函数参数 uint256 正好是这个大小。 tag_9 jumpi // 不小于的情况就跳到 tag_9 去解析数据 // 小于的话说明参数传递有问题,revert. /* "#utility.yul":153:154 */ 0x00 /* "#utility.yul":150:151 */ dup1 /* "#utility.yul":143:155 */ revert /* "#utility.yul":105:157 */ tag_9: pop // 这里就是将 memory 中的 ABI 编码数据解码出来压到栈上。 // 这里就 1 个 uint256 参数,直接取出放在栈顶 /* "#utility.yul":176:192 */ mload swap2 /* "#utility.yul":14:198 */ swap1 pop jump // out // tag_3 这个“函数” return(即跳到 tag_2) // constructor 代码结束。 tag_7: // 将代码中 sub_0 copy 到内存中,返回。 /* "test.sol":62:656 contract Foo {... */ dataSize(sub_0) dup1 dataOffset(sub_0) 0x00 codecopy 0x00 return // EVM 执行结束 // 返回的代码就是合约部署后的代码,相当于 solc --bin-runtime stop // 以下是合约主体的代码。 // 在合约部署时,这部分代码完全不会执行到,只是当作纯数据处理。 // 部署后,有合约调用交易时,会执行这部分代码。 sub_0: assembly { /* "test.sol":62:656 contract Foo {... */ mstore(0x40, 0x80) callvalue dup1 iszero tag_1 // 由于合约中没有 receive 或者 fallback 函数 // 这里判断转账金额不为 0 // 就会直接 revert jumpi 0x00 dup1 revert tag_1: // 解析 calldata,取 selector pop jumpi(tag_2, lt(calldatasize, 0x04)) shr(0xe0, calldataload(0x00)) dup1 0xc2985578 // 如果是这个值,则跳 tag_3 eq tag_3 jumpi tag_2: // 其他情况说明这个交易在尝试调用不存在的函数,revert 0x00 dup1 revert // tag_3 就是上面合约的 foo 代码。 /* "test.sol":265:332 function foo() public view returns (uint) {... */ tag_3: /* "test.sol":301:305 uint */ 0x00 /* "test.sol":324:325 x */ sload // 取 x 变量(slot 0) /* "test.sol":265:332 function foo() public view returns (uint) {... */ mload(0x40) /* "#utility.yul":160:185 */ swap1 dup2 mstore // 放到内存中 /* "#utility.yul":148:150 */ 0x20 /* "#utility.yul":133:151 */ add /* "test.sol":265:332 function foo() public view returns (uint) {... */ mload(0x40) dup1 swap2 sub swap1 return // return // 合约的一些 meta data 参考:https://docs.soliditylang.org/en/v0.8.10/metadata.html#encoding-of-the-metadata-hash-in-the-bytecode auxdata: 0xa26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c63430008090033 } |
完整的字节码如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
➜ test solc --optimize --opcodes test.sol ======= test.sol:Foo ======= Opcodes: // 构造函数及部署代码 PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x40 MLOAD PUSH2 0xD6 CODESIZE SUB DUP1 PUSH2 0xD6 DUP4 CODECOPY DUP2 ADD PUSH1 0x40 DUP2 SWAP1 MSTORE PUSH2 0x2F SWAP2 PUSH2 0x37 JUMP JUMPDEST PUSH1 0x0 SSTORE PUSH2 0x50 JUMP JUMPDEST PUSH1 0x0 PUSH1 0x20 DUP3 DUP5 SUB SLT ISZERO PUSH2 0x49 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP MLOAD SWAP2 SWAP1 POP JUMP JUMPDEST PUSH1 0x78 DUP1 PUSH2 0x5E PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN INVALID // 部署后的代码 PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x4 CALLDATASIZE LT PUSH1 0x28 JUMPI PUSH1 0x0 CALLDATALOAD PUSH1 0xE0 SHR DUP1 PUSH4 0xC2985578 EQ PUSH1 0x2D JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST PUSH1 0x0 SLOAD PUSH1 0x40 MLOAD SWAP1 DUP2 MSTORE PUSH1 0x20 ADD PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 RETURN INVALID // auxdata,字节码的元标识 LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 SUB CALLDATACOPY 0xA6 0xD0 0xF8 0xE1 0x2D 0x27 LOG3 PUSH27 0x26E2BECB7FE90EE8E3D86A8970E157D9CB79B1A7FE2B64736F6C63 NUMBER STOP ADDMOD MULMOD STOP CALLER |
使用下面的命令可以打印出可读性更好的中间语言代码(但里面有许多 solidity 自定义函数的层层封装,会显得比较多),其与汇编的逻辑是一致的,也可供参考。
1 2 |
solc --ir test.sol solc --ir-optimized test.sol |
下面命令可以打印合约 storage 的布局。
1 2 3 4 5 |
➜ test solc --storage-layout test.sol ======= test.sol:Foo ======= Contract Storage Layout: {"storage":[{"astId":3,"contract":"test.sol:Foo","label":"x","offset":0,"slot":"0","type":"t_uint256"}],"types":{"t_uint256":{"encoding":"inplace","label":"uint256","numberOfBytes":"32"}}} |
总结
通过上面的分析,可以更透彻的理解智能合约部署和调用的底层逻辑。下面是一些总结。
合约部署
从用户角度看,合约部署是向零地址地址发送合约部署代码,注意零地址不是 0x00..
。以太坊角度看, data 作为智能合约代码执行,并将输出结果作为合约代码保存在合约地址上。
以案例分析中的合约为例,可以用 evm 验证。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# 生成合约的部署代码 $ solc --bin --optimize test.sol ======= test.sol:Foo ======= Binary: 608060405234801561001057600080fd5b506040516100d63803806100d683398101604081905261002f91610037565b600055610050565b60006020828403121561004957600080fd5b5051919050565b60788061005e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60005460405190815260200160405180910390f3fea26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c63430008090033 # 在上述输出后添加上 '00'*0x20 作为 constructor 的参数。 # 注意这个参数要在 code 的尾部,而不能通过 --input 传递。 $ evm run --code 608060405234801561001057600080fd5b506040516100d63803806100d683398101604081905261002f91610037565b600055610050565b60006020828403121561004957600080fd5b5051919050565b60788061005e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60005460405190815260200160405180910390f3fea26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c634300080900330000000000000000000000000000000000000000000000000000000000000000 # 输出如下: 0x6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60005460405190815260200160405180910390f3fea26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c63430008090033 # 对比可以发现,前面输出确实就是合约的部署后的代码。 $ test solc --bin-runtime --optimize test.sol ======= test.sol:Foo ======= Binary of the runtime part: 6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60005460405190815260200160405180910390f3fea26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c63430008090033 |
可以得到如下结论:
- 智能合约的 constructor 代码,是在部署时执行的。而执行没结束前,无法取得输出,也就向目标地址部署代码。因此 constructor 执行时,合约地址的 codesize 是 0。这就是许多文章都提到,用 codesize 判断某个地址是不是合约的方法可能存在误判的原因。
- 向地址发送 data 是少有的可以直接执行任意 EVM 代码的地方。正常情况下:
- 向普通账户发送 data,只当作附加信息处理。
- 向合约账户发送 data,会当作 input 处理。
- 以太坊 RPC 接口
eth_call
可在不上链的情况下执行一笔交易。利用这个接口,向 0 地址发送 EVM 代码即可执行任意的 EVM 代码。某些项目没有注意到这一点,被黑客绕过了检查。
如下是利用 eth.call 方法可以执行任意 VM 代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
# 这段代码的作用是返回 0x000000000000000000000000000000ff $ evm --input 60ff60005260106010f3 disasm 60ff60005260106010f3 00000: PUSH1 0xff 00002: PUSH1 0x00 00004: MSTORE 00005: PUSH1 0x10 00007: PUSH1 0x10 00009: RETURN # 执行效果如下: $ evm --code 0x60ff60005260106010f3 run 0x000000000000000000000000000000ff |
相当于:
1 2 |
> eth.call({data:"0x60ff60005260106010f3"}) "0x000000000000000000000000000000ff" |
合约调用
合约调用的交易将 data 作为 input。合约调用的过程也可以用 evm 模拟。
--code
为前面部署后的 Foo 合约。 --input
为 foo()
函数所对应的 selector。
1 2 3 |
$ evm --code 6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60005460405190815260200160405180910390f3fea26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c63430008090033 --input 0xc2985578 run 0x0000000000000000000000000000000000000000000000000000000000000000 # 返回 x 在 storage 中默认的值 0。 |
注:
- 合约调用时,data 作为 EVM 的 input,使用合约自身的代码作为 code。这时调用方不再有执行任意 EVM 代码的机会。
- 根据 solidity 的约定,将 input 前 4 字节作为 selector,用来决定要调用的函数。在前面的分析可以看出,合约代码中使用了类似 switch case 的形式判断 selector 来决定跳转到哪个位置执行(即调用哪个函数)。具体来说,dispatcher 是首先将合约所有的函数签名按照大小排序,然后匹配则是超过一定数量则二分匹配。而合约只会对 public 函数生成 selector。对于内部函数则不会生成。因此内部函数无论如何是无法调用到的(根据内部函数签名生成一个 selector 去尝试调用显然也是不会成功的。)
- 对于 EVM 来说其所做的事只是将 data 作为 input 执行 code 而已。这些 ABI 的约定其实完全是 solidity 编译器所决定的。如果开发一个私人的编译器,生成的代码以其他形式处理参数序列化的形式,函数选择的方式,也是可以的。
- payable, receive, fallback 这类的语义都是在 solidity 层面才有的,对于 EVM 来说并不存在专门对应的指令。以 payable 为例,某个函数是不是 payable 的,是 solidity 专门生成了 EVM 指令来判断,callvalue 不为 0 时,如果不主动 revert 则相当于是 payable 的。
参考
- EVM Deep Dives: The Path to Shadowy Super Coder
- https://hacking.app/category/programming-languages/solidity/
- https://www.youtube.com/playlist?list=PLNLh1EyDzSGP-lkNCBhCptoJ-NMu_BYfS
- Solidity 字节码 Bytecode 的理解
- 智能合约安全系列文章之反编译篇
- 以太坊智能合约 OPCODE 逆向之理论基础篇
- 在线 keecak256 计算
- Layout in Memory
- Layout of State Variables in Storage
- Chen, Ting, Zihao Li, Xiapu Luo, Xiaofeng Wang, Ting Wang, Zheyuan He, Kezhao Fang, 等. 《SigRec: Automatic Recovery of Function Signatures in Smart Contracts》. IEEE Transactions on Software Engineering, 2021 年, 1–1. https://doi.org/10.1109/TSE.2021.3078342.
- https://recon.cx/2018/montreal/schedule/system/event_attachments/attachments/000/000/053/original/RECON-MTL-2018-Reversing_blockchains_smart_contracts.pdf
附录 I 工具介绍
为了方便读者深入学习,这里简短介绍字节码分析的相关工具。
一、Remix IDE 请自行阅读官方文档。
二、go-ethereum 项目官网有工具介绍,这里会较常用字节码调试工具 evm
。下面是参数介绍
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
evm [global options] command [command options] [arguments...] VERSION: 1.10.17-stable-25c9b49f COMMANDS: compile compiles easm source to evm binary //evm 抽象语法树,已经废弃 disasm disassembles evm binary //字节码生成操作码 run run arbitrary evm binary //运行字节码,预期的下一个参数是一串16进制字节码,而不是文件 statetest executes the given state tests //不清楚它检查状态输入是什么 transition executes a full state transition //不清楚状态转移的工具是什么 transaction performs transaction validation block-builder builds a block help Shows a list of commands or help for one command GLOBAL OPTIONS: --bench benchmark the execution //基准测试 --create indicates the action should be create rather than call --debug output full trace logs //跟踪执行堆栈、存储 --verbosity value sets the verbosity level (default: 0) --code value EVM code //直接在命令行中以字节码为参数 --codefile value File containing EVM code. If '-' is specified, code is read from stdin //从文件在寻找输入 --gas value gas limit for the evm (default: 10000000000) --price value price set for the evm (default: 0) --value value value set for the evm (default: 0) --dump dumps the state after the run --input value input for the EVM //部署时的构造函数参数 --inputfile value file containing input for the EVM //从文件中读取构造函数参数 --memprofile value creates a memory profile at the given path --cpuprofile value creates a CPU profile at the given path --statdump displays stack and heap memory information --prestate value JSON file with prestate (genesis) config --json output trace logs in machine readable format (json) --sender value The transaction origin --receiver value The transaction receiver (execution context) --nomemory disable memory output --nostack disable stack output --nostorage disable storage output --noreturndata enable return data output --help, -h show help --version, -v print the version |
可以参考我在 stackexchange 中的回答,简单的使用方法就不赘述了。根据官方源码中的介绍,实际上它更新了许多新东西。简单地说是一个调试 evm 的工具,可以指定 fork 分叉,也可以自定义区块高度。然后自定义初始账户在 alloc 里,可以自定义交易在 txt 里,自定义链配置在 env 里,然后输出 storageroot、执行后的账户状态、交易 RLP 编码、堆栈跟踪结果等。
三、字节码逆向。我一般使用这两个网站 Online Solidity Decompiler、dedaub。
逆向后的伪代码的抽象程度较高,能够辅助分析字节码。其中 dedaub 全部用十进制表示数,会让人忽视内在的设计思路,不是很推荐。
四、我也尝试过使用 truffle 的调试器,感觉还可以,和 remix ide 差不多。它还推出了 vscode 插件,但是功能不如命令行的好用。感兴趣可阅读插件安装和调试教程。
五、JEB 也有合约逆向工具,主要用法是,打开项目时字节码文件的后缀是 evm-bytecode
。进入项目后在左下角找到如下图的这一行,然后右键选择 Decompile。具体可见官方手册。它的类型推断做的不好,但是这也是目前的难点。
六、Octopus由于内存布局的方式改变了,已经不能用这,作者还折腾了很久…总之,目前没有找到好用的能够显示字节码数据流图的工具。
七、笔者尝试了 IDA, JEB, Binary Ninja 等工具后,发现要么缺少这方面功能,要么很久没维护,过时了。笔者能力足够时,将会自己编写一个显示字节码中数据流图的工具,欢迎感兴趣的朋友一同完成。
附录 II EVM Tracer
a 静态类型的跟踪堆栈
执行的命令为 evm --codefile BYTECODE_FILE --debug --statdump run
,代码过长,以 gist 链接附上。
b storage 合约的 sloc 汇编
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 |
EVM assembly: /* "function.sol":70:270 contract Storage {... */ mstore(0x40, 0x80) callvalue dup1 iszero tag_1 //如果栈顶元素是1,那么就跳转 jumpi 0x00 dup1 revert tag_1: pop dataSize(sub_0)//运行时字节码大小,关键逻辑在这里 dup1 dataOffset(sub_0)//写入区块链是,部署时字节码的偏移量 0x00 codecopy 0x00 return stop sub_0: assembly { //这是调用函数时的入口 /* "function.sol":70:270 contract Storage {... */ mstore(0x40, 0x80) callvalue dup1 iszero tag_1//入口 jumpi 0x00 dup1 revert tag_1: pop jumpi(tag_2, lt(calldatasize, 0x04))//如果calldata小于4字节,那么跳转到tag回滚 shr(0xe0, calldataload(0x00)) dup1 0x2e64cec1 eq tag_3//匹配 retrive签名 jumpi dup1 0x6057361d eq tag_4//匹配store签名 jumpi tag_2: 0x00 dup1 revert /* "function.sol":189:268 function retrieve() public view returns (uint256){... */ tag_3: tag_5 tag_6 jump // in tag_5: mload(0x40) tag_7 swap2 swap1 tag_8 jump // in tag_7: mload(0x40) dup1 swap2 sub swap1 return /* "function.sol":119:183 function store(uint256 num) public {... */ tag_4: tag_9 0x04 dup1 calldatasize sub dup2 add swap1 tag_10 swap2 swap1 tag_11 jump // in tag_10: tag_12 jump // in tag_9: stop /* "function.sol":189:268 function retrieve() public view returns (uint256){... */ tag_6: /* "function.sol":230:237 uint256 */ 0x00 /* "function.sol":255:261 number */ dup1 sload /* "function.sol":248:261 return number */ swap1 pop /* "function.sol":189:268 function retrieve() public view returns (uint256){... */ swap1 jump // out /* "function.sol":119:183 function store(uint256 num) public {... */ tag_12: /* "function.sol":173:176 num */ dup1 /* "function.sol":164:170 number */ 0x00 /* "function.sol":164:176 number = num */ dup2 swap1 sstore pop /* "function.sol":119:183 function store(uint256 num) public {... */ pop jump // out /* "#utility.yul":7:84 */ tag_15: /* "#utility.yul":44:51 */ 0x00 /* "#utility.yul":73:78 */ dup2 /* "#utility.yul":62:78 */ swap1 pop /* "#utility.yul":7:84 */ swap2 swap1 pop jump // out /* "#utility.yul":90:208 */ tag_16: /* "#utility.yul":177:201 */ tag_25 /* "#utility.yul":195:200 */ dup2 /* "#utility.yul":177:201 */ tag_15 jump // in tag_25: /* "#utility.yul":172:175 */ dup3 /* "#utility.yul":165:202 */ mstore /* "#utility.yul":90:208 */ pop pop jump // out /* "#utility.yul":214:436 */ tag_8: /* "#utility.yul":307:311 */ 0x00 /* "#utility.yul":345:347 */ 0x20 /* "#utility.yul":334:343 */ dup3 /* "#utility.yul":330:348 */ add /* "#utility.yul":322:348 */ swap1 pop /* "#utility.yul":358:429 */ tag_27 /* "#utility.yul":426:427 */ 0x00 /* "#utility.yul":415:424 */ dup4 /* "#utility.yul":411:428 */ add /* "#utility.yul":402:408 */ dup5 /* "#utility.yul":358:429 */ tag_16 jump // in tag_27: /* "#utility.yul":214:436 */ swap3 swap2 pop pop jump // out /* "#utility.yul":523:640 */ tag_18: /* "#utility.yul":632:633 */ 0x00 /* "#utility.yul":629:630 */ dup1 /* "#utility.yul":622:634 */ revert /* "#utility.yul":769:891 */ tag_20: /* "#utility.yul":842:866 */ tag_32 /* "#utility.yul":860:865 */ dup2 /* "#utility.yul":842:866 */ tag_15 jump // in tag_32: /* "#utility.yul":835:840 */ dup2 /* "#utility.yul":832:867 */ eq /* "#utility.yul":822:885 */ tag_33 jumpi /* "#utility.yul":881:882 */ 0x00 /* "#utility.yul":878:879 */ dup1 /* "#utility.yul":871:883 */ revert /* "#utility.yul":822:885 */ tag_33: /* "#utility.yul":769:891 */ pop jump // out /* "#utility.yul":897:1036 */ tag_21: /* "#utility.yul":943:948 */ 0x00 /* "#utility.yul":981:987 */ dup2 /* "#utility.yul":968:988 */ calldataload /* "#utility.yul":959:988 */ swap1 pop /* "#utility.yul":997:1030 */ tag_35 /* "#utility.yul":1024:1029 */ dup2 /* "#utility.yul":997:1030 */ tag_20 jump // in tag_35: /* "#utility.yul":897:1036 */ swap3 swap2 pop pop jump // out /* "#utility.yul":1042:1371 */ tag_11: /* "#utility.yul":1101:1107 */ 0x00 /* "#utility.yul":1150:1152 */ 0x20 /* "#utility.yul":1138:1147 */ dup3 /* "#utility.yul":1129:1136 */ dup5 /* "#utility.yul":1125:1148 */ sub /* "#utility.yul":1121:1153 */ slt /* "#utility.yul":1118:1237 */ iszero tag_37 jumpi /* "#utility.yul":1156:1235 */ tag_38 tag_18 jump // in tag_38: /* "#utility.yul":1118:1237 */ tag_37: /* "#utility.yul":1276:1277 */ 0x00 /* "#utility.yul":1301:1354 */ tag_39 /* "#utility.yul":1346:1353 */ dup5 /* "#utility.yul":1337:1343 */ dup3 /* "#utility.yul":1326:1335 */ dup6 /* "#utility.yul":1322:1344 */ add /* "#utility.yul":1301:1354 */ tag_21 jump // in tag_39: /* "#utility.yul":1291:1354 */ swap2 pop /* "#utility.yul":1247:1364 */ pop /* "#utility.yul":1042:1371 */ swap3 swap2 pop pop jump // out auxdata: 0xa26469706673582212202171984ccb0a85eea6b8ddfdd3927496135929d4d68bc35c2a03ee376ced497564736f6c634300080a0033 } |