VMP虚拟指令逆向分析算法

2022-10-01 02:41乐德广王雨芳龚声蓉
计算机工程与设计 2022年9期
关键词:寄存器逆向指令

乐德广,赵 杰,王雨芳,龚声蓉

(1.常熟理工学院 计算机科学与工程学院,江苏 常熟 215500;2.淘宝(中国)软件有限公司 CRO安全部,浙江 杭州 311121)

0 引 言

软件保护是一种防止软件被恶意攻击而受到破解、篡改或盗版等威胁的软件安全技术,是当前软件安全研究领域的热点[1]。常见的软件保护技术有代码混淆[2]、软件加密[3]、数字水印[4]和虚拟机保护(virtual machine protection,VMP)[5]等。其中,虚拟机保护设计一套私有虚拟指令集和虚拟机架构,并将宿主程序的CPU指令变换成自定义的虚拟指令,大幅增加指令复杂度。当执行软件时,由虚拟机中的解释器对变换的虚拟指令进行解释执行,从而在实现原始指令功能的同时,为软件提供有效的保护。许多学者对此进行了大量的研究,提出了各种改进方法[6-8],并出现了VMProtect[9]、Code Virtualizer[10]和Themida[11]等商业软件。

除了虚拟机保护的正向研究,部分学者针对许多恶意代码利用虚拟机保护逃避检测等问题从对虚拟机保护的逆向分析角度开展研究,如文献[12]提出一种从程序执行路径中自动识别和简化虚拟化代码的通用方法,它根据虚拟环境与真实环境间切换的边界信息提取虚拟化代码,但是此方法一次只能分析一条执行路径且只能对部分代码虚拟化进行分析。文献[13]设计一个虚拟机保护的逆向分析框架,并使用多粒度动态手段和符号实现自动化指令追踪、虚拟代码提取和指令精简。此框架在过滤干扰指令的同时保留有用信息以供分析,但不能保证符号执行所提取虚拟指令的完整性。

针对当前虚拟机保护分析方法对于虚拟指令还原方面存在的不足,本文提出一种结合动态数据流和语义特征分析的虚拟机保护软件分析算法,利用动态调试将虚拟机解释器动态执行过程中的数据流信息记录成Trace文件,并提取虚拟机指令的执行轨迹,精确定位出虚拟机解释器,然后通过聚类分析识别虚拟机的解释例程,接着基于虚拟机解释过程的语义特征采用语义特征分析算法对虚拟机解释例程的指令执行过程进行局部变量表和操作数栈语义分析。最终结合操作数栈栈顶的变化值、操作数栈的读次数和局部变量区的读次数对虚拟指令进行还原。本文算法不仅能正确还原虚拟机保护程序的虚拟指令,而且大大降低逆向分析难度。

1 虚拟机软件保护技术

本文所指的虚拟机是一种软件保护架构,其主要思想是用虚拟机可识别的虚拟指令解释执行宿主程序的受保护代码,从而在完成软件正常运行的同时,降低受保护代码被恶意逆向攻击的概率[7]。虚拟机保护前后软件架构如图1所示。

图1 虚拟机保护的软件架构

在图1中,当保护后软件运行到受保护代码时,控制流转向受保护软件中嵌入的虚拟机执行。虚拟机中的初始化代码对处理器现场环境进行保存并初始化虚拟机解释器执行环境,包括对某些寄存器赋初值,将处理器的上下文信息转移到虚拟机上下文环境等。然后读取虚拟指令,并对虚拟指令进行解释执行,直到完成所有虚拟指令的解释执行。最后,还原处理器现场环境,跳出虚拟机,由处理器继续执行程序后面未保护的代码序列。

从图1可以看出,虚拟机框架核心为虚拟指令集和虚拟机解释器,其中虚拟机解释器通常是一个使用宿主机机器指令实现的轻量级虚拟指令解释器,其解释执行过程主要包含取指、译码、分派和执行4个步骤。根据组织方式不同,虚拟机解释器分为集中式解释执行[14]和线索式解释执行[12],其中集中式解释执行过程如图2所示。

图2 集中式解释执行

