热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Solidity,EVM,智能合约部署与调用

“去年学习 EVM 相关知识时的一点记录。”最近面试的时候我会比较喜欢问一些关于 EVM 底层实现的问题,比如有没有看过 Solidity 编译出来的代码什么样?合约是怎么根据 calldata 中 

去年学习 EVM 相关知识时的一点记录。


最近面试的时候我会比较喜欢问一些关于 EVM 底层实现的问题,比如有没有看过 Solidity 编译出来的代码什么样?合约是怎么根据 calldata 中 selector 这四个字节找到对应的函数的?等等。


在实际的合约审计和链上分析过程中,这些冷门知识是基本用不到的。我当时之所以学习这部分,主要还是受二进制安全荼毒太深,碰到一些新东西时总会想搞清楚它底层具体干了什么。


当然面试时我问这种东西并不是想为难候选人,更多的是想考察他们是否有寻根究底的意识。这种态度不管是做研究还是做业务都很重要,很多时候正是这种原始的好奇心决定的一个人可以探得多深,走得多远。


今天群里有个编译原理课还没开的学生小伙伴在问哪里有分析 EVM 字节码的资料。嗯。。就觉得现在的年轻人确实了不起。


这里把之前的一些笔记放出来,送给有需要的人。

友情提示,电脑屏幕阅读体验更佳。


01


Solidity 生成的 EVM 代码长什么样?


以一个很简单的合约代码为例:

// SPDX-License-Identifier: UNLICENSEDpragma 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
// auxdataLOG2 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.solsolc --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 disasm60ff60005260106010f300000: PUSH1 0xff00002: PUSH1 0x0000004: MSTORE00005: PUSH1 0x1000007: PUSH1 0x1000009: RETURN# 执行效果如下:$ evm --code 0x60ff60005260106010f3 run0x000000000000000000000000000000ff

 

相当于 web3.js 里的 :

> eth.call({data:"0x60ff60005260106010f3"})"0x000000000000000000000000000000ff"



03



合约调用


合约调用的交易将 data 作为 input。合约调用的过程也可以用 evm 模拟。

# --code 为前面部署后的 Foo 合约# --input 为 foo() 函数所对应的 selector
$ evm --code 6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60005460405190815260200160405180910390f3fea26469706673582212200337a6d0f8e12d27a37a26e2becb7fe90ee8e3d86a8970e157d9cb79b1a7fe2b64736f6c63430008090033 --input 0xc2985578 run0x0000000000000000000000000000000000000000000000000000000000000000
# 返回 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

(最后的视频比本文介绍的要详细的多,想了解更多推荐看一下)







