正文
①:(or/and (not)reg, (not)reg) => (not)reg
②:(not (and (not)reg1, (not)reg2)) => or reg1,reg2
③:(not (or (not)reg1, (not)reg2)) => and reg1,reg2
④:(not)((not)reg) => reg
⑤: 消除死指令: [1] mov reg,reg
[2] push reg
pop reg
⑥:(not)(add (not)reg1, reg2) => cmp reg1, reg2 或者 sub reg1, reg2
⑦:(or/and (not)exp, (not)exp) => (not)exp (注:exp是表达式)
⑧:(and/or/add numb1, numb2) 直接计算 (注:numb1、numb2表示数字)
⑨: 寄存器折叠: mov reg2,reg3; mov reg1,reg2 => mov reg1,reg3
上述优化规则并非全部在特征03中运算时执行。例如,优化规则⑥实际上是一个指令还原的匹配规则,其前式在运算后会被放入vm栈顶 [esp+xxx] 的某个位置。这一操作对应特征 01,因此可将优化规则⑥融入处理特征 01 的函数中。
虚拟寄存器
这里规定如下:若从 [VmDataPtrReg] 区域获取立即数(如 numb1),并将其放入vm栈顶 [esp+0x10] 位置(假设),若该位置为空,则需为此位置创建虚拟寄存器,并生成一条还原指令 mov VirtualReg001, numb1,其中 VirtualReg001 为该位置新创建的虚拟寄存器。下面将通过示例b-01-03来说明虚拟寄存器的创建及消除过程。
示例b-01-03代码清单:
#include
#include"VMProtectSDK.h"
__declspec(naked) intnaked_function(int a) {
VMProtectBegin("naked_function");
_asm {
mov ecx,0x333
ret
}
VMProtectEnd();
}
intmain(){
int x = naked_function(0x666);
if (x == 0x777) {
std::cout <"succeed!!! " << std::endl;
} else {
std::cout <"failed!!! " << std::endl;
}
getchar();
return 0;
}
在naked_function 函数被虚拟机保护后,现在指令还原的效果如下:
从以上的日志信息可知,存在一条 mov ecx, VirtualReg001 的还原语句。在退出vm虚拟机环境时,pop reg1 指令隐含的语义动作等价于 mov reg1, reg2/imm/VirtualRegXXX。当 reg2 与 reg1 相同时,可以使用优化规则⑤消除该指令,即移除 mov reg1, reg2。对于虚拟寄存器而言,仅当最终出现 mov reg, VirtualRegXXX这种形式时,VirtualRegXXX才被视为有解。例如,上面VirtualReg001 对应指令为 ecx,因此可以用ecx替换VirtualReg001出现过的地方。
这一简化过程可通过脚本或插件自动替换实现,或通过后续所述的二次扫描处理。以下是通过二次扫描后,指令还原的效果:
分支
vm虚拟机在处理JCC指令时,会将判断逻辑拆分为多个操作。例如jl指令:
JL 跳转的条件是 SF ≠ OF,即溢出标志和符号标志不同时为0。
虚拟机(VM)将分别判断 SF 和 OF 标志位(如下所示),并根据标志位信息决定是否跳转。
(not)(or(not)0x80,(not)eflags) => and 0x80,eflags
(not)(or(not)0x800,(not)eflags) => and 0x800,eflags
要判断是否是jl指令,只需要在特征01中判断来自vm栈底来的表达式是否是为"and 0x80,eflags"。遇到JCC指令时,需保存寄存器和堆栈副本的信息。解析至返回地址后,检查是否仍有未处理的分支。若有,则恢复之前保存的寄存器和堆栈副本,并切换至另一个分支,依此逐一解析所有指令。需要注意的是,强行切换分支,可能会引发内存访问异常等问题,此时无需惊慌。因为提前扫描确认了该操作的特征类型,异常发生时可直接设置EIP跳过异常地址。
为统一处理 JCC 指令的程序流程,设定规则为:第一次不跳转,第二次跳转。此设计的优势在于,还原后的指令存放的数据结构是动态数组,第二次跳时,还原的指令可直接附加到数组末尾,而无需重新去构建控制流图(CFG)。
以下示例b-01-04展示了测试jl指令的代码清单,以及第一次扫描后指令还原的效果。
#C0C0C0;text-decoration:underline">示例 b-01-04
二次扫描后,指令还原的效果:
循环
循环与分支的区别在于循环至少包含一条回边,但两者的处理步骤一致。唯一不同之处是需额外添加标记:在解析过程中,为每条还原指令附上当前 VmDataPtrReg 值的标记。获取还原指令后,与之前还原的指令对比,若 VmDataPtrReg 值相同,则表明循环已找到落脚点,并在该指令前插入一个标签,然后再次检查是否仍有未处理的分支。若无未处理分支,则指令还原流程结束。至此,整个指令还原框架搭建完成。
示例b-01-05展示的是测试循环的代码清单,以及第一次扫描后指令还原的效果。
#C0C0C0;text-decoration:underline">示例 b-01-05
二次扫描后,指令还原的效果:
02. 疑难问题
二次扫描
二次扫描,具体是指重新调试目标程序,与第一次扫描过程相同,唯一区别在于利用了第一次扫描时收集的信息。现在我们来讨论二次扫描的必要性。先看示例b-02-00,该示例展示了测试imul指令的代码清单,以及第一次扫描后指令还原的效果。
#C0C0C0;text-decoration:underline">示例 b-02-00
注意观察,在还原imul指令的过程中,多出了一条
mov edx, 469670
指令。
成因分析
:
imul指令在虚拟机(VM)中会先计算第二个和第三个操作数,中间结果被置于栈顶[esp+0x1C](假定),该地址实际上隐式映射到edx寄存器(此映射关系仅在退出vm环境时,解析 pop edx 指令才会明确),然而,原先[esp+0x14]位置映射的edx寄存器关系未被清除,导致一个数值被放入[esp+0x14]位置时,触发了一次 mov赋值操作。
二次扫描需解决的问题
:
在还原imul指令的过程中,需提前识别[esp+0x1C]位置映射到edx寄存器,并且及时清除edx寄存器在[esp+xxx]中多余的映射关系。因此,在第一次扫描时,信息收集显得尤为重要。
第一次扫描时,收集信息的具体步骤
:
为每个置于栈顶 [esp+xxx] 的表达式分配唯一编号。在特征 06 退出虚拟机环境时,遇到 pop reg1 指令,若还原指令非 mov reg1, reg1,则记录三项数据:第一操作数的寄存器号数、第二操作数的编号及运算符类型(即 mov)。
下面为二次扫描后指令还原的效果,可见冗余(或错误)的指令已被消除:
注:由示例b-02-00可以看到,虚拟寄存器也可以用来处理一些特定类型的操作数。
恒等变换
在恒等变换中,最典型的指令就是
xor
指令。在数理逻辑中,有:
A⊕B = (A∧¬B)∨(¬A∧B)
= ((A∧¬B)∨¬A)∧((A∧¬B)∨B)
= (¬B∨¬A)∧(A∨B)
下面示例b-02-01展示了
xor
指令测试,及指令还原后的效果。
#C0C0C0;text-decoration:underline">示例 b-02-01
从打印的日志信息上看,可以观察到 xor ecx, edx 指令还原的整个信息流。
现在整理如下:
or (not)edx,(not)edx => (not)edx 下条指令的第二个操作数
or (not)ecx,(not)((not)edx) => or (not)ecx,edx 第五条指令第二个操作数
or (not)ecx,(not)ecx => (not)ecx 下条指令的第一个操作数
or (not)((not)ecx),(not)edx => or ecx,(not)edx 第五条指令第一个操作数
or (not)(or ecx,(not)edx),(not)(or (not)ecx,edx) => or (and (not)ecx,edx),(and ecx,(not)edx)
表达式 or (and (not)ecx, edx), (and ecx, (not)edx) 对应逻辑公式为 (A∧¬B)∨(¬A∧B),因此指令最终还原为 xor ecx, edx。
待定指令
若不同指令在还原时使用了相同匹配规则,导致无法区分,则称为待定指令。示例 b-02-02 展示了 cmp 和 sub 指令的测试情况,仔细观察右侧框住的日志信息,可以看到 cmp 和 sub 指令在还原时使用了相同的匹配规则。
#C0C0C0;text-decoration:underline">示例 b-02-02
cmp/sub reg1,reg2 对应的匹配规则,现整理如下:
or (not)reg1, (not)reg1 => (not)reg1 下条指令的第一个操作数
add (not)reg1, reg2 下条指令的操作数
or (not)(add (not)reg1, reg2 ),(not)(add (not)reg1, reg2 ) => (not)(add (not)reg1, reg2)
取反操作有:(not)reg1 = 1-reg1,因此
(not)(add (not)reg1, reg2) = 1-((1-reg1)+reg2) = reg1-reg2,
即对应的是优化规则⑥:(not)(add (not)reg1,reg2) => sub/cmp reg1, reg2
如何消除 cmp/sub 的待定状态?
答:方法很简单。上述结果会存储至vm栈顶 [esp+xxx] 的某个位置。每次操作 [esp+xxx] 时,需判断其中的表达式是否为待定状态。若为待定状态,则读取 [esp+xxx] 时对应 sub 指令,而写入 [esp+xxx] 或不操作 [esp+xxx] 时对应 cmp 指令。
此外,待定指令还包括 test/and reg1, reg2。其对应的匹配规则,整理如下:
or (not)ebx, (not)edx 下条指令的操作数
or (not)(or (not)ebx, (not)edx), (not)(or (not)ebx, (not)edx) => and reg1, reg2
因此匹配规则为:and reg1, reg2 => test/and reg1, reg2
CMOVcc
CMOVcc 指令在保护过程中会被转换为 JCC 指令。初次还原后,观察还原代码可发现 CMOVcc 指令的痕迹较为明显。尤其在第二次扫描后,更为明显。示例 b-02-03 展示了 cmovc 指令测试的代码清单,及第一次扫描后的指令还原效果。
#C0C0C0;text-decoration:underline">示例 b-02-03
第二次扫描后,指令还原的状态: