原文:http://www.msreverseengineering.com/blog/2018/1/31/finspy-vm-part-2-vm-analysis-and-bytecode-disassembly
这是我分为三部分的关于分析和去虚拟化FinSpy VM系列的第二部分。第一部分 是着重于从FinSpy VM的x86实现中去混淆以便于分析的教程。本部分是关于FinSpy VM的实际分析,包括分析结果以及分析过程。在第三部分 将提供工具来去除FinSpy的VM保护程序。
与上次相同的GitHub仓库 已更新并包含:
针对FinSpy VM实现的所有的包括注释的反汇编,并在适当的情况下的一些手动反编译
FinSpy VM字节码反汇编程序
本样本的VM字节码反汇编
压缩和解压的VM字节码程序
引用的IDC和python脚本
引用的注释
我努力将分析FinSpy VM的整个过程编写成一个教程式的文章,而不是更为传统的在反虚拟化文档和一般的安全报告中非常普遍的结果导向式的报告。在我看来,我在第一部分取得了成功,第三部分也适用于这种表达方式。然而,使用这种风格写作第二部分更加困难。静态逆向工程的非线性使得最终的文本难以呈现。静态逆向工程涉及处理不完整的信息并逐渐将其完善成为完整的信息,这涉及到很多跳跃和一开始显得隐晦但多次重复之后变得清晰的笔记。将其写成文档很可能是不连贯的,而且很长(甚至比目前的还要冗长)。
我本该更清楚地知道:当我教静态逆向工程 时,我从一些功能的开头开始,经过长时间的动手操作,逐步描述我对代码的理解,以及如何将其转换为IDA数据库的注释。取而代之,视频可能是第二好的选择(视频不像课堂讨论那样互动,但至少它们会连同错误和其它所有东西展示整个过程)。
我已经阅读了本文的一些草稿,试图将标准的面向结果的去虚拟化报告与实际操作的风格结合起来。在对我的草稿不满意之后,我决定做出妥协。也就是说,我的文章同时包括这两种风格。本文的第一部分描述了在分析FinSpy VM后得出的结论。第二部分尽我所能展示了分析过程,包括插入的和外部链接的注释、VM各部分的汇编语言片段以及汇编语言的反编译。
我不知道我是否成功地写了一些易于理解的东西。如果你觉得这个文档太令人兴奋了,我希望你阅读第三部分,它也会被写成一个标准的教程。自从发布第一部分 以来,Filip Kafka也发布了对FinSpy VM的分析 ,两位来自微软的研究人员也发表了对FinSpy的分析 。希望任何有兴趣对FinSpy VM进行逆向工程的人都可以利用这些资源学习。
VM软件保护是解释器。也就是说,VM软件保护将原始x86代码翻译成其自己的专有字节码语言(这个过程称为虚拟化)。在运行时,无论原始的未虚拟化的x86代码何时运行,VM解释器都会执行未虚拟化的x86代码所转换的字节码。这种保护技术的好处是分析人员不能再直接看到他们可能熟悉的原始x86指令。相反,他们必须首先分析解释器,然后使用所获得的知识来理解x86被翻译成的字节码。通过一些反分析技术,这个过程通常非常复杂:
花指令
FinSpy VM属于比较简单的。
由于VM软件保护是解释器,因此它们与普通解释器有许多共同之处,可用于更多典型的编程语言。
如上所述综合在一起,那么,解释器的框架通常是这样的:
简而言之,下面是对图中每个阶段的分析和以及分析它们能得到什么:
FinSpy VM遵循刚刚呈现的原型。整个VM上下文结构可以在这里 看到。
FinSpy VM上下文结构只包含一个专用寄存器,用于虚拟化某些x86指令中的32位内存操作数。
FinSpy VM不会将x86状态(VM入口时的寄存器和标志)复制到VM上下文结构中,而是将它们保存到主机x86栈中,将ESP寄存器的值保存到上下文结构中,并在需要使用或修改寄存器时,将保存的ESP值作为索引读取或写入寄存器值。
FinSpy VM有一个栈区,它在进入VM时将主机ESP值指向这个栈区。也就是说,作为VM指令处理程序之一执行的任何x86指令(如push eax)都将修改VM内的栈区而不是x86栈,因为它位于VM之外。
FinSpy VM还有一个用于动态代码生成的专用栈。该样本的VM字节码程序中大约50%的指令使得x86机器代码运行时生成并执行。就虚拟化混淆器而言,这是不寻常的,并且会在随后进行讨论。
FinSpy VM被设计为允许多个线程同时使用VM。实际上,FinSpy VM维护一个全局指针数组,每个线程ID一个,每个指针指向该特定线程的VM上下文结构。进入VM后,FinSpy会检查是否已经为该线程分配了VM上下文结构。如果没有则分配一个新的并初始化它。 也就是说,如果线程还没有在全局数组中分配VM上下文结构,则预初始化阶段分配一个,并且这两个初始化阶段在VM上下文结构中填充值:
VM字节码程序在程序的.text节末尾以二进制大对象(binary large object,blob)的形式存储。可以使用APLib压缩库压缩blob,在这种情况下,指令在预初始化过程中被解压缩。VM指令结构具有固定的0x18字节大小。这样可以很容易地计算后续的VM指令,以防指令不涉及控制流转移:只需向VM指令指针添加0x18即可。
解压缩后,VM字节码指令仍然被加密。.text节有一个固定的XOR键,可以根据每个样本进行更改。在执行指令时,FinSpy VM指令将部分来自VM EIP的0x18字节指令复制到VM上下文结构中,然后将每个DWORD与固定密钥异或(异或会跳过第一个DWORD,因为它包含指令的key)。 每种指令类型的前8个字节具有相同的布局,而最后0x10个字节包含VM指令操作数,并且格式根据指令的类别有所不同。
DWORD Key:每条指令都与一个32位key关联。VM可以通过key查找指令,就像在VM开始执行之前定位第一条指令一样(在跳转到VM入口点之前,调用者把key压入栈)。VM指令集包含函数调用,函数调用目标可以通过key指定。
BYTE Opcode:该字节在0x00到0x21的范围内,指定34个VM指令操作码类型之一。
BYTE DataLength:某些指令在其操作数区域中保存可变长度数据,该字段描述了该数据的长度。
BYTE RVAPosition1和BYTE RVAPosition2:某些指令指定x86二进制文件中的位置。由于操作系统将二进制文件作为模块加载时基址可能会发生变化,因此可能需要重新计算这些位置以反映新的基址。FinSpy VM通过将二进制文件中的位置指定为RVA,然后将基址添加到RVA以获取执行模块中的实际虚拟地址解决这一问题。如果这两个字节中的任何一个不为零,则FinSpy VM会将其视为存储32位RVA的指令数据区域的索引,并通过将基址添加到RVA来修复RVA。
BYTE Data[16]:指令如果有操作数的话存储在这里。每条指令都可以用不同的方式解释这个数组的内容。
34条指令分为三组。
4.3.1第一组:有条件和无条件跳转
FinSpy将X86中所有标准的条件跳转指令(即JZ、JNZ、JB、JO、JNP等)虚拟化,包括无条件跳转在内总共有17种VM操作码。这些VM指令通过检查在主机栈底部保存的EFLAGS DWORD中各自的条件来实现。即ZF存储在EFLAGS的第0x40位,JZ VM指令的实现会在保存的EFLAGS中检查这个位,如果ZF没有设置则继续下一个VM指令,否则跳转到一个不同的指令。
从技术上讲,条件分支指令的实现允许以两种不同的方式指定分支目标。第一种是作为VM EIP的位移,在这种情况下,下一个VM EIP的计算方式是VMEIP+位移(位移存储在&Data[1]) 。第二种是如果&Data[1]处的位移为0,则将&Data[5]处的DWORD用作指定为RVA的原始X86位置(即除VM指令以外导致VM退出的其它内容)。但是在实践中,该样本中没有VM字节码指令使用后一种方法。
VM的无条件跳转指令以涉及动态代码生成的更复杂的方式实现。它在技术上也允许将分支目标指定为针对VM EIP的相对位移或x86 RVA,实际上只使用了前一种方法。
4.3.2第二组:在专用寄存器上的操作
如前所述,VM有一个我们称之为scratch的专用寄存器。第二组VM指令涉及这个寄存器。这些指令有着不同寻常的控制流,它们都将控制转移到当前VM EIP之后的物理位置上的下一条指令。以下是这些指令及其操作数:
如前所述,scratch寄存器用于虚拟化32位X86内存操作数。下面是lea eax,dword ptr [ebp+eax*2+0FFFFFDE4h]指令如何转换为VM指令的例子(来自VM字节码反汇编 ):
这一组有四条指令,尽管其中两条指令是相同的。另外,这是唯一一组使用VMContext结构中pDynamicCodeStack成员的指令。这强调了VM上下文结构和VM指令是如何交织在一起的,并且不能彼此独立地理解。
原始X86
这些指令中最简单的指令只是执行一个非控制流x86指令。它在动态代码栈上生成以下代码序列,然后执行它:
标记为@RESUME的位置(被调用函数的返回位置)通过压入下面的VM指令的地址开始(这是通过把VM上下文结构中的当前VM指令指针加上0x18(sizeof(VMInstruction))得到的)。接下来,它会保存寄存器和标志,将EBX设置为指向此线程的VM上下文结构的指针,然后在特殊入口点重新进入VM。在进入普通初始化阶段之前,此特殊入口点将VM EIP设置为刚刚压入栈的值,并从动态代码生成栈指针中减去0x30。 请注意,我一直在使用动态代码生成栈的说法,重点强调栈。第一种X86型VM指令不需要使用栈,因为它仅用于虚拟化没有控制流的指令。我们目前正在讨论的VM指令需要利用栈功能,因为如果被调用函数执行的VM指令使用了动态代码生成功能,并且生成的代码与我们刚生成的代码位于相同的位置它会覆盖用于函数返回的代码序列。因此,在刚刚讨论的动态代码写入动态代码生成栈之后,它还必须增加栈大小以防止覆盖。同步地,特殊的VM重新进入的返回位置必须递减动态代码生成栈指针。
X86调用,间接地址
与X86相关的最后一部分VM指令与我们刚才讨论的指令非常相似,只是它用于虚拟化间接调用指令,即在虚拟化时目标未知的调用。也就是说,像call eax这样的指令将被这个VM指令虚拟化。它在动态代码生成栈上生成以下代码,增加动态代码生成栈指针并执行它:
生成的代码与前一条指令的代码几乎相同,因此不需要太多注释。唯一的区别在于压入调用的返回地址后会发生什么。前一个VM指令类型将.text段中的地址压入栈,并返回到它。相反,该VM指令执行从VM字节码指令的数据区域复制出来的原始x86指令。
现在我们有了编写这个样本的反汇编程序所需的一切。我们知道每个VM指令的作用,以及每个VM指令如何对其操作数进行编码——而且这些信息不一定是特定于此样本的,它可能可以用于很多样本。
不同的样本可能有很多元素不同,至少对于这个特定的样本来说我们是知道它们的。
由于我只有一个样本,因此我在反汇编器中对所有这些元素进行硬编码。也就是说对于其它任何样本,您需要确定刚才描述的三条信息。这就是我通常编写反混淆程序的方式:我从一个样本开始,编写代码来解决它。然后我获得另一个样本并确定需要更改哪些内容以使我以前的代码适应新样本。如果有任何特定于样本的信息,那么我需要设计一个从给定样本中提取信息的程序。我重复这个过程,直到我的工具可以解决给定家族的任何样本。
我决定用Python编写我的FinSpy VM反汇编程序,因为我想我可能需要使用我的x86库(其中维护最好的版本也是用Python编写的)。您可以在这里 找到FinSpy VM反汇编程序。在这个过程中有些时候我希望我选择的是OCaml,因为OCaml非常适合编写操作其它程序的程序,并且具有强大的类型检查功能以防止出现常见错误。事实上,我甚至最初编写函数来将反汇编的指令输出为OCaml数据类型,但是在考虑了Python和OCaml之间的互操作性之后我放弃了这个想法。
首先,我编写了一个用于从用户指定的VM字节码二进制程序中读取VM指令(即0x18字节块)并将它们与样本特定的值进行异或运算的函数。
接下来,我设计了一个类层次结构来表示VM指令。由于每条指令都有一个固定长度的0x18字节编码,并且每个指令对前8个字节的处理方式都是相同的,所以应该使用一个GenericInsn类处理通用功能。该类实现了一个名为Init的函数,该函数接受解密后的字节数组和指令在该文件中的位置作为输入,并对前8个字节中的公共元素进行解码。
我从GenericInsn类中派生了一些其它抽象类,以处理操作数编码相同的指令族。这些类包含由构造函数调用的用于解码指令操作数的常用函数,以及用于帮助打印的一些常用函数。这些类是:
ConditionalBranch,获取17条分支指令
StackDisp32,用于一些专用寄存器指令
StackDisp8,用于其它专用寄存器指令
Imm32,用于其它专用寄存器指令
我为这些类中的每个VM指令类型派生类。这些类在它们的构造函数中从它们的基类中调用解码例程,并将指令打印为字符串以提供支持。除了当专用寄存器指令通过数字索引引用x86寄存器时我传递的是寄存器的名称而不是索引之外,关于打印指令的内容并不多。对于嵌入x86机器码的指令类型,我也调用反汇编库将机器码打印为可读的x86汇编语言。
最后是设计一种为给定的编码为0x18字节的数组的解密指令创建正确的Python对象的机制。为此,我创建了一个将操作码字节对应到枚举元素的字典,其中枚举元素指定了它所表示的VM指令。然后,我创建了一个将这些枚举元素对应到构造正确对象类型的lambda函数的字典。
我将刚刚描述的Python对象创建机制与一开始描述的原始指令读取机制相结合,产生了一个生成Python指令的生成器。至此,反汇编程序就仅仅是一个打印指令对象的问题:
这里是预初始化阶段的汇编语言链接 ,以及该代码粗略的反编译 。我们将从反编译结果中重新生成代码片段,解释其运作方式。请打开二进制文件,然后按照第一个显示汇编语言链接所示的说明进行操作。
任何VM在进入时首先要保存寄存器和标志的内容。FinSpy VM也不例外:
FinSpy VM的构建方式使得多个线程可以同时执行VM代码。因此,每个线程必须具有自己的VM上下文结构,并且在全局数据项中不能包含太多的VM状态。FinSpy通过分配一个包含持有指向每个线程的VM上下文结构的初始化为NULL的指针的全局数组来解决这个问题。
预初始化函数做的第一件事是检查一个全局指针,看看我们是否已经分配了线程特定的数组,如果没有则分配。如下面的反编译代码片段所示:
在上一步之后,FinSpy知道已经分配了VM上下文结构指针的全局数组,因此它将查询VM上下文结构是否已分配给当前正在执行的线程。它将当前线程ID右移2以屏蔽最低的两位,然后将其作为数组的索引。如果尚未分配,则必须为当前线程分配一个上下文结构。
field_4:00401AAD:此结构中指向上一个DWORD的指针(0xFFFC偏移量)
field_28:00401FA9:dwBaseAddress
field_50:00401998:初始化为field_54的地址
F inSpy将其VM指令保存在VMInstruction结构体的一个很大的数组中。这个数组可能会被压缩,在这种情况下第一次执行预初始化的下一阶段时,指令将被解压。后续线程特定的VM上下文结构的预初始化在第一次解压之后就不需要再解压了。
本节描述如何获得上一节中的结果。在预初始化的前几个阶段之后,我观察到FinSpy解密存储在其.text节中的一些混淆过的代码并执行。第一次看到时我不知道代码是干什么的,结果发现它负责解压VM字节码程序。以下是我怎么发现的。
作为额外的隐藏手段,解压代码本身通过XOR进行加密,并且也使用前一篇文章中看到的相同的条件跳转对技术进行混淆。第一次分析预初始化的这部分时,我首先注意到它分配了一小块RWX内存,从.text节复制数据并解密:
继续看上一个代码片段之后的汇编代码:
意识到我解密的代码可能实现了APLib之后,我决定试着解压,看看发生了什么。如果我得到了乱码则可能需要深入理解代码以查看它是否包含任何自定义的修改。这是静态逆向工程的弊端——如果我一直在动态地分析这些代码,我仅仅需要将它从内存dump出来。
我开始使用IDC单行代码dump压缩的blob: savefile(fopen("e:\\work\\malware\\blah.bin","wb"),0,0x40C4CD,0x60798);
如果你想要压缩的blob,你可以在这里 找到它。
现在我需要解密它。首先,我去APLib网站下载了APLib软件包 。接下来,我只是简单地尝试在对我的blob调用独立的解压缩工具,但失败了并显示了一个描述不清的错误消息。我开始阅读源代码并在调试器中跟踪解压缩工具,发现原因是APLib需要在加密的blob前面有一个头部。我着手为我的数据创建头部,但在20分钟内我并没有成功做到这一点并且这让我很不耐烦,于是我开始寻找另一种方式。
我找到了开源的Kabopan项目 ,该项目声称提供了各种从头开始重新实现的加密和压缩算法的Python库。我读了几分钟的例子后,写了一个小脚本:
本部分在上一部分的预初始化阶段后立即恢复执行。初始化阶段的汇编代码在这里 。为了清楚地说明,以下大部分内容使用反编译的C代码 。总结一下上一节:FinSpy VM的预初始化阶段会两次尝试访问先前分配的结构。如果结构不存在,它将执行前一节所述的分配和预初始化阶段。如果它们存在,它将跳过这些阶段并直接跳转到初始化阶段:
像预初始化代码一样,本节包含许多结构引用。我继续更新我的文档以描述我对每个结构引用的理解。再次提醒,这些笔记 可以在这里找到。
按照上一节的说明设置函数指针后,VM两次将主机的ESP保存到VM上下文结构中,然后将主机的ESP设置到VM上下文结构末尾附近的一个位置。因此很显然VM状态包含一个物理上位于上下文结构末尾的栈。
尽管我的笔记 让我能够高效地将所有东西整理在一起,但是分析至此我仍然不清楚VM上下文结构中的许多内容。随后的代码加载来自解压的内存区域的指令,可选地转换它们,然后将控制转移到负责实现它们的相关x86代码。
该阶段的反编译的伪代码在这里 ,注释的汇编代码在这里 。
在初始化上下文结构之后,FinSpy VM将使用我们已经分析过的一些字段。幸亏我的笔记使得我很容易理解发生了什么。回忆解压缩指令时预初始化阶段的这两个语句:
再次注意预初始化中的下面这一行:
将指令从全局指令数组中复制出来并解密之后,VM入口点将调用一个通过将当前模块的基地址加到指令中来选择性地以DWORD大小修复指令的函数。该功能等同于操作系统的加载程序,允许指令引用模块中的地址而不必关心模块的加载位置。
最后,在指令被解密并且可选地修复之后,VM然后将控制转移到实现该指令的x86代码。
伪代码:
无论如何,谈论恢复成x86程序目前为时过早。现在已经对VM字节码程序进行了解密,可以更容易地对VM指令集进行逆向。如果对VM操作码的x86指令处理程序如何使用VM指令结构的某些部分感到困惑,那么可以简单地查看这些指令的实例以便理解而不必猜测。
在讨论VM指令集之前回到与VM退出和重新进入有关的VM上下文结构中找到的五个函数指针。所有这些的反汇编可以在这里 找到。
用于返回到VM的第一个函数指针在VM上下文结构中称为fpFastReEntry,只包含VM入口点内初始化阶段的地址。它假设寄存器和标志包含有意义的值,并且它们尚未被放置在栈上。
下一个返回序列假定EAX包含前一节中函数指针的地址,并且标志和寄存器保存在栈中。它只是将标志和寄存器从栈中弹出,并将控制转移到EAX中的函数指针。
当执行分支时,VM的有条件和无条件分支指令使用此返回序列。它可以为VM EIP添加一个位移——非常类似于x86处理器实现相关分支的方式。它还能够转移到指定为RVA的x86位置,但是这个代码从未在我研究过的样本中使用过。
当不执行分支时,VM的有条件和无条件分支指令使用此返回序列。它只是将VM指令的大小0x18添加到当前VM指令指针,并通过栈状态处理程序重新进入VM。
最后,这个VM重入函数指针在函数调用返回后由动态生成的代码调用。代码必须从动态生成的代码栈中弹出最后一帧,并在发出函数调用之后将VMContext-> pCurrInsn更新为VM指令。由于VM指令指针保存在寄存器和标志之上的栈中,因此该代码将寄存器和标志上移一个DWORD,并将栈指针加4。最后,它使用栈状态函数指针重新进入VM。
有关这些指令的注释的反汇编代码可以在此处 找到。
条件跳转指令都非常简单,几乎相同。回忆一下在VM入口点将控制权转移给任何指令时,主机栈在底部保存的标志以及紧接其上的保存的寄存器。条件跳转指令全部将保存的标志加载到主机栈底部,然后使用test指令从保存的EFLAGS寄存器中分离出各个位,然后测试所需的位组合。我在IDA中创建了一个枚举来为符号常量赋予名称以看起来更直观:
当遇到上面的代码时,你可能会记不住哪个x86条件跳转指令对应于OF!=SF时跳转。我也经常忘记它们,当我需要时会参考我的X86到中间语言转换器的代码。下面是一张表,你不用去查了。
由于我之前编写过反汇编程序,因此我意识到了Data[0]和所选寄存器之间的关系。x86机器码按照以下顺序为其32位寄存器分配一个编号:EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI。因此,Data[0]按顺序选择一个寄存器。找出哪一个寄存器被这些指令之一访问是一件简单的事情——只需按照寄存器名称的顺序保存一个数组。
我还担心Data[0]不在[0,7]范围内时会发生什么。负数会导致索引到主机栈下方,大于等于8会导致索引到保存的寄存器上方。我不确定去虚拟化时如何处理这些可能性。我在我的反汇编程序中对这些情况提供了有限的支持,如果有这种情况我会处理这个问题。幸运的是没有发生这种情况。
[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)
最后于 2018-3-19 11:05
被houjingyi编辑
,原因: