00 前言
在安全领域,对可执行程序进行逆向分析是对目标程序的安全性进行研究的一种重要方式。尤其是在没有源代码的情况下,通过对可执行程序进行逆向分析,可以更清晰的了解目标程序的内部逻辑,以便及时的发现可能出现的漏洞,避免漏洞被恶意利用。
尤其是在智能合约领域,任何问题都可能造成无法挽回的后果。即使是在有源码的前提下,我们也同样无法保证我们所使用的编译器是没有被黑客恶意修改过的,会不会在编译合约过程中插入恶意代码,类似的攻击曾经就甚至出现在苹果公司的XCode工具。因此对合约的逆向分析,直接了解合约可执行脚本对于合约的安全性来说变得至关重要。
01 背景
合约逆向主要分为两步,第一步是将机器指令翻译为人类可以理解的汇编指令,迁移到Neo的合约脚本,就是将我们通过Neo编译器编译出的AVM智能合约脚本翻译成由Neo OpCode为主体的ASM代码,本文主要介绍这个过程。第二部就是将ASM代码在反编译回我们更容易阅读理解的高级编程语言,比如C或者python,这部分内容我将会在下一篇文章中进行介绍。
02 分析
在考虑对Neo合约脚本进行反汇编的时候,首先需要对脚本的结构进行和数据类型进行解析。
Neo合约的的指令和OpCode存在一一对应的关系,每个指令都由一个字节进行编码。Neo合约脚本本身是一串hex字符串,里面既包含着合约执行需要的指令,而一个字节的hex是占据两个字符。所以在解析指令的时候,需要从当前位置读取两个字符,并转换成16进制的整型,从而获取和OpCode的对应关系。
除了指令之外,Neo合约脚本里还存在着大量的数据,比如地址位置,内置的管理员账户,调用的合约地址,调用的系统函数名,逻辑判断的判断条件等等。这些数据都是参杂在指令之间的,在合约执行的过程中,通过解析指令来读取后续的数据信息。
03 归类
由上一节知道合约脚本存在两种类型的数据:指令和纯数据。那么在对脚本的翻译过程中,我们需要区分哪些是数据,哪些是指令,以便对不同类型的数据进行不同的处理。通过对Neo的虚拟机执行逻辑的分析,我们可以了解到,Neo的虚拟机是在识别到指定类型的指令之后,根据指令的不同,直接从指令之后读取相应长度的脚本作为数据,也就是说,所有的数据,都是紧跟在当前指令之后的。基于此,我们只需要把指令分为读取数据和不读取数据的两大类就可以了。
根据NeoVM,会读取数据的指令有:
0x4C ==>> OpCode.PUSHDATA1 顺序读取一个字节
0x4D ==>> OpCode.PUSHDATA2 顺序读取两个字节
0x4E ==>> OpCode.PUSHDATA4 顺序读取四个字节
0x62 ==>> OpCode.JMP 顺序读取两个字节
0x63 ==>> OpCode.JMPIF 顺序读取两个字节
0x64 ==>> OpCode.JMPIFNOT 顺序读取两个字节
0x64 ==>> OpCode.CALL 顺序读取两个字节
0x67 ==>> OpCode.APPCALL 顺序读取二十个字节
0x69 ==>> OpCode.TAILCALL 顺序读取二十个字节
0x68 ==>> OpCode.SYSCALL 根据后续一个字节内容顺序读取相应字节
在这里我们不需要关心指令具体的作用,只需要知道它是否读取数据以及读取多少数据就足够我们对脚本进行反汇编,至于指令具体的功能,只在我们进行反编译的时候才有作用。除了以上列出的指令之外,剩下的指令都可以直接翻译为OpCode。
04 反汇编
通过以上分析,其实只要我们直接将合约脚本过一遍,边遍历边输出就可以完成反汇编。但是呢,因为我还需要为之后的反编译过程作准备,所以这里我采取先解析脚本,然后将解析结果存储起来,最后再输出反汇编的流程。
为了存储反汇编结果,我先定义一个NeoCode的类,这个类用于存储具体的指令,指令在脚本中的位置信息,是否读取数据,读取的数据,数据的类型以及部分用于后续反编译时会需要的字段。
public class NeoCode
{
public OpCode code = OpCode.NOP;
public bool beginOfLoop = false;
public bool endOfLoop = false;
public int addr;
public byte[] paramData;
public ParamType paramType;
.........
}
数据的类型是根据具体的指令来进行判断的,比如如果指令是SYSCALL,那么数据就是字符串,输出的时候就需要从hex转成string进行输出:
public string AsString()
{
return Encoding.UTF8.GetString(this.paramData);
}
如果是JMP等的跳转指令,则需要转换成整型:
public int AsAddr()
{
return BitConverter.ToInt16(this.paramData, 0);
}
如果是APPCALL等的,则就是直接输出hex字符串:
public string AsHexString()
{
var str = "0x";
for (var i = 0; i < this.paramData.Length; i++)
{
var s = this.paramData[i].ToString("x02");
str += s;
}
return str;
}
在对脚本进行反汇编的过程中,每识别到一个指令,就新创建一个NeoCode对象,然后根据当前上下文对这个NeoCode进行初始化。
反汇编的结果存储在Dictionary<int, NeoCode>对象里。在输出反汇编结果的时候,就再遍历这个Dictionary 对象,然后调用每个NeoCode内置的toString方法进行输出:
public string toString()
{
var name = this.getCodeName();
if (this.paramType == ParamType.ByteArray) name += "[" + this.AsHexString() + "]" + " (" + this.AsString() + ")";
else if (this.paramType == ParamType.String) name += "[" + this.AsString() + "]";
else if (this.paramType == ParamType.Addr) name += "[" + this.AsAddr() + "]";
return this.addr.ToString("x02") + ":" + name;
}
05 展示
以上过程已经详细解释了Neo合约反汇编的原理和流程,接下来展示一个反汇编的结果:
合约源码:
public static void Main()
{
string a = "hello ";
string b = "world";
for (int i = 0; i < 10; i++)
{
string c = a + b;
}
}
反汇编结果:
00:PUSH5
01:NEWARRAY
02:TOALTSTACK
03:NOP
04:PUSHBYTES6[0x68656c6c6f20] (hello )
0b:FROMALTSTACK
0c:DUP
0d:TOALTSTACK
0e:PUSH0(false)
0f:PUSH2
10:ROLL
11:SETITEM
12:PUSHBYTES5[0x776f726c64] (world)
18:FROMALTSTACK
19:DUP
1a:TOALTSTACK
1b:PUSH1(true)
1c:PUSH2
1d:ROLL
1e:SETITEM
1f:PUSH0(false)
20:FROMALTSTACK
21:DUP
22:TOALTSTACK
23:PUSH2
24:PUSH2
25:ROLL
26:SETITEM
27:JMP[37]
2a:NOP
2b:FROMALTSTACK
2c:DUP
2d:TOALTSTACK
2e:PUSH0(false)
2f:PICKITEM
30:FROMALTSTACK
31:DUP
32:TOALTSTACK
33:PUSH1(true)
34:PICKITEM
35:CAT
36:FROMALTSTACK
37:DUP
38:TOALTSTACK
39:PUSH3
3a:PUSH2
3b:ROLL
3c:SETITEM
3d:NOP
3e:FROMALTSTACK
3f:DUP
40:TOALTSTACK
41:PUSH2
42:PICKITEM
43:PUSH1(true)
44:ADD
45:FROMALTSTACK
46:DUP
47:TOALTSTACK
48:PUSH2
49:PUSH2
4a:ROLL
4b:SETITEM
4c:FROMALTSTACK
4d:DUP
4e:TOALTSTACK
4f:PUSH2
50:PICKITEM
51:PUSH10
52:LT
53:FROMALTSTACK
54:DUP
55:TOALTSTACK
56:PUSH4
57:PUSH2
58:ROLL
59:SETITEM
5a:FROMALTSTACK
5b:DUP
5c:TOALTSTACK
5d:PUSH4
5e:PICKITEM
5f:JMPIF[-53]
62:NOP
63:FROMALTSTACK
64:DROP
65:RET