在图2中,虚拟机解释器有一个统一的调度器作为所有虚拟指令的调度者。开始执行后,会取第一条虚拟指令并译码后跳转到它对应的解释例程,每个解释例程执行完后,会跳转回调度器,继续执行下一个虚拟指令的取指、译码和分派后跳转到它对应的解释例程,直至整个虚拟指令序列执行完。从图2可以看出,每个解释例程结束之后须跳转至共享的取指、译码与分派例程,代码执行的控制流发生了转移,不仅增加处理器所需执行的代码量,而且破坏代码的空间局部性,增加指令缓存负担,因此集中式解释器的性能较低[14]。近年来,一些虚拟机保护软件采用了线索式解释执行方式,该方式将取指、译码和分派代码嵌入到每个解释例程的最后,从而直接由一个解释例程转移至另一个解释例程执行,如图3所示。

图3 线索式解释执行

在图3中,虚拟机解释器没有统一的调度器,每个待执行解释例程根据其执行的先后顺序被串联起来。开始执行后,会依次执行每个解释例程,在上一条指令的解释例程执行完后,控制流会跳到至下一个解释例程。从图3可以看出,每个线索式解释执行有自己独立的取指、译码和分派例程,控制转移次数大大减少,提高代码的局部性,减轻指令缓存负担,从而提升虚拟解释器的效率。

2 VMP虚拟指令逆向分析算法

2.1 动态追踪记录

获取虚拟机动态执行信息是基于语义特征的虚拟机逆向分析重要环节。为此,本文通过指令动态追踪的方法记录虚拟机执行轨迹。一个执行轨迹代表了一个程序在运行中的动态指令的有限序列 {I1,…,In}。 可将虚拟机指令的执行轨迹记录记作Trace。指令执行轨迹的记录可以使用TEMU[15]等虚拟机软件,或者IDA[16]等动态调试器。也可以使用Pin[17]等二进制插桩工具。由于IDA允许嵌入第三方插件脚本,本文选择IDA调试器来搜集虚拟机保护程序的执行轨迹。图4显示了使用IDA调试器的trace功能执行一个被虚拟机保护的函数时,trace记录了该函数执行的所有汇编指令序列。

图4 trace记录的汇编指令序列

从图4中可以看出,该Trace记录主要包含了以下信息:①完整执行的汇编指令;②被执行汇编指令的静态地址;③每条指令执行后被修改的寄存器及其被修改后的值。由于虚拟机的解释例程中往往采用了代码混淆保护,实际执行到的指令条数规模庞大。为此,基于缓存延迟写入技术,将执行期的每条指令的寄存器值和内存读写信息记录为Trace。

2.2 解释例程语义分析

本小节通过定位虚拟机的动态解释例程位置,分析这些解释例程上汇编指令的语义特征,从而确定虚拟指令的含义。虚拟机保护采用的是基于栈的虚拟机,其栈帧包括局部变量表和操作数栈两个部分。每条虚拟指令在解释执行时,根据虚拟指令的操作码,会将操作数在操作数栈中进行计算,并保存在局部变量表中。不同的虚拟操作指令对局部变量表和操作数栈的读写次数以及对操作数栈栈顶的改变都有各自的语义特征。通过对这些特征的分析,从而确定它们对应的操作码语义。图5显示了虚拟机解释例程的语义分析流程。

图5 解释例程语义分析流程

根据图5所示的分析流程,具体步骤如下所示。

(1)确定局部变量表位置

当虚拟机保护后的可执行程序运行时,其在进入虚拟机之前,程序会开辟一段内存作为局部变量表。局部变量表的内存位置在离开虚拟机之前不会变化,虚拟机通过指令“sub esp,esp_space”来开辟局部变量表的内存空间,所以通过在进入虚拟机之前的Trace中查找“sub esp,esp_space”,并获取Trace记录中该条指令执行后ESP寄存器被修改后的值,即为局部变量表的位置:local_var_table_addr=esp。

(2)识别解释例程

在大量的虚拟指令序列中,其虚拟机在完成一次解释执行前,都会表现出固有的特征,比如跳转到取指令的位置。针对采用线索式解释执行的虚拟机解释器,其通过以下两种方式实现相邻解释例程的跳转。

方式1:

xxxx: push reg

xxxx: ret

方式2:

xxxx: jmp reg

以上两种方式中,reg的值是下一跳解释例程的地址,通过在虚拟机的Trace记录中匹配以上两种情况可以进行解释例程的划分和识别。此外,每一个解释例程执行结束之前,ESP寄存器都会指向局部变量表的位置,可以以该约束作为解释例程识别的加强条件。解释例程识别算法如算法1所示。

算法1:解释例程识别算法

输入:动态追踪记录(Trace)

输出:解释例程集合(vm_handlers)

(1)创建一个新的解释例程。

(2)轮询Trace记录。

(3)获取一条新指令。

(4)将该指令添加到解释例程中。

(5)判断解释例程跳转特征模式匹配是否成功?

(6)如果成功,则将当前的解释例程存入解释例程集合(vm_handlers)中,并新建一个解释例程,跳转到步骤(2)。

(7)如果不成功,判断是否为最后一条指令?

(8)如果不是最后一条指令,跳转到步骤(2)。

(9)如是最后一条指令,结束轮询Trace记录。

如算法1所示,轮询Trace记录,如果匹配到两种解释例程的结束特征,则创建一个新的解释例程实例,之后把轮询的后续指令添加到该实例中,直至一个新的特征出现,则表示该解释例程的指令序列结束。以此类推,循环结束后,可以得到所有被执行的解释例程的实例集合(vm_hanlders)。

(3)确定解释例程执行前后所有寄存器值

根据步骤(2)识别的解释例程,确定每个解释例程执行前后寄存器的值。在虚拟机的Trace记录中,第1行会显示每个寄存器的值,之后Trace每条记录的最后1列记录每条指令执行后被修改的寄存器及其被修改后的值。根据这两个条件,可以确定每个解释例程执行前后所有寄存器的值。使用寄存器作为健值key的两个map来记录这两组状态,这里分别用vm_handler.enter_status和vm_handler.exit_status表示,如式(1)和式(2)所示

(1)

(2)

其中, key={eax,ebx,ecx,edx,edi,esi,esp,ebp}。 由于操作数栈所对应的寄存器会变化,即不同的解释例程或者同一个解释例程的开始和结束时的操作数栈所对应的寄存器可能不同,所以这里使用两个map来记录寄存器的动态值,后续步骤会利用这两个map来计算操作数栈的变化值。确定解释例程执行前后所有寄存器值算法如算法2所示。

算法2:确定解释例程执行前后所有寄存器值算法

输入:解释例程集合(vm_handlers)

输出:解释例程执行前寄存器状态(vm_hander.enter_status);解释例程执行后寄存器状态(vm_handler.exit_status)

(1)遍历解释例程集合。

(2)如果是第1个解释例程,则将第1条指令中的寄存器及其值赋给vm_handler.enter_status和vm_handler.exit_status,即vm_handler(1).enter_status=inst1_status,vm_handler(1).exit_status=inst1_status。

(3)如果不是第1个解释例程,则将上一个解释例程的exit_status赋给当前解释例程的enter_status和exit_status,即vm_handler(i).enter_status=vm_handler(i-1).exit_status,vm_handler(i).exit_status=vm_handler(i-1).exit_status,其中1

(4)遍历每个解释例程中的指令。

(5)判断是否有寄存器的值发生变化?

(6)如果有寄存器的值发生变化,则获取每条指令执行后被修改的寄存器及其被修改后的值,即vm_handler(i).chg_status,其中1≤i≤N。

(7)在vm_handler.exit_status中更新修改的寄存器值,即vm_handler(i).exit_status=vm_handler(i).chg_status,其中1≤i≤N。

在算法2中,inst1_status表示第1条指令寄存器及其值,vm_handler(i).chg_status表示当前解释例程中修改后的寄存器及其值,N表示解释例程数。

(4)确定解释例程操作数栈寄存器

操作数栈所对应的寄存器(op_stack_reg)是指对操作数栈进行寻址的段寄存器。虚拟机中的操作数栈对应的寄存器并不固定,它可能通过寄存器轮转的方式动态修改。首先确认在进入虚拟机之前,操作数栈所对应的寄存器。其初始匹配特征如下所示

mov reg, esp

通过匹配上述特征,可以找到初始操作栈所对应的寄存器,初始通过ESP寄存器传递,之后可能是 {eax,ebx,ecx,edx,edi,esi,esp,ebp} 中的任意寄存器即: op_stack_reg∈{eax,ebx,ecx,edx,edi,esi,esp,ebp}。

设每个解释例程的操作数栈入口寄存器为op_stack_entry_reg,操作数栈出口寄存器为op_stack_exit_reg,则它们的初始值分别如式(3)和式(4)所示

op_stack_entry_reg = op_stack_reg

(3)

op_stack_exit_reg = op_stack_reg

(4)

然后跟踪分析操作数栈的变化情况进一步确定每个解释例程操作数栈所对应的寄存器。虚拟机会通过以下两种方式切换操作数栈所对应的寄存器。

方式3:

xxxx:xchg op_stack_reg, reg1

xxxx:mov reg2, reg1

方式4:

xxxx:mov reg, op_stack_reg

根据以上两种方法所实现的判断条件都不是强约束,即可能出现符合条件但操作数栈对应的寄存器没有改变的情况。因此,进一步通过其变化值的范围来增加识别的准确性,操作数栈用于在解释执行时存放操作数,每次执行前后的变化范围与其支持的操作数数量和字长有直接关系。在虚拟机的实现中,算数指令不改变操作数栈栈顶值,加载和存储指令及操作数栈管理指令改变操作数栈栈顶值的范围为[-8,8]。解释例程操作数栈对应寄存器判定算法如算法3所示。

算法3:解释例程操作数栈对应寄存器判定算法

输入:解释例程集合(vm_handlers);当前解释例程的操作数栈寄存器(op_stack_reg)

输出:操作数栈入口寄存器(op_stack_entry_reg);操作数栈出口寄存器(op_stack_exit_reg)

(1)构建方式3正则表达式的特征匹配字符串。

(2)设置xchg_on标志为False。

(3)遍历解释例程集合。

(4)用当前解释例程的操作数栈寄存器给解释例程的操作数栈入口寄存器赋值,即op_stack_entry_reg=op_stack_reg。

(5)遍历每个解释例程中的指令。

(6)判断方式3的xchg指令特征模式正则匹配是否成功?

(7)如果方式3的xchg指令特征模式正则匹配成功,则设置xchg_on标志为True。

(8)判断指令的操作数1(inst.op_data1)是否等于操作数栈寄存器?

(9)如果指令的操作数1等于操作数栈寄存器,则操作数栈寄存器op_stack_reg等于指令的操作数2(inst.op_data2),即op_stack_reg=inst.op_data2。

(10)如果指令的操作数1不等于操作数栈寄存器,则操作数栈寄存器op_stack_reg等于指令的操作数1,即op_stack_reg=inst.op_data1。

(11)方式3的xchg指令特征模式正则匹配不成功,则判断方式4特征模式正则匹配是否成功?

(12)如果方式4特征模式正则匹配成功,则候选的操作数栈寄存器op_stack_reg_candidate为指令的操作数1,即op_stack_reg_candidate=inst.op_data1。

(13)判断xchg_on是否为真True?

(14)如果xchg_on为真True,则操作数栈寄存器op_stack_reg为指令的操作数1,即op_stack_reg=inst.op_data1。

(15)设置xchg_on标志为假False。

(16)计算操作数栈栈顶变化值op_stack_chg,即op_stack_chg=vm_handler.exit_status [op_stack_reg_candidate]-vm_handler.enter_status[op_stack_entry_reg]。

(17)判断操作数栈栈顶值op_stack_chg的范围是否为[-8,8]。

(18)如果操作数栈栈顶值op_stack_chg不在[-8,8]范围,则出口操作数栈寄存器op_stack_exit_reg为op_stack_reg_candidate,即op_stack_exit_reg=op_stack_reg_candidate。

(19)如果操作数栈栈顶值op_stack_chg在[-8,8]范围,则出口操作数栈寄存器op_stack_exit_reg为op_stack_reg,即op_stack_exit_reg=op_stack_reg。

