专栏名称: 看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
目录
相关文章推荐
计算机与网络安全  ·  网络安全资料库 ·  14 小时前  
看雪学苑  ·  欢迎投递简历~ ·  昨天  
51好读  ›  专栏  ›  看雪学苑

VMProtect3.5.1脱壳临床指南

看雪学苑  · 公众号  · 互联网安全  · 2025-06-05 17:59

正文

请到「今天看啥」查看全文



①:(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
              [2push reg
pop reg 

⑥:(not)(add (not)reg1, reg2)    => cmp reg1, reg2 或者 sub reg1reg2
⑦:(or/and (not)exp, (not)exp)   => (not)exp (注:exp是表达式)
⑧:(and/or/add numb1numb2)    直接计算    (注:numb1numb2表示数字)
⑨: 寄存器折叠:  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"

// 需要vmp保护的函数
__declspec(naked) intnaked_function(int a) {
VMProtectBegin("naked_function");
// immediate test01
    _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



第二次扫描后,指令还原的状态:








请到「今天看啥」查看全文