虚拟机保护逆向入门

前言

软件防逆向工程与逆向工程相伴发展,早期的有花指令,反调试技术,代码混淆与加密,加压缩壳或者加密壳等等保护手段,这些技目前已经有了较好的解决方案,自动化的分析方法也比较成熟。目前比较前沿的软件保护技术是虚拟机保护(Virtual-Machine-Protect),当然这种虚拟化的思想也广泛用于软件开发等其他领域。

最近在打一些CTF比赛的时候,感觉虚拟机保护的逆向已经成了Reverse题目的一个新趋势,甚至现在的CTF中,稍微简单的虚拟机题目都快成入门题了。当然这里的虚拟机往往不是商业的vmp之类的保护软件,而是出题人实现的小型虚拟机,题目本身一般也没有实现某些复杂的功能。本篇文章简单的介绍一下虚拟机保护的基本原理,并记录一些最近的几个题目。受限于笔者能力水平,有错误望指正交流。

虚拟机保护原理

基本原理

这里的虚拟机当然并不是指VMWare或者VirtualBox之类的虚拟机,而是指的意思是一种解释执行系统或者模拟器(Emulator)。所以虚拟机保护技术,是将程序可执行代码转化为自定义的中间操作码(OperationCode,如果操作码是一个字节,一般可以称为Bytecode),用以保护源程序不被逆向和篡改,opcode通过emulator解释执行,实现程序原来的功能。在这种情况下,如果要逆向程序,就需要对整个emulator结构进行逆向,理解程序功能,还需要结合opcode进行分析,整个程序逆向工程将会十分繁琐。这是一个一般虚拟机结构:

虚拟机保护逆向入门_第1张图片

这种虚拟化的思想,广泛用于计算机科学其他领域。从某种程度上来说,解释执行的脚本语言都需要有一个虚拟机,例如LuaVM,PythonInterpreter。静态语言类似Java通过JVM实现了平台无关性,每个安装JVM的机器都可以执行Java程序。这些虚拟机可以提供一种平台无关的编程环境,将源程序翻译为平台无关的中间码,然后翻译执行,这是JVM虚拟机的基本架构(图片来自geeksforgeeks):

虚拟机保护逆向入门_第2张图片

从这个解释中,我们可以看到虚拟机保护有其缺点,就是程序运行速度会受到影响。在商用的一些虚拟机保护软件中,可以提供SDK,在编程的时候可以直接添加标记,只保护关键算法。

分析方法

从这里开始,本文所指的虚拟机都是私有实现的小型虚拟机,并不是指成熟的商用虚拟软件,例如VMProtect、Themida等,这些保护软件的逆向工作研究工作很多,不过比较完善和系统的解决方案还没有出现。

在目前的CTF比赛中,虚拟机题目常常有两种考法:

·给可执行程序和opcode,逆向emulator,结合opcode文件,推出flag

·只给可执行程序,逆向emulator,构造opcode,读取flag

拿到一个虚拟机之后,一般有以下几个逆向过程:

·分析虚拟机入口,搞清虚拟机的输入,或者opcode位置

·理清虚拟机结构,包括Dispatcher和各个Handler

·逆向各个Handler,分析opcode的意义

在实际调试中,个人觉得最为关键的是要搞清虚拟机的执行流。调试过程中,在汇编层面调试当然是最基本最直接的方法,但是由于虚拟机Handler可能比较多,调试十分繁琐。若虚拟机内部没有很复杂的代码混淆,可以考虑使用IDA进行源码级调试,这对于快速整理emulator意义很有帮助。再进一步,可以结合IDA反编译伪代码,加上一些宏定义,加入输出,重新编译,可以十分快速的逆向整个emulator执行过程。

目前比赛中,虚拟机题目特点是核心算法不是很复杂,虚拟机本身没有反调试和代码加密混淆的加入。当然,随着整体CTF水平的不断进步,未来虚拟机逆向难度只会越来越高。

题目示例