在算法3中,遍历解释例程中的指令序列,使用正则表达式来匹配所述特征,如果匹配到xchg指令特征,则置xchg_on标志为True。待后续匹配到mov指令特征,认定操作数栈所对应的寄存器发生变化,记录mov的另一个寄存器为op_stack_reg,并在遍历结束后设置解释例程的op_stack_exit_reg值为op_stack_reg。如果在匹配到mov指令特征之前没有匹配到xchg指令特征,则判定其疑似发生变化并设置op_stack_reg_candidate。在解释例程指令序列一次遍历结束后,利用第(3)步骤的exit_status和enter_status判断操作数栈顶的变化值op_stack_chg。如果按照操作数栈对应寄存器没变化的情况计算其变化值超出了正常的变化范围[-8,8],则认定疑似变化的op_stack_reg_candidate是真实变化的情况,修改该解释例程的op_stack_exit_reg值为op_stack_reg_candidate。

(5)标记解释例程中对操作数栈和局部变量表读指令

现在每个解释例程的操作数栈和局部变量表所对应的寄存器已经可知,以这些寄存器作为查找条件可找到每个解释例程中对操作数栈和局部变量表读操作的指令集合,分别如式(5)和式(6)所示

vm_handler.op_stack_insts={Is1,Is2,…Isi,…}

(5)

vm_handler.local_var_table_insts={Iv1,Iv2,…Ivi,…}

(6)

解释例程中对操作数栈和局部变量表的读汇编指令特征如下所示

mov regX,[regBase+imm]

以上使用mov指令从对应的操作数栈或者局部变量表区域将数据读出来。regBase表示操作数栈或者局部变量表对应的寄存器,其中局部变量表对应的寄存器是esp,操作数栈对应的寄存器会变化,由算法4确认。imm表示一个立即数。regX表示本次读操作的被赋值的寄存器。把以上特征使用正则表达式匹配,其匹配算法描述如下所示:

算法4:解释例程中对操作数栈和局部变量表读的指令判定算法

输入:解释例程集合(vm_handlers);操作数栈入口寄存器(op_stack_entry_reg)

输出:局部变量表读指令(local_var_table_insts);操作数栈读指令(op_stack_insts)

(1)遍历解释例程集合。

(2)根据式(3)用操作数栈入口寄存器构建操作数栈读指令的正则表达式匹配特征。

(3)遍历每个解释例程中的指令。

(4)判断内存地址的读写操作。

(5)如果为读内存地址,则进行局部变量表读指令特征正则匹配。

(6)如果局部变量表读指令特征正则匹配成功,则记录该局部变量表读指令vm_handler.local_var_table_insts.append(i)。

(7)否则,进行操作数栈读指令特征正则匹配。

(8)如果操作数栈读指令特征正则匹配成功,则记录该操作数栈读指令vm_handler.op_stack_insts.append(i)。

(6)分析解释例程对应虚拟指令

基于以上步骤,可以进一步分析每个解释例程对操作数栈栈顶的变化值(op_stack_chg),对操作数栈的读次数(op_stack_read_times)和局部变量表的读次数(local_var_table_read_times)。其中,对操作数栈栈顶的变化值可以根据式(1)和式(2)中的vm_handler.enter_status和vm_handler.exit_status通过式(7)进行计算

vm_handler.op_stack_chg=
vm_handler.exit_status[vm_handler.op_stack_exit_reg]-
vm_handler.enter_status[vm_handler.op_stack_entry _reg]

(7)

另外两个值根据步骤(5)获取的指令集合可知,其计算分别如式(8)和式(9)所示

vm_handler.op_stack_read_times=
length(vm_handler.op_stack_insts)

(8)

vm_handler.local_var_table_read_times=
length(vm_handler.local_var_table_insts)

(9)