推荐阅读
  • 本指南介绍了如何在ASP.NET Web应用程序中利用C#和JavaScript实现基于指纹识别的登录系统。通过集成指纹识别技术,用户无需输入传统的登录ID即可完成身份验证,从而提升用户体验和安全性。我们将详细探讨如何配置和部署这一功能,确保系统的稳定性和可靠性。 ... [详细]
  • 您的数据库配置是否安全?DBSAT工具助您一臂之力!
    本文探讨了Oracle提供的免费工具DBSAT,该工具能够有效协助用户检测和优化数据库配置的安全性。通过全面的分析和报告,DBSAT帮助用户识别潜在的安全漏洞,并提供针对性的改进建议,确保数据库系统的稳定性和安全性。 ... [详细]
  • 在使用 Qt 进行 YUV420 图像渲染时,由于 Qt 本身不支持直接绘制 YUV 数据,因此需要借助 QOpenGLWidget 和 OpenGL 技术来实现。通过继承 QOpenGLWidget 类并重写其绘图方法,可以利用 GPU 的高效渲染能力,实现高质量的 YUV420 图像显示。此外,这种方法还能显著提高图像处理的性能和流畅性。 ... [详细]
  • 深入解析 Lifecycle 的实现原理
    本文将详细介绍 Android Jetpack 中 Lifecycle 组件的实现原理,帮助开发者更好地理解和使用 Lifecycle,避免常见的内存泄漏问题。 ... [详细]
  • 解决Bootstrap DataTable Ajax请求重复问题
    在最近的一个项目中,我们使用了JQuery DataTable进行数据展示,虽然使用起来非常方便,但在测试过程中发现了一个问题:当查询条件改变时,有时查询结果的数据不正确。通过FireBug调试发现,点击搜索按钮时,会发送两次Ajax请求,一次是原条件的请求,一次是新条件的请求。 ... [详细]
  • Android开发技巧:使用IconFont减少应用体积
    本文介绍如何在Android应用中使用IconFont来显示图标,从而有效减少应用的体积。 ... [详细]
  • 文章目录Golang定时器Timer和Tickertime.Timertime.NewTimer()实例time.AfterFunctime.Tickertime.NewTicke ... [详细]
  • MySQL Decimal 类型的最大值解析及其在数据处理中的应用艺术
    在关系型数据库中,表的设计与SQL语句的编写对性能的影响至关重要,甚至可占到90%以上。本文将重点探讨MySQL中Decimal类型的最大值及其在数据处理中的应用技巧,通过实例分析和优化建议,帮助读者深入理解并掌握这一重要知识点。 ... [详细]
  • 本文介绍了如何使用 Node.js 和 Express(4.x 及以上版本)构建高效的文件上传功能。通过引入 `multer` 中间件,可以轻松实现文件上传。首先,需要通过 `npm install multer` 安装该中间件。接着,在 Express 应用中配置 `multer`,以处理多部分表单数据。本文详细讲解了 `multer` 的基本用法和高级配置,帮助开发者快速搭建稳定可靠的文件上传服务。 ... [详细]
  • 如何将TS文件转换为M3U8直播流:HLS与M3U8格式详解
    在视频传输领域,MP4虽然常见,但在直播场景中直接使用MP4格式存在诸多问题。例如,MP4文件的头部信息(如ftyp、moov)较大,导致初始加载时间较长,影响用户体验。相比之下,HLS(HTTP Live Streaming)协议及其M3U8格式更具优势。HLS通过将视频切分成多个小片段,并生成一个M3U8播放列表文件,实现低延迟和高稳定性。本文详细介绍了如何将TS文件转换为M3U8直播流,包括技术原理和具体操作步骤,帮助读者更好地理解和应用这一技术。 ... [详细]
  • 本文详细解析了 Yii2 框架中视图和布局的各种函数,并综述了它们在实际开发中的应用场景。通过深入探讨每个函数的功能和用法,为开发者提供了全面的参考,帮助他们在项目中更高效地利用这些工具。 ... [详细]
  • ClassList对象学习心得与表单事件非空校验技巧
    ClassList对象学习心得与表单事件非空校验技巧 ... [详细]
  • 【问题】在Android开发中,当为EditText添加TextWatcher并实现onTextChanged方法时,会遇到一个问题:即使只对EditText进行一次修改(例如使用删除键删除一个字符),该方法也会被频繁触发。这不仅影响性能,还可能导致逻辑错误。本文将探讨这一问题的原因,并提供有效的解决方案,包括使用Handler或计时器来限制方法的调用频率,以及通过自定义TextWatcher来优化事件处理,从而提高应用的稳定性和用户体验。 ... [详细]
  • Python 程序转换为 EXE 文件:详细解析 .py 脚本打包成独立可执行文件的方法与技巧
    在开发了几个简单的爬虫 Python 程序后,我决定将其封装成独立的可执行文件以便于分发和使用。为了实现这一目标,首先需要解决的是如何将 Python 脚本转换为 EXE 文件。在这个过程中,我选择了 Qt 作为 GUI 框架,因为之前对此并不熟悉,希望通过这个项目进一步学习和掌握 Qt 的基本用法。本文将详细介绍从 .py 脚本到 EXE 文件的整个过程,包括所需工具、具体步骤以及常见问题的解决方案。 ... [详细]
  • 当使用 `new` 表达式(即通过 `new` 动态创建对象)时,会发生两件事:首先,内存被分配用于存储新对象;其次,该对象的构造函数被调用以初始化对象。为了确保资源管理的一致性和避免内存泄漏,建议在使用 `new` 和 `delete` 时保持形式一致。例如,如果使用 `new[]` 分配数组,则应使用 `delete[]` 来释放内存;同样,如果使用 `new` 分配单个对象,则应使用 `delete` 来释放内存。这种一致性有助于防止常见的编程错误,提高代码的健壮性和可维护性。 ... [详细]
author-avatar
mobiledu2502895753
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有