在这一部分,我选择了最近3个VM类题目,题目可以在这里下载(https://github.com/b1ngoo/VMCTF),限于篇幅(太长估计也看的不舒服2333),就只写3个题目了。

DDCTF 2018黑盒破解

这个题目可以属于给二进制程序,构造opcode,令程序输出Binggo字样,

然后在服务器上输入该opcode,可以读取flag。

file命令看一下:64位的ELF文件

IDA中看下逻辑,main函数十分清楚。然后跑一下,题目要求是这样的:首先输入password(就是给的txt文件中的数字),然后要求输入passcode,令程序输出Binggo。本地测试通过之后,然后将该passcode提交到服务器上,就会得到flag。

main函数最关键的判断在这里:

虚拟机保护逆向入门_第3张图片

查找引用,找到sub_40133D函数,如果对这个函数再次查找应用,又找到了14个函数:

虚拟机保护逆向入门_第4张图片

但是其实我们还是没有找到调用sub_40133D函数的地方,这里我就陷入了僵局。这时随意翻看main函数,在关键判断之前还调用了一个sub_401A48函数,并且该函数的参数就是我们输入的passcode,进去看下,如果反编译看伪代码的话,其实是十分不友好的,但是如果看CFG图的话,还是可以看出来这是一个虚拟机的Dispatcher位置,在这个基本块里面通过callrax跳入到每个Handler里面:

虚拟机保护逆向入门_第5张图片

接下来通过逆向这部分代码找到可以输入的passcode,并且可以找到对应关系:

“””

sub_400DC1——> $  func1

sub_400E7A——> 8  func2

sub_400F3A——> C  func3

sub_401064——> t  func4

sub_4011C9——> 0  func5

sub_40133D——> E  func6

sub_4012F3——> u  func7

sub_4014B9——> #  func8

sub_400CF1——> ;  func9

“””

然后给出相对于a1偏移存储的内容:

“””

(a1+ 288):指向字符串的索引位置

(a1+665):临时存放变量位置

(a1+664):输入的每个值位置

“””

然后通过再次动调,我们可以了解到我们这部分的任务:通过输入字节码,调用不同函数(func1~func9),将长度为20的字符串PaF0!&Prv}H{ojDQ#7v=转为长度为6的字符串Binggo。然后在func6函数中,做检测,如果是Binggo,就满足条件。

接下来的工作就是一个个看这几个func,通过伪代码大致可以理解一下:

“””

func1:

*(a1+665)= str[a1+288]

func2:

str[a1+288]= *(a1 + 655)

func3:

*(a1+665)= *(a1+665) + *(a1+664) - 33

func4:

*(_BYTE*)(a1 + 665) = *(_BYTE *)(a1 + 665) - *(_BYTE *)(a1 + 664) + 33;

func5:

++*(a1+ 288)

func6:

put(str,20)

func7:

--*(a1+ 288)

func8:

str[a1+288]= input[*(a1+288) + *(a1+664) - 48] - 49

func9:

for( i = 0; *(a1 + 664) > i; ++i )

++*(a1 + 288)

str[a1+288]= input[*(a1+288) + *(a1+664) - 48] - 49

“””

最后慢慢构造出满足条件的passcode,还有别忘了构造完成后需要将字符串索引通过func7移到开始位置,然后调用puts函数输出。构造方法有很多种,我是从头往后构造,看了其他人的writeup,也有师傅是从后往头构造。这是我的passcode:

“””

$t/80$C)80$CI80$CX80$Cg80$Cj80#J1uuuuuuuEs

“””

本地验证一下通过:

虚拟机保护逆向入门_第6张图片

远程也是可以通过的:

虚拟机保护逆向入门_第7张图片

RCTF 2018 Simple vm

这个题是今年RCTF2018的一个入门题吧,感谢这位师傅的出题和分享,学

到了很多。题如其名,这是一个虚拟机程序,题目本身给了一个ELF64位的可执行程序,给了一个bin文件,这是opcode文件。

vm_run在sub_400896函数,进去之后发现没有代码加密或者混淆,直接可

以反编译和生成CFG图:

虚拟机保护逆向入门_第8张图片

