“去年学习 EVM 相关知识时的一点记录。”
最近面试的时候我会比较喜欢问一些关于 EVM 底层实现的问题,比如有没有看过 Solidity 编译出来的代码什么样?合约是怎么根据 calldata 中 selector 这四个字节找到对应的函数的?等等。
在实际的合约审计和链上分析过程中,这些冷门知识是基本用不到的。我当时之所以学习这部分,主要还是受二进制安全荼毒太深,碰到一些新东西时总会想搞清楚它底层具体干了什么。
当然面试时我问这种东西并不是想为难候选人,更多的是想考察他们是否有寻根究底的意识。这种态度不管是做研究还是做业务都很重要,很多时候正是这种原始的好奇心决定的一个人可以探得多深,走得多远。
今天群里有个编译原理课还没开的学生小伙伴在问哪里有分析 EVM 字节码的资料。嗯。。就觉得现在的年轻人确实了不起。
这里把之前的一些笔记放出来,送给有需要的人。
友情提示,电脑屏幕阅读体验更佳。
01
—
Solidity 生成的 EVM 代码长什么样?
以一个很简单的合约代码为例:
// 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 汇编
solc --asm --optimize test.sol > test.asm
注:--optimize 表示对 evm bytecode 进行优化,从而可以生成较为精简的汇编代码。
生成如下,代码的解析见注释:
======= 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
}
完整的字节码如下:
➜ 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 自定义函数的层层封装,会显得比较多),其与汇编的逻辑是一致的,也可供参考。
solc --ir test.sol
solc --ir-optimized test.sol
下面命令可以打印合约 storage 的布局。
➜ 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"}}}
读懂了上面具体的 EVM 指令的功能,可以更透彻的理解智能合约部署和调用的底层处理逻辑。
02
—
合约部署
从用户角度看,合约部署是向 0x00..00 地址发送合约部署代码。
以太坊角度看,则是将发往 0x00..00 地址的 data 作为智能合约代码执行。并将输出结果数据作为合约代码保存在计算好的合约地址上。
以前面的合约为例,可以用 geth 中的 evm 程序验证这个说法。
# 生成合约的部署代码
$ 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 判断某个地址是不是合约的方法可能存在误判的原因。
向 0x00..00 地址发送 data 是少有的可以直接执行任意 EVM 代码的地方。正常情况下:
向普通账户发送 data,只当作附加信息处理。
向合约账户发送 data,会当作 input 处理。
以太坊 RPC 接口 eth_call 可在不上链的情况下执行一笔交易。利用这个接口,向 0 地址发送 EVM 代码即可执行任意的 EVM 代码。
如下是利用 eth.call 方法可以执行任意 VM 代码的对比示例:
# 这段代码的作用是返回 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
相当于 web3.js 里的 :
> eth.call({data:"0x60ff60005260106010f3"})
"0x000000000000000000000000000000ff"
03
—
合约调用
合约调用的交易将 data 作为 input。合约调用的过程也可以用 evm 模拟。
# --code 为前面部署后的 Foo 合约
# --input 为 foo() 函数所对应的 selector
$ evm --code 6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60005460405190815260200160405180910390f3fea26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c63430008090033 --input 0xc2985578 run
0x0000000000000000000000000000000000000000000000000000000000000000
# 返回 x 在 storage 中默认的值 0。
同样可以发现:
合约调用时,data 作为 EVM 的 input,使用合约自身的代码作为 code。这时调用方不再有执行任意 EVM 代码的机会。
根据 solidity 的约定,将 input 前 4 字节作为 selector,用来决定要调用的函数。在前面的分析可以看出,合约代码中使用了类似 switch case 的形式判断 selector 来决定跳转到哪个位置执行(即调用哪个函数)。而合约只会对 public 函数生成 selector。对于内部函数则不会生成。因此内部函数无论如何是无法调用到的(根据内部函数签名生成一个 selector 去尝试调用显然也是不会成功的)。
对于 EVM 来说其所做的事只是将 data 作为 input 执行 code 而已。这些 ABI 的约定其实完全是 solidity 编译器所决定的。如果开发一个私人的编译器,生成的代码以其他形式处理参数序列化的形式,函数选择的方式,也是可以的。
payable, receive, fallback 这类的语义都是在 solidity 层面才有的,对于 EVM 来说并不存在专门对应的指令。以 payable 为例,某个函数是不是 payable 的,是 solidity 专门生成了 EVM 指令来判断,callvalue 不为 0 时,如果不主动 revert 则相当于是 payable 的。
04
—
小结
当时弄清楚这部分东西主要靠的是读源码和手工测试。
读以太坊源码的话首推自然是 geth。不过只想搞清楚 EVM 大概原理的话可以看这个(py-evm 的前身,代码风格比较粗犷,但比较好理解)
https://github.com/ethereum/pyethereum/blob/b704a5c6577863edc539a1ec3d2620a443b950fb/ethereum/fastvm.py
经群友提醒还有一些不错的资源
https://www.evm.codes/
https://ethervm.io/
https://www.youtube.com/watch?v=RxL_1AfV7N4&t=1s
(最后的视频比本文介绍的要详细的多,想了解更多推荐看一下)