累计签到:131 天 连续签到:9 天
|
注: Q*量子网络验证并不是自己写的加密壳, 而是使用的VMProtect. 本文也不会对Q*量子网络验证的验证部分进行分析与破解。
前言VMProtect的本地授权锁,自带VMProtect的虚拟机保护,只需要将被保护的代码设置锁定到序列号,也可以根据需要添加一些到期时间或者运行时间的限制,就有一定的防破J效果。首先被保护的代码无论如何都需要一组能正常运行的序列号进行解密,如果对这些被保护的代码进行patch,也可以通过增加函数保护标记数量来增加破J者的工作量,可以说是很简单又有效的防脱壳和防破J的办法。然而一旦VMProtect的VMProtectSetSerialNumber的流程被分析出来,并且keygen了,那无论有多少个带授权锁的虚拟化保护标记都没有用了。目前对VMProtect授权锁的破J方案里有patch模数后自己进行keygen,以及在合适的时机修改解密结果,两种相对容易的方案,下面就来简单分析这两种破J方法的可行性。 分析前的准备x64dbg 分析/调试工具 3.5-3.8版本的VMProtect加密测试用 一个64位的PE本地授权锁样本,以下分析基于该样本。 先准备一个样本,需要使用的SDK函数的原型如下。 ```c++
int VMP_API VMProtectSetSerialNumber(const char serial);
void VMP_API VMProtectBeginUltraLockByKey(const char ); - 测试的例子只需要写个VMProtectSetSerialNumber,然后使用VMProtectBeginUltraLockByKey保护一个其他函数即可。
- ```c++
- void Test_Lic()
- {
- VMProtectBeginUltraLockByKey("lock");
- cout << "LockByKey" << endl;
- VMProtectEnd();
- }
- void Test_VMP()
- {
- string serial;
- VMProtectSerialNumberData data;
- ifstream ifile("./test.key", ios::in | ios::binary);
- if (ifile.is_open())
- {
- ifile >> serial; // 只能读一行所以序列号不要带换行符
- cout << "SetSerial Status: " << VMProtectSetSerialNumber(serial.c_str()) << endl;
- if (VMProtectGetSerialNumberData(&data, sizeof(data)))
- {
- cout << "State: " << data.nState << endl; // 状态
- wcout << L"Username: " << data.wUserName << endl; // 用户名
- wcout << L"EMail: " << data.wEMail << endl; // 邮箱
- cout << "Expire: " << (int)data.dtExpire.wYear << (int)data.dtExpire.bMonth << (int)data.dtExpire.bDay << endl; // 到期日期
- cout << "MaxBuild: " << (int)data.dtMaxBuild.wYear << (int)data.dtMaxBuild.bMonth << (int)data.dtMaxBuild.bDay << endl; // 最大创建日期
- cout << "RunningTime: " << (int)data.bRunningTime << endl; // 每次允许运行的分钟数
- cout << "UserDataLength: " << (int)data.nUserDataLength << endl; // 自定义附加数据长度
- if (data.nUserDataLength)
- {
- cout << "UserData: " << (char*)data.bUserData << endl; // 自定义附加数据
- }
- }
- }
- Test_Lic();
- system("pause");
- }
复制代码
编译出来,直接使用VMProtect3.8加壳,并设置密钥长度,这里直接设置4096,加壳/反调试与反虚拟机等非本文分析的重点,故全部略过,只加密函数。
然后我们可以用VMP以前提供的keygen(在2.x版本里附带)生成一个序列号,只需要导出密钥对并复制粘贴到Keygen的源码里即可。生成序列号后,去掉它的换行符,再写到test.key给测试程序读取。keygen里其实已经写了序列号是RSA算法,破解的话要么替换模数要么修改解密结果,但无论哪种都需要正常的序列号运行并解密,知道是RSA,我们的目标就是尽可能找到公钥跟模数了。 分析key解密以及大数存放的加解密用x64dbg调试,直接找到VMProtectSetSerialNumber的位置,下断点,运行就可以看到序列号了。
但这个位置,一般会被其他虚拟化水印保护,不会这么容易被找到,遇到这种情况,可以在RtlEnterCriticalSection处下断点,观察堆栈跟寄存器是否有key的出现,如下图,可以在rdx跟rsp+78处看到序列号。32位也是这样但寄存器不一样,要看ecx跟ebp。
继续跟进,在RtlAllocateHeap处下断点并运行,第一次停下的情况。
0x2AC为序列号的长度,这里分配的内存会用来存放序列号base64解码后的结果,执行到retn,记录下分配的地址,运行后停下,可以看到解密结果与直接base64解码的结果相同。
第二次分配是0x202的大小,0x200同样也是RSA 4096的数据长度,分配的内存用来将这个base64解码结果转成VMP自己的大数结构存放,同样执行到retn,记录分配地址后运行。停下后就能看到数据了,但这些数据被加密了,可以在没写入之前下硬件写入断点,跟踪分析得到解密算法。
限于篇幅,这里不详细讲解怎么跟踪的算法,毕竟纯体力活,直接说结论,VMP使用了存放大数的地址跟随机生成的20字节的salt进行加密,salt存放在堆栈,可以直接在堆栈搜这个存放大数的地址,salt就在后面。
这次的salt就是6D 26 75 F5 3C D2 7D DA AE 9F 95 F3 79 60 1B 39 8B 66 3F 77,因为是随机生成的所以只能用在这次解密。这里直接提供解密后的结果,算法会在后面提供。
- 00 01 69 71 63 7D 93 5F FF 4C E3 5D 49 AF 43 E2
- 0F 98 D9 32 67 61 41 14 99 93 01 0A 89 19 50 72
- CB 15 E5 AC 3A B4 8A 55 3A 4B 71 08 F2 27 B2 62
- 4D 72 EB 28 4F 1E 67 DF A6 9E 8E CA FC 41 CA 97
- D8 4C 4E 36 A5 39 42 00 1C F6 04 3C CD 8D 69 DB
- 5D 58 33 7B D2 D7 51 DB 67 5D B4 72 72 6A F8 3F
- F1 DB 8D 87 64 F5 56 AA 61 F9 3C 73 26 6C 2D 15
- A0 9D 3A B8 A5 CF 50 A2 80 47 75 5A 07 91 2B 4B
- 0E 29 02 26 D8 90 18 E9 5E C9 23 C2 F1 1A A6 88
- 4B 3D 8C 68 49 4F E1 1A 09 D3 84 27 3C 85 A7 CF
- A1 08 A7 D9 76 63 C8 35 CD C5 87 E4 25 03 44 27
- AA 1C 16 B7 79 B7 7D AF 7E 30 31 5E 67 00 43 02
- C5 11 BB 93 F7 A6 2F DF B7 B3 65 38 32 64 68 56
- B1 73 A8 BB B6 5C 99 02 47 4D A5 85 D4 D1 A7 A4
- 92 C0 73 5F 3A 78 2F CC 60 FD 2D C3 B7 8C 51 1F
- 07 DB DC 5F 44 DE CA FE 76 86 AA 1E 12 0B 15 BA
- E6 66 10 A7 51 13 77 F4 76 27 AD 92 84 8C 2A 1E
- 00 C9 50 B3 74 0D DA AB 38 E5 A5 51 F6 8D A1 8F
- D5 EB C4 DE 36 C8 A4 22 95 AA F5 2C AE FF 48 48
- 2A B1 78 37 3B 5F 3D FA F8 B8 7A 3A FD 5F B3 29
- 08 93 5F F6 25 05 EF CB 77 56 30 D3 70 11 C3 1C
- 27 76 5B 17 0F CB 5E 9D 76 5F 88 C4 7B 29 64 27
- D5 27 5F 30 87 AC F3 F6 3E D7 BB 4C 82 2E 83 F8
- 12 92 65 19 94 7B 2E CA 2E 6D FA A3 68 97 13 BA
- A3 6C 76 D8 5E 59 67 2D 72 E5 AF 5B E6 33 0F A1
- 1B F6 7B A2 6F 39 7D 38 D0 3A DE E6 B9 06 88 0C
- 39 BC 22 95 B6 51 D4 C9 B8 2B 81 7C 02 F1 60 58
- 7E 4A 20 F6 EA 01 4D DB 2C BB F5 25 25 2B A8 9F
- 00 77 FD 73 42 B4 26 14 57 8F C7 2F 46 5F 55 7C
- 6B E4 73 84 11 2D CC 0F B0 7D 65 30 3C E1 84 83
- 1E E5 91 A7 B4 DA 6A 4E 96 2E B8 97 35 50 A5 E4
- F8 94 C4 43 32 9C 39 2B EF 28 AC CF 83 6C 0C 90
- 60 45
复制代码
回到第三次RtlAllocateHeap,分配的大小是0xC04,用来存放公钥(0x4,公钥也可以自定义只不过一般不做),模数(0x200),消息(base64解码结果 0x200),以及两个缓冲区(0x400*2),存放顺序是随机的,每次运行都不一样,同样执行到retn,记录分配地址后运行,查看并解密数据,这次分配可以视为是解密结束了。64位分配大小是0x20,32位是0x10。
同样提取数据并解密,这次解密用的跟之前提取的salt一样,但解密用的内存地址要用这个本身的,稍微整理一下。
根据前面的msg解密结果可以看出来,这部分的数据,两个字节为间隔,前后交换位置了,调整一下就行。 虽然这部分内容是乱序存储的,但pub通常是0x10001不用特意去提取,tmp1是最重要的VMPSerialData,也就是序列号解密后的结果,可以用0x200个00以及数据区的00 02来特征搜索定位,tmp2没找到什么用处,它也有0x200个00,但没有00 02这个特征,查找到了就直接舍弃,msg可以直接明文查找排除,剩下的就是mod模数了,至此提取数据的部分搞定,我们得到了这样子的数据。
- 00 02 E2 2F 25 20 8C 86 9D 52 60 1C B1 F2 56 E4
- 30 C3 00 01 01 02 08 4A 6F 68 6E 20 44 6F 65 03
- 0C 6A 6F 68 6E 40 64 6F 65 2E 63 6F 6D 04 10 00
- 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 07
- 76 A4 A8 A3 89 83 00 F8 FF 33 31 2F 77 DD 3D D2
- FD 5B 77 CF A7 08 DC 39 08 ... // 后面的是没用的填充数据
复制代码
VMPSerialData结构这部分可以参考keygen的VMProtectGenerateSerialNumber函数,测试程序提取出来的数据具体结构如下。 - 00 02 E2 2F 25 20 8C 86 9D 52 60 1C B1 F2 56 E4 30 C3 00 // 前面的0002 以及最后的00固定 剩下的用随机长度的随机数填充
- 01 01 // 版本号标记 目前固定是两个01
- 02 // 用户名
- 08 4A 6F 68 6E 20 44 6F 65 // 一字节长度+文本
- 03 // 邮箱
- 0C 6A 6F 68 6E 40 64 6F 65 2E 63 6F 6D // 一字节长度+文本
- 04 // 机器码
- 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 // 一字节长度+机器码
- 07 // ProductCode 最重要的一个 没有它不能解密被锁定序列号的代码
- 76 A4 A8 A3 89 83 00 F8 // 固定八个字节的ProductCode
- FF // CRC并且解析结束
- 33 31 2F 77 DD 3D D2 FD 5B 77 CF A7 08 DC 39 08 // 20字节的SHA1校验值 防止直接篡改这部分的数据
- //解析结束 后面的是无用的随机填充数据
复制代码
还有其他字段的解析,如到期日期,时间限制等,不做赘述,可参考VMProtectGenerateSerialNumber的实现。 同样的我们也可以用VMProtectGenerateSerialNumber来生成自己的序列号,因为我们已经拿到了ProductCode,其他的限制字段都可以不加,只需要自己生成一组RSA,并替换掉程序的mod值就可以keygen了。 大数加解密算法的版本差异vmp使用的大数都在内存中加密了,加解密算法根据版本的不同有一些细微差异,但都需要随机生成的20字节salt跟内存地址进行加解密,下硬件写断点跟踪虚拟机可以得到算法。 3.5 ```c++
//解密
uint16t* salt = (uint16_t)psalt;
for (size_t i = 0; i < BigNum.size() / 2; i++)
{
size_t offset = (addr + (addr >> 7)) % 16;
size_t salt = ((uint8t*)(salt)+offset) + 0x37 + (addr >> 4);
buffer = (uint16_t)((buffer ^ salt) + addr);
addr += 2;
} //加密
uint16t* salt = (uint16_t)psalt;
for (size_t i = 0; i < BigNum.size() / 2; i++)
{
size_t offset = (addr + (addr >> 7)) % 16;
size_t salt = ((uint8t*)(salt)+offset) + 0x37 + (addr >> 4);
buffer = (uint16_t)((buffer - addr) ^ salt);
addr += 2;
} - 3.6与3.7相同
- ```c++
- //解密
- uint16_t* salt_ = (uint16_t*)psalt;
- for (size_t i = 0; i < BigNum.size() / 2; i++)
- {
- size_t offset = ((uint16_t)addr + ((uint16_t)(addr) >> 7)) % 10;
- size_t salt = salt_[offset] + 0x73 + ((uint16_t)(addr) >> 4);
- buffer[i] = (uint16_t)((buffer[i] ^ salt) + addr);
- addr += 2;
- }
- //加密
- uint16_t* salt_ = (uint16_t*)psalt;
- for (size_t i = 0; i < BigNum.size() / 2; i++)
- {
- size_t offset = ((uint16_t)addr + ((uint16_t)(addr) >> 7)) % 10;
- size_t salt = salt_[offset] + 0x73 + ((uint16_t)(addr) >> 4);
- buffer[i] = (uint16_t)((buffer[i] - addr) ^ salt);
- addr += 2;
- }
复制代码
3.8 ```c++
//解密
uint16t* salt = (uint16_t*)psalt;
for (size_t i = 0; i < BigNum.size() / 2; i++)
{
size_t offset = ((uint16_t)addr + ((uint16_t)(addr) >> 7)) % 10;
size_t idx = offset ^ ((uint16_t)addr >> 5);
sizet salt = salt[offset] + (_rotl(0xFACE001E, idx % 8) & 0xFFFF);
buffer = (uint16_t)((buffer ^ salt) + addr);
addr += 2;
} //加密
uint16t* salt = (uint16_t*)psalt;
for (size_t i = 0; i < BigNum.size() / 2; i++)
{
size_t offset = ((uint16_t)addr + ((uint16_t)(addr) >> 7)) % 10;
size_t idx = offset ^ ((uint16_t)addr >> 5);
sizet salt = salt[offset] + (_rotl(0xFACE001E, idx % 8) & 0xFFFF);
buffer = (uint16_t)((buffer - addr) ^ salt);
addr += 2;
} - 大体解密思路差不多,只不过对一些常数跟salt做了微调。有了算法就能解开这些大数了。
- ## patch解密结果的可行性
- 由于有序列号有CRC校验,不能直接修改部分VMPSerialData的字节来变更序列号的属性,比如说机器的绑定或者到期时间等等,需要更新一下序列号自带的SHA1才行。不过我们有VMP自带的那份keygen,可以做一些小修改,只需要删掉RSA加密的部分,让它直接生成VMPSerialData的数据就可以了。调用VMProtectGenerateSerialNumber时,除了必要的ProductCode,其他的字段都可以不用设置,重新生成一份VMPSerialData,就可以一直使用了。
- patch流程则是:先传入一个伪造的同长度的序列号,Hook RtlAllocateHeap 函数,在之前说到的分配0xC04大小的内存时,记录下内存地址,在分配0x20的时候,解密这个大数,需要在之前的分配0x202的时候记录下分配的地址,并在堆栈上搜索这个地址,就能找到解密需要的salt,然后把已经生成的VMPSerialData patch到tmp1对应的区域,即可正常运行。如果分不清tmp1 tmp2,也可以把tmp1 tmp2都patch了,只需要搜索有0x200个00开头的区域就行了。
- ## Keygen的可行性
- 3.5以及之前的版本可行且容易实现,因为我们已经拿到了ProductCode,跟上面一样能自己生成序列号,甚至都不用对keygen做修改,只需要自己生成一组RSA,Hook RtlAllocateHeap函数后在第一次分配0x202大小的内存,存储的就是mod的模数(第二次是分配存放序列号base64解码的),在第二次分配0x202的时候对这个模数进行解密,替换成自己的模数,加密覆盖回去即可keygen。3.6以后,vmp不会单独分配存储模数的空间,而是一次性分配出所有需要的空间并进行乱序存储,需要设置硬件写入断点才可以找到合适的patch模数的时机,硬件断点写起来较为麻烦,故本文不做考虑,只讲述大致流程,有兴趣的可以自行尝试。
- ## 编写dll辅助patch破解授权锁
- 综合上面的分析,我们可以写一个dll注入到主程序里对RtlAllocateHeap进行Hook并修改RSA解密后的结果,方案如下。
复制代码
1.Hook RtlEnterCriticalSection ,由于这个函数调用频繁容易误判,需要判断返回地址是否属于主程序的调用,再通过判断寄存器跟堆栈上是否出现了序列号来判断是否为VMProtectSetSerialNumber调用的,记为EnterRVA。这步用调试器手动查找。
2.一旦由EnterRVA调用了RtlEnterCriticalSection,则Hook RtlAllocateHeap,在分配0x202大小空间的时候,记录地址,分配0xC04空间的时候,搜索0x202的地址获得salt,并且可以尝试内置几种VMP解密大数的算法解密0x202的大数,只要解密成功就能自动判断VMP的版本,最后分配0x20空间的时候,便可以用记录的salt解密0xC04的大数了,将tmp1区域的数据直接拷贝出来,解析后去掉限制类的字段(机器码,到期时间等),重新生成一份无限制VMPSerialData储存到本地的文件并结束程序。
3.重新打开程序,读取本地的文件,伪造序列号,按照上面的流程重新找到tmp1,将VMPSerialData加密后patch进去即可。
4.为了方便调整如EnterRVA字段这种频繁改动的字段,将一些字段存到ini里读取。 - 考虑到生成VMPSerialData跟patch在流程上有一定冲突,以及功能上的精简,故拆成两个dll来完成上面的工作,VMPGetKey.dll专门生成VMPSerialData并存到文件,VMPKeyPatcher.dll专门读取VMPSerialData的文件并进行patch操作。
- 有了dll以后可以将破解流程简化为
- 手动调试,定位到VMProtectSetSerialNumber调用的RtlEnterCriticalSection,也就是EnterRVA,写到InjectConfig.ini文件并调整参数->正常运行的情况下注入VMPGetKey.dll获取VMPSerialData.data(可在InjectConfig.ini中调整名字)->伪造任意同长度序列号,注入VMPKeyPatcher.dll完成授权锁的破解。
- ## 使用dll对某网络验证的授权锁进行破解
- 该样本只用来测试本地授权锁,不分析网络验证,用易语言编译一个样本并添加授权锁保护标记。
复制代码
加密后有一个dllbox,本身没啥用,获取完序列号就可以干掉。
可以搞一个winspool.drv劫持补丁来获取VMProtectSetSerialNumber的EnterRVA,或者能过反调试的人直接调试,在点击登录并弹出信息框后,于主线程TEB+0x100处的地址就是存放序列号的地址。
之后同样在RtlEnterCriticalSection处下断点并获取EnterRVA的地址,图里堆栈上的地址减掉0x400000就是EnterRVA了,填写到InjectConfig.ini里。
劫持补丁也做了个自动查找的功能,直接运行后会输出EnterLog.log,里面有EnterRVA的地址,如果有多行EnterRVA,一般是第一个,如果自动查找不到,建议还是手动查找。然后根据序列号的长度判断KeySize要写多少,就取序列号base64解码后的长度*8,然后写最接近的那个就行。 获取完毕,之后的dll注入可以考虑直接干掉VMP的dllbox,考虑到VMP要求必须要加载dllbox成功才可继续流程,往加载的dllbox的入口处写入mov eax,1;retn 0xC;即可。之后需要把序列号设置在TEB+0x100那里,drv补丁会导出VMPLic.key并自行设置,不想使用drv的自己用调试器搞也行。干掉dllbox后,剩下的就是加载两个Dll了,这些事情将InjectConfig.ini的LoadMode设置为0,drv补丁就会自动处理,只需要填写EnterRVA。 ini填写完EnterRVA,再将LoadMode设置为1,加载VMPGetKey.dll自动导出VMPKeyData.data,固定序列号的工作由drv补丁处理,看到文件了就算成功。
最后把ini的LoadMode设置为2,加载VMPKeyPatcher.dll进行破解,伪造序列号的工作也同样由drv补丁处理,成功进入主界面,所有按钮均可点击,包括锁定的按钮。
至此VMProtect本地授权锁破解完毕。 总结如前言所说,VMProtect授权锁的强度还是太依赖于VMProtectSetSerialNumber的函数,虽然对所有保护标记都有做加密保护,但只要对序列号解密后获取8字节的ProductCode,便可自己构造序列号或者patch解密结果了。
该样本仅用于测试分析VMProtect的本地授权锁, 不涉及Q*量子网络验证部分, 这部分可以简单的ret即可。该验证没有任何分析的必要, 过掉授权锁就行。(友好建议Q*量子验证采用其他更有效的防破解方案, 例如某网络验证S*的PIC盾以及防脱壳AntiDump算法的思路, 目前S*的PIC盾还没想到如何破解)
|
温馨提示:
1、在论坛里发表的文章仅代表作者本人的观点,与本网站立场无关。
2、论坛的所有内容都不保证其准确性,有效性,时间性。阅读本站内容因误导等因素而造成的损失本站不承担连带责任。
3、当政府机关依照法定程序要求披露信息时,论坛均得免责。
4、若因线路及非本站所能控制范围的故障导致暂停服务期间造成的一切不便与损失,论坛不负任何责任。
5、注册会员通过任何手段和方法针对论坛进行破坏,我们有权对其行为作出处理。并保留进一步追究其责任的权利。
6、如果有侵犯到您的权益,请第一时间联系邮箱 990037279@qq.com ,站长会进行审查,情况属实的会在三个工作日内为您删除。
最后回复时间:2025-02-25 22:15:03社区官方发言人回复了此贴
|