首页
论坛
专栏
课程

[翻译]手把手静态分析FinSpy VM:第二部分,VM分析和字节码反汇编

2018-3-10 16:30 4067

[翻译]手把手静态分析FinSpy VM:第二部分,VM分析和字节码反汇编

2018-3-10 16:30
4067

原文:http://www.msreverseengineering.com/blog/2018/1/31/finspy-vm-part-2-vm-analysis-and-bytecode-disassembly

1.概述

这是我分为三部分的关于分析和去虚拟化FinSpy VM系列的第二部分。第一部分是着重于从FinSpy VM的x86实现中去混淆以便于分析的教程。本部分是关于FinSpy VM的实际分析,包括分析结果以及分析过程。在第三部分将提供工具来去除FinSpy的VM保护程序。

上次相同的GitHub仓库已更新并包含:

针对FinSpy VM实现的所有的包括注释的反汇编,并在适当的情况下的一些手动反编译

FinSpy VM字节码反汇编程序 

本样本的VM字节码反汇编

压缩和解压的VM字节码程序

引用的IDC和python脚本

引用的注释

2.我的歉意 

我努力将分析FinSpy VM的整个过程编写成一个教程式的文章,而不是更为传统的在反虚拟化文档和一般的安全报告中非常普遍的结果导向式的报告。在我看来,我在第一部分取得了成功,第三部分也适用于这种表达方式。然而,使用这种风格写作第二部分更加困难。静态逆向工程的非线性使得最终的文本难以呈现。静态逆向工程涉及处理不完整的信息并逐渐将其完善成为完整的信息,这涉及到很多跳跃和一开始显得隐晦但多次重复之后变得清晰的笔记。将其写成文档很可能是不连贯的,而且很长(甚至比目前的还要冗长)。

我本该更清楚地知道:当我教静态逆向工程时,我从一些功能的开头开始,经过长时间的动手操作,逐步描述我对代码的理解,以及如何将其转换为IDA数据库的注释。取而代之,视频可能是第二好的选择(视频不像课堂讨论那样互动,但至少它们会连同错误和其它所有东西展示整个过程)。

我已经阅读了本文的一些草稿,试图将标准的面向结果的去虚拟化报告与实际操作的风格结合起来。在对我的草稿不满意之后,我决定做出妥协。也就是说,我的文章同时包括这两种风格。本文的第一部分描述了在分析FinSpy VM后得出的结论。第二部分尽我所能展示了分析过程,包括插入的和外部链接的注释、VM各部分的汇编语言片段以及汇编语言的反编译。

我不知道我是否成功地写了一些易于理解的东西。如果你觉得这个文档太令人兴奋了,我希望你阅读第三部分,它也会被写成一个标准的教程。自从发布第一部分以来,Filip Kafka也发布了对FinSpy VM的分析两位来自微软的研究人员也发表了对FinSpy的分析。希望任何有兴趣对FinSpy VM进行逆向工程的人都可以利用这些资源学习。

第一部分:高层分析总结

3.VM体系结构的基础知识

VM软件保护是解释器。也就是说,VM软件保护将原始x86代码翻译成其自己的专有字节码语言(这个过程称为虚拟化)。在运行时,无论原始的未虚拟化的x86代码何时运行,VM解释器都会执行未虚拟化的x86代码所转换的字节码。这种保护技术的好处是分析人员不能再直接看到他们可能熟悉的原始x86指令。相反,他们必须首先分析解释器,然后使用所获得的知识来理解x86被翻译成的字节码。通过一些反分析技术,这个过程通常非常复杂:

  • 混淆x86解释器
  • 混淆虚拟化后的程序
  • 为每个样本使用不同的语言,目标是要求每个样本都需要人工分析
                操作码字节和指令之间的对应关系是随机的

                花指令


FinSpy VM属于比较简单的。

3.1VM作为解释器

由于VM软件保护是解释器,因此它们与普通解释器有许多共同之处,可用于更多典型的编程语言。

  1. 指令编码。解释器需要它们自己的标准化方式来表达指令。对于字节码语言来说是指令及其必需的相关数据(如指令的操作数)的二进制编码。实际上,解释器通常根据数据结构或类定义编码指令。
  2. VM上下文。解释器总是有一些与它们相关的状态,也被称为VM上下文结构。例如,解释程序可以访问特定于该语言的寄存器,和/或可能是栈或数据区域(这些都是常见的例子,在实践中状态可能只包含这些元素中的一部分和/或特定于该语言的其它元素)。解释器状态通常打包成一个结构或对象以允许解释器的多个副本存在于整个程序的执行过程(例如,允许多个线程运行VM实例)。
  3. 解释。解释器指令的实现:
  • 以一个指向解释器的上下文结构的指针作为输入; 
  • 查询结构以获得它执行需要的任何信息(例如,如果指令使用寄存器的值,并且寄存器保存在上下文结构中,那么指令必须从上下文结构中检索寄存器的值)。
  • 执行指令及其操作数指定的任何操作;
  • 用结果更新上下文结构,例如,将某个值存储到寄存器,数据节或上下文的栈中。

如上所述综合在一起,那么,解释器的框架通常是这样的:

void Interpret(VMContext *vmContext)
{
  while(!vmContext->bShouldExit)
  {
    switch(vmContext->pCurrInsn->opcode)
    {
      // Instruction: add r0, r1, r2 [r0 <- r1+r2]
      case VMOpcode_AddRegReg:
        
        // Updates vmContext->pCurrInsn and vmContext->bShouldExit
        VMAddRegReg(vmContext);
        break;

      // Instruction: add r0, r1, imm32 [r0 <- r1+imm32]
      case VMOpcode_AddRegImm:
        VMAddRegImm(vmContext);
        break;
      
      /* ... more instructions ... */
    }
  }
}
示例指令处理程序可能如下所示:
void VMAddRegReg(VMContext *vmContext)
{
  // Fetch register values
  DWORD lhsVal = vmContext->regVals[vmContext->pCurrInsn->lhsReg];
  DWORD rhsVal = vmContext->regVals[vmContext->pCurrInsn->rhsReg];

  // Perform addition, update context
  vmContext->regVals[vmContext->pCurrInsn->destReg] = lhsVal + rhsVal;

  // Update VMEip to next instruction
  vmContext->pCurrInsn += sizeof(VMInstruction);

  // Don't signal VM exit
  vmContext->bShouldExit = false;
}

3.2VM混淆器体系结构原型

尽管实际中存在一些变形,但是VM仍然通常非常贴近刚刚描述的原型。下图显示了VM执行的基本模型。并非所有的VM都完全遵循这个模式。例如,它们中的一些具有预初始化阶段,而另一些则没有;其中一些使用直接线程架构,其中每条指令都将控制转移到下一条指令,而不需要中央调度程序在循环中调用函数指针。

