查看原文
其他

一个简单虚拟机的逆向

zplusplus 看雪学院 2019-05-27

1. 检索指令和寄存器


嗨,今晚有点累,于是我下载了一些非常喜欢非常动听的音乐,是时候来逆向了。之前有收到一些和我观点不太一样的关于这个教程的问题,那么就来写个小教程吧。

 

关于HyperUnpackMe2我已经听说过很多次了,所以最后我选择了它。打开了IDA 4.3, 是的,我不使用被破解的工具。毕竟,这些工具对于某些人来讲是必不可缺的。

 

然后,打开CrackedMe。CrackMe开始有许多拙劣的anti-IDA的小把戏,需要un-define(U 键)跳转/调用指针,然后将指针指向的区域重新定义为“代码”(C 键)。CrackMe将指针隐藏在了LoadLibrary和一些字符串里边,比如像“VirtualAlloc”这样。好了,可笑并且无趣,我们想要看的是虚拟机。希望它没有被加密,否则,在VM变得明晰之前不得不开Olly来脱壳了。

 

那么,如何使用IDA 4.3在代码中寻找VM的代码呢?


简单的办法:使用滚动条+一款古老的逆向工具:Zen(禅,译:不好意思没用过)。

 

我们要找什么,什么可以成为“Zen”点?


好吧,当我浏览aspr 1.2 dll时,我发现push序列后边跟着一个ret可能会是一个“Zen”点。事实上,这在壳的待办事项清单中。


VM呢?


好吧,VM由仿真指令组成,通常都有一个循环代码跳转到公共的的函数或者地址。在这种情况下,我们需要寻找指针、函数列表。是的,这个列表可以是很多东西。可能是对象,比如,按这种方式来存储。


我们如何从VM中区分或者当VM被硬编码进一个对象的时候该怎么办?

 

其实答案很简单。去测试这些函数,寻找重复的模式。举例,如果在这些函数中引用相同的参数,并且对应的参数按照某种模式被不止一个函数使用,此时你可能已经在虚拟机里了。个人而言,我总是试图找到对常见攻击点的引用,比如程序计数器(相当于EIP)。这可能并不是一直都很容易的。像 *F 此类的 绑定流虚拟机(binded flow VMs) 是相当复杂的(顺带说一句,你可以使用各种技巧来记录它的日志)。

 

回到我们的crackme。先拖动滚动条,到处看一看,我在随机的跟随跳转和调用中发现了这样一个令人感兴趣的列表,如下:

 

看起来是不是很有趣?一长串列表的指针。来探索一下其中的一个指针吧(第一个链接表指向了第二个的头部 ):

 

IDA 将这一小段解释为数据,但是按下“C”键将其标记为代码之后就变为了。 

 

有趣,不是吗?一个jmp紧随着XOR操作。让我们在所有的块上使用“C”键标记,来看看发生了什么:

 

这些是我最初按下"C"键的地方。来审查下代码。所有的片段都跳转到了相同的地址,这意味着它们有一个公共的尾部。

 

注意第一条指令:在所有的入口处都是重复的mov ecx,esi!这看起来不是一种模式吗?获取相同逻辑的参数被传递给了esi?很明确,ecx是下一条循环左移指令shl的移位计数。同时,这些代码片段中[edi]寄存器被用作shl的操作目标地址。并且,这三行代码块呈现出了相同的结构,仅仅是核心指令(“执行器”)发生了变化:byte ptrword ptrdword ptr


有没有可能是三种提到的对应的虚拟shl指令?

我们需要搞明的是这里SHL的源参数由esi传递,目标很明显了在edi,我们有一系列shl了,shl了byte、word、dword型。

 

其实,我们足够幸运了。VM通常从指令集结构上来看会更加的复杂。这个虚拟机没有很多复杂的与指令不同类型寄存器、内存、偏移有关的实现。它似乎使用了固定的指令的源/目标标记:ESI是一个指向源的通用指针,EDI则是指向目标结果的通用指针(如我们逆向所看到的,VM的通用寄存器被用来作为指令的内存引用。如果SHL的目标是一个通用VM寄存器R1,那么EDI则可能包含指向R1的指针。)

 

一种常用的或者说更加标准的切入点在VM中是NOP指令的等价实现。怎么去发现那些NOP呢?


easy~那些NOP的等价实现什么也没干但是却更新了VM的内部状态。因此, 如果一条指令仅仅是更新了VM的状态,例如,只是被用来更新PC(Progma Counter)的代码就非常有可能是该虚拟机的NOP。这个CrackMe的VM非常简单,因此我们直接把NOP作为切入点来识别复杂的指令就行了。

 

此刻,是时候来逆向这个指令块并且命名它们了。逆向的结果产生了一些像下边这样的结果:

 

所有的这些指令结构都和那条SHL一样(或多或少)。一个非常有趣的点是观察IDIV指令的实现。你可能注意到了,这条指令被分成了IDIVIDIV_RESET。你可能还记得,IDIV指令还会返回除法的余数。如果你检查一下这两个虚拟的OPCODE是被怎样实现的就会发现:

IDIVEDI返回了一个不同的寄存器。这会让你应该想一想为什么。结果显而易见,其中一个是除法的结果,另一个则是除法的余数。作为由二元集合(源/目的)组成的VM指令,作者需要实现完全相同的三元指令的工作。

 

请注意,通常我会在重建虚拟机之前会查看一下所有的指令集,以便弄清楚一些我还未谈到的重要事项。我总是寻找关于VM寄存器结构给出的提示。举例来说,当发现以下的指令时,我首先会想到:

 

PUSHF?为什么这里需要一个PUSHF指令?


PUSHF 指令在比较操作之后保存了EFLAGS寄存器。emmmmm......并且之后在一个和EAX寄存器相关的结构中POP出来。EAX寄存器是否在其他的虚拟机代码片段中被使用?是的。必须的啊。

 

此刻你应该问下自己:为什么这个指令会在比较之后保存EFLAGS到一个相关的结构中去?万一你还不明白的话,[EAX + 0CH]明显是一个VM的虚拟EFLAGS寄存器。因此我们可以打开IDA的struct页,创建一个结构体并且添加DWORD直到创建到了feild_0CH。我们把0CH处的这个成员就叫做VM_EFLGAS吧。

 

如上所示。

 

此时,已经识别出来我们的第一个VM寄存器了!在逆向的过程中接着找寻其他的吧。在代码中,还有下边的这段:

 

当我看见的时候,我注意到:这段片段取了一个固定的VM寄存器(说它固定是因为VM结构中的偏移基址是固定的,EAX010H)以及固定的减4操作。从EDI中取出末尾2字节然后保存这两字节。你知道有什么汇编操作是需要在写寄存器的时候,寄存器的值是减少的吗?

 

加油。此刻看起来应该更加明晰了:

 

希望无需注释你也能看懂。这个是PUSH DWORD操作。

 

又一个VM寄存器被发现了。继续吧。我们依然缺少EIP、通用寄存器。接着找吧。浏览指令,可以发现:

 

这里,这个指令和CMP指令有着相同的布局,但是这里出现了一个JZ指令。这是一个跳转,非常好。EIP肯定会在这里使用,当我们跳转到某处时必须以某种方式改变EIP的值。我们已经知道了EAX+0CH是我们的EFLAGS。因此,这里的虚拟EFLAGS被赋值到了CPU的EFLAGS中,让后JZ指令被执行了。如果跳转没有被执行到,EDI中的参数会被赋值到EAX+08H。此刻我们知道了EAX包含了VM的上下文。


因此,我敢打赌EAA+08H中被赋值的参数是我们在跳转之后新的EIP所在(从技术上来讲,这意味着这条虚拟指令执行的是JNZ,而不是JZ!)。

 

所以:

 

找到VM EIP寄存器了。此时,请你来尝试识别下边这条虚拟指令吧:

 

我不会给你任何提示。。除了这是使用ESP和EIP的指令。请自行思考。

 

另一个有趣的点是,你应该始终牢记,在编写VM时,作者并不遵循特定的“规则”。因此,指令并不是一定是标准的。指令可以做任何作者期望的事情。举例来讲,下边这条指令:

 

你应该注意到了:这里使用了物理ESP寄存器!为什么呢?


这里使用了真正的ESP,然后取虚拟的VM stack设置到了物理栈上。并且通过EDX执行了一个函数调用。这意味着这个虚拟机具备通过在虚拟栈上压入虚拟参数,然后调用这条交换栈空间的虚拟指令(如果你对处理器很了解的话,它通过在特权门之间复制参数以示栈切换),在物理的CPU上执行代码的能力。同时,也需要注意到,物理CPU执行函数的返回值被保存到了VM 上下文的某个地方。

 

