吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 97|回复: 1
上一主题 下一主题
收起左侧

[原创] 谈谈我对VMProtect代码保护”通解”的一点看法

[复制链接]
跳转到指定楼层
楼主
lifeengines 发表于 2026-5-9 12:03 回帖奖励
本帖最后由 lifeengines 于 2026-5-9 12:15 编辑

偶然一阵醒意袭来,偶然进到论坛看到有两篇关于VMProtect的技术文章让我想起了多年以前我也曾花了一段时间研究过VMProtect还写了不少相关的代码,于是惊坐起翻起了许久未曾改过的相关代码仓库甚是感慨遂有了这篇文章这篇文章不会有过于详细的技术分享但也并非标题党,文题中的”通解“就是指的对于VMProtect甚至一系列包括Themida在内的许多代码保护方案的通用解法,之所以打引号是以为所谓通用解法也只是指解题思路基本相同。要寻求通解那么我们就需要先说下这类代码保护的基本思路和架构,这里我就简化:VMProtect这种代码虚拟机是栈虚拟机(Themida的是堆虚拟机)其工作逻辑是在当前线程的栈当中找空间来储存代码执行的Context上下文,最主要的就是要储存被保护代码执行所需要的寄存器值,因此VMProtect的Context在最终代码执行的时候是和虚拟机代码本身的栈数据混搭穿插在栈中间的,由于VMProtect在代码保护的时候就要追踪栈空间的变动,因此Context的分布还是随机动态调整的,这进一步增加分析难度。VMProtect的代码保护采取的是将原始代码汇编编译转换成一套自定义的opcode然后每个opcode有它的一个或者多个(代码可以不同但是逻辑功能必定相同)opcode的执行代码片段就是所谓的”handler”,这些handler在开发的时候需要针对opcode进行针对性设计,所以其变化演进相对很慢(这就是VMProtect这类代码虚拟机比较大的弱点),将代码转换成它自己的opcode序列以后,就形成了它自己的一套汇编代码,然后这段汇编代码还可以当成普通汇编那样处理,要么继续生成另外一套opcode套娃要么堆opcode进行等价替换变形插花(思路都是在代码量执行效率当中取个平衡),opcode编码完毕以后,就有一套对应的opcode处理代码,此时将ocpode的处理代码变形插花然后联合框架代码就形成了最的程序。了解其思路以后,我们就可以来设计我们的“通解”思路了,我这里只给出一个针对实现理解代码和最终干预代码执行流程的”通解“,做到完整代码还原只给出大体思路。(注意:对于绕过反调试这种和代码保护无关的方法本文就不涉及了,尽管r3下也有通解,但是这些方法公开会很快被针对)首先针对于比较简单的流程干预思路,主要有以下几个步骤:
  • 拿到代码执行记录
  • 简化代码
  • 染色还原context偏移
  • 匹配Hanlder特征,还原opcode伪代码执行记录
  • 简化opcode
  • 带opcode伪代码调试确定干预意图
  • 接管代码执行进行干预
  • 重编译opcode到汇编(实现代码还原)
下面分步骤介绍:
  • 拿到代码执行记录
对于很多想学习VMProtect的来说,其实这一步并不顺畅,因为公开的工具太少,x64dbg自带的追踪又太慢,对VMProtect这种Handler代码越来越大越多的情况用更快的代码执行记录方法会节省很多时间,我个人首推JIT转译代码虚拟机,也就是我自己使用的方法,在虚拟机代码入口接管执行流程,然后将代码放到一个堆代码虚拟机中执行,这个虚拟机的opcode就是原始代码,hander就是转译后的代码本身,寄存器存放位置就是实际的寄存器位置以及一个小堆空间,用于存放虚拟机执行占用的1-2个寄存器),这种方法好处非常多,实现简单(不需要编写handler,不需要定义opcode),效率够高,支持sm代码自修该,由于是jit也支持动态条件干预(比如对于循环执行检测直接跳过重复代码记录)。
        拿到代码记录以后我们就会有一份从代码进入虚拟机到执行完毕退出虚拟机的完整代码执行记录,类似:

执行记录包括了常用寄存器,eflags值和代码地址以及具体执行的代码二进制数据(不少情况下,能拿到完整的代码执行记录就已经能够实现软件破解效果了,只需要控制入口数据比如用虚拟机快照功能跑出授权和未授权的代码执行记录,通过文本对比工具一眼就能看到具体判断逻辑处从而定位出准确的代码干预点,然后在这个点反转控制流就实现破解,根本无需分析一行汇编代码),有了这些数据就来到了第二步 2.简化代码拿到代码执行记录以后,这个记录可能是海量的,超多的重复执行代码,而且是经过高度插花,混淆,变形替换的代码,直接分析是不可能分析的,打死都不能这样分析,因此我们要想办法简化代码,由于代码执行记录是自上而下的且带有寄存器值,因此有通用简化的办法,这里最核心的优化手段就是等价替换,没有什么复杂的技术,但是实现方法就各有各的办法了,我使用的方法是自己写了一个轻量的汇编匹配替换语言,大概长下面这个样子:
  
这里再贴点我当时写代码的一些用法注释,大概就能明白我的意思了:
[C++] 纯文本查看 复制代码
/*花型语法:
-----------------:花型分割线,至少5个-,分割以后可以定义多个花型
花型选项,包括:
.mode        isReverseMode        //花型的匹配模式为倒叙或则顺序,用于过滤花型
.x86                        //cpu指令集类型,
.x86-64                        //花型作用的cpu指令集类型,如果不设置,那么支持所有
.max <integer>                        //花型最大匹配的指令数量
.uuid        <integer>        //花型的唯一id号
.off        //禁用花型,默认为启用
.on                //启用花型
.comment        <string>        //用于ollydbg调试时自动注释
.pattern        <string>        //用于DNA时传递给分析程序使用,patternASM只作传递而已,没有任何处理
.solo                                        //用于调试花型时使用,表示实例范围内只启用该花型
//表示该花型必须包含给定的16进制数据,主要用于在大范围数据当中加速搜索,例如:
.hexsig{1000,200} 55 13 ?? 1C
表示先搜索55 13 ? 1C为特征的数据,然后在匹配结果的前1000字节和后200字节范围内进行花型搜索
?表示1个任意字节,
接着就是匹配花型:
=表示表达式,可以通过简单的表达式进行简单的计算
如imm1=imm2+imm1
::表示指令的匹配属性,如:
::{*}{+}{?}
*:表示可以重复匹配该行无数次
?:表示只匹配0或者1次
+:表示至少匹配1次
-:表示尽可能少的匹配
{repeat,1,2}:表示重复次数范围,如同正则表达式规则
stack:表示指令必须不影响栈平衡
eflags:表示指令必须不影响eflags
user1-user4,提供4个用户标记的flags,
如果指定了{user1-4}属性,那么指令匹配必须要在testInst函数调用时给定了相应的TIF_OPT_USERFLAG1参数
{mCF,mPF,mAF,mZF,mZF,mSF,mIF,mDF,mOF}:表示修改这一系列标志位
{tCF,tPF,tAF,tZF,tZF,tSF,tIF,tDF,tOF}:表示测试这一系列标志位
{mEflags,tEflags}:表示有Eflags修改,或测试
{$1,$2}info前缀表示记录该行匹配的详细记录,这在DNA匹配时相当有用,匹配成功后,可以通过获取info的信息来分析具体的匹配情况
相应的!符号,表示取反,即{!mCF}表示没有修改CF
这些标志都是基于指令的,与实际执行无关,而eflags,stack,属性标记是基于指令执行结果的

需要注意的是,一行指令只允许有1个::标记,即使使用了||连接,也就是
{?}::mov || {+}::lea 是非法的,{+}属性并不会起作用,{?}会作用与整行
寄存器有几个伪寄存器,分别为:cax,cbx,ccx,cdx,csp,cbp,csi,cdi,分别对应不同平台的eax,rax,根据patternASM初始化的位数而定

指令属性:
{size1}{mnem12}{strict}{!}{8}{16}{32}{64}{cs}{ds}{es}{fs}{gs}{ss}
{cs}-{ss}可以强制匹配指定段的指定,替换指令则可以强制替换指令的段,如果不指定,那么替换指令的段属性将从原指令复制
{0}表示按处理器指针大小匹配
any表示任何指令
jcc表示跳转指令,匹配distorm的FC_CND_BRANCH
如any reg1,imm1
{size1}{mem1}{reg1}均属于变量定义

操作数属性:
如push {size1}{16}reg1,imm1
{size1}引用的size变量
{!}not
{*}所有op
如果需要匹配可以使用relax属性
ref和!ref还支持显示模式,如下:
{ref_01234abc}        操作数需要引用rax,rbx,rcx,rdx,r10,r11,r12当中的一个
{!ref_89}操作数不能引用r8,r9
{ref_123_reg1_reg2}表示同时引用reg1,reg2
所有ref系列都不能作用于imm,因为无意义

{ref_xxx}
{!ref_xxx}作用于指令的时候表示匹配或不匹配指令的隐式寄存器。这和作用于操作数时意义是不一样的。

express不能应用relax,size这样的属性如
call {relax}$+5可以编译成功,但是relax属性会被忽略

any reg1,mem默认匹配第一个参数是reg类型,但是一旦使用了ref或!ref将导致第一个参数仍然测试非reg类型的指令,因为内存操作数仍然可能引用reg1

{8}{16}{32}{64}
{cover}        //表示匹配的寄存器覆盖了初始寄存器,如
{stack}::{strict}any reg1 ->nop
pop {cover}reg1,表示此时的寄存器必须包含reg1
{!cover}
{belong}
如果cover,只是判断依据是被包含
{!belong}
{relax} relax关键字表示松弛比对,目前的含义为不比较操作数的大小

操作数类型
op1:表示所有操作数
reg1:寄存器
imm1:立即数
mem1:寻址
如果操作数类型不跟数字,直接未reg,imm,mem那么表示只是op类型匹配。不匹配具体细节
如 any {*}{!}mem表示所有没有内存寻址操作数的指令

使用例子:
push eax        直接匹配
push reg1,凡是push 寄存器的都匹配
push {!ref}esp

替换花型可以使用{original}表示直接替换为原始指令,如
push reg1 ->{original} && nop
替换指令支持的属性:
{original} {!ref}
替换指令支持的操作数类型
rnr1_0123456789abcdef_nr
0-f后缀是寄存器选择范围,
nr是表示,随机选择的寄存器还需要排除原指令使用的寄存器
{rnr1}表示随机选取一个寄存器,当第一次定义的时候可以利用rnr1_0123-f定义随机选取的范围,从eax,ebx,ecx,edx,ebp,esp,esi,edi,r8-r15分别代表0-15寄存器
当第二次使用时,就不需要指定了,可以直接通过rnr1引用

注意替换指令当中,是用&& 连接多条指令,而非||
//配合.hexsig可以加速花型搜索
//例如当我们知道花型当中至少包含1个字节的具体数据时
//那么大范围内存搜索就可以通过先搜索hex特征,然后
//将范围缩小,这样可以呈指数型的加快搜索速度
.hexsig{1000,200} 55 8B ? CC
mov reg,imm32        -> push imm32 && pop reg


{stack}::{mnem1}any op1,reg1        ->{mnem1}any op1,imm1
mnem1!=xchg|cmpxchg
imm3=imm2-imm1
imm4=imm3+2

$n对象目前支持的属性:
$n.ip表示匹配指令的ip,如果匹配集合为空,那么失败
如
{$1}::call $+5
jmp $1.ip
那么jmp $1.ip会匹配到jmp <call指令>

2019-01-22
*        支持表达式类型了,如call $+5
        $表示当前指针,由于语法关系,表达式不能以特定字符开头,如call 5+$就是非法的,因为5可以被解析为正常指令
        $1-$n表示info变量,可以通过如call $1.ip来访问,因此$1实际上可以当成标签来用
 *        支持$n变量,如$1.ip
 *        支持jcc指令,匹配所有条件跳转
*
*/


