由于笔者非科班出身,在本领域的全部知识都为自行学习,故本篇提到的一些"术语"可能并不标准,见谅
VMProtect (下面简称VMP) 提供的授权锁机制,本质上是一种基于 RSA 算法的代码级授权绑定保护手段。
具体而言:在未加壳的原始程序中,被打上授权锁标记(如 VMProtectBeginUltraLock)的函数逻辑是完整的。但经过 VMP 引擎编译加壳后,该函数的核心代码会被抽离、加密并转化为虚拟化指令。此时,该函数处于“锁定”状态,不再具备完整的独立执行能力。
若该函数要被正常执行,必须在运行时依赖一个关键的动态解密因子(通常表现为一个 8 字节的核心校验/解密密钥,即 ProductCode)。而这个至关重要的解密因子,正是被封存在客户端输入的序列号(SerialNumber)中。当程序运行时,VMP 必须通过其内置的 RSA 公钥指数以及模数 对序列号进行解密,提取出ProductCode,才能完成受保护函数的动态解密与上下文拼接。
简而言之:VMP 的序列号并不仅仅是一个非黑即白的通行证,它本身就是受保护代码赖以运行的"解密密钥"。这也意味着,整个授权体系的绝对命门,就落在序列号的 RSA 解密环节上。
先说一下RSA算法的解密公式:
m=c^e mod n
其中,m 代表解密出的明文结果(包含至关重要的 ProductCode 等),c 代表密文体(即客户端输入的序列号),e 代表公钥指数,而 n 则是 RSA 的模数。
明确了公式后,我们来看看业界在应对 VMP 授权锁时的标准手段。
常规思路的本质都是替换n(也就是模数),只要换上我们自己生成的n,就能用对应的自己的私钥自行签发合法的序列号(当然,前提是在正版授权通过的环境下先提取出那个关键的 8 字节 ProductCode)。
要实现替换 n,目前主流无非是一动、一静两种手段,但这两种手段在现代 VMP 面前都显得越发吃力:
1、通过动态调试,定位到程序将 n 载入大数结构(Bignum)的那一刻,直接在内存中将其替换。
痛点: 虽然省力,但这种方法往往只对老版本的 VMP 奏效。在较新的 VMP 版本中,完整的 n 几乎永远不会以连续、明文的形态暴露在内存中。
2、还原 VM 指令,逆向出具体的解密算法,找到程序中静态存储且被加密的 n。用还原出的算法将我们自己的 n 加密后,覆盖回原程序的文件中。
痛点: 这种方法近乎无痕,非常优雅。但代价是极其高昂的时间成本。面对 VMP 犹如天书般的虚拟指令流,需要极大的毅力和分析能力,且不同版本之间的算法往往不一致,复用性极差。
那么为什么在黑盒状态下,追溯 n 简直是一场噩梦?
这正是本篇另辟蹊径,完全放弃替换 n 的根本原因:
VMP 在进行 RSA 解密时,会在堆上专门开辟并规划出一块内存区域(我称其为“VM运算区”)。在这个VM运算区内,VMP 确实会把明文 n 的片段一个个地、位置并不明确地、写入内存。但对于一个完全黑盒的程序而言,当我们找到了一些疑似写入明文 n 片段地位置并试图记录这些片段时会发现:即使我们通过硬件断点或日志追踪,捕获了目标地址所有 DWORD/QWORD (32/64位) 的写入操作,保存下来的日志依然犹如天书。因为在这个 VM 运算区里面,伴随着真实 n 片段的写入,VMP 还会插入大量的垃圾指令和虚假写入。在我们完全不知道原始明文 n 的任何一个片段的情况下,面对日志里成千上万次真假难辨的内存操作,想要从中精准挑出真正的 n 的片段并拼凑完整,无异于大海捞针。
既然在黑盒环境下死磕模数 n 是一条看不到尽头的死胡同,那我们不妨跳出底层虚拟机指令的泥潭,重新审视 RSA 算法本身的数学根基。
回顾 RSA 的核心解密方程,一直以来,几乎所有的攻防焦点都集中在 n 身上,逆向者们绞尽脑汁想要替换它,却往往忽略了公式中另一个至关重要的参数——公钥指数 e。
在业界标准中,e 通常是一个固定的小常数(如 65537,即 0x10001)。本篇所探讨的另类思路,正是将矛头对准了这个 e。
本思路基于一个最基础的数学常识:任何数的 1 次方,都等于它本身。
如果我们能通过某种手段,在程序进行模幂运算之前,将这个公钥指数 e 强行修改为 1。那么,这个看似坚不可摧的非对称加解密公式,就会在瞬间发生“坍缩”,公式将直接退化为:
m=c^1 mod n
随之而来的是第二步:在 RSA 算法的规范中,输入的密文体 c 在数值上必定严格小于模数 n。那么,一个较小的数值 c 尝试对一个更大的数值 n 进行取模运算,结果会发生什么?
答案是:结果依然是它本身。
于是,整个 RSA 解密体系的最终数学表达就退化成了:
m=c
这意味着它达到了绝对的 输入 = 输出,而整个过程中我们丝毫不关心黑盒的n,而是转而针对明确的e。
那么理论部分介绍完毕,接下来开始实操验证。
以VMProtect 3.10.6 Demo版本为例,先编写一个Demo程序,逻辑很简单,输入序列号,按回车,授权通过则会弹出MessageBox :
void TestMain()
{
VMProtectBeginUltraLockByKey("MyProtect");
MessageBoxA(0,"TEST","TESTAAA",0);
}
int main()
{
system("pause");
char HWID[28];
VMProtectGetCurrentHWID(HWID, 42);
printf("%s\n", HWID);
system("pause");
printf("Please Input Num:\n");
char SerialNumber[512] = { 0 };
scanf_s("%511s", SerialNumber,_countof(SerialNumber));
VMProtectSetSerialNumber(SerialNumber);
TestMain();
return 0;
}
编译完成以后,添加授权锁,选择RSA-2048算法并生成一个正版授权序列号然后加壳。
前置条件依然是拿到那个8字节的ProductCode顺便找到Base64解码函数(这一步没什么技术含量教程满地都是,故不过多赘述)
提取到当前程序的ProductCode为:33 8E B3 AC 08 C0 26 0C
随后将重新程序拖入x32dbg中运行,在Base64解码函数头部设置断点,随后输入正版序列号,程序在Base64解码函数头部断下。(序列号的存放位置就位于VM运算区的下方)
让该函数执行完毕,得到内存中经过Base64解码后的密文体,并对它设置硬件访问断点
接下来断下来的位置,就是在读取密文体并转换为大数的位置
在这个位置可以观察堆栈中的地址,找到它转大数的Buffer,并对着它已经转换完毕的字节下硬件访问断点,F9跑起来以后,再次断下来的位置就位于大数运算函数中(也就是真正的解密函数),且尚未开始真正运算的位置。
此时我们需要稍作思考:
既然e和n都是以加密的形式静态存放在程序的只读段中,那么这个大数运算函数就必然会以相对位置的形式传参并读取这个静态且加密的e以及n
但问题在于:此时的代码 几乎 处于全段VM的状态,它几乎不可能以push或mov的形式压栈。
然而,计算机的底层是纯粹的数学和物理,这里要引用翁恺老师的一句话:“计算机的世界里没有黑魔法”。
VMP 无论有多厉害,它的虚拟化和混淆无论有多抽象,它都不可能颠覆冯诺依曼架构,更不可能打破物理 CPU 运算的客观规律。
既然明确了在几乎全段 VM 状态下,它不可能直接使用原生真实的 push 和 mov 暴露意图,那么它的虚拟化抽象指令就必然要在内部模拟数据流转的过程。而无论这个过程被伪装得多复杂,当它真正要将数据送入底层运算单元时,那些关键参数(指针、长度)就必然要在物理内存中“实体化”,其数据结构的内存布局也必然会暴露出现实世界中标准密码学组件的痕迹。那么此时,我们就可以在程序的 VM 内存工作区中,直接利用这些客观存在的物理特征,跨过指令的迷宫,直接搜索出它喂给解密函数的参数,其中就必然包含那个静态的 e 的相对位置。
在这个特定的上下文片段中,VMP 必须将密码学组件所需的元数据实体化。
这就意味着,密文的缓冲指针、e和n的寻址偏移及其长度,这些关键要素会以某种内部结构体的形式被集中存放,以便于底层运算单元调用。
最终,正如我们前面推演的那样,在序列号 Base64 字符串附近的 VM 运算区内,我们可以明显看到大数解密函数初始化时所需数据的大致内容,也可以全局搜索大致的特征,但没必要。
此时,具体操作就水到渠成了:我们以内存中明文的 Base64 序列号字符串作为基址锚点,减去前文推导出的固定偏移,即可精准锁定存放公钥指数 e 的偏移量与长度的内存地址。
接下来重载程序,带条件的在此地址下硬件写入断点。当程序再次断下时,程序正处于调用大数解密函数的前夕,所有关键要素已在内存中就位。
虽然我们知道e和n在静态内存中都是加密的,那么到了这一步,我们还需要顺藤摸瓜去逆向它的解密算法吗?.....完全不需要。
但在动手之前,这里有一个很重要的坑:
我们知道e(也就是公钥指数)这个东西,开发者无论是为了运算效率还是为了方便,一般都会采用0x10001这个数值,这个数值几乎与RSA算法是硬性绑定的关系。
那么在 x86/x64 架构(小端序)的内存中,这个数值理应展现为:01 00 01 00
但我们打开VMP保存的工程文件可以看到,e的Base64字符串是:AAEAAQ==,解码后是:00 01 00 01
怎么回事?
00 01 00 01转换成数值也就是0x1000100,为什么会出现这个现象呢?难道我们的理论有误?
其实并非,得到这个数值恰恰说明我们的理论是正确的,因为VMP为了在运算过程中为了迎合大数运算的标准,它需要把0x10001这个数值在内存中的字节进行端序反转,那么01 00 01 00在反转端序之后就是00 01 00 01了。
明确了这一点以后,既然它是加密的,那么我们完全不用管它,我们直接将原本的e的偏移+0x3,也就是默认是以00 01 00 01中的第一个00为起始位置,加0x3就变成了以00 01 00 01中的 最后一个01为起始位置,随后长度直接改为0x1,修改完以后,F9运行程序
此时会发现:即使输入的是正版序列号,程序依然提示无效。
这是预期之内的完美失败,它恰恰证明了我们的理论是成立的。因为 e 被改成了 1,合法的密文被原封不动地当成了明文输出,自然无法通过后续的格式校验。
既然程序现在变成了输入什么解密出来就还是什么,我们还需要费劲去逆向它解密后的数据结构吗?
...根本不需要。我们可以直接白嫖 VMP,来帮我们构造一份完美的明文数据,简单来说就是:
随便生成一个自己的序列号,然后用自己的模数解密出明文,然后再将明文Base64编码,这就是Payload了(我称之为"万能序列号"),随后拿着这个我们自己的"万能序列号"就可以直接做 解密结果 = 输入内容 的验证
1、打开VMP,随便拖进来一个程序,拖进来直接点击授权,选择相同的算法RSA-2048,点左上角的保存工程
2、随后关闭VMP,打开刚才保存的工程文件,然后把我们先前拿到的8字节33 8E B3 AC 08 C0 26 0C编码为Base64得到M46zrAjAJgw=,然后将文件中原本的ProductCode替换为M46zrAjAJgw=
3、重新打开VMP,并拖入这个工程文件,随便生成一个授权,到期时间这些随便指定,随心所欲,不勾选的默认不限制
4、拿着我们自己生成的序列号,以及我们自己的模数n去解密,我的做法是写个python脚本,输入序列号和解码后的模数内容,直接解密出明文
5、复制这些解密后的明文字节,把它Base64编码一下,得到Base64明文序列号:
AAJUsrwhlrxt4R4O+wABAQUDCzMIBzOOs6wIwCYM///A3CXqJ8Sg4NVEv+nJfBoP9GtF5qjjW271je86Mi1F0tonwleaQ6Jlp63pPVVAnCTmAzAGn/RDXyIFilz3ZQs9bACM/Z9JSzXZN+8+HY2/lQFfEoS90gb/giN1zQ3rJeh7oT8lX2ADFxLquTFK7uss7obRqCkEDRa+7pnVm0f9m7AQxRcBDzLV+s8wYQPoiK9VgFe6DBK/bH1v3L7JAvkgs1cEG67fQDU67ToVcQX+591WigKNhYPIaKwnzsGjgz4rfuZ5jlaVAHpNwJS8zLWcLw9UyNXkw/Qmr2NPqLICvA==
至此,我们的"万能序列号"准备完毕,接下来开始验证:
1、打开目标程序,直接输入我们的这个明文Payload序列号
2、继续断在大数解密函数初始化时往内存里写入e的偏移和长度的位置
3、断下来以后,e的偏移直接+0x3,长度直接改为0x1
4、F9运行,弹出被授权锁保护函数中的MessageBox,成功。
本篇实际验证了VMProtect 3.8.0 Ultimate、VMProtect 3.9.4 Ultimate、VMProtect 3.10.6 DEMO这些版本的32/64位程序
总结与反思:
相对于前面提到的两种针对n的方法而言,本篇讲到的方法要简单得多,因为前面两种方法面对的是未知的n,而本篇的方法面对的是已知的e,目标要明确得多,整个过程仅仅只做两个字节的修改,通用性相对上面提到的两种常规方法而言也好很多。
故本篇的核心思想就是:当e=1的时候,算法的解密结果就是你的输入内容,你可以自行构建任意明文内容,并直接将其当作输入,那么程序解密完毕后得到的数据依然是你输入的数据,所以本篇实际上是利用VMP自己生成了一个合法的序列号,并且用自己的模数把自己的序列号进行解密,自己生成了一个明文的序列号作为输入,随后将e从0x10001改为0x1,从而达到这个效果,一切基于对RSA解密方程的理论推导。
当然,这种手法也有其特定的应用边界:它本质上是利用了
m = c^1 mod n (当 c<n 时 m=c)的数学特性。这意味着,该手法不仅适用于 VMP,在已知明文格式的前提下,它对任何将 e 暴露在内存中的 RSA 应用都具有破坏力。
计算机世界里没有黑魔法,一切花里胡哨的混淆,最终都要向数学和物理规律低头。当我们跳出VMP犹如天书的执行流,转而从数据流和密码学的视角向下俯视时,也许只需要极微小的拨动,就能让坚不可摧的堡垒轰然倒塌。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于 1天前
被Gmcixy2531编辑
,原因: