一个简单虚拟机的逆向
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 ptr
,word ptr
,dword 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
指令的实现。你可能注意到了,这条指令被分成了IDIV
和IDIV_RESET
。你可能还记得,IDIV
指令还会返回除法的余数。如果你检查一下这两个虚拟的OPCODE是被怎样实现的就会发现:
IDIV
中EDI
返回了一个不同的寄存器。这会让你应该想一想为什么。结果显而易见,其中一个是除法的结果,另一个则是除法的余数。作为由二元集合(源/目的)组成的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结构中的偏移基址是固定的,EAX
,010H
)以及固定的减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
以及我的朋友Zero
和HAVOK
。
以下是看雪论坛一篇相似的翻译(通过“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