——虚拟机指令分析 Virtual Machine Command Analysis
In code obfuscation, a virtual machine is a mechanism used to execute a different instruction set than the one used by the machine that runs the program.
原理
基本原理
虚拟机保护技术是指,将程序代码转换为自定义的中间操作码OpCode(当操作码只占一个字节可称作ByteCode),通过一种解释执行系统或者模拟器(Emulator)解释执行,实现代码基本功能。
逆向这种程序,一般需要对emulator结构进行逆向,结合opcode进行分析,得到各个操作码对应的基本操作,从而理解程序功能,整个程序逆向工程将会十分繁琐。(图片来自:https://mp.weixin.qq.com/s/4Nfso1OuHeQgCTGYv2IF5Q)
这种虚拟化的思想,广泛用于计算机科学领域。从某种程度上来说,解释执行的脚本语言都需要有一个虚拟机,例如LuaVM,PythonInterpreter。静态语言类似Java通过JVM实现了平台无关性,每个安装JVM的机器都可以执行Java程序。这些虚拟机可以提供一种平台无关的编程环境,将源程序翻译为平台无关的中间码,然后翻译执行。JVM虚拟机的基本架构(图片来自geeksforgeeks)
VMP虚拟机
VMP虚拟机保护技术指的是将基于x86汇编系统的可执行代码转换为字节码指令系统的代码,达到保护原有指令不被轻易逆向和修改的目的。从本质上来说,就是创建一套虚拟指令系统对原本的x86汇编指令系统进行一次封装,将原本的汇编指令转换为另一种表现形式。
虚拟指令有自己的机器码,但和原本的x86汇编机器码完全不一样,而且常常是一堆无意义的代码,他们只能由VM虚拟解释器(Dispatcher)来解释并执行。我们在逆向时看到的汇编代码其实不是x86汇编代码,而是字节码(伪指令),它是由指令执行系统定义的一套指令和数据组成的一串数据流,所以虚拟机的脱壳很难写出一个通用的脱壳机,原则上只要虚拟指令集一变动,原本的伪指令的解释就会发生变化。
VMProtect 壳
VMProtect 是一款纯虚拟机保护软件,效果很好,但也有缺点,就是会影响程序速度,因此在一些对速度要求很高的场合就不适合用了。
分析方法
在目前的CTF比赛中,虚拟机题目常常有两种考法:
- 给可执行程序和 opcode,逆向 emulator,结合 opcode 文件,推出 flag
- 只给可执行程序,逆向 emulator,构造 opcode,读取 flag
题目
RCTF2018 simple-vm
这个题目的指令分析,我真是吐了,又多又恶心,谁看谁知道。复制大部分程序,跑出来指令分析。
1 |
|
具体过程大部分手写在草稿纸上了,我是原始人。总之最后分析出来了逻辑,其实就是 x^y
,我写麻烦了。
1 | enc = [ |
解得 flag
有个fseek
与ftell
的使用,fseek
定位光标,组合使用可以得到文件字节长度。
DDCTF2018 黑盒破解
(不要想东西,你的脑子不是用来想东西的)
没错这个题目就是构造 opcode 读取 flag 的。
IDA64位打开,main函数很清晰:
sub_401E98
和sub_4016BD
是虚拟机保护部分的指令初始代码;*(v4 + 16)
读入字节码指令,前4个字符不是exit
,调用judge_603F00
并使其为1
的是sub_40133D
函数。
![[]](/2019/11/12/20191112vmp/h1.png)
![[]](/2019/11/12/20191112vmp/h2.png)
sub_4016BD
函数的返回值应是1
才会进入到正确分支。
![[]](/2019/11/12/20191112vmp/h3.png)
![[]](/2019/11/12/20191112vmp/h4.png)
静态调试 & 动态调试分析一下该函数做了什么:
(下面是巨丑无比的草稿)
1 | base = (a1 + 8) |
以上为虚拟机初始化各种解析的东西,,
可以看到sub_401A48
是将输入作为虚拟机指令解析,执行对应操作函数:
![[]](/2019/11/12/20191112vmp/h5.png)
一共有 9 种操作,输入 op,遍历 9 种操作查看与哪个匹配,b600[op] == a1 + 0x198 + [a1 + 4*(j + 0x48LL)+ 8]
,那么 handler 在a1 + 0x2A0
中存放为a1 + 8*(p + 0x54) + 8
。a1 + 0x298
为下一个指令。
idc 脚本跑出 opcode 和 handler,注意要在动态调试时跑,数据有变动。
1 |
|
idapython 脚本:
1 | //坑中 |
结果如下:
1 | 24 $ 400dc1 // tmp = base[reg0]; |
可以看到比较关键的函数 sub_40133D
,当下一个指令为s
时,进入 if 分支,judge_603F00
为1,即主函数中成功输出 flag 文件。具体的判断函数分析有、、复杂,但我们可以看到主函数的提示信息\n\nPlease Input Your Passcode,If You See print the \"Binggo\" string,Congratulations,You Win. Good luck!
,所以我们需要让这个函数中的s
输出为Binggo\x00
。
![[]](/2019/11/12/20191112vmp/h6.png)
构造指令,注意最后\00
的构造,因为有判断不能为 0 ,所以不能延续之前的构造方式,考虑用#
指令对应的函数#B1
构造得:
1 | Cc80CH80C&80t(8080C)80#B1uuuuuuEs |
还有师傅倒着构造好机智。
最后跑下脚本连接服务器得 flag
1 | from pwn import * |
RCTF2018 magic
这是一个(我觉得很)难的综合题,涉及动态调试程序入口点、暴力破解、程序复用思想、rc4、虚拟机指令分析等知识点。
寻找程序入口点
第一步关键函数定位,运行程序得到如下结果
![[]](/2019/11/12/20191112vmp/0.png)
查找字符串,IDA分析出的main函数有点儿假,动态调试什么时候输出字符串,得到sub_402357
中调用的sub_402218
输出了字符串,2357
运行中才运行到的函数,是关键函数,静态状态下交叉引用无结果。大体逻辑如下,先经过了一个sub_402268
check函数。
![[]](/2019/11/12/20191112vmp/1.png)
![[]](/2019/11/12/20191112vmp/3.png)
爆破时间验证
根据上面关键函数的内容,我们可知dword_4099D0[0]
不能为0,所以需要使程序进入if流程,即需要使 v4 == 0x700
。
下面异或的随机数的生成也有当前时间决定,所以应该是有一个确定的时间。
timeoff
即time64返回值的范围大约是$ 2^{17} $,可以考虑暴力。
table
数组会异或生成随机数,参与执行sub_4027ED
,改变v4
与v3
的值。
![[]](/2019/11/12/20191112vmp/2.png)
爆破时间的思路主要就是程序的复用,而复用方式有很多种
官方wp 用 multiasm 写 shellcode 调用程序自身代码
有机会填下坑 (咕咕咕)
-
然而我真的跑不出来 orz
CTFtime wp 原始复制IDA反编译程序代码稍作修改
真的麻烦
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
char table[256] = {
0x58, 0x71, 0x8F, 0x32, 0x05, 0x06, 0x51, 0xC7, 0xA7, 0xF8, 0x3A, 0xE1, 0x06, 0x48, 0x82, 0x09,
0xA1, 0x12, 0x9F, 0x7C, 0xB8, 0x2A, 0x6F, 0x95, 0xFD, 0xD0, 0x67, 0xC8, 0xE3, 0xCE, 0xAB, 0x12,
0x1F, 0x98, 0x6B, 0x14, 0xEA, 0x89, 0x90, 0x21, 0x2D, 0xFD, 0x9A, 0xBB, 0x47, 0xCC, 0xEA, 0x9C,
0xD7, 0x50, 0x27, 0xAF, 0xB9, 0x77, 0xDF, 0xC5, 0xE9, 0xE1, 0x50, 0xD3, 0x38, 0x89, 0xEF, 0x2D,
0x72, 0xC2, 0xDF, 0xF3, 0x7D, 0x7D, 0x65, 0x95, 0xED, 0x13, 0x00, 0x1C, 0xA3, 0x3C, 0xE3, 0x57,
0xE3, 0xF7, 0xF7, 0x2C, 0x73, 0x88, 0x34, 0xB1, 0x62, 0xD3, 0x37, 0x19, 0x26, 0xBE, 0xB2, 0x33,
0x20, 0x3F, 0x60, 0x39, 0x87, 0xA6, 0x65, 0xAD, 0x73, 0x1A, 0x6D, 0x49, 0x33, 0x49, 0xC0, 0x56,
0x00, 0xBE, 0x0A, 0xCF, 0x28, 0x7E, 0x8E, 0x69, 0x87, 0xE1, 0x05, 0x88, 0xDA, 0x54, 0x3E, 0x3C,
0x0E, 0xA9, 0xFA, 0xD7, 0x7F, 0x4E, 0x44, 0xC6, 0x9A, 0x0A, 0xD2, 0x98, 0x6A, 0xA4, 0x19, 0x6D,
0x8C, 0xE1, 0xF9, 0x30, 0xE5, 0xFF, 0x33, 0x4A, 0xA9, 0x52, 0x3A, 0x0D, 0x67, 0x20, 0x1D, 0xBF,
0x36, 0x3E, 0xE8, 0x56, 0xBF, 0x5A, 0x88, 0xA8, 0x69, 0xD6, 0xAB, 0x52, 0xF1, 0x14, 0xF2, 0xD7,
0xEF, 0x92, 0xF7, 0xA0, 0x70, 0xA1, 0xEF, 0xE3, 0x1F, 0x66, 0x2B, 0x97, 0xF6, 0x2B, 0x30, 0x0F,
0xB0, 0xB4, 0xC0, 0xFE, 0xA6, 0x62, 0xFD, 0xE6, 0x4C, 0x39, 0xCF, 0x20, 0xB3, 0x10, 0x60, 0x9F,
0x34, 0xBE, 0xB2, 0x1C, 0x3B, 0x6B, 0x1D, 0xDF, 0x53, 0x72, 0xF2, 0xFA, 0xB1, 0x51, 0x82, 0x04,
0x30, 0x56, 0x1F, 0x37, 0x72, 0x7A, 0x97, 0x50, 0x29, 0x86, 0x4A, 0x09, 0x3C, 0x59, 0xC4, 0x41,
0x71, 0xF8, 0x1A, 0xD2, 0x30, 0x88, 0x63, 0xFF, 0x85, 0xDE, 0x24, 0x8C, 0xC3, 0x37, 0x14, 0xC7
};
char new_table[256];
struct vstruct{
int8_t a;
int32_t b, c;
}vars0[256];
vstruct * sub_402690(signed int idx, vstruct * v_head)
{
vstruct *result; // rax
if ( idx >= 0 && idx <= 255 )
result = &v_head[idx];
else
result = 0LL;
return result;
}
void sub_4026D0(int num, vstruct * v_head)
{
vstruct *temp1; // rax
vstruct *temp2; // rax
vstruct *temp3;
temp3 = sub_402690(num, v_head);
if ( temp3 )
{
if ( num & 0xF )
temp1 = sub_402690(num - 1, v_head);
else
temp1 = 0;
if ( num + 15 <= 0x1E )
temp2 = 0;
else
temp2 = sub_402690(num - 16, v_head);
if ( temp1 || temp2 )
{
if ( temp1 )
{
temp3->b = temp3->a + temp1->b;
temp3->c = 2 * temp1->c;
}
if ( temp2 )
{
int d = temp3->a + temp2->b;
if ( d < temp3->b )
{
temp3->b = d;
temp3->c = 2 * temp2->c | 1;
}
}
}
else
{
temp3->b = temp3->a;
}
}
}
int main()
{
for(unsigned int timeoff = 0x5AFFE790; timeoff <= 0x5B028A8F; ++timeoff) {
srand(timeoff);
for(int i = 0; i < 256; ++i)
new_table[i] = table[i] ^ rand();
for(int i = 0; i < 256; ++i) {
vars0[i].a = new_table[i];
vars0[i].b = 0x7FFFFFFF;
vars0[i].c = 0;
sub_4026D0(i, vars0);
}
if(vars0[255].b == 0x700 && vars0[255].c != 0)
printf("[*] Find time = %x\n[*] %x\n", timeoff, vars0[255].c); // [*] Find time = 5b00e398 [*] 322ce7a4
}
return 0;
}
根据上面跑出的结果,时间应是0x5b00e398
,dword_4099D0[0]
为0x322ce7a4
。
时间验证通过方法
Patch
call time64
为mov eax, 5B00E398h
将补丁应用到原有的二进制文件上的步骤为
Edit
->Patch Program
/Apply patchs to input file
…mov [rbp+timeoff], eax
打断点,每次手动更改为0x5b00e398
。
分析输入验证主函数
调到这里让我好找1551,接下来运行有了正常的输入输出但是没找到在哪里,随便输入试了下,跳到了这里输出错误,交叉引用发现sub_4023B1
中读入大小为 26 的字符串,是主要逻辑函数。
执行过程其实是:key_func``sub_402357
在执行完成之后回到函数sub_4032A0
,经过几次跳转来到函数sub_403180
,发现onexit()
函数,这个函数的作用是注册一个函数,使得程序在exit()
的时候自动调用这个被注册的函数,所以在假main
函数exit()
时,执行到了sub_403260
,调用了sub_4023B1
, 即main_func
。
![[]](/2019/11/12/20191112vmp/5.png)
主函数用了时间验证中得到的dword_4099D0[0]
,即time_data
;
输出了输入提示信息,用time_data
作为 key 加密过的亚子;
读入长度为 26 的字符串,某加密算法(其实就是rc4)加密;
if 条件句判断函数是循环指令码的感觉,虚拟机指令;
判断函数返回值为 0 则进入错误分支,反之,某加密算法解密,因为相同可知为对称加密算法,可以考虑程序复用,(不太清楚sub_401FFB
进行了什么操作,)然后 putssomething 输出相关正确分支内容。
![[]](/2019/11/12/20191112vmp/6.png)
程序运行:vm(rc4(flag))
逆向思路:vm -> mid_str -> rc4 -> flag
虚拟机指令分析
先上该部分代码
![[]](/2019/11/12/20191112vmp/7.png)
![[]](/2019/11/12/20191112vmp/8.png)
![[]](/2019/11/12/20191112vmp/9.png)
虚拟机实现用到了setjmp|longjmp
,参考资料(http://www.cs.cmu.edu/afs/cs/academic/class/15213-s02/www/applications/recitation/recitation7/B/r07-B.pdf),我是无脑~~翻译~~谷歌翻译搬运工:
1 | int setjmp(jmp_buf env); |
易于处理异常和中断。
setjmp
将环境(例如寄存器)保存在env
中,供longjmp
稍后使用longjmp
完成后,调用后程序开始,对于setjmp
,就像setjmp
返回了val
应用实例:
考虑如下情景,编写一个项目,当它出现段错误的时候就会失败。相反,只需优雅地重新启动它即可。
如何做到?catch
SIGSEGV
错误,使用setjmp|longjmp
跳转回到开始。一个应用
setjmp|longjmp
的常见 bug:不能从一个调用setjmp
的函数 return 。
还用到了signal
函数,设置一个函数来处理信号
1 | void (*signal(int sig, void (*func)(int)))(int) |
这个地方很巧妙地,先将input_flag
拷贝到Count
,然后用signal
设置一个函数。
![[]](/2019/11/12/20191112vmp/10.png)
![[]](/2019/11/12/20191112vmp/13.png)
汇编更清晰一点
![[]](/2019/11/12/20191112vmp/11.png)
设置函数如下
![[]](/2019/11/12/20191112vmp/12.png)
接下来看取指令过程
op = setjmp(Count)
返回值一开始为 0,到如下分支
![[]](/2019/11/12/20191112vmp/15.png)
sub_404716
中调用longjmp
,设置 v5
为 op = setjmp(Count)
返回值,即 op = opcode[i]
。故 byte_405340
是opcode
数组。
在各个 handler 中有一个如下操作,除法,除数为 0 时会引起异常,会到 sign_func 执行操作,执行完又到了循环中的 setjmp 执行再次取指令等操作。所以这一部分其实是执行的 sign_func 中的运算操作。
![[]](/2019/11/12/20191112vmp/14.png)
dword_409040
作为虚拟机寄存器reg
。
静态/动态调试分析可得出如下指令集
1 | before while(1): |
动态调试 got match_str :
![[]](/2019/11/12/20191112vmp/16.png)
根据分析出来的程序写脚本,逆过程得到rc4
加密过的flag
1 | enc = [ |
encode_flag_str :
1 | 0x23 0x8c 0xbe 0xfd 0x25 0xd7 0x65 0xf4 0xb6 0xb3 0xb6 0xf 0xe1 0x74 0xa2 0xef 0xfc 0x38 0x4e 0xd2 0x1a 0x4a 0xb1 0x10 0x96 0xa5 |
rc4 解得 flag
有两种方法:
将上述字符串作为输入串,用原函数跑(因为是对称加密)
Patch 修改16进制数据,跑出来
看吾爱师傅用了脚本
1
2
3
4
5
6
7
8
9from idaapi import *
def PatchArr(dest, str):
for i in range(len(str)):
c = str[i]
idc.PatchByte(dest+i, ord(c));
print 'ok'
input = "\x23\x8c\xbe\xfd\x25\xd7\x65\xf4\xb6\xb3\xb6\x0f\xe1\x74\xa2\xef\xfc\x38\x4e\xd2\x1a\x4a\xb1\x10\x96\xa5"
PatchArr(0x0060FD54, input)代码解得
1
2input = "\x23\x8c\xbe\xfd\x25\xd7\x65\xf4\xb6\xb3\xb6\x0f\xe1\x74\xa2\xef\xfc\x38\x4e\xd2\x1a\x4a\xb1\x10\x96\xa5"
# 没错在坑
1 | @ck_For_fun_02508iO2_2iOR} |
运行程序,输入上述字符串,得到字符画
1 | the part of flag was protected by a magic spell! |
(真难看),其实就是rctf{h
所以 flag 就是
1 | rctf{h@ck_For_fun_02508iO2_2iOR} |
参考
[2] 虚拟机保护逆向入门