根据这3个数据,可以推断该解释例程所对应的虚拟指令,将虚拟机的虚拟指令集进行简化和宽归类,分别为加载/存储指令、算术指令、操作数栈管理指令和空指令。其中,加载/存储指令涉及到一个变量或常量在局部变量表和操作数栈之间的传递。针对这类指令,它的典型特征分别是:vm_load加载指令将一个本地变量加载到操作数栈中,那么意味着对操作数栈会有一个压栈操作,即体现在操作数栈顶变化值为-4或-8,具体的绝对值与压栈变量的长度有关;另外,还会对局部变量区有1次读操作。所以,当分析一个解释例程对操作数栈栈顶的变化值为负数且对局部变量表的读写次数不为0,那么就推导其是一个vm_load加载指令。其中,当操作数栈顶变化之为-4时,为vm_iload表示将一个4字节长度的变量加载到操作数栈中,当操作数栈顶变化值为-8,为vm_lload表示将一个8字节长度的变量加载到操作数栈中。对应的,vm_store存储指令有一个出栈操作,操作数栈的变化值为4或8,且对局部变量区有1次写操作,其中vm_istore表示将一个4字节长度的数值从操作数栈存储到局部变量表中,vm_lstore表示将一个8字节长度的数值从操作数栈存储到局部变量表中。vm_const指令类似vm_load指令,不同的是加载的是常量,所以区别在于对局部变量区没有读写操作。其中,vm_sipush表示将一个2字节的常量加载到操作数栈中,vm_iconst表示4字节的常量加载到操作数栈,vm_lconst表示8字节的常量加载到操作数栈。

算术指令vm_math的语义特征是它会去读操作数栈而不改变操作数栈顶。读的次数根据算术符需要操作数的个数。比如取反指令需要1个操作数,加法指令需要2个操作数,除法指令需要3个操作数。分别使用vm_math1、vm_math2 和vm_math3表示有1、2和3个操作数的情况。

操作数栈管理指令vm_pop用于直接控制操作数栈,它会将变量从操作数栈出栈从而改变操作数栈顶值,且对局部变量表没有读写操作。其中,当出栈变量的字长分别为2、4、6和8时,它们对应的虚拟指令分别为vm_sipop、vm_ipop、vm_T6pop、和vm_dpop。

空指令vm_nop在当操作数栈栈顶变化值为0、操作数栈读写次数为0且局部变量表读次数为0时,判定此解释例程没有执行任何操作。

根据如上所述,解释例程的虚拟指令还原算法如算法5所示。

算法5:解释例程的虚拟指令还原算法

输入:操作数栈栈顶的变化值(op_stack_chg);对操作数栈的读次数(op_stack_read_times);对局部变量表的读次数(local_var_table_read_times)

输出:解释例程虚拟指令(byte_code)

(1)判断op_stack_chg操作数栈变化范围是否为-2,-4或-8?

(2)如果是,则进一步判断局部变量表读次数local_var_table_read_times是否大于0?

(3)如果局部变量表读次数local_var_table_read_times大于0,虚拟指令为Tload。

(4)如果局部变量表读次数local_var_table_read_times小于或等于0,虚拟指令为Tconst。

(5)如果不是,则判断op_stack_chg操作数栈变化范围是否为0?

(6)如果是,判断操作数栈的读次数op_stack_read_times是否大于0?

(7)如果操作数栈的读次op_stack_read_times数大于0,虚拟指令为Tmath。

(8)如果操作数栈的读次数op_stack_read_times小于或等于0,虚拟指令为Tnop。

(9)如果不是,则判断op_stack_chg操作数栈变化范围是否为2、4、6或者8?

(10)如果是,则进一步判断局部变量表读次数local_var_table_read_times是否大于0?

(11)如果局部变量表读次数local_var_table_read_times大于0,虚拟指令为Tstore。

(12)如果局部变量表读次数local_var_table_read_times小于或等于0,虚拟指令为Tpop。

(13)op_stack_chg操作数栈变化都不属于以上范围,则虚拟指令为TUnknown,表示为未知虚拟指令。

3 测试与评估

为测试和评估本文算法,首先构建了虚拟机保护和逆向环境分别对合成测试用例和第3方的测试用例进行实验。然后,通过分析逆向还原后的虚拟指令在给定对应输入下的执行逻辑能否正确,并是否获得和虚拟机保护前后相同的输出结果,来验证逆向还原结果的正确性。最后,分别从虚拟机保护前后和逆向还原后的指令数量比较和简化效果进行有效性评估。

3.1 测试环境