有了通用的语义以后,我们就可以不断的积累各种等价替换花型,然后堆代码执行等价替换,无论VMProtect经过多少混淆,只要等价花型积累到一定数量,最终都能大大简化代码,况且我们这里简化代码的目的并非是要让代码能够清晰可读,而是只要简化到能够匹配出hander特征就够了,这样我们就能知道代码对应的hander是哪个,至于代码如何巧妙的执行的,我们其实并不关心,比如当代码简化到下面情况,就差不多了:
[Asm] 纯文本查看 复制代码
00007FF7825429E4 JMP        R9                  
00007FF7826B37CD MOVZX      EDI, BYTE [RBP-0x1] 
00007FF7826B37D2 MOV        R8D, 0x1703c7ae     
00007FF7826B37D8 LEA        RSI, [R8*4+0x652b39ae]
00007FF7826B37E0 XOR        DIL, R11B           
00007FF7826B37E3 LEA        RAX, [RSI+RSI*4-0x12666cea]
00007FF7826B37EB XCHG       RSI, RAX            
00007FF7826B37ED ADD        AX, 0x602           
00007FF7826B37F1 ROR        DIL, 0x1            
00007FF7826B37F4 DEC        DIL                 
00007FF7826B37F7 ADD        RSI, 0xcbb2bb0e     
00007FF7826B37FE LEA        R8, [R8*4-0x13d9137e]
00007FF7826B3806 XOR        DIL, 0x10           
00007FF7826B380A BTS        AX, AX              
00007FF7826B380E MOV        ECX, ESI            
00007FF7826B3810 SBB        DIL, 0x90           
00007FF7826B3814 NOT        R8B                 
00007FF7826B3817 BTS        R8W, 0x18           
00007FF7826B381D MOVZX      R10D, AL            
00007FF7826B3821 XOR        R11B, DIL           
00007FF7826B3824 ROR        AL, CL              
00007FF7826B3826 OR         CX, CX              
00007FF7826B3829 BSWAP      R8                  
00007FF7826B382C ADC        RDI, RSP            
00007FF7826B382F DEC        ECX                 
00007FF7826B3831 DEC        SI                  
00007FF7826B3834 SHL        RCX, CL             
00007FF7826B3837 MOV        RDX, [RDI+R10-0x68] 
00007FF7826B383C ROR        RSI, 0xa0           
00007FF7826B3840 BTS        SI, CX              
00007FF7826B3844 MOVSX      EDI, AX             
00007FF7826B3847 MOV        [R10+RBX-0x70], RDX 
00007FF7826B384C XCHG       R10, R8             
00007FF7826B384F MOV        ESI, [R8+RBP-0x6d]  
00007FF7826B3854 XOR        ESI, R11D           
00007FF7826B3857 CQO                            
00007FF7826B3859 XADD       R10W, DI            
00007FF7826B385E JS         0x7ff78214929b      


