Pre:
逆向分析以太坊智能合约(part1) 学习记录+Demo测试原文地址:https://arvanaghi.com/blog/reversing-ethereum-smart-contracts/
介绍以太坊虚拟机(Ethereum Virtual Machine,EVM)的工作原理,以及如何对智能合约(smart contract)进行逆向分析。
以太坊虚拟机:
以太坊虚拟机(EVM)是一种基于栈的、准图灵完备(quasi-Turing complete)的虚拟机。
基于栈:
EVM并不依赖寄存器,任何操作都会在栈中完成。操作数、运算符以及函数调用都置于栈中,并且EVM知道如何处理数据、执行智能合约。
以太坊使用Postfix Notation(后缀表示法)
来实现基于栈的运行机制。简而言之,操作符最后压入栈,可以作用于先前压入栈的数据。
举个例子:来看一下2 + 2
操作,在脑海中,我们知道中间的运算符(+
)表示我们想执行2加2这个操作。将+
放在两个操作数之间是一种办法,我们也可以将它放在两个操作数后面,即2 2 +
,这就是后缀表示法。
准图灵完备:
如果一切可计算的问题都能计算,那么这样的编程语言或者代码执行引擎就可以称为“图灵完备(Turing complete)”。这个概念并不在意解决问题的时间长短,只要理论上该问题能被解决即可。比特币脚本语言不能称为图灵完备语言,因为该语言的应用场景非常有限。
在EVM中,我们可以解决所有问题。但我们还是将其成为“准图灵完备”,这主要是因为成本限制问题。
gas
是EVM中的一个可计算单位,可以用来衡量操作所需的成本。当某人在区块链上发起交易时,交易代码以及待执行的任何后续代码都需要在矿工的主机上执行。由于代码需要在矿工的内存中执行,这个过程会消耗矿工主机的成本,如电力成本、内存以及CPU计算成本等。
为了激励矿工来保证交易顺利进行,发起交易的那个人需要声明gas price
,或者他们愿意为每个计算单元支付的价格。将这个因素考虑在内后,对于非常复杂的问题,所需的gas
量将变得非常庞大,此时由于我们需要为gas
定价,因此在以太坊中,从经济角度来考虑的话复杂的交易并不划算。
Bytecode的组成:
在编译合约时,我们可以得到完整的Bytecode
字节码,可划分为3个部分:
-
Deployment Bytecode(部署字节码)
- 执行初始化新合约账户的所有操作,包含:
- 包含用户实际交易调用这个新合约时需要执行的字节码(即运行时字节码)
- 合约的构造函数中进行初始化处理的代码
-
Runtime bytecode(运行时字节码)
- 当新合约被调用时所执行的所有字节码,不包含需要在部署中用来初始化合约的字节码。
-
Auxdata
- Auxdata 是源代码的密码指纹,用于验证。这只是数据,从未由 EVM 执行。
Greeter.sol
link:
1 | pragma solidity >=0.4.22 <0.6.0; |
solc编译:
1 | $ solc --bin Greeter.sol |
1 | $ solc --bin-runtime Greeter.sol |
如上所示,我们可知runtime bytecode
是contract bytecode
的一个子集。
逆向分析:
我们只逆向runtime bytecode,因为这个过程足以告诉我们合约具体做了哪些工作。
不知道原文代码块的编译器版本,所以没法获得一样的字节码,主要看看分析思路
ethersplay的导入过程:
-
复制 ascii 十六进制字符串,然后在
Binary Ninja
中创建一个新文件。 -
右键单击并选择
Paste From -> Raw Hex
。 -
将此文件另存为
test.evm
并关闭它。在用 -
不能直接将solc命令行输出的bytecode直接保存在
.evm
文件里,会读取不到
QuickView:
与原文的图不一样,问题不大,参考思路,自己推导
Ethersplay插件可以识别runtime bytecode中的所有函数,从逻辑上进行划分。对于这个合约,Ethersplay
发现了函数:kill()
、greet()
。后面我们会介绍如何提取这些函数。
第一条指令:
当我们向智能合约发起交易时,首先碰到的是合约的dispatcher(调度器)
。Dispatcher
会处理交易数据,确定我们需要交互的具体函数。
模拟opcode在栈里的操作:
函数调度的前置判断:
模拟opcode在栈里的操作:
为什么EVM需要检查我们提供的calldata大小是否至少为4字节?这里涉及到函数的识别过程。
EVM会通过函数keccak256哈希的前4个字节来识别函数。也就是说,函数原型(函数名以及所需参数)
需要交给keccak256哈希函数处理。在这个合约中,我们可以得到如下结果:
1 | keccak256("greet()") = cfae3217... |
因此,greet()
的函数标识符为cfae3217
,kill()
的函数标识符为41c0e1b5
。Dispatcher会检查我们发往合约的calldata
(或者消息数据)大小至少为4字节,以确保我们的确想跟某个函数交互。
函数标识符大小始终为4字节,因此如果我们发往智能合约的数据小于4字节,除非定义了回退函数,否则必定无法匹配到可交互的函数,就无法与任何函数交互。
函数调度:
模拟opcode在栈里的操作:
其中重要的操作是:
-
通过逻辑移位的方式,取出4字节的函数标识符。
-
kill()
的函数标识符与 tx的函数标识符比对,相等则跳到 kill函数去
这个过程正是dispatcher的“调度”过程:将calldata
函数标识符与智能合约中所有的函数标识符进行对比。
1 | # kill()的函数标识符与 tx的函数标识符比对 |
1 | # greet()的函数标识符与 tx的函数标识符比对 |
如果我们没有跳转到kill()函数,那么dispatcher
依然会采用相同逻辑,将calldata函数标识符与greet()函数标识符进行对比。
Dispatcher
会检查智能合约中的每个函数,如果不能找到匹配的函数,则会将我们引导至程序退出代码。
Summary:
以上是对以太坊虚拟机工作原理的简单介绍,画个图总结一下
(6) kill()的函数标识符与 tx的函数标识符比对,跳转到kill函数
Online Solidity Decompiler:
有一些提示信息不错,伪代码,反编译块的输入和输出,前期不熟悉的时候可以自己手动模拟stack的操作了,后期可以直接看input/output。
Refs:
-
Understanding Solidity Assembly: Using
shr
andshl
for Byte Manipulation -
https://github.com/CoinCulture/evm-tools/blob/master/analysis/guide.md (每个opcode的出入参个数)
-
https://www.ethervm.io/#MSTORE (这里可以看每个opcode对stack的操作)