采用以下软硬件环境进行测试:i5-4590 3.30 GHz处理器,Windows7操作系统,VS2017编译环境,VMProtect版本v3.3.1和IDA版本v7.0。此外,用汇编语言和C语言实现待保护的原始代码,用Python语言实现本文逆向分析算法,测试流程如图6所示。

图6 测试流程

在图6中,每个测试步骤的左侧表示该步骤输出的内容,指向下一个测试步骤的虚线表示作为下一个步骤的输入。其中,前2个属于虚拟机加固保护的步骤,后2个步骤是逆向分析的过程。

3.2 正确性测试与评估

正确性是指逆向还原后虚拟指令执行的逻辑与原汇编指令相比是否发送变化。根据逆向工程的定义,首先得保证经过逆向还原处理后的程序在功能上与原程序要保持相似或者一致,所以在逆向还原处理过程中必须得充分考虑每一步操作对程序功能将会造成的影响,即应用程序是否能正确进行逆向还原转换。根据应用程序常见方法的内部逻辑特点及类型,构建funcAdd函数作为测试用例,其接受2个参数,并用汇编指令实现加法操作并返回结果。为了更加直观地测试分析,直接以汇编指令作为原始输入。采用VMProtect的API开始保护处标记VMProtectBegin()和结束保护标记VMProtectEnd()指定虚拟化保护的位置区间,其测试代码如下所示。

__declspec(noinline)

int funcAdd(int a, int b) {

VMProtectBegin("funcA");

__asm

{

mov eax, b

add a, eax

}

VMProtectEnd();

return a;

}

使用VS2017编译Release版本的可执行程序,把该可执行文件作为输入,使用VMProtect v3.3.1进行代码虚拟化保护,其界面如图7所示。

图7 VMProtect界面

VMProtect执行后输出虚拟机保护的可执行程序,把该可执行程序作为输入打开IDA,使用IDA Debugger调试该可执行程序,并通过trace记录该可执行程序的运行指令。以参数7和38调用funcAdd函数,trace记录共运行了4273条指令。然后把该trace文件作为输入,使用本文逆向分析算法进行分析,分析结果如下所示。

358: istore_11 0xb50000

413: istore_9 0x21

473: istore_3 0x2bfb38

522: istore_4 0x2dccc0

578: istore_7 0x5aa56314

631: istore_2 0x206

681: istore_10 0x2

729: istore_8 0x15

778: istore_12 0x7efde000

824: istore_0 0xf8a785

870: istore_5 0xc19bf2a7

922: iconst 0x2bfb2c

957: iconst 0x4

1009: math_2 0x2bfb2c <> 0x4 = 0x2bfb30

1049: istore_0 0x216

1104: dpop

1131: iload_3 0x2bfb38

1177: iconst 0xfffffff8

1233: math_2 0x2bfb38 <> 0xfffffff8 = 0x2bfb30

1273: istore_6 0x217

1333: math_1 ldr 0x21 = [0x2bfb30]

1373: istore_14 0x21

1422: iload_14 0x21

1475: iload_3 0x2bfb38

1525: iconst 0xfffffffc

1581: math_2 0x2bfb38 <> 0xfffffffc = 0x2bfb34

1610: istore_13 0x213

1666: math_1 ldr 0x15 = [0x2bfb34]

1708: math_2 0x21 <> 0x15 = 0x36

1747: istore_13 0x206

1800: iload_3 0x2bfb38

1851: iconst 0xfffffffc

1903: math_2 0x2bfb38 <> 0xfffffffc = 0x2bfb34

1932: istore_10 0x213

1982: dpop str [0x2bfb34] = 0x36

2012: iconst 0x4010b3

2067: iload_11 0xb50000

2117: math_2 0x4010b3<>0xb50000 = 0xf510b3

2158: istore_15 0x202

2206: iload_12 0x7efde000

2268: iload_8 0x15

2317: iload_14 0x21

2371: iload_13 0x206

2426: iload_7 0x5aa56314

2476: iload_4 0x2dccc0

2531: iload_3 0x2bfb38

2577: iload_9 0x21

在以上分析结果中,每一行代表一条指令,其中第1列表示行号,第2列表示指令名称,后面是具体的操作数。以上结果中的 “1708:math_20x21<>0x15=0x36” 虚指令表示2个操作数的算术指令,带入加法运算验证可知其确为加法指令。从该虚指令向上分析,根据记录 “1333:math_1 ldr 0x21=[0x2bfb30]” 和 “1666:math_1ldr 0x15=[0x2bfb34]” 可知,这2个操作数都是使用ldr操作码分别从不同的内存区域中读取得到。从该虚指令向下分析,根据记录 “1982:dpop str[0x2bfb34]=0x36” 可知,其运算结果使用str指令存入0x2bfb34内存区域。如上所述,根据本算法分析出的解释例程序列,可从结果正确推出原始逻辑是加法操作。

3.3 有效性测试与评估

为了证实本文算法逆向工程分析的有效性,下面进一步使用构造函数funcFor、冒泡排序算法bubble和base64编码算法作为测试用例进行测试,其中funcFor的代码如下所示。

int funcFor(int a,int b) {

VMProtectBegin("funcFor");

int c=b/a;

for (int i=0;i

a+=b+i;

}

VMProtectEnd();

return a;

}

funcFor函数编译后有12条汇编指令,即VMProtect保护12条汇编指令。然后以参数a=7和b=38调用funcFor函数,运行时其内部for的逻辑循环了5次,整个函数共执行了42条汇编指令;另外,再以参数数组 “{7,11,27,26,55,42}” 调用bubble冒泡排序算法,以参数"JEhu-VodiWr2/F9mixBcaAZTtjx4Rs9cJDLbpEG8i7hPKswcFdsn6MWwINP+Nwmw4AEPpVJevUEvRQbqVMVoLlw=="调用base64编码算法。

上述3个函数的测试结果分别如表1和表2所示,其中表1表示静态信息,包括保护前后可执行程序的大小和被保护的指令个数。表2表示动态信息,为VMP保护后程序的逆向分析结果,包括原始运行汇编指令数量、保护后运行的汇编指令数量,分析后得到的虚指令数量和算法分析的时间。

表1 静态信息

表2 动态信息

从表2可以看出,原始指令执行的数量与虚拟机保护后执行的指令数量基本呈正相关,但bubble和base64并不呈正相关,虚拟机保护后运行的指令数量与分析后的指令数量呈正相关,分析时间与保护后的指令数量呈正相关。VMProtect虚拟化转换后,汇编指令数量增加量级在500~2500倍左右,经过本算法分析简化后,指令数量能够简化50倍左右。虚指令数量相比于原始指令数量,可获知VMProtect在作虚拟化转换时,指令量级增加量在10~50倍左右。因此,基于化简后的虚指令样本作进一步分析,能够极大地降低逆向分析的难度。

4 结束语

由于虚拟机保护采用代码虚拟化技术加固软件,现有的静态指令反汇编及控制流还原等方法逆向分析被虚拟机加固的代码时存在较大困难。本文提出通过动态Trace数据流分析、聚类分析和特征识别等手段,获取解释例程语法语义信息,并将虚拟机动态解释执行的解释例程还原成相应的虚拟指令。实验结果表明了本文算法的正确性和有效性,能够在保持虚拟指令语义正确性的同时,大大较少逆向分析的指令,从而减轻逆向分析的难度。下一步工作将在对指令进行追踪记录时基于处理器硬件直接追踪和记录执行过程中的指令,进一步提升动态追踪的记录效率。其次,在进行虚拟机解释例程的划分识别过程中,可以利用机器学习中的聚类方法对解释例程的指令特征进行聚类分析,进一步提高解释例程识别的准确性。此外,虚拟机解释执行过程的局部变量表和操作数栈语义特征作为本文算法的基础,后续仍需对其语义相关的指令识别算法进行优化和改进,进一步提高本文算法的覆盖率和完备性。

猜你喜欢
寄存器逆向指令
逆向而行
逆向思维天地宽
《单一形状固定循环指令G90车外圆仿真》教案设计
Lite寄存器模型的设计与实现
二进制翻译中动静结合的寄存器分配优化方法
移位寄存器及算术运算应用
中断与跳转操作对指令串的影响
一种基于滑窗的余度指令判别算法
MAC指令推动制冷剂行业发展
Lx5280模拟器移植设计及实施