从结构可以看出虚拟机一般的switch/case结构,所以loc_4008A2可以理解为vm_dispatcher,各个case为vm_handler。赛后做这个题目,我们就用最傻的方法,一步步调试这个vm,结合opcode,理解各个handler。在IDA的反编译结果中,出现了两个比较关键的全局变量:dword_6010A4和c。我们可以将之认为是用于临时存放opcode指令和代码的变量。

给出p.bin文件的解析:

“””

01 # 读取addr赋值给指针

30000000  # 带读取的addr

15 # 读取字符串所在addr赋值给vm.c

00010000 # Inputflag字符串长度地址,后面就是字符串

#dword_6010A4是字符串的ASCII,c是字符串所在的地址

# 这部分完成了打印字符串

0E # c++

12 # dword_6010A4 = p[c]

0B # putchar(dword_6010A4);

0C # 循环

00010000 # 循环次数所在地址(也就是字符串的长度)

35000000 # 每次循环开始的指针

# 这部分完成了循环

15 c = READ_INT();

10010000 # 要读入的字符串长度地址

0E # c

0A # dword_6010A4 = getchar();

66 # nop

16 # p[c] = dword_6010A4存放到0x111位置处

0C # 循环

10010000 # 循环的次数

47000000 # 每次循环开始的指针

03 #取0x140偏移处的内容0x20赋值给dword_6010A4

40010000

10 # c = dword_6010A4;

11 # dword_6010A4 += val;

F1000000 # val

13 # dword_6010A4 =p[dword_6010A4]; 到这个位置,就将输入的ASCII赋值到dword_6010A4中了

04 # p[dst] = dword_6010A4;

43010000 # dst 0x143

#开始对输入做变化,0x141~0x144可以理解为变化临时存放为位置

08 # dword_6010A4 =~(dword_6010A4 & c)

04 # 将dword_6010A4存放到后面的地址上

41010000 # 0x141地址

10 # c = dword_6010A4将一次变化后的值给c,进行迭代变化

03 # 取0x140处内容给c

40010000 # 0x20

08

04

42010000 # 将第二次变化内容复制到0x142地址处

03 # 取第一次变化后的值给dword_6010A4

41010000 # 存放第一次变化后的值

03 # 取每个输入值

43010000 # 每个输入值存放位置

08 #

10 # c = dword_6010A4

03 # 取第二次变化给dword_6010A4

42010000

08 # 最后一次变化

0444010000 #

03 # dword_6010A4赋值

40010000

11 # dword_6010A4 add

F1000000 # add value

10 # c = dword_6010A4

03 #将一个地址处内容赋值给dword_6010A4,这里是将变化后的输入赋值给dword_6010A4

44010000 # addr,存放输入变化后的值

16 # p[c] = dword_6010A4

05 # 设置c为0x20

40010000

0E # c++

06 # 将c设置到0x140位置

40010000

0C # loop

45010000 # loop 次数

55000000

ch(49,32) = -33

ch(32,ch(49,32)) = -1

ch(49,ch(49,32)) = -18

ch( ch(32,ch(49,32)) ,ch(49,ch(49,32)) ) = 17

====> xor(49,32) = 17

====> xor(input[i],i+0x20)

03 #取地址0x146位置的0x1f赋值给dword_6010A4

46010000 # 地址参数

11 # add

05000000

13 # dword_6010A4 =p[dword_6010A4]

10 # c = dword_6010A4

03 # dword_6010A4 = p[src]

46010000 #

11 # add dword_6010A4 += val

11010000

13 # dword_6010A4 =p[dword_6010A4]

17 # dword_6010A4 -= c;

18 # OP_JNE

60010000

0C # 循环

46010000

B6000000

“””

这就是这个程序的执行流,调试过程十分麻烦,vm实在是太磨人了。理解

上述过程,其实就是对输入做异或,并与p.bin文件部分内容进行比较的过程,然后写出逆向算法,解出flag:

“””

# -*- coding:utf-8 -*-

a ="1018431415474017101D4B121F49481853540157515305565A08585F0A0C5809"

a = a.decode("hex")

a = [ord(i) for i in a]

print a

f = ""

for i in range(len(a)):

