第一次接触vm这类的题型。一开始我以为vm和vmp是类似的,但是后来才知道vm指的是自己实现一套指令系统,然后在此基础上运行字节码。类似java的虚拟机。
其实也不算是第一次做vm的题,很久之前在南邮ctf平台上做过的WxyVM这道题也是vm。这题蛮简单的,以至于我一直都不知道它其实也是vm题。
不知道为什么,网上有关虎符ctf逆向的题解不多,vm这道题只找到一份题解,令则师傅的原创-虎符第三题 -vm 虚拟机逆向。令则师傅写得挺清楚的。不过,好歹是我第一次做出这种基于栈的vm题,还是写一下题解温习一下吧。
总述
题目给出了两个文件,
- vm,解释器,解释字节码并执行,ELF程序
- code,字节码,十六进制数据
如何运行?
linux终端内键入./vm ./code
,回车后输入flag,若错误则输出n
vm的main函数:
VM结构体
上图21~28行的变量可以看作一个结构体,这不是写在程序里的,是等下分析33行的vm函数的代码时总结出来的。
struct VM{
dword vm_eip;
dword vm_sp; VMstruct[1]
qword* code; VMstruct + 1
qword* stack; VMstruct + 2
qword* c;
qword* d;
}VMstruct;
vm_eip是表示当前指令运行到哪里了,比如运行code文件的0x100处的指令时,vm_eip = 0x100。
vm_sp表示栈内有几个数据元素,配合下面的stack这个变量,可以表示栈顶元素(stack[sp-1])
code是code文件中的字节码
stack是一个数组,本程序通过数组来模拟了一个栈,所有的数据运算都通过这个模拟的栈进行。
c是用于存储数据的数组,本题中c存储了三组数据,c[i+0x64]存储输入的flag,c[i+0x32]存储预设的一组数据,c[i]存储flag加密后的数据。i取值均为0到0x29,含0x29(最后c[i]和c[i+0x32]相比较,相等则答对flag)
d存储两个循环变量i,j,i = d[0], j=d[1]
为什么上面的结构体c和d两个变量,我没有使用像vm_eip,stack这种明确的变量名?
因为单独分析vm程序的vm函数根本无法分析出c和d的作用。必须结合code文件中的字节码才能分析出其含义。
分析操作码
vm文件的vm函数(sub_400A50):
经典的while-switch结构选择opcode(操作码),不同的操作码模拟不同的操作。
将一些基础的变量改下名之后,代码中的变量和我们总结出的VM结构体有这样的关系:
*(_BYTE *)(code + vm_eip)
VMstruct.vm_eip。当前eip,程序运行到的地方
*(_BYTE *)(code + vm_eip + 1)
data。表示操作数。只有单操作数指令才会出现这个变量
(signed int)VMstruct[1]
VMstruct.vm_sp。表示栈内有几个数据元素。
*((_QWORD *)VMstruct + 1)
VMstruct.code。code字节码地址。
*((_QWORD *)VMstruct + 2)
VMstruct.stack。模拟栈
*((_QWORD *)VMstruct + 3)
VMstruct.c。存储数据的数组。
*((_QWORD *)VMstruct + 4)
VMstruct.d。存储两个循环变量
分析也没啥技巧,就慢慢来,以0xc的操作码为例:
case 0xC: // case 0xc : pop a; pop b; b/a;
v58 = VMstruct[1]; //VMstruct.vm_sp
v59 = *((_QWORD *)VMstruct + 2); //VMstruct.stack
v60 = v58 - 1;
v58 -= 2;
VMstruct[1] = v60;
v61 = *(_BYTE *)(v59 + v60); //pop a
VMstruct[1] = v58;
v7 = (_BYTE *)(v58 + v59);
vm_eip_1 = (unsigned __int8)*v7; //pop b
if ( !v61 )
return vm_eip_1;
VMstruct[1] = v60;
v9 = (unsigned __int16)vm_eip_1 / v61;// temp = b/a
goto LABEL_8;
//(省略一些中间代码)
LABEL_8:
*v7 = v9; //push temp
LABEL_9:
code = *((_QWORD *)VMstruct + 1);
vm_eip_1 = *VMstruct + 1;
*VMstruct = vm_eip_1; //eip+=1
goto LABEL_2;
经过漫长的分析(里面的变量繁杂,代码可读性极低,需要一点一点看),可以整理出下面的指令,我基本上是用伪汇编表示的。
本题中的指令一部分是无操作数(单字节码)的,另一部分是单操作数(双字节码,即一个操作码加上一个操作数)的。如果下面的指令中含有data这个字眼,说明该指令是单操作数的。
1 getchar()
2 pop a; printf(a)
3 vm_eip ++
4 push data
5 push d[data]
6 pop d[data]
7 push c[data]
8 pop c[data]
9 pop a; pop b; push b+a
0xa pop a; pop b; push b-a
0xb pop a; pop b; push b*a
0xc pop a; pop b; push b/a
0xd pop a; pop b; push b%a
0xe pop a; pop b; push b^a
0xf pop a; pop b; push b&a
0x10 pop a; pop b; push b|a
0x11 pop a; push -a;
0x12 pop a; push ~a;
0x13 pop a; pop b; if (b!=a) {vm_eip+=2} else {vm_eip+=data}
0x14 pop a; pop b; if (b==a) {vm_eip+=2} else {vm_eip+=data}
0x15 pop a; pop b; if (b<=a) {vm_eip+=2} else {vm_eip+=data}
0x16 pop a; pop b; if (b<a) {vm_eip+=2} else {vm_eip+=data}
0x17 pop a; pop b; if (b>=a) {vm_eip+=2} else {vm_eip+=data}
0x18 pop a; pop b; if (b>a) {vm_eip+=2} else {vm_eip+=data}
0x19 pop a; push c[a]
0x1a pop a; pop b; c[a] = b
0x1b pop a; push d[a]
0x1c pop a; pop b; d[a] = b
0x1d vm_eip += data; if (*vm_eip>0x1d) return vm_eip
0x1e return vm_eip
0x9~0x12是一些简单的算数运算,无操作数,所涉及的数据都是从栈中弹出的。
0x13~0x18是进行判断、实现程序跳转的,类似jnz,jbe等。
0x1d其实就是jmp,注意data是char类型的(*(char *)(code + vm_eip + 1)
),有正有负,比如0xe8是负数。
import ctypes
print(ctypes.c_byte(0xe8)).value
#输出-24
0x1e是ret
下面说一些组合指令:
0x4-0x8:向c数组写入一个数据
0x1-0x8:输入一个数据,并写入c数组
0x4-0x6:为循环变量i或j(d[0]或d[1])赋值
等等。。
分析字节码
首先很容易看出前两部分:
一:内置数据的赋值
这部分数据是code文件内含的,用于和加密后的字符串作比较。
第一部分字节码就是把这些数据赋值到c[0x32+i]
def part1(self):
'''
验证flag的数据,存储在c[0x32]~c[0x32+0x29]
0x0 ~ 0xa7 :
04 66 08 32 04 4E 08 33 04 A9 08 34 04 FD 08 35
04 3C 08 36 04 55 08 37 04 90 08 38 04 24 08 39
04 57 08 3A 04 F6 08 3B 04 5D 08 3C 04 B1 08 3D
04 01 08 3E 04 20 08 3F 04 81 08 40 04 FD 08 41
04 36 08 42 04 A9 08 43 04 1F 08 44 04 A1 08 45
04 0E 08 46 04 0D 08 47 04 80 08 48 04 8F 08 49
04 CE 08 4A 04 77 08 4B 04 E8 08 4C 04 23 08 4D
04 9E 08 4E 04 27 08 4F 04 60 08 50 04 2F 08 51
04 A5 08 52 04 CF 08 53 04 1B 08 54 04 BD 08 55
04 32 08 56 04 DB 08 57 04 FF 08 58 04 28 08 59
04 A4 08 5A 04 5D 08 5B
'''
self.c = [0] * 0x100
with open(r'code', 'rb')as f:
self.b = f.read()
for i in range(0xa8//4):
group = self.b[i*4:i*4+4]
data, offset = group[1], group[3]
self.c[offset] = data
二:输入flag
输入flag,数据保存至c[0x64+i]
def part2(self):
'''
输入flag,存储在c[0x64]~c[0x64+0x29]
0xa8 ~ 0x125 :
01 08 64 01 08 65 01 08 66 01 08 67 01 08 68 01
08 69 01 08 6A 01 08 6B 01 08 6C 01 08 6D 01 08
6E 01 08 6F 01 08 70 01 08 71 01 08 72 01 08 73
01 08 74 01 08 75 01 08 76 01 08 77 01 08 78 01
08 79 01 08 7A 01 08 7B 01 08 7C 01 08 7D 01 08
7E 01 08 7F 01 08 80 01 08 81 01 08 82 01 08 83
01 08 84 01 08 85 01 08 86 01 08 87 01 08 88 01
08 89 01 08 8A 01 08 8B 01 08 8C 01 08 8D
'''
flag = input('请输入长度为42的字符串:')
if len(flag) != 42:
print('字符串长度不合要求')
else:
for i in range((0x126-0xa8)//3):
group = self.b[0xa8+i*3 : 0xa8+i*3+3]
data, offset = ord(flag[i]), group[2]
self.c[offset] = data
三:加密部分
翻译字节码脚本
这部分不像前两部分的指令简单,所以我先写了个翻译字节码的脚本:
class Translate():
def opcode(self):
'''
得到一个字节码字典
'''
dic = r'''1 getchar()
2 pop a; printf(a)
3 vm_eip ++
4 push data
5 push d[data]
6 pop d[data]
7 push c[data]
8 pop c[data]
9 pop a; pop b; push b+a
0xa pop a; pop b; push b-a
0xb pop a; pop b; push b*a
0xc pop a; pop b; push b/a
0xd pop a; pop b; push b%a
0xe pop a; pop b; push b^a
0xf pop a; pop b; push b&a
0x10 pop a; pop b; push b|a
0x11 pop a; push -a;
0x12 pop a; push ~a;
0x13 pop a; pop b; if (b!=a) {vm_eip+=2} else {vm_eip+=data}
0x14 pop a; pop b; if (b==a) {vm_eip+=2} else {vm_eip+=data}
0x15 pop a; pop b; if (b<=a) {vm_eip+=2} else {vm_eip+=data}
0x16 pop a; pop b; if (b<a) {vm_eip+=2} else {vm_eip+=data}
0x17 pop a; pop b; if (b>=a) {vm_eip+=2} else {vm_eip+=data}
0x18 pop a; pop b; if (b>a) {vm_eip+=2} else {vm_eip+=data}
0x19 pop a; push c[a]
0x1a pop a; pop b; c[a] = b
0x1b pop a; push d[a]
0x1c pop a; pop b; d[a] = b
0x1d vm_eip += data; if (*vm_eip>0x1d) return vm_eip
0x1e return vm_eip'''
self.opcode_dict = {}
for i in dic.split('\n'):
num, opcode = int(i.split('\t')[0], 16), i.split('\t')[1]
self.opcode_dict[num] = opcode
def translate_part_3_opcode(self):
'''
翻译字节码
'''
self.opcode()
with open(r'code', 'rb')as f:
self.b = f.read()
eip = 0x126
while eip < 0x1e8:
op = self.b[eip]
if 'data' in self.opcode_dict[op]:
#将data替换为具体的操作数
data = self.b[eip+1]
print('{0:<8}{1:<5}{2:<7}{3}'.format(hex(eip), hex(op), hex(data), self.opcode_dict[op].replace('data', hex(data))))
eip += 2
else:
print('{0:<8}{1:<12}{2}'.format(hex(eip), hex(op), self.opcode_dict[op]))
eip += 1
def __init__(self):
self.opcode()
self.translate_part_3_opcode()
t = Translate()
本脚本没有写具体跳转的指令翻译(因为本来就是个辅助分析的程序而已)
比如这条输出:
0x178 0x1d 0xbc vm_eip += 0xbc; if (*vm_eip>0x1d) return vm_eip
需要手动计算出跳转的位置,手动替换成下面这条指令:
0x178 0x1d 0xbc jmp 0x134
又比如这条:
0x18c 0x16 0x34 pop a; pop b; if (b<a) {vm_eip+=2} else {vm_eip+=0x34}
需要手动翻译成:
0x18c 0x16 0x34 pop a; pop b; cmp a,b; jbe 0x1c0;
分析
最终我们得到了第三部分的伪汇编指令:
0x126 0x4 0x0 push 0x0
0x128 0x6 0x0 pop d[0x0]
0x12a 0x5 0x0 push d[0x0]
0x12c 0x4 0x7 push 0x7
0x12e 0x16 0x56 pop a; pop b; cmp a,b; jbe 0x184;
0x130 0x4 0x0 push 0x0
0x132 0x6 0x1 pop d[0x1]
0x134 0x5 0x1 push d[0x1]
0x136 0x4 0x6 push 0x6
0x138 0x16 0x42 pop a; pop b; cmp a,b; jbe 0x17a;
0x13a 0x5 0x0 push d[0x0]
0x13c 0x4 0x6 push 0x6
0x13e 0xb pop a; pop b; push b*a
0x13f 0x5 0x1 push d[0x1]
0x141 0x9 pop a; pop b; push b+a
0x142 0x4 0x64 push 0x64
0x144 0x9 pop a; pop b; push b+a
0x145 0x19 pop a; push c[a]
0x146 0x12 pop a; push ~a;
0x147 0x5 0x0 push d[0x0]
0x149 0x5 0x1 push d[0x1]
0x14b 0x4 0x2 push 0x2
0x14d 0x9 pop a; pop b; push b+a
0x14e 0xb pop a; pop b; push b*a
0x14f 0xf pop a; pop b; push b&a
0x150 0x4 0x64 push 0x64
0x152 0x5 0x0 push d[0x0]
0x154 0x4 0x6 push 0x6
0x156 0xb pop a; pop b; push b*a
0x157 0x5 0x1 push d[0x1]
0x159 0x9 pop a; pop b; push b+a
0x15a 0x9 pop a; pop b; push b+a
0x15b 0x19 pop a; push c[a]
0x15c 0x5 0x0 push d[0x0]
0x15e 0x5 0x1 push d[0x1]
0x160 0x4 0x2 push 0x2
0x162 0x9 pop a; pop b; push b+a
0x163 0xb pop a; pop b; push b*a
0x164 0x12 pop a; push ~a;
0x165 0xf pop a; pop b; push b&a
0x166 0x10 pop a; pop b; push b|a
0x167 0x5 0x1 push d[0x1]
0x169 0x4 0x7 push 0x7
0x16b 0xb pop a; pop b; push b*a
0x16c 0x5 0x0 push d[0x0]
0x16e 0x9 pop a; pop b; push b+a
0x16f 0x1a pop a; pop b; c[a] = b
0x170 0x5 0x1 push d[0x1]
0x172 0x4 0x1 push 0x1
0x174 0x9 pop a; pop b; push b+a
0x175 0x4 0x1 push 0x1
0x177 0x1c pop a; pop b; d[a] = b
0x178 0x1d 0xbc jmp 0x134
0x17a 0x5 0x0 push d[0x0]
0x17c 0x4 0x1 push 0x1
0x17e 0x9 pop a; pop b; push b+a
0x17f 0x4 0x0 push 0x0
0x181 0x1c pop a; pop b; d[a] = b
0x182 0x1d 0xa8 jmp 0x12a
;以上为第一部分,二重循环。设输入为x,求出f(x),存储在c[0]~c[0x29]
;第一个部分循环跳出至此
0x184 0x4 0x1 push 0x1
0x186 0x6 0x0 pop d[0x0]
0x188 0x5 0x0 push d[0x0]
0x18a 0x4 0x2a push 0x2a
0x18c 0x16 0x34 pop a; pop b; cmp a,b; jbe 0x1c0;
0x18e 0x5 0x0 push d[0x0]
0x190 0x4 0x2 push 0x2
0x192 0xd pop a; pop b; push b%a
0x193 0x4 0x0 push 0x0
0x195 0x14 0xf pop a; pop b; cmp a,b; jnz 0x1a4;
0x197 0x5 0x0 push d[0x0]
0x199 0x19 pop a; push c[a]
0x19a 0x5 0x0 push d[0x0]
0x19c 0x4 0x1 push 0x1
0x19e 0xa pop a; pop b; push b-a
0x19f 0x19 pop a; push c[a]
0x1a0 0x9 pop a; pop b; push b+a
0x1a1 0x5 0x0 push d[0x0]
0x1a3 0x1a pop a; pop b; c[a] = b
0x1a4 0x5 0x0 push d[0x0]
0x1a6 0x4 0x2 push 0x2
0x1a8 0xd pop a; pop b; push b%a
0x1a9 0x4 0x1 push 0x1
0x1ab 0x14 0xb pop a; pop b; cmp a,b; jnz 0x1b6;
0x1ad 0x4 0x6b push 0x6b
0x1af 0x5 0x0 push d[0x0]
0x1b1 0x19 pop a; push c[a]
0x1b2 0xb pop a; pop b; push b*a
0x1b3 0x5 0x0 push d[0x0]
0x1b5 0x1a pop a; pop b; c[a] = b
0x1b6 0x5 0x0 push d[0x0]
0x1b8 0x4 0x1 push 0x1
0x1ba 0x9 pop a; pop b; push b+a
0x1bb 0x4 0x0 push 0x0
0x1bd 0x1c pop a; pop b; d[a] = b
0x1be 0x1d 0xca jmp 0x188
;至此为第二部分,按照下标的奇偶,分别将上一步得到的数据进一步加密
;以下为第三部分,判断c[i]是否和c[i+0x32]是否相等,相等输出y,反之输出n
0x1c0 0x4 0x0 push 0x0
0x1c2 0x6 0x0 pop d[0x0]
0x1c4 0x5 0x0 push d[0x0]
0x1c6 0x4 0x29 push 0x29
0x1c8 0x18 0x4 pop a; pop b; cmp a,b; jae 0x1cc;
0x1ca 0x1d 0x1b jmp 0x1e5
;jmp to printf('y')
0x1cc 0x5 0x0 push d[0x0]
0x1ce 0x19 pop a; push c[a]
0x1cf 0x4 0x32 push 0x32
0x1d1 0x5 0x0 push d[0x0]
0x1d3 0x9 pop a; pop b; push b+a
0x1d4 0x19 pop a; push c[a]
0x1d5 0x14 0xc pop a; pop b; cmp a,b; jnz 0x1e1;
;jmp to printf('n')
0x1d7 0x5 0x0 push d[0x0]
0x1d9 0x4 0x1 push 0x1
0x1db 0x9 pop a; pop b; push b+a
0x1dc 0x4 0x0 push 0x0
0x1de 0x1c pop a; pop b; d[a] = b
0x1df 0x1d 0xe5 jmp 0x1c4
0x1e1 0x4 0x6e push 0x6e
;printf('n')
0x1e3 0x2 pop a; printf(a)
0x1e4 0x1e return vm_eip
;printf('y')
0x1e5 0x4 0x79 push 0x79
0x1e7 0x2 pop a; printf(a)
然后有需要慢慢分析。我是手动维护了一个栈,然后手动模拟了一遍程序。
最后整理出一下加密逻辑:
def part3(self):
'''
加密flag,加密后的数据放在c[0]~c[29]
'''
for i in range(7):
for j in range(6):
#self.c[j*7+i] = ((~self.c[6*i+j+0x64]) & ((j+2)*i)) | ((self.c[6*i+j+0x64]) & (~((j+2)*i)))
self.c[j*7+i] = self.c[6*i+j+0x64] ^ ((j+2)*i)
#因为a ^ b = (~a & b) | (a & ~b),所以上面两行代码等价
for i in range(1, 0x2a):
if i % 2 == 0:
self.c[i] = (self.c[i] + self.c[i-1]) & 0xff
elif i % 2 == 1:
self.c[i] = (self.c[i] * 0x6b) & 0xff
for i in range(0x29+1):
if self.c[i] != self.c[i+0x32]:
break
if i == 0x29:
print('y')
else:
print('n')
加密部分有两步,第一步逻辑很复杂,但是可以化简(如果我单独思考,肯定没法化简出来,这个化简是令则大佬在题解中说的)。
第二部根据奇偶分别运算。
特别注意循环的起始和终止条件!!!
特别注意c和python之间变量位数的不同,注意&0xff!!!
逆向
最后逆着写出解密脚本:
class Decode():
def __init__(self):
import ctypes
c_0x32 = [102, 78, 169, 253, 60, 85, 144, 36, 87, 246, 93, 177, 1, 32, 129, 253, 54, 169, 31, 161, 14, 13, 128, 143, 206, 119, 232, 35, 158, 39, 96, 47, 165, 207, 27, 189, 50, 219, 255, 40, 164, 93]
c = [0] * 0x2a
c_0x64 = [0] * 0x2a
c[0] = c_0x32[0]
for i in range(1,0x2a):
if i % 2 == 0:
for j in range(0x100):
if c_0x32[i] == (j + c_0x32[i-1]) & 0xff:
c[i] = j
elif i % 2 == 1:
for j in range(0x100):
if c_0x32[i] == (j * 0x6b) & 0xff:
c[i] = j
for i in range(7):
for j in range(6):
c_0x64[6*i+j] = c[j*7+i] ^ ((j+2)*i)
print(''.join(map(chr, c_0x64)))