简而言之,下面是对图中每个阶段的分析和以及分析它们能得到什么:

  • 保存CPU状态(Save CPU state)。每个VM在执行之前先保存寄存器和标志。通常是将它们压入栈,但根据架构,可能涉及将其复制到固定的数据区域。分析这个阶段通常提供的信息很少。
  • 预初始化(Pre-initialization)。在第一次执行之前,VM可能必须分配数据结构(全局和/或为每个线程),或通过添加进程的映像基址来修复指定为相对虚拟地址(relative virtual addresses,RVA)的指针。这个阶段通常通过对静态数据项进行检查,例如初始化为NULL的用来分配内存的指针,或在第一次预初始化执行之前初始化为false的布尔值。预初始化后,静态指针/布尔值被设置为非零值。分析此阶段可以深入了解VM使用的数据结构,以及VM解释器的代码和数据在二进制文件中的位置。
  • 初始化(Initialization)。预初始化后,VM必须准备其上下文结构以开始执行由传递给VM入口点的指针类型值指定的VM指令。这个阶段的细节高度依赖于VM选择的底层实现。分析这个阶段通常会提供有关VM如何将寄存器和标志从主机CPU传递到虚拟CPU的信息,以及VM解释器如何将VM字节码伪指针转换为指向VM指令的实际指针(如果适用)。它还可以提供对VM上下文结构的深入了解。
  • 取指令(Fetch)。准备好要执行的上下文之后,VM必须加载其第一条指令的详细信息。分析此阶段可以深入了解指令的存储方式以及VM如何定位它们。
  • 解码(Decode)。在找到第一条指令后,VM可能需要执行特殊处理才能获得真正的指令。最常见的例子是当指令以加密格式存储时,必须在执行前解密。其它例子包括对指令内编码的常数操作数值的修改。常数可以按照刚刚讨论过的加密,或者可以有其它机制来转换常量(例如,将RVA转换为虚拟地址,将键转换为值等)。分析这个阶段提供了在编写VM字节码的反汇编程序之前需要了解的关于如何解码指令的至关重要但通常不完整的信息。
  • 调度(Dispatch)。最后,VM必须确定当前指令指定哪个字节码操作,并将控制权转移给负责执行指定操作的x86代码。这通常是通过从指令中提取一个字节的操作码并将其用作指向函数指针表的索引来完成的。通过分析这个阶段,可以了解指令的哪一部分指定了操作,以及说明VM如何将该信息转换为指向要执行的代码的指针(即,我们知道VM操作码实现的函数指针表的保存位置)。混淆更严重的VM(如VMProtect)可能不会将原始指针存储在表中,这些指针可能会以某种方式加密,并且必须在调度前解密。
  • 指令执行(Instruction execution)。一旦VM将控制转移到实现给定VM指令的x86代码,那么x86代码就必须执行所有必要的操作来查询和更新状态,从而实现虚拟指令的执行。分析这个阶段是当细节最终都汇集在一起​​的时候。通常我们会在整个初始化阶段观察对VM状态的修改,在初始化阶段我们无法解密,因为我们不知道指令将如何使用和修改VM状态的这些部分。分析指令将全面测试并扩展我们对VM体系结构的理解。这也是理解VM实际提供哪些功能的最关键阶段——没有这些信息,我们不能编写反汇编程序,因为我们不知道任何指令的实际功能。
  • VM继续和/或退出(VM continuation and/or exit)。在指令执行后,可能需要在VM字节码流中顺序执行下一条指令。或者,由于有条件跳转或无条件跳转(可能是相对地址或绝对地址),可能需要跳转到VM内的另一个位置。或者,如果VM实现了函数调用,我们可能必须在跳转之前的某个地方保存一个返回地址(或其它能够继续执行的地址)。最后,所有VM都能够停止执行它们并切换回正常的x86执行。这些阶段的分析强调了对VM指令指针和寻址模式的理解,并且提供了有关VM如何与VM之外的x86代码进行通信的信息。VM退出后重新进入通常特别有趣。

4. FINSPY 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机器代码运行时生成并执行。就虚拟化混淆器而言,这是不寻常的,并且会在随后进行讨论。

4.1预初始化和初始化

FinSpy VM被设计为允许多个线程同时使用VM。实际上,FinSpy VM维护一个全局指针数组,每个线程ID一个,每个指针指向该特定线程的VM上下文结构。进入VM后,FinSpy会检查是否已经为该线程分配了VM上下文结构。如果没有则分配一个新的并初始化它。
也就是说,如果线程还没有在全局数组中分配VM上下文结构,则预初始化阶段分配一个,并且这两个初始化阶段在VM上下文结构中填充值:

  • 将模块的基地址存储到VM上下文结构中。
  • 初始化x86栈区指针以指向x86栈区最后一个DWORD。
  • 将主机ESP值复制到VM上下文结构中。由于初始化阶段将标志和寄存器压入栈,VM指令可以通过索引保存的ESP值来访问保存的寄存器和标志的值。
  • 将x86 ESP寄存器设置为指向当前VM x86栈区域的底部。
  • 初始化动态代码栈指针以指向该区域的开始(动态代码栈向上增长,不像x86通用栈向下增长)。
  • 在VM指令用来将控制权转交给其它VM指令的上下文结构中安装五个函数指针。其中一些函数指针只是继续执行下一条指令,其中一些实现有条件和无条件跳转,并且其中一个用于在被调用的VM功能完成执行后返回。对于VM连续使用如此多的函数指针是FinSpy的另一个不寻常的特性。
  • 检查存储在二进制文件中的VM字节码程序是否被压缩,如果被压缩,是否已经解压。如果没有解压,则解压VM字节码程序并缓存结果。
  • 使用进入VM之前压入的DWORD来定位应该首先执行的解压缩的VM字节码程序中的VM指令,并将指向指令的指针存储为VM的EIP。

4.2 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指令操作数,并且格式根据指令的类别有所不同。

struct VMInstruction
{
  DWORD Key;
  BYTE Opcode;
  BYTE DataLength;
  BYTE RVAPosition1;
  BYTE RVAPosition2;
  BYTE Data[16];
};
这些字段作用如下:            

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]:指令如果有操作数的话存储在这里。每条指令都可以用不同的方式解释这个数组的内容。

4.3 VM指令集

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之后的物理位置上的下一条指令。以下是这些指令及其操作数:

  • mov scratch,0
  • mov scratch,imm32 [操作数:&Data[0]处的imm32]
  • shl scratch,imm32 [操作数:&Data[0]处的imm32]
  • add scratch,imm32 [操作数:&Data[0]处的imm32]
  • mov scratch,savedReg32 [操作数:&Data[0]处savedReg32的索引]
  • add scratch,savedReg32 [操作数:&Data[0]处savedReg32的索引]
  • mov savedReg32,scratch [操作数:&Data[0]处savedReg32的索引]
  • mov dword ptr [scratch],savedReg32 [操作数:&Data[0]处savedReg32的索引]
  • mov scratch,dword ptr [scratch]
  • mov dword ptr [scratch],imm32 [操作数:&Data[0]处的imm32] 
  • mov dword ptr [imm32],scratch [操作数:&Data[0]处的imm32] 
  • push scratch

如前所述,scratch寄存器用于虚拟化32位X86内存操作数。下面是lea eax,dword ptr [ebp+eax*2+0FFFFFDE4h]指令如何转换为VM指令的例子(来自VM字节码反汇编):

0x035d78: MOV SCRATCH, 0
0x035d90: MOV SCRATCH, EAX          ; scratch := EAX
0x035da8: SHL SCRATCH, 0x000001     ; scratch := EAX*2
0x035dc0: ADD SCRATCH, EBP          ; scratch := EBP+EAX*2
0x035dd8: ADD SCRATCH, 0xfffffde4   ; scratch := EBP+EAX*2+0FFFFFDE4h
0x035df0: MOV EAX, SCRATCH          ; EAX := EBP+EAX*2+0FFFFFDE4h
4.3.3第三组:X86相关的指令
也许FinSpy VM唯一有趣的特性是它如何处理非虚拟化的X86指令。每个X86虚拟化软件保护都需要一种执行非虚拟化指令的机制。通常情况下这是通过退出VM,执行存储在程序的某个可执行部分中的非虚拟化X86指令,然后将控制权转移回VM来实现的。FinSpy VM采用在其指令集中包含指令的方法,该指令集包含用于X86指令的原始机器代码,这些代码被复制到具有RWX权限的内存并在VM执行时按需执行。这比上面描述的方法更隐蔽,因为在那种方法中未加密的X86指令必须出现在二进制文件的某处。对于FinSpy VM,因为VM指令是经过加密的,所以这允许非虚拟化的X86指令在大多数时间内保持加密的状态。

这一组有四条指令,尽管其中两条指令是相同的。另外,这是唯一一组使用VMContext结构中pDynamicCodeStack成员的指令。这强调了VM上下文结构和VM指令是如何交织在一起的,并且不能彼此独立地理解。

原始X86

这些指令中最简单的指令只是执行一个非控制流x86指令。它在动态代码栈上生成以下代码序列,然后执行它:

9D             popf
61             popa
??    ...   ?? [raw X86 machine code copied from VM instruction's data at &Data[0]]
68 ?? ?? ?? ?? push offset fpVMReEntryStateInRegs
C3             ret
即该序列恢复保存的标志和寄存器,然后执行从VM指令的数据区域复制出来的单个x86指令,然后重新进入VM的初始化序列。
X86调用,直接地址
该指令用于虚拟化对固定目标的调用指令。例如call sub_1234将通过此VM指令实现,而call eax不会。它在动态代码生成栈上生成一系列代码,将动态代码生成栈指针加上0x30,然后执行它生成的代码:
9D             popf
61             popa
68 ?? ?? ?? ?? push offset @RESUME
68 ?? ?? ?? ?? push (Data[4]+ImageBase)
C3             ret
@RESUME:
68 ?? ?? ?? ?? push offset NextVMInstruction
60             pusha
9C             pushf
68 ?? ?? ?? ?? push offset VMContext
5B             pop ebx
68 ?? ?? ?? ?? push offset fpVMReEntryReturn
C3             ret
该序列像以前一样通过恢复保存的标志和寄存器开始。接下来,它会压入随后生成的代码的地址,这些代码将用作该函数的返回地址。接下来,它会压入.text部分中的地址,通过添加基地址将其从RVA修复为VA,然后返回。这三条指令一起模拟直接调用指令。

标记为@RESUME的位置(被调用函数的返回位置)通过压入下面的VM指令的地址开始(这是通过把VM上下文结构中的当前VM指令指针加上0x18(sizeof(VMInstruction))得到的)。接下来,它会保存寄存器和标志,将EBX设置为指向此线程的VM上下文结构的指针,然后在特殊入口点重新进入VM。在进入普通初始化阶段之前,此特殊入口点将VM EIP设置为刚刚压入栈的值,并从动态代码生成栈指针中减去0x30。                
请注意,我一直在使用动态代码生成栈的说法,重点强调栈。第一种X86型VM指令不需要使用栈,因为它仅用于虚拟化没有控制流的指令。我们目前正在讨论的VM指令需要利用栈功能,因为如果被调用函数执行的VM指令使用了动态代码生成功能,并且生成的代码与我们刚生成的代码位于相同的位置它会覆盖用于函数返回的代码序列。因此,在刚刚讨论的动态代码写入动态代码生成栈之后,它还必须增加栈大小以防止覆盖。同步地,特殊的VM重新进入的返回位置必须递减动态代码生成栈指针。

X86调用,间接地址

与X86相关的最后一部分VM指令与我们刚才讨论的指令非常相似,只是它用于虚拟化间接调用指令,即在虚拟化时目标未知的调用。也就是说,像call eax这样的指令将被这个VM指令虚拟化。它在动态代码生成栈上生成以下代码,增加动态代码生成栈指针并执行它:

9D             popf
61             popa
68 ?? ?? ?? ?? push offset @RESUME
??    ...   ?? [raw X86 machine code copied from VM instruction's data at &Data[4]]
@RESUME:
68 ?? ?? ?? ?? push offset NextVMInstruction
60             pusha
9C             pushf
68 ?? ?? ?? ?? push offset VMContext
5B             pop ebx
68 ?? ?? ?? ?? push offset fpVMReEntryReturn
C3             ret

生成的代码与前一条指令的代码几乎相同,因此不需要太多注释。唯一的区别在于压入调用的返回地址后会发生什么。前一个VM指令类型将.text段中的地址压入栈,并返回到它。相反,该VM指令执行从VM字节码指令的数据区域复制出来的原始x86指令。

5.反汇编FINSPY VM程序

现在我们有了编写这个样本的反汇编程序所需的一切。我们知道每个VM指令的作用,以及每个VM指令如何对其操作数进行编码——而且这些信息不一定是特定于此样本的,它可能可以用于很多样本。 

不同的样本可能有很多元素不同,至少对于这个特定的样本来说我们是知道它们的。

  • 对于该样本我们知道VM字节码程序的原始内容。
  • 我们知道用于解密指令的XOR键。
  • 对于该样本我们知道哪个操作码对应哪个VM指令。

由于我只有一个样本,因此我在反汇编器中对所有这些元素进行硬编码。也就是说对于其它任何样本,您需要确定刚才描述的三条信息。这就是我通常编写反混淆程序的方式:我从一个样本开始,编写代码来解决它。然后我获得另一个样本并确定需要更改哪些内容以使我以前的代码适应新样本。如果有任何特定于样本的信息,那么我需要设计一个从给定样本中提取信息的程序。我重复这个过程,直到我的工具可以解决给定家族的任何样本。

5.1反汇编程序体系结构

我决定用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指令的生成器。至此,反汇编程序就仅仅是一个打印指令对象的问题:

for x in insn_objects_from_file("Tmp/dec.bin"):
    print x
你可以在这里看到这个样本的VM字节码程序的完整反汇编。

第二部分:底层分析细节

6. FINSPY VM预初始化分析

这里是预初始化阶段的汇编语言链接,以及该代码粗略的反编译。我们将从反编译结果中重新生成代码片段,解释其运作方式。请打开二进制文件,然后按照第一个显示汇编语言链接所示的说明进行操作。

6.1保存主机寄存器和标志

任何VM在进入时首先要保存寄存器和标志的内容。FinSpy VM也不例外:

.text:00401BF6  push    eax ; PUSHFD sequence, in order
.text:00401D5C  push    ecx
.text:004019D2  push    edx
.text:004020A4  push    ebx
.text:00401CEC  push    ebx ; EBX in place of ESP
.text:00401DA7  push    ebp
.text:00401F41  push    esi
.text:00401EFF  push    edi
.text:00401E9D  pushf

6.2位置无关代码(POSITION-INDEPENDENT CODE,PIC)

像许多混淆过的程序(以及一些没有混淆过的程序)一样,FinSpy VM的x86实现使用PIC。在没有混淆过的程序中PIC用来减少程序内那些如果基地址发生变化则需要重定位的指针的数量。出于相同的原因,在混淆过的程序中也会使用PIC,并且它还被用来掩盖实际上访问特定内存地址的操作。接下来的两条指令将EBP设置为PIC基地址:

.text:00401F60  call    $+5 ; Establish position-independent code (PIC) base
.text:00401F65  pop     ebp ; EBP = 00401F65
随后涉及EBP的任何的内存操作将相对于所示的地址(0x00401F65)进行计算。例如:
.text:004020D7  mov     eax, [ebp+0D75h] ; == [401F65h+0D75h] == [402CDAh]
.text:00402CDA  dd 0C4CDh ; <- this is the referenced address and its contents

6.3分配全局每线程VM上下文结构数组

FinSpy VM的构建方式使得多个线程可以同时执行VM代码。因此,每个线程必须具有自己的VM上下文结构,并且在全局数据项中不能包含太多的VM状态。FinSpy通过分配一个包含持有指向每个线程的VM上下文结构的初始化为NULL的指针的全局数组来解决这个问题。

预初始化函数做的第一件事是检查一个全局指针,看看我们是否已经分配了线程特定的数组,如果没有则分配。如下面的反编译代码片段所示:

DWORD *  gp_VMContext_Thread_Array = NULL;
DWORD **gpp_VMContext_Thread_Array = &gp_VMContext_Thread_Array;

DWORD *GetVMContextThreadArray()
{
  return *gpp_VMContext_Thread_Array;
}

void SetVMContextThreadArray(DWORD *pNew)
{
  *gpp_VMContext_Thread_Array = pNew;
}

void VMPreInitialize(DWORD encodedVMEip)
{
  if(GetVMContextThreadArray() == NULL)
    SetVMContextThreadArray(Allocate(0x100000));

6.4分配线程特定的VM上下文结构

在上一步之后,FinSpy知道已经分配了VM上下文结构指针的全局数组,因此它将查询VM上下文结构是否已分配给当前正在执行的线程。它将当前线程ID右移2以屏蔽最低的两位,然后将其作为数组的索引。如果尚未分配,则必须为当前线程分配一个上下文结构。

DWORD GetThreadIdx()
{
  // If we're executing in user-mode...
  if(g_Mode != X86_KERNELMODE)
    return GetCurrentThreadId() >> 2;
  
  // Kernel-mode code has not been decompiled here: it relies upon an RVA
  // that has been set to 0 in my sample, so can't be analyzed
}

// ... Continuing inside of void VMPreInitialize(DWORD encodedVMEip) ...
DWORD *dwGlobal   = GetVMContextThreadArray();
DWORD dwThreadIdx = GetThreadIdx();
VMContext *thContext = dwGlobal[dwThreadIdx];
if(thContext == NULL)
{
  thContext = Allocate(0x10000);
  dwGlobal[dwThreadIdx] = thContext;

6.5初始化线程特定的VM上下文结构

如果当前线程的VM上下文结构尚未在上一步中分配,则FinSpy会对VM上下文结构进行一些初步初始化。这是我们真正开始了解VM上下文结构设计的地方(完整的在这里)。
// Base address for currently-running executable
thContext->dwBaseAddress = g_BaseAddress;

// Last DWORD in VM Context structure
thContext->dwEnd = (DWORD)thContext + 0xFFFC;

// Initialize pointer to data section
thContext->pData = &thContext->Data[1];
分析此代码和后续代码时,汇编语言包含许多如下所示的结构引用:
.text:00401B19  mov     ecx, [ebp+0D79h] ; ecx = .text:00402CDE     BASE_ADDRESS dd 400000h
.text:00401FA9  mov     [ebx+28h], ecx   ; <- structure reference: field_28
.text:00401A2F  mov     eax, ebx
.text:00401BCC  add     eax, 0FFFCh
.text:00401AAD  mov     [ebx+4], eax     ; <- structure reference: field_4
.text:00401EC2  lea     eax, [ebx+50h]   ; <- structure reference: field_50
.text:00401C56  add     eax, 4
.text:00401998  mov     [ebx+50h], eax   ; <- structure reference: field_50
我知道我后来需要清楚这些结构字段在何处以及如何初始化,因此我在文本编辑器中打开了一个新窗口,并对每个结构字段的访问做了笔记。后来我根据这些笔记在IDA中创建了结构体。完整的笔记可以在这里找到。下面是我在上面的汇编片段中引用的字段的注释:

field_4:00401AAD:此结构中指向上一个DWORD的指针(0xFFFC偏移量)

field_28:00401FA9:dwBaseAddress

field_50:00401998:初始化为field_54的地址

6.6分析解压缩代码的结果

FinSpy将其VM指令保存在VMInstruction结构体的一个很大的数组中。这个数组可能会被压缩,在这种情况下第一次执行预初始化的下一阶段时,指令将被解压。后续线程特定的VM上下文结构的预初始化在第一次解压之后就不需要再解压了。

// If dwInstructionsDecompressedSize was 0, then the instructions aren't
// compressed, so just copy the raw pointer to the instructions to the
// pointer to the decompressed instructions
if(dwInstructionsDecompressedSize == 0)
  *dwInstructionsDecompressed = *dwInstructionsCompressed;

// If the decompressed size is not zero, this signifies that the
// instructions are compressed. Allocate that much memory, decompress the
// instruction data there, and then cache the decompression result
else
{
  // If we've already decompressed the instructions, don't do it again
  if(*dwInstructionsDecompressed == NULL)
  {
    // Allocate RWX memory for the obfuscated, encrypted APLib decompression
    // code
    void *pDecompressionStub = Allocate(dwDecompressionStubSize);
    memcpy(pDecompressionStub, dwDecompressionStubSize, &x86DecompressionStub);
    
    // Decrypt the decompression code (which is still obfuscated)
    XorDecrypt(pDecompressionStub, dwDecompressionStubSize, dwXorKey);
    
    // Allocate memory for decompressed instructions; decompress
    VMInstructions *pDecInsns = Allocate(dwInstructionsDecompressedSize);
    fpDecompress Decompress = (fpDecompress)pDecompressionStub;
    Decompress(*dwInstructionsCompressed, pDecInsns);
    
    // Update global pointer to hold decompressed instructions
    *dwInstructionsDecompressed = pDecInsns;
  }
}
// Store the pointer to decompressed instructions in the context structure
thContext->pInstructions = *dwInstructionsDecompressed;

// Locate the first instruction by encoded VM EIP
thContext->pCurrInsn = FindInstructionByKey(thContext, encodedVMEip);

6.7分析解压缩代码的过程

本节描述如何获得上一节中的结果。在预初始化的前几个阶段之后,我观察到FinSpy解密存储在其.text节中的一些混淆过的代码并执行。第一次看到时我不知道代码是干什么的,结果发现它负责解压VM字节码程序。以下是我怎么发现的。

作为额外的隐藏手段,解压代码本身通过XOR进行加密,并且也使用前一篇文章中看到的相同的条件跳转对技术进行混淆。第一次分析预初始化的这部分时,我首先注意到它分配了一小块RWX内存,从.text节复制数据并解密:

.text:004020D7  mov     eax, [ebp+0D75h] ; eax = .text:00402CDA  dd 0C4CDh
.text:00401E1A  add     eax, [ebp+0D79h] ; eax = 0C4CDh + .text:00402CDE BASE_ADDRESS dd 400000h
                                         ; i.e. eax contains 0x40C4CD, an address in .text
.text:00401A1F  push    eax              ; eax = 0x40C4CD
.text:00401CDE  push    ecx              ; ecx = .text:00402CD6 dd 60798h
.text:00401E8A  mov     eax, [ebp+0D6Dh] ; eax = .text:00402CD2 dd 67Dh

.text:00401B94  push    eax
.text:00401B95  call    ALLOCATE_MEMORY  ; Allocate 0x67D bytes of RWX memory
.text:00401B9A  mov     edi, eax
.text:00401C18  push    edi              ; Save address of allocated memory
.text:00401D45  mov     esi, [ebp+0D61h] ; esi = .text:00402CC6 dd offset dword_40BE50

.text:00401CA5  cld                      ; Clear direction flag for rep movsb below
.text:00401FF9  mov     ecx, [ebp+0D6Dh] ; .text:00402CD2 dd 67Dh

.text:00401E54  push    ecx              
.text:00402064  rep movsb                ; Copy 0x67D bytes from dword_40BE50 to allocated RWX memory
.text:00401E41  pop     ecx              ; Restore ecx = 60798h
.text:00401A86  pop     edi              ; Restore edi = address of allocated region
.text:00401C2C  mov     eax, [ebp+0D7Dh] ; .text:00402CE2 XOR_VALUE dd 2AAD591Dh

.text:00401D7C  push    eax              ; eax = value to XOR against allocated memory
.text:00401D7D  push    ecx              ; ecx = size of allocated region
.text:00401D7E  push    edi              ; edi = beginning of allocated region
.text:00401D7F  call    loc_40250B       ; XOR_DWORD_ARRAY: XOR DWORDs starting at edi+4, to edi+(ecx>>2)
我写了一个简单的IDC脚本复制函数loc_40250B(XOR_DWORD_ARRAY)的功能。它可以在这里找到。
static DecryptXOR(encAddr, encLen, xorVal)
{
  auto encNum;
  auto cursor;
  auto i;

  // Skip first DWORD
  cursor = encAddr + 4;
  encNum = (encLen - 4) >> 2;
    
  for(i = 0; i < encNum; i = i + 1)
  {
    PatchDword(cursor,Dword(cursor)^xorVal);
    cursor = cursor + 4;
  }
}
然后我调用它来解密上面代码片段中引用的代码区域:DecryptXor(0x40BE50,0x67D,0x2AAD591D)。

继续看上一个代码片段之后的汇编代码:

.text:00401CB9  pop     ecx              ; Restore ecx = .text:00402CD6 dd 60798h
.text:00402046  pop     eax              ; Restore eax = 0x40C4CD
.text:00401F79  mov     esi, eax         ; esi = 0x40C4CD
.text:00401FBE  push    ecx              ; Allocate 60798h bytes of memory
.text:00401FBF  call    ALLOCATE_MEMORY
.text:00401FC4  mov     edx, edi         ; edx = 0x67D-byte region of RWX memory, decrypted previously
.text:00401AD6  mov     edi, eax         ; edi = 60798h bytes of memory just allocated
.text:00401A4C  push    edi              ; Second argument = edi (60798h-byte region)
.text:00401A4D  push    esi              ; First argument = 0x40C4CD
.text:00401A4E  call    edx              ; Execute decrypted code in smaller region
现在我知道解密后的区域包含从0x40C4CD复制的代码,所以我开始分析它。它使用前一篇文章中看到的相同的条件跳转对技术进行混淆。作为一名已经对恶意软件进行了1​​5年逆向工程的人,虽然由于近期研究的焦点不同而有些生疏了,但我立即有了似曾相识的感觉。下面的代码我已经看过无数次了:
.text:0040C320     add     dl, dl
.text:0040C1E6     jnz     short @@RETURN
.text:0040BF24     mov     dl, [esi]        
.text:0040C01F     inc     esi    
.text:0040C100     adc     dl, dl    
@@RETURN:
.text:0040BFE8     retn    
首先看到的是adc dl,dl指令。如果我经常从事恶意软件分析,就会立即认识到这是APLib解压缩代码的一部分。我在Google上搜索了诸如x86 decompression "adc dl,dl"之类的东西,最终找到了将其识别为APLib解压库的恶意软件分析报告。

意识到我解密的代码可能实现了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库。我读了几分钟的例子后,写了一个小脚本:

from kbp.comp.aplib import decompress

with open("compressed.bin", "rb") as g:
    dec,decSize = decompress(g.read()).do()
    print decSize
with open("decompressed.bin", "wb") as f:
    f.write(dec)
我的脚本打印了预期的解压缩后的大小,所以我知道脚本是可用的。解压后的文件可以在这里找到。我将解压后的二进制文件加载到IDA中,不知道里面会有什么。数据中有一些经常出现的模式,但此时我还不知道其格式。稍后我会更全面地分析它。

7. FINSPY VM初始化分析

本部分在上一部分的预初始化阶段后立即恢复执行。初始化阶段的汇编代码在这里。为了清楚地说明,以下大部分内容使用反编译的C代码。总结一下上一节:FinSpy VM的预初始化阶段会两次尝试访问先前分配的结构。如果结构不存在,它将执行前一节所述的分配和预初始化阶段。如果它们存在,它将跳过这些阶段并直接跳转到初始化阶段:

//
// From pre-initialization phase, shown previously
//
// Try to get the global VM Context * array
if(GetVMContextThreadArray() == NULL)
  SetVMContextThreadArray(Allocate(0x100000));

// Once allocated, retrieve the global pointer
DWORD *dwGlobal   = GetVMContextThreadArray();

// Try to get the per-thread VMContext *
DWORD dwThreadIdx = GetThreadIdx();
VMContext *thContext = dwGlobal[dwThreadIdx];
if(thContext == NULL)
{
  // We detailed this phase in the previous section:
  // Allocate and pre-initialize the VMContext * if not present
}

//
// Beginning of initialization phase
//
// Once allocated, retrieve the thread-specific pointer
DWORD *dwGlobal   = GetVMContextThreadArray();
DWORD dwThreadIdx = GetThreadIdx();
VMContext *thContext = dwGlobal[dwThreadIdx];

// ... execution resumes here after both pointers are allocated ...

7.1初始化VM函数指针

大多数初始化阶段使用PIC来计算函数指针的值。随后的分析搞清楚了这些有点难以单独理解的函数指针的功能。我将在随后更自然的语境中解释它们(第9节)。

像预初始化代码一样,本节包含许多结构引用。我继续更新我的文档以描述我对每个结构引用的理解。再次提醒,这些笔记可以在这里找到。 

vm_initialize:
thContext->pPICBase = (void *)0x00402131;

// Points to the vm_initialize label above
thContext->fpFastReEntry = (fpReEntryNoState)0x0040247C;
thContext->fpLookupInstruction = &FindInstructionByKey;

if(g_Mode != X86_KERNELMODE)
  thContext->fpEnterFunctionPointer = (fpReEntryHasState)0x00402B92;
else
  thContext->fpEnterFunctionPointer = (fpReEntryHasState)0x00402BC6;

thContext->fpExecuteVMInstruction = (fpReEnterToInstruction)0x00402C39;
thContext->fpRelativeJump = (fpReEntryHasState)0x00402B74;
thContext->fpNext = (fpReEntryHasState)0x00402B68;
我很快注意到fpFastReEntry函数指针指向VM初始化代码。回顾第3.2节中显示的通用VM组织结构图,请注意,VM指令需要在VM入口点内定位指令获取/解码代码。我猜想那个地方的代码可能是用来干这个的。这大部分是正确的(实际上一些初始化是在取指令之前执行的,即在上面的伪代码中显示的初始化)。

7.2主机<->VM状态转移

按照上一节的说明设置函数指针后,VM两次将主机的ESP保存到VM上下文结构中,然后将主机的ESP设置到VM上下文结构末尾附近的一个位置。因此很显然VM状态包含一个物理上位于上下文结构末尾的栈。

thContext->pSavedEsp1 = ESP;
thContext->pSavedEsp2 = ESP;
ESP = thContext->pDataEnd;

8.分析FINSPY VM指令提取,解码和派发

尽管我的笔记让我能够高效地将所有东西整理在一起,但是分析至此我仍然不清楚VM上下文结构中的许多内容。随后的代码加载来自解压的内存区域的指令,可选地转换它们,然后将控制转移到负责实现它们的相关x86代码。

8.1FINSPY VM指令提取

该阶段的反编译的伪代码在这里,注释的汇编代码在这里

在初始化上下文结构之后,FinSpy VM将使用我们已经分析过的一些字段。幸亏我的笔记使得我很容易理解发生了什么。回忆解压缩指令时预初始化阶段的这两个语句:

thContext->pInstructions = *dwInstructionsDecompressed;
thContext->pCurrInsn = FindInstructionByKey(thContext, encodedVMEip);
也就是说,VM上下文结构的pCurrInsn成员已经包含了一个在pInstructions数据blob中某处的一个指针。现在FinSpy VM入口点的下一部分使用这些字段:
VMInstruction *Fetch(VMContext *thContext)
{
  memcpy(&thContext->Instruction,thContext->pCurrInsn, sizeof(VMInstruction));
  XorDecrypt(&thContext->Instruction, sizeof(VMInstruction), dwXorKey);
  return &thContext->Instruction;
}
这里我们可以知道指令结构体成员中每个条目的大小,即0x18。并且我们还看到这里使用我们之前看到的解密解压代码相同的函数。我们将在8.4节对它们进行更多的讨论。

再次注意预初始化中的下面这一行:

thContext->pCurrInsn = FindInstructionByKey(thContext, encodedVMEip);
回忆一下前一篇文章,在将控制权转移到VM入口点之前每个VM入口点将一个值压入栈。前一篇文章中的一个例子如下:
.text:004065A4     push    5A403Dh                  ; <- encodedVMEip
.text:004065A9     push    ecx                      ; 
.text:004065AA     sub     ecx, ecx                 ; 
.text:004065AC     pop     ecx                      ; 
.text:004065AD     jz      loc_401950               ; VM entrypoint
encodedVMEip参数用于查找应该从该VM入口点开始执行的第一条VM指令。即FindInstructionByKey函数接受encodedVMEip作为参数,遍历解压缩的指令,并尝试找到第一个匹配该值的DWORD。
VMInstruction *FindInstructionByKey(VMContext *vmc, DWORD Key)
{
  VMInstruction *insns = vmc->pInstructions;
  while(insns->Key != Key)
    insns++;
  return insns;
}
所以我们现在知道:
  • 每个VM指令都与一个key关联。
  • key是VM指令结构体中的第一个元素。
  • VM可以按key查找指令。

8.2FINSPY VM指令解码

该阶段的反编译的伪代码在这里,注释的汇编代码在这里

将指令从全局指令数组中复制出来并解密之后,VM入口点将调用一个通过将当前模块的基地址加到指令中来选择性地以DWORD大小修复指令的函数。该功能等同于操作系统的加载程序,允许指令引用模块中的地址而不必关心模块的加载位置。

void Decode(VMContext *thContext)
{
  VMInstruction *ins = &thContext->Instruction;
  if(ins->Fixup1)
    *(DWORD *)&ins->Data[ins->Fixup1 & 0x7F] += thContext->dwImageBase;
  if(ins->Fixup2)
    *(DWORD *)&ins->Data[ins->Fixup2 & 0x7F] += thContext->dwImageBase;
}

8.3FINSPY VM指令派发

该阶段的反编译的伪代码在这里,注释的汇编代码在这里

最后,在指令被解密并且可选地修复之后,VM然后将控制转移到实现该指令的x86代码。

伪代码:

// Locate the handler table in the .text section
fpInstruction *handlers = (fpInstruction *)vmc->pPICBase + 0x9AF;
  
// Invoke the relevant function in the handler table
handlers[i->Opcode](vmc);
x86:

.text:00402311  movzx   ecx, byte ptr [ebx+3Ch] ; Fetch the opcode byte
.text:0040248D  mov     eax, [ebx+24h]          ; Grab the PIC base
.text:004023FC  add     eax, 9AFh               ; Locate the VM handler table
.text:0040218C  jmp     dword ptr [eax+ecx*4]   ; Transfer control to the opcode handler

8.4粗略分析VM字节码

在本章中关于取指令/解码/派发指令的部分,我们了解到每条指令0x18个字节,并且每条指令都通过一个DWORD级的异或来加密。我之前解压过指令blob,现在可以解密它并查看一下。我使用了我之前在分析预初始化解压代码时编写的相同的脚本。我刚刚编写了一个脚本来在循环中调用该函数,并在解压的blob的IDB内执行它:
auto i;
for(i = 0; i < 0x00060797; i = i + 0x18)
  DecryptXOR(i, 0x18, 0x2AAD591D);
运行该脚本后,大部分数据格式仍然未知。数据大多为零,但显然非常有结构。由于我之前编写过一个x86反汇编程序,因此我觉得一些非零数据看起来非常像x86机器码,我把其中的一些变成了代码,IDA证实了这一点:
seg000:00000078   dd 5A1461h
seg000:0000007C   db 1Bh ; VM opcode: 0x1B
seg000:0000007D   db 6   ; x86 instruction length
seg000:0000007E   db 0   ; fixup1 location
seg000:0000007F   db 0   ; fixup2 location
seg000:00000080 ; ---------------------------------------------------------------------------
seg000:00000080   mov     dword ptr [eax], 1D7h
seg000:00000080 ; ---------------------------------------------------------------------------
seg000:00000086   db 0Ah dup(0)

seg000:00000090   dd 5A1468h
seg000:00000094   db 1Bh ; VM opcode: 0x1B       
seg000:00000095   db 5   ; x86 instruction length
seg000:00000096   db 1   ; fixup1 location       
seg000:00000097   db 0   ; fixup2 location       
seg000:00000098 ; ---------------------------------------------------------------------------
seg000:00000098   mov     eax, offset unk_1F000
seg000:00000098 ; ---------------------------------------------------------------------------
seg000:0000009D   db 0Bh dup(0)

seg000:000000A8   dd 5A146Ch
seg000:000000AC   db 1Bh ; VM opcode: 0x1B       
seg000:000000AD   db 1   ; x86 instruction length
seg000:000000AE   db 0   ; fixup1 location       
seg000:000000AF   db 0   ; fixup2 location       
seg000:000000B0 ; ---------------------------------------------------------------------------
seg000:000000B0   leave
seg000:000000B0 ; ---------------------------------------------------------------------------
seg000:000000B1   db 0Fh dup(0)

seg000:000000C0   dd 5A146Dh
seg000:000000C4   db 1Bh ; VM opcode: 0x1B       
seg000:000000C5   db 3   ; x86 instruction length
seg000:000000C6   db 0   ; fixup1 location       
seg000:000000C7   db 0   ; fixup2 location       
seg000:000000C8 ; ---------------------------------------------------------------------------
seg000:000000C8   retn    4
seg000:000000C8 ; ---------------------------------------------------------------------------
seg000:000000CB   db 0Dh dup(0)
令人好奇的是为什么数据blob中包含单个独立的x86指令?VM是否真的包含原始x86指令?虽然并非所有的指令都是这样的,但事实证明大约一半的VM指令包含原始x86机器码。我想如果我如果能将剩余的非原始的x86 VM指令转换为x86,那么将整个VM字节码程序恢复为x86可能很容易。

无论如何,谈论恢复成x86程序目前为时过早。现在已经对VM字节码程序进行了解密,可以更容易地对VM指令集进行逆向。如果对VM操作码的x86指令处理程序如何使用VM指令结构的某些部分感到困惑,那么可以简单地查看这些指令的实例以便理解而不必猜测。

9.分析FINSPY VM退出和重新进入

在讨论VM指令集之前回到与VM退出和重新进入有关的VM上下文结构中找到的五个函数指针。所有这些的反汇编可以在这里找到。

9.1重新进入VM,寄存器中的状态

用于返回到VM的第一个函数指针在VM上下文结构中称为fpFastReEntry,只包含VM入口点内初始化阶段的地址。它假设寄存器和标志包含有意义的值,并且它们尚未被放置在栈上。

9.2重新进入VM,栈中的状态

下一个返回序列假定EAX包含前一节中函数指针的地址,并且标志和寄存器保存在栈中。它只是将标志和寄存器从栈中弹出,并将控制转移到EAX中的函数指针。

.text:00402BB2     mov     [esp-4], eax        ; Save VM initialization location from EAX onto stack
.text:00402BAB     popf                        ; Restore flags
.text:00402BBD     popa                        ; Restore registers
.text:00402B9C     jmp     dword ptr [esp-28h] ; Branch to VM initialization location

9.3执行分支,栈中的状态

当执行分支时,VM的有条件和无条件分支指令使用此返回序列。它可以为VM EIP添加一个位移——非常类似于x86处理器实现相关分支的方式。它还能够转移到指定为RVA的x86位置,但是这个代码从未在我研究过的样本中使用过。

.text:00402B74 VM__RelativeJump__Execute:
.text:00402B74     mov     eax, [ecx+1]                         ; Instruction data at DWORD +1 contains
.text:00402B74                                                  ; a displacement for the VM EIP
.text:00402B77     test    eax, eax                             ; Was it non-zero?
.text:00402B79     jz      short jump_to_x86                    ; No -- branch to x86 code at [ecx+5]
.text:00402B7B     add     [ebx+VMContext.pCurrInsn], eax       ; Add the displacement to VM EIP
.text:00402B7D     mov     eax, [ebx+VMContext.fpVMEntry]       ; eax := state-in-registers VM entry
.text:00402B80     mov     esp, [ebx+VMContext.SavedESP1]       ; esp := restore host ESP
.text:00402B83     jmp     [ebx+VMContext.fpVMReEntryToFuncPtr] ; branch to state-on-stack re-entry
.text:00402B86 ; ---------------------------------------------------------------------------
.text:00402B86
.text:00402B86 jump_to_x86:                             ; CODE XREF: ...
.text:00402B86     mov     eax, [ecx+5]                         ; Otherwise, use [ecx+5] as x86 RVA
.text:00402B89     add     eax, [ebx+VMContext.dwBaseAddress]   ; Convert RVA to VA
.text:00402B8C     mov     esp, [ebx+VMContext.SavedESP1]       ; esp := restore host ESP
.text:00402B8F     jmp     [ebx+VMContext.fpVMReEntryToFuncPtr] ; branch to state-on-stack re-entry

9.4不执行分支,栈中的状态

当不执行分支时,VM的有条件和无条件分支指令使用此返回序列。它只是将VM指令的大小0x18添加到当前VM指令指针,并通过栈状态处理程序重新进入VM。

.text:00402B68 VM__NextInsn__Execute:
.text:00402B68     add     [ebx+VMContext.pCurrInsn], size VMInstruction ; Point VM EIP to next instruction
.text:00402B6B     mov     eax, [ebx+VMContext.fpVMEntry]                ; EAX := ordinary VM re-entry location
.text:00402B6E     mov     esp, [ebx+VMContext.SavedESP1]                ; Restore host ESP
.text:00402B71     jmp     [ebx+VMContext.fpVMReEntryToFuncPtr]          ; branch to state-on-stack re-entry

9.5函数返回,栈中的状态

最后,这个VM重入函数指针在函数调用返回后由动态生成的代码调用。代码必须从动态生成的代码栈中弹出最后一帧,并在发出函数调用之后将VMContext-> pCurrInsn更新为VM指令。由于VM指令指针保存在寄存器和标志之上的栈中,因此该代码将寄存器和标志上移一个DWORD,并将栈指针加4。最后,它使用栈状态函数指针重新进入VM。

.text:00402C6F     mov     eax, [esp+24h] ; Get saved VMInstruction * from stack
.text:00402CB0     lea     esi, [esp+20h] ; esi = address of registers begin
.text:00402C4E     lea     edi, [esp+24h] ; edi = address of VMInstruction *
.text:00402C40     mov     ecx, 9
.text:00402C66     std                    ; Move saved registers/flags up by
.text:00402C93     rep movsd              ; one DWORD, erasing the VMInstruction *
.text:00402CAA     cld
.text:00402C79     add     esp, 4         ; Adjust stack to remove superfluous DWORD
.text:00402C8B     mov     [ebx+VMContext.pCurrInsn], eax ; Set next instruction
.text:00402C58     sub     [ebx+VMContext.pScratch], 30h  ; Pop function stack
.text:00402C83     mov     eax, [ebx+VMContext.fpVMEntry] ; Get VM entry pointer
.text:00402C9F     jmp     [ebx+VMContext.fpVMReEntryToFuncPtr] ; Use state-on-stack return

10.分析FINSPY VM指令集

vm指令集有34组操作码,可以分成三组。
  • 条件跳转和无条件跳转
  • 涉及专用寄存器的操作
  • X86相关指令

10.1第一组,条件跳转和无条件跳转

有关这些指令的注释的反汇编代码可以在此处找到。

条件跳转指令都非常简单,几乎相同。回忆一下在VM入口点将控制权转移给任何指令时,主机栈在底部保存的标志以及紧接其上的保存的寄存器。条件跳转指令全部将保存的标志加载到主机栈底部,然后使用test指令从保存的EFLAGS寄存器中分离出各个位,然后测试所需的位组合。我在IDA中创建了一个枚举来为符号常量赋予名称以看起来更直观:

FFFFFFFF ; enum X86Flags, mappedto_291
FFFFFFFF X86Flags_CF  = 1
FFFFFFFF X86Flags_PF  = 4
FFFFFFFF X86Flags_AF  = 10h
FFFFFFFF X86Flags_ZF  = 40h
FFFFFFFF X86Flags_SF  = 80h
FFFFFFFF X86Flags_OF  = 800h
现在我们可以很容易地将这个枚举应用到反汇编代码中,以确定哪些位正在被测试。以下是VM JL指令的x86代码:
.text:00403007     lea     ecx, [ebx+VMContext.CurrentInsn.InsnData]
.text:00402FD2     mov     eax, [ebx+VMContext.SavedESP1]
.text:00402FE8     mov     eax, [eax] ; Grab saved flags from the host stack
.text:0040302D     test    eax, X86Flags_OF ; 800h
.text:00402FFA     setnz   dl
.text:0040303C     test    eax, X86Flags_SF ; 80h
.text:0040304C     setnz   al
.text:00403014     cmp     al, dl
.text:00403021     jnz     short loc_402FDD

; JNZ path
    .text:00402FDD     jmp     [ebx+VMContext.fpVMRelativeJump]

; JZ path
    .text:00402FC9     jmp     [ebx+VMContext.fpVMNext]
代码测试EFLAGS中的溢出标志(OF)和符号标志(SF),并且如果它们彼此不同则执行相对跳转(上面代码中的JNZ路径)。如果它们相同,则执行转移到下一个VM指令(上面代码中的JZ路径)——与在真正的x86处理器上的条件跳转的工作方式完全相同。

当遇到上面的代码时,你可能会记不住哪个x86条件跳转指令对应于OF!=SF时跳转。我也经常忘记它们,当我需要时会参考我的X86到中间语言转换器的代码。下面是一张表,你不用去查了。

Branch | Condition
JS       SF
JNS      !SF
JO       OF
JNO      !OF
JZ       ZF
JNZ      !ZF
JP       PF
JNP      !PF
JB       CF
JAE      !CF
JA       !CF && !ZF
JBE      CF || ZF
JL       SF != OF
JGE      SF == OF
JG       SF == OF && !ZF
JLE      SF != OF || ZF
无条件跳转指令使用涉及动态生成的x86代码的一些把戏。似乎在我们讨论10.3节中的动态代码生成——VM指令之后再讨论无条件跳转指令比较好。

10.2第二组,专用寄存器指令 

有关这些指令的注释的反汇编代码可以在此处找到。
分析专用寄存器指令一开始让我感到很困惑。正如你可以在下面的代码中看到的,其中每个包含一个看起来像[ecx+eax*4+4]的内存引用,其中ECX指向在VM入口之前保存的ESP,并且EAX等于7-data[0]。
.text:00403982     lea     ecx, [ebx+VMContext.CurrentInsn.InsnData] ; point ECX to the data region
.text:004039B0     movzx   ecx, byte ptr [ecx]                       ; get the first byte
.text:00403995     mov     eax, 7
.text:0040396C     sub     eax, ecx                                  ; EAX := 7-data[0]
.text:00403947     mov     ecx, [ebx+VMContext.SavedESP1]            ; ECX := host ESP
.text:0040395E     mov     eax, [ecx+eax*4+4]                        ; Grab saved register
.text:00403951     mov     [ebx+VMContext.dwScratch], eax            ; Store loaded register into SCRATCH
.text:00403978     add     [ebx+VMContext.pCurrInsn], size VMInstruction
.text:004039B8     mov     eax, [ebx+VMContext.fpVMEntry] 
.text:004039A2     mov     esp, [ebx+VMContext.SavedESP1] 
.text:0040398D     jmp     [ebx+VMContext.fpVMReEntryToFuncPtr]
我最终意识到主机栈包含保存的标志后面跟着保存的寄存器。因此,内存操作数索引的+4超过了保存的标志。然后,使用eax*4中的EAX值选择一个保存的寄存器。如果Data[0]中的字节为0,则该指令将选择保存的寄存器数组中的第7个元素——即第一个压入的寄存器EAX。如果Data[0]中的字节为1,则该指令将选择保存的寄存器数组中的第6个元素——即第二个压入的寄存器ECX。

由于我之前编写过反汇编程序,因此我意识到了Data[0]和所选寄存器之间的关系。x86机器码按照以下顺序为其32位寄存器分配一个编号:EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI。因此,Data[0]按顺序选择一个寄存器。找出哪一个寄存器被这些指令之一访问是一件简单的事情——只需按照寄存器名称的顺序保存一个数组。

我还担心Data[0]不在[0,7]范围内时会发生什么。负数会导致索引到主机栈下方,大于等于8会导致索引到保存的寄存器上方。我不确定去虚拟化时如何处理这些可能性。我在我的反汇编程序中对这些情况提供了有限的支持,如果有这种情况我会处理这个问题。幸运的是没有发生这种情况。

10.3第三组,x86相关指令 

有关这些指令的注释的反汇编代码可以在此处找到。

高层次的总结已经描述了这些指令的目的:执行其机器码在相关VM指令的数据区域内提供的x86指令。分析它们唯一真正的困难是理解它们写入动态代码区域的指令。正如在下面的代码中看到的,写入的值经过了混淆:

.text:004040F1     mov     edi, [ebx+VMContext.pScratch]
.text:004040BF     mov     eax, [ebx+VMContext.SavedESP1]
.text:004040DD     and     eax, 2Fh
.text:00404110     add     edi, eax
.text:0040412E     push    edi
.text:00404119     mov     word ptr [edi], 6ACh         ; <- Write 0x6AC
.text:0040408F     xor     word ptr [edi], 6731h        ; <- XOR with 0x6731
.text:00404139     add     edi, 2
.text:004040B1     lea     esi, [ebx+VMContext.CurrentInsn.InsnData]
.text:00404086     movzx   ecx, byte ptr [ebx+VMContext.CurrentInsn.DataLength]
.text:004040A2     rep movsb
.text:0040414F     mov     byte ptr [edi], 0E9h         ; <- Write 0xE9
.text:00404165     xor     byte ptr [edi], 81h          ; <- XOR with 0x81
.text:0040405E     mov     eax, [ebx+VMContext.fpVMEntry]
.text:004040CA     mov     [edi+1], eax
.text:00404068     mov     byte ptr [edi+5], 6Ch        ; <- Write 0x6C
.text:004040D3     xor     byte ptr [edi+5], 0AFh       ; <- XOR with 0xAF
.text:004040E5     add     dword ptr [ebx+VMContext.pCurrInsn], size VMInstruction
.text:0040415B     pop     eax                         
.text:004040FD     mov     esp, [ebx+VMContext.SavedESP1]
.text:00404071     jmp     eax

下面这两行:

.text:00404119     mov     word ptr [edi], 6ACh         ; <- Write 0x6AC
.text:0040408F     xor     word ptr [edi], 6731h        ; <- XOR with 0x6731
写入0x06AC,然后将该值与0x6731异或。幸运的是,使用IDA的计算器功能(在反汇编列表中按?)来执行这些计算是很容易的。该片段写入0x619D(9D 61)。此时你可以查看英特尔处理器操作码对应的字节,或者如果你比较懒的话请在二进制文件中找到一些对齐字节,使用IDA的Edit->Patch Program->Change Bytes..功能,写入字节,然后将它们转换为代码以使IDA自动为你对它们反汇编。
10.3.1无条件跳转
无条件跳转同样使用动态生成的代码,虽然并不需要这样。它会将以下字节写入VM栈区:E9 02 F8 B0 F9 B8 [0x004033C9] FF E0,其中括号中的值是特定于样本的值,它指代.text段中包含代码的地址。
如果反汇编上面那段的x86机器码会发现前五个字节是x86无条件长相对跳转。如果你写一个固定位移的相对跳转到一个不可预知的地址(因为FinSpy VM栈基地址是动态分配的,因此可能会在运行时改变,所以是不可预测的),结果也是一个不可预测的地址。那么,这个指令肯定会崩溃?我对这条指令的原始反汇编是CRASH,直到我看到这条指令在反汇编程序列表中出现的频率很高才迫使我仔细观察。
.text:00403398     lea     ecx, [ebx+VMContext.CurrentInsn.InsnData] ; ECX := &Instruction->Data[0]
.text:004033D1     mov     edi, [ebx+VMContext.pScratch]         ; EDI := dynamic code generation stack
.text:00403237     mov     eax, [ebx+VMContext.SavedESP1]        ; EAX := host ESP
.text:00403219     and     eax, 2Fh
.text:00403350     lea     edi, [eax+edi-4AEDE7CFh]              ; Stupid obfuscation on the value of EDI
.text:00403244     mov     dword ptr [edi+4AEDE7CFh], 0B0F802E9h ; [EDI]    := E9 02 F8 B0
.text:00403266     mov     word ptr [edi+4AEDE7D3h], 0B8F9h      ; [EDI+4]  := F9 B8
.text:00403382     call    $+5
.text:00403387     pop     eax
.text:00403253     lea     eax, [eax+42h] ; 0x004033C9
.text:00403305     mov     [edi+4AEDE7D5h], eax                  ; [EDI+6]  := 0x004033C9
.text:00403229     mov     word ptr [edi+4AEDE7D9h], 0E0FFh      ; [EDI+10] := FF E0
.text:004032A1     mov     al, [ecx]                             ; al = first byte of instruction data
.text:004032CA     mov     [edi+4AEDE7CFh], al                   ; [EDI] := al, overwriting E9
.text:00403412     mov     eax, [ebx+VMContext.SavedESP1]        ; EAX := host ESP
.text:004033B6     lea     edi, [edi+4AEDE7CFh]                  ; EDI := beginning of code we just wrote
.text:0040332F     push    dword ptr [eax]                       ; Push saved flags
.text:004032E2     popf                                          ; Restore flags
.text:0040331E     jmp     edi                                   ; Jump to the code we just generated
仔细检查后,第一个字节的值(以前为E9)将在0x004032CA被VMInstruction数据数组的第一个字节覆盖。我在解压的VM字节码(样本中的操作码0x06)中找到了此指令的一个实例,并查看指令数据中第一个字节的值,即0xEB(实际上,每个操作码为0x06的VM指令其第一个数据字节都是0xEB)。然后,用EB代替E9,我们最终得到了更合理的反汇编代码:
.text:0041EFBC EB 02              jmp     short loc_41EFC0
.text:0041EFBC
.text:0041EFBC                ; --------------------------------
.text:0041EFBE F8                 db 0F8h ; °
.text:0041EFBF B0                 db 0B0h ; ¦
.text:0041EFC0                ; --------------------------------
.text:0041EFC0
.text:0041EFC0                loc_41EFC0:
.text:0041EFC0 F9                 stc     ; Set carry flag
.text:0041EFC1 B8 C9 33 40 00     mov     eax, offset loc_4033C9
.text:0041EFC6 FF E0              jmp     eax
现在可以分析上面代码中引用的位置,即loc_4033C9:
.text:00403371         jb      short loc_4032FD ; JB will be taken, since carry flag was set previously

; JB path
    ; The code here is identical to the VM conditional branch taken re-entry sequence

; JNB path
    ; No real point in analyzing this, as the JB will always be taken
    .text:0040327C         add     [ebx+VMContext.pCurrInsn], size VMInstruction
    .text:00403426         mov     eax, [ebx+VMContext.fpVMEntry] 
    .text:004033E6         mov     esp, [ebx+VMContext.SavedESP1] 
    .text:00403345         jmp     [ebx+VMContext.fpVMReEntryToFuncPtr] 



[公告]LV6级以上的看雪会员可以免费获得《2019安全开发者峰会》门票一张!!

最后于 2018-3-19 11:05 被houjingyi编辑 ,原因:
最新回复 (5)
穆恩 3 2018-3-17 19:14
2
0
原作者Rolf  Rolles  有超过十年的VM保护研究经验。  这一系列是他最近发表的,详细的介绍了对他对FinSpy虚拟机的分析过程,从虚拟机结构分析、到字节码的反编译、到最终还原x86代码,非常值得一读。原文很长,译者的工作量相当大,很了不起,感谢译者。
呼吁版主加精!
bambooqj 2018-3-17 19:56
3
0
文章真的不错.
supersoar 2018-8-28 01:16
4
0
不错 感谢分享,老外还是牛逼啊,搞VM逆向分析,佩服
zhangya昊 2018-9-1 13:02
5
0
这文章不错啊,虚拟安全还是值得学习的
xuanzee 2019-6-20 19:45
6
0
回复收藏一下,感谢楼主
游客
登录 | 注册 方可回帖
返回