f += chr(a[i]^(i+32))

print f

“””

其实这种方法算是最麻烦的,该方法是从汇编层面调试二进制程序,我还看

到更为简单的一种方法是通过IDA反编译结果(https://expend20.github.io/2018/05/24/RCTF-simple-vm.html),稍微改改之后,重新编译,然后就可以进行源码级调试。这里的修改基本不用动IDA的反编译结果,就是需要加些IDA的宏定义,在每个case后面都使用printf输出当前指针位置,就可以自动的分析出程序的执行流,十分方便。

从赛后复盘的角度是可以慢慢调试虚拟机的,搞清每个字节码的意义,然后

完整的理解虚拟机解释执行过程。不过随着虚拟机的复杂,这种方法将会非常费时,应该学习一些自动化的处理方法。

RCTF 2018 magic

又是今年RCTF的一个Re题,题目也出很好,我感觉还挺难的,赛后看了一

些其他师傅的WP,感觉收获很多。这里前面爆破time的第一次检查不再赘述(吾爱有个师傅的处理方法很不错https://www.52pojie.cn/thread-742361-1-1.html),只说说第二个对输入的检查。当第一次对时间检查通过之后,程序执行流来到了函数sub_4023B1位置:

虚拟机保护逆向入门_第9张图片

scanf之后,对输入进行加密,加密后的数据作为虚拟机输入。这个地方是利用setjmp/longjmp实现的一个虚拟机,虚拟机opcode位于

0x405340处。地址409040是虚拟机用于数据处理的区域,我们可以将之定义为dword型数组。该虚拟机值得主要的是这个地方,调试的过程中需要注意一下,这里是通过异常处理完成了一个Handler:

虚拟机保护逆向入门_第10张图片

异常处理在这个signal回调函数中:

虚拟机保护逆向入门_第11张图片

对于虚拟机的逆向,往往都是通过首先逆出来各个Handler,然后结合opcode

搞清楚程序算法,在目前这个阶段,这个算法一般不会很难。如果算法很难,opcode很长,这种情况题目就算题出的很难的。接下来给出opcode的解释:

“””

con[1] = cmp

con[2] = input

AB 03 00 mov con[3],0

AB 04 1A mov con[4],0x1A

AB 00 66 mov con[0],0x66

AA 05 02 mov con[5],con[2] #input

A9 53 con[5] += con[3]  

A0 05 mov con[5],*(byte)con[5]    

AB 06 CC mov con[6],0xCC

A9 56 con[5] += con[6]

AB 06 FF mov con[6],0xFF

AC 56 con[5] &= con[6]

AE 50 con[5] ^= con[0]

AD 00 not con[0]

AA 06 05 mov con[6],con[5]

AA 05 01 mov con[5],con[1]

A9 53 con[5] += con[3]

A0 05 mov con[5],*(byte)con[5]

AF 56 00 if con[5]==con[6]

算法总结:

key = 0x66

((input[i] + 0xCC ) &0xFF) ^key == cmp[i]

~key

“””

直接爆破就可以得到需要输入值:

“””

comp ="89C1EC50973A5759E4E6E442CBD90822AE9D7C07808F1B4504E8"

comp = [ord(i) for i incomp.decode("hex")]

print comp

'''

key = 0x66

((input[i] + 0xCC ) &0xFF) ^key == cmp[i]

~key

'''

key = 0x66

f = []

for i in range(len(comp)):

for j in range(256):

if ((j + 0xCC) &0xFF ) ^ key == comp[i]:

f.append(j)

key = (~key) & 0xFF

print f

“””

值得注意的是,这个只是vm_run的输入值,需要利用程序中的解密算法解

密后得到输入值,这个可以在vm_run成功后,在地址0x040249D下断点,在内存将要解密的值进行替换,最后解密后如下所示:

这个只是其中一部分的flag,将之输入后会得到一个图案,图案为rctf{h:

虚拟机保护逆向入门_第12张图片

拼接之后即为flag。

注:本文为合天智汇原创,未经允许,严禁转载!

你可能感兴趣的:(虚拟机保护逆向入门)