我花费了半小时逆向了几乎所有的虚拟指令集、寄存器,相信你也可以做到!其中只有一些指令会复杂些,但是对于虚拟机逆向并不是很重要(我的意思是,对于理解通用的结构来说)。

 


2. 虚拟机的一般结构


如果我们检查一个虚拟机的一般结构,通常会发现一个大循环来负责执行虚拟程序,以获取指令、解码指令、执行指令这种方式来模拟处理器复杂的执行状态。HyperCrackme2就采用了这种虚拟机结构。


1、建立虚拟机Context

2、进入虚拟机循环

3、读取VM.EIP地址处的字节码,检查指令的类型,以下是支持的指令类型:

  • 二进制指令

  • 一元指令

  • 流程控制指令

  • 特殊指令

  • 调试指令

  • NOP和HLT("退出VM")指令。-后者结束虚拟机循环。


4、跳转到VM循环的起始处


这个结构足够通用,请牢记在心。从通用的角度来看,每个虚拟机都包含了一下元素:


  • VM的初始化块/初始化函数;

  • 执行VM程序中的指令的循环块/循环函数;

  • 通用块/函数,用于解码VM指令的参数、寄存器、寻址模式以及VM创造者任何想要解码的东西;

  • 一个用来执行每个VM指令任务的列表块。这些指令大致相当于现代CPU中用于分解和执行常见ASM指令的微代码;

  • 一组宏指令,VM相关,不容易映射到ASM操作码,这些指令可能更难理解。

一个HyperCrackme2初始化结构元素的例子可以通过查看下边这个IDA的注释:

 

如你所见,这个REST_VM_PROCESS是上边所描述的第2点,而在ja short IS_UNARY_INSTR下边的这部分则是第3.1点。此代码段中的代码,除了清理寄存器之外,预读了第一条指令的OPCODE(VM.EIP指向的字节),然后分析了该OPCODE以选取VM的哪个执行单元来应用于获取的字节码。

 

现在让我们来看下这个VM的“构建块”,Setup_Binary_Instruction_Params函数,这个函数负责处理二进制VM OPCODE。为了检查下一个代码片段,请记住,EAX包含了我们的VM_CONTEXT。因为我们已经知道了EAX+8指向了VM_EIP

 

我认为现在了解我们正在寻找的东西很重要,否则分析将毫无用处。我们正在尝试恢复VM的指令结构,以及关于这个结构更详细的描述。填充二进制指令参数的过程必须知道如何解码二进制指令,所以通过检查字节码如何生成,可以重建VM 指令格式。


我们应该期待什么?


这严重依赖于指令集的复杂性,因为这完全取决于作者的选择。而这些又是我们必须逆向的。所以,我们始终必须仔细的检查指令的字节码是如何使用的,因为作者可能从这种指令类型变换为另一种指令类型。同时,请记住,VM指令并不总是大小都一样的,因为X86指令大小都是不一样的。

 

你可能无法将下边使用的方法应用于其他VM。每一个VM都使用了自己的操作码和VM结构,因此,你应当首先尝试理解哪些代码片段可以用来提示重建VM结构。

 

检查一下下边这段代码:

 

这小段应该是很明确的吧:加载虚拟EIP``[EAX+1]指向的的第二个字节,然后赋值给DL寄存器。在做出详细的注释之前,我们应当始终注意到这段代码仅仅使用了单个字节作为虚拟指令!更进一步的看看。

 

这小段代码和前边那个实在是太相似了(从概念上来讲)。EAX依然是我们的VM.EIP的地址,此时组成操作码的第三字节被加载到内存中被test(技术上来讲,只是高4位被test,如果你注意到and/shr操作的话)。还要注意后边跟随的指令。EDI包含了我们的VM_CONTEXT的地址。因此,ECX寄存器包含了一个DWORD的索引,这个被VM_CONTEXT应用于索引一个DWORD 指针,这个指针在偏移010H的地方。


不知你是否还记得?VM_CONTEXT+010H == VM_ESP