上面这段只经过简单的少数几个花型简化,当时已经满足我们的要求了,其中我们并不关心handler如何解码偏移值,解码opcode那些,我们只关心
00007FF7826B3837        MOV               RDX, [RDI+R10-0x68] ,
由于VMProtect是栈机,所以其Context里面的寄存器肯定不会离栈储存器太远,同时因为我们代码执行记录有寄存器值,所以我们在这里可以直接解码出RDI+R10-0x68的值,最终算出的便宜就是指向栈Context的某个寄存器,然后00007FF7826B3847        MOV               [R10+RBX-0x70], RDX 这里也是一样,根据rdi=000000DA82AFF090,r8=C50B364800000000,r9=00007FF7826B37CD,r10=0000000000000068,对应的寄存器值,那么很容易标记出这里的RDI寄存器其实就是栈顶地址,R10是动态解密后的偏移,和-0x68计算后得0,实际上最终我们可以记录opcode执行记录 mov vdi,[vsp],如法炮制,我们就可以最终整理出所有的opcode执行记录,而且每个opcode的执行记录我们也要保存对应地址,寄存器值,然后我们还可以找到opcode地址,方法也很简单,我们只需要定位出opcode数据的大体范围然后关注内存访问,找到访问opcode数据的访问指令比如00007FF7826B384F        MOV               ESI, [R8+RBP-0x6d]  ,这里同样计算出R8+RBP-0x6d的值就是opcode的对应地址3.染色还原context偏移由于是代码虚拟机,因此代码入口必定会进行保存现场的操作,实际上VMProtect入口代码无论你看到是执行的什么代码,简化以后基本都是压栈操作,因为保存现场一定是第一时间保存,否则就是破坏现场了,那么我们就可以利用这个特点,使用unicorn或者bochs这些库模拟执行这些入口代码,把寄存器染成特殊值,比如
  
这样我们就能拿到寄存器的初始布局,然后后续的位置追踪就需要在下一步的还原opcode伪代码执行记录时根据代码具体行为追踪这些寄存器的偏移(主要是VMProtect主动调整以及vsp变化引起的偏移相对变化),这个过程会有点代码工作,但是逻辑并不复杂。4.匹配Hanlder特征还原opcode代码执行记录通过第二步的处理,我们就有了简化以后的代码执行记录,如果花型替换写的足够等价,那么简化后的代码执行效果和原始的执行效果是等价的,因此我们开始进行特征匹配处理,这里同样使用花型匹配脚本,比如:

这是一个很久以前一个老版本VMProtect特征匹配的部分脚本,不过这种处理方式是通用的,新版本处理流程也是一样的,因为前面说过,VMProtect的handler是要精心设计的,所以不会经常变化,通过上面精简后的代码配合hander匹配脚本匹配出hander,然后结合我们追踪的寄存器偏移,我们就能还原出完整的opcode执行记录,形成一个类似js压缩的map信息,opcode的执行记录其实和上面的代码执行记录没啥区别,只是由于是VMProtect自己的opcode,我们要用伪码来表达,所以分析伪码就是我们所有”通解“过程中比较耗时的一个地方,好在opcode不算多,而且VMProtect源代码也泄露了,同时在VMProtect里面也是明文的,跨版本和程序也基本通用,所以算准一次性工作。
5.简化opcode
拿到opcode执行记录以后,这里理论上我们还可以如同上面简化代码执行记录一样对opcode执行记录进行花型替换简化,但是实际上至少老版本VMProtect这里的混淆并不严重,只有几个逻辑门指令和跳转之内的特殊照顾,因为在这里进行opcode混淆膨胀,对最终代码执行性能影响非常大需要平衡,不可能过于复杂,而且实际上这里的混淆意义也不是很大,对于我们分析来讲,就是多加一个花型替换机制而已(由于没有实际需求,所以这里我并未实现具体的花型替换)。
6.带opcode伪代码调试确定干预意图
到这一步,实际上我们就已经能够明确的分析原始代码的逻辑了,但是由于分析一个程序,动态分析比静态分析好太多,因此我们还可以进一步开发一个调试器插件,然后实现类似前端开发根据map下断点执行的功能,具体到代码又可以搬出我们第一步实现的JIT转译代码虚拟机,因为我们代码虚拟机可以控制整个流程,因此我们可以监控所有代码内存的访问,那么我们只需要监控对opcode的访问,就能在opcode地址上下断点,也能判断opcode指令的开始结束和长度,以及从执行记录寄存器值里面取固定常量的值,然后就能实现单步opcode,下断点等等各种调试功能,这里会有不少代码量,我之前实现了一版非常好用的ollydbg插件,但是ollydbg不支持64位,x64dbg插件接口太鸡肋只实现了一个没啥UI的拙劣版,所以这里就不展示了。
7.接管代码执行进行干预
确定好代码干预目标以后,我们就可以开始实现代码干预了,我这里仍然首推使用我们之前用到的JIT转译代码虚拟机,根据目标,我们可以在虚拟机入口就通过内存访问异常,或者hook或者其他手段等接管代码执行流程,进入JIT代码虚拟机模拟执行代码,但是这里执行我们可以根据效率需求,只在关键的地方编译patch代码或者patch函数调用,其他地方仅仅转译,性能会非常高(比如可以跳过函数调用,只监控模块内代码执行),然后根据设定的触发条件,在目标地址执行动作,从而改变代码的执行流程或者数据,这种方式可以实现无侵入patch,所以不用管VMProtect的代码校验这些。
8.重编译opcode到汇编(实现代码还原)   
    一般实现了patch我们就已经达到目的了,基于目前的软件逆向,我想不出需要还原VMProtect代码的必要,不过这里也给出一个思路,前面我们已经拿到了opcode执行记录,那么我们可以写一个编译器将opcode重新编译成x86/arm指令,从而实现逻辑等同代码还原,类似llvm的ir到code或者tcg做的那样。

好久没水过文章了,不太会写了,排版也不咋会了,将就看,希望能给还喜欢研究VMProtect或者相关代码虚拟机保护的人一些思路,本来打算将匹配替换代码整理成库开源出来的,一看代码好多东西揉在一坨了(俗称屎山)只能作罢,看看后面有兴趣的时候能不能让AI帮忙解耦一下再说,有这种脚本性质的汇编匹配替换,很多工作还是和方便的,比如特征匹配,上面的花型替换,即可以简化代码,也可以反过来实现代码混淆,多弄点往复杂方向替换的花型可以让代码快速膨胀,还能实现VMProtect所不具备的迁程代码混淆,因为这个匹配替换引擎有上下文还可以用LUA脚本。


Picture (4).png (121.12 KB, 下载次数: 0)

Picture (4).png

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
cxn1ce 发表于 2026-5-9 16:31
感谢分享~~
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-5-9 16:33

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表