虚拟机逆向初探
虚拟机保护原理
内容转自https://zhuanlan.zhihu.com/p/38028963
基本原理
这里的虚拟机当然并不是指VMWare或者VirtualBox之类的虚拟机,而是指的意思是一种解释执行系统或者模拟器(Emulator)。所以虚拟机保护技术,是将程序可执行代码转化为自定义的中间操作码(Operation_Code,如果操作码是一个字节,一般可以称为Bytecode),用以保护源程序不被逆向和篡改,opcode通过emulator解释执行,实现程序原来的功能。在这种情况下,如果要逆向程序,就需要对整个emulator结构进行逆向,理解程序功能,还需要结合opcode进行分析,整个程序逆向工程将会十分繁琐。这是一个一般虚拟机结构:
这种虚拟化的思想,广泛用于计算机科学其他领域。从某种程度上来说,解释执行的脚本语言都需要有一个虚拟机,例如LuaVM,Python_Interpreter。静态语言类似Java通过JVM实现了平台无关性,每个安装JVM的机器都可以执行Java程序。这些虚拟机可以提供一种平台无关的编程环境,将源程序翻译为平台无关的中间码,然后翻译执行,这是JVM虚拟机的基本架构(图片来自geeks_forgeeks):
从这个解释中,我们可以看到虚拟机保护有其缺点,就是程序运行速度会受到影响。在商用的一些虚拟机保护软件中,可以提供SDK,在编程的时候可以直接添加标记,只保护关键算法。
分析方法
在目前的CTF比赛中,虚拟机题目常常有两种考法:
给可执行程序和opcode,逆向emulator,结合opcode文件,推出flag
只给可执行程序,逆向emulator,构造opcode,读取flag
拿到一个虚拟机之后,一般有以下几个逆向过程:
分析虚拟机入口,搞清虚拟机的输入,或者opcode位置
理清虚拟机结构,包括Dispatcher和各个Handler
逆向各个Handler,分析opcode的意义
调试过程中,在汇编层面调试当然是最基本最直接的方法,但是由于虚拟机Handler可能比较多,调试十分繁琐。
若虚拟机内部没有很复杂的代码混淆,可以考虑使用IDA进行源码级调试,这对于快速整理emulator意义很有帮助。
再进一步,可以结合IDA反编译伪代码,加上一些宏定义,加入输出,重新编译,可以十分快速的逆向整个emulator执行过程。
入门例题:WxyVM1
一道不是很VM的VM题,比较简单,反编译得到源代码:
1 | __int64 __fastcall main(int a1, char **a2, char **a3) |
main函数逻辑清晰,输入flag经过sub_4005B6处理后和601060比较即可。跟进4005B6:
1 | __int64 sub_4005B6() |
结合注释发现是要考虑加法,减法,和异或就好了,非常简单。
这里采用idc脚本来解决,贴上idc脚本常用函数链接(已备份)
1 |
|
值得注意的是,byte_0x6010C0是char型也就是一个byte和byte_0x601060是四字节,因此result需要乘4保证addr的正确性。
最后调用idc脚本即可。调用方法为:File->Script File->idc文件
执行后得到flag:nctf{Embr4ce_Vm_j0in_R3}
DDCTF黑盒测试(俩re手干了一天)
源码分析
解包后得到两个文件,一个elf,另一个txt文件,名字是flag-48ee204317,源代码:
1 | __int64 __fastcall main(int a1, char **a2, char **a3) |
sub_401E98是关键函数,步入之后的代码:
1 | __int64 __fastcall sub_401A48(__int64 a1) |
乍一看其实比较难懂,此时结合汇编:
注意到有个call rax,应该就是靠这个来实现跳转到各个handle内,而该函数应该就是Dispatcher,这里再回去看代码应该就很好理解了。
动调找opcode
有了上面的分析,下面开始进行动态调试,在byte_603900处下断点,在虚拟机中输入password绕过,再输入passcode:
我们注意到,要想进行到跳转语句,必须满足表达式
1 | byte_603900[v2] == *(_BYTE *)(a1 + *(int *)(a1 + 4 * (j + 72LL) + 8) + 408) |
而v2是我们输入的passcode,由此我们可以得出,byte_603900的作用应该是让opcode与我们输入的passcode进行一个一一对应的映射,此处我们在动态调试的情况下用idc脚本dump出符合所有 的opcode,就可以找出我们所输入的passcode是由那些字符(opcode)组成的。
我们在调用dispatch的地方下断点,F7步入后,调用idc脚本:
1 |
|
这样我们就可以得到opcode:0x2a,0x27,0x3e,0x5a,0x3f,0x4e,0x6a,0x2b,0x28
接着再从ida扒出byte_603900的数据,找出映射之前组成passcode的字符:
1 | byte_603900 = [0x02, 0x00, 0x00, 0x0E, 0x16, 0x54, 0x20, 0x18, 0x11, 0x45, |
最终得到映射前的opcode:
1 | [’$’, ‘8’, ‘C’, ‘t’, ‘0’, ‘E’, ‘u’, ‘#’, ‘;’] |
再次动调跟踪每个opcode对应的case
有了opcode,接下来的工作是跟踪每一个opcode对应的case。
通过对byte_603F00的交叉引用,找到其直接调用的函数
我们发现该函数是通过fastcall进行调用的,说明该函数应该就是其中的一个case,我们再对sub_401D33D进行交叉引用:
找到了每个case对应的函数。
接下来,我们通过动态调试,在输入passcode的地方输入之前得到的opcode,此处仅以opcode(‘8’)演示
在此处下断点后,if语句种的条件应当是成立的,继续F8,在fastall的地方F7步入,我们就可以找到opcode(8)对应的case
用同样的方法,我们可以找到每个opcode对应的case:
1 | $ -> sub_400DC1(func1) |
对每个case的功能进行分析
接下来是SG大爹的硬核分析环节:
我们先把伪代码进行初步的简化
1 | func1: |
其中,check()的逻辑是
1 | if (a1 + 664) == 's' |
然后进行动态调试,可以发现
- +655: 临时变量tmp
- +8: str = “PaF0!&Prv}H{ojDQ#7v=”
- +288: pt
- +16: 我们输入的passcode(input)
- +664: 下一位输入(next, 即input[i + 1])
并且,一些奇怪的东西如下:
- *((a1 + 280) + (int)(a1 + 288) + a1): str[pt]
- ((a1 + 16) + (a1 + 288) + *(a1 + 664) - 48): input[pt + input[i] - 48]
那么,可以拿出伪代码的进一步分析了
1 | func1($): |
并且,在check()中,我们还发现:程序的目的是将str = “PaF0!&Prv}H{ojDQ#7v=” 转换成 str = “Binggo”,然后我们才可以得到flag
显然地,str的位数远大于”Binggo”,我们得考虑在PaF0!&P的第二个P处用’\0’将字符串截断
构造passcode使程序输出Binggo
从头开始构造吧
首先看P -> B,我们先取出’P’,用’$’使得temp = str[pt],此时pt = 0,则temp = ‘P’
两者的ASCII分别是80, 66,考虑用’t操作’
$80 - next + 33 = 66$,显然$next = 47$,即’/‘
然后分别用’8’’0’来进行更新,并且将pt指针后移一位
综上,第一步的opcode应该是$t/80
同理,PaF0!&转换成Binggo需要的opcode应该是$t/80$C)80$CI80$CX80$Cg80$Cj80
现在该考虑截断操作了
这里我们不能用func3来构造了,这样得到的是一个不可见字符
那只能考虑func8来进行一下复杂操作了
我们考虑在P -> B的时候额外插入一个111来让pt指向第七个字符时直接构造’\0’,最后调整pt到str前端,最后用’E’调用check()并用’s’满足check()的条件
最终的passcode就是
$t/81110$C)80$CI80$CX80$Cg80$Cj80#0uuuuuuuuEs