这意味着当ECX是0的时候我们就得到了ESP寄存器的地址。然后如果是1的话,是一个ESP地址之后的一个DWORD,知道ESP之后的第十五个DWORD(半子节范围0-15,你懂的)。因此,我们发现了二进制操作码的的第三字节,至少是高半子节可能的用法。如果我们成功执行了上边那段代码的JZ指令,将会跳转到下边的这段代码:

 

你可以注意到,这段代码取出了EAX(VM.EIP)后的第一个DWORD然后赋值给EDI。我们知道EDI寄存器包含了VM OPCODE的目标参数!这段代码有助于我们理解第一个DWORD仅用于OPCODE的目的,以及在其之后包含OPCODE参数。

 

这是目前我们知道的VM_CONTEXT

 

继续尝试我们的二进制OPCODE映射到VM_INSTRUCTION格式的分析吧。我们已经在虚拟指令中遇见了offset + 1,offset + 2,看下最后这个吧,offset + 3的:

 

这个offset+ 3的字节直接使用MOVSX指令加载到了ECX中。


你应该已经知道我想说什么了:为什么是 MOVSX?EDI参数之后和这个字节进行了加法操作, 而EDI中包含着我们的目标参数。为什么这里需要给我们的参数加上一些东西哪?当然是操作数的偏移了。

 

所以,此时我们可以重建一下虚拟指令的结构了:

 

我同意这部分我们有做太多的注释。原因是这些都是非常依赖于虚拟机的。



3. 逆向虚拟机指南


前面章节中显示的步骤是理解VM的重要一步。


  • 你可以跳过最初的VM指令结构,只要它不是在每条指令内解密/解码。


  • 此刻,我们必须深入的检查指令集来找到一些可以识别的东西,例如NOP指令,也有可能虚拟机根本没有这个指令。


  • 一旦指令集明确了,至少在最小程度上, 必须特别小心的寻找可能的VM寄存器的使用方式。最终它们的用法可能并不明确,比如它们可以进行轮转、在每个VM入口重新映射等等。但是我们不需要关心。知识总是增加的,人总是会犯错的——特别是你直觉上滥用Zen来加速你的分析的时候:)。


  • 我们必须直指VM的心脏,它的解码器。它包含了VM中的所有的重要信息以及VM指令的结构,因为通常它负责调度和执行指令的(预)处理。你必须牢记,解码器通常必须分析VM指令以获取某些东西,比如操作吗长度,参数等。同时,它有可能部分处理是在指令自身中就完成的。制作固定大小的指令(例如,16字节)。


  • 然后呢?然后我们必须回到指令集中来,尝试理解具体的,非标准的执行通常不属于VM处理器的职责的OPCODE。(例如,对真实函数的调用,API,计算块等等)。


  • 此刻我们应该可以解码大多数VM了,可以尝试调试一两条指令来验证是否如我们所想,VM的寄存器是否符合我们的方案。


  • 但是迟早你会编写一种易于DUMP VM程序的代码。你可能会希望有一个IDA插件或者脚本来解码VM程序。或者更简单但是效果稍差,可以编写一个简单的对每一条指令表中指令HOOK 的logger,(简单的讲,编写调试器加载器,在断点事件中使用断点,或者注入dll来HOOK 这个表)。当OPCODE被调用的时候,你的HOOK就DUMP下OPCODE的名称,以及参数。然后你就可以重建程序流程了。一个好用的logger插件是VM.EIP dumper,它允许你为每个VM指令分配正确的KEY,最终具备改变条件跳转结果的可能,因此最终可以跳过那个大循环而只记录VM程序的主要部分。最后,你可以使用logger中的VM.EIP来重新组装大部分的VM程序。


好吧。我希望这篇文章可以帮助你们更好的理解VM。我看到的常见的教学风格是放置学分,因此非常感谢Community以及我的朋友ZeroHAVOK

 

以下是看雪论坛一篇相似的翻译(通过“Zen”关键词找到的:)):

 

参考阅读:


  • https://bbs.pediy.com/thread-52165.htm



- End -



看雪ID:zplusplus                                           

https://bbs.pediy.com/user-747358.htm



本文由看雪翻译小组 zplusplus 编译,sudozhange校对

来源@Maximus

转载请注明来自看雪社区





热门技术文章推荐:







公众号ID:ikanxue
官方微博:看雪安全

商务合作:wsc@kanxue.com




    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存