一、算法逆向
发现里面藏了一套自定义指令
用反汇编工具把整个代码段过了一遍,通过异常表找到了函数的边界。
有个接近 3000 字节的大函数引起了我的注意——里面一个系统调用都没有,全是纯计算。
这个函数开头有个很大的数字 0x9E3779B97F4A7C15。
然后一大段指令在填一个 256 格的数组。我用 Python 把这段指令逐条模拟了一遍,
确认填出来的就是 0,1,2,3,4,...,255。
接着一个循环跑了 256 次,每次拿一个随机数算出个位置出来,
把数组里两个位置的值交换一下。跑完之后这个数组就被打乱了。
到这里我才反应过来:程序里面藏了一套自己设计的指令系统。
程序里有一段代码专门负责读取加密的指令、翻译成实际操作、然后执行。
相当于自己在程序里面写了一个迷你版的 CPU。
指令集全部整理
我把这套指令能做的事情全部列了一遍,一共 44 种。
加减乘除、与或非、移位、比较、读写内存这些常规的都有,
还有几个特殊的:一个做哈希混合的,一个生成随机数的,
一个会在运行过程中交换查找表里的两个值(等于程序一边跑一边修改自己的指令),还有一个表示整个流程结束的。
【这种活全丢给豆包干】
校验算法本身也是加密的
这套指令也是加密的。用的是 RC4 这种加密方法。
解密密钥的来源是一条比较长的链:
先用一个固定的起始值和 "PHILIA93" 这个字符串生成 64 字节的中间数据,
然后拿这份数据的 MD5 前 12 字节和 CRC32 前 4 字节拼在一起当 RC4 的密钥,
用这个密钥去解 .rdata 里面 5796 字节的密文。
解密结果也算一个 CRC32 来验证有没有解对。
程序的整体流程搞清楚了
从命令行拿两个十六进制参数解码成 32 个字节,
放进这套算法的内存里,然后执行编译好的校验指令。
校验指令做的事情就是拿你给的 32 个字节一个一个和里面写死的答案比。
防调试的部分
算法外面还包了一层防调试检查。反汇编出来的关键判断:
mov rax, gs:[0x60] ; 拿到进程的信息结构
movzx edx, byte ptr [rax+2] ; 读"是否正在被调试"的标志
test dl, dl
mov edx, 0
cmove r12, rdx ; 没被调试的话起始数字就是 0
mov edx, [rax+0xbc] ; 读全局调试标志
and edx, 0x70
je skip
xor r12, 大常数 ; 有调试痕迹就把起始数字搞乱
; 还检查了进程堆的标志和硬件断点寄存器 Dr0~Dr3
说白了就是:你没挂调试器,起始数字就是 0,后面的解密流程是正常的。
一旦挂了调试器,起始数字就变了,后面解密出来的东西全是乱的。
还有代码段完整性检查——对代码段内容算校验值和固定数字比较,
不一样就把起始数字搞乱。正常情况下文件没被改过所以能通过。
二、爆破
搞清楚了算法结构之后,下一步是把加密的指令段解开。
理论上可以在 Python 里把整条解密流程重新实现一遍,
但中间那段底层指令的手动模拟太容易出错了。
所以我直接把 cm4.exe 加载到我自己的 Python 进程里跑。
cm4.exe 没开地址随机化,加载位置是固定的。
我在那个位置上分配了一块可读可写可执行的内存,
把文件的各个段拷到对应的位置,
然后手动把导入表里的函数地址一个个填进去。
这样整个程序就在我的进程里面了。
坑一:地址被截断
分配内存的系统调用返回了 64 位的地址,但 ctypes 默认按 32 位处理,
结果地址被截断了,写内存直接崩了。
改成手动指定返回值类型为 64 位指针就好了。后面好几个系统调用同样的问题。
坑二:代码完整性校验没过
程序里那段检查代码段有没有被改的代码,
拿到的模块地址是 python.exe 的,不是 cm4.exe 的。
对 python.exe 的代码做校验,结果和 cm4.exe 里存的值对不上。
起始数字被污染了,后面解密出来的指令全是乱码。
一开始我以为是解密流程有 bug,查了半天。
后来加了个异常处理钩子,崩的时候把信息打出来看,才发现校验就没通过。
解决方法:把那段污染起始数字的代码 NOP 掉,13 个字节。
正常跑的时候这个校验会通过所以没事,在我的加载器里它永远过不了,NOP 掉没影响。
坑三:抓数据的时机不对
我需要拿到解密后的指令数据,最开始在分配内存的时候就去读了。
但分配只是划了块地,解密在后面才做,所以拿到的是垃圾数据。
后来在代码段末尾的空隙里写了跳板代码,
在算法开始跑之前先把程序地址、长度和起始数字存到一块空闲内存里,
然后跳回原来的位置继续正常跑。程序跑完之后再从那块空闲内存把解密好的数据读出来。
坑四:字符集搞混了
获取系统函数地址时用了宽字符版本的查找函数但传了个普通字符串。
宽字符版本要双倍宽的字符串,我给的是单字节的,
找不到模块返回了空,所有系统函数地址写成了空指针,第一个系统调用就崩了。
换单字节版本的查找函数就好了。
三、写注册机
解密拿到指令数据
上面几个坑都解决之后,跑了一次 main,成功拿到了解密后的校验算法数据。
CRC32 校验也通过了,说明解密结果是对的。
还原被打乱的查找表
要读懂校验算法在干什么,得先把查找表还原出来。
打乱时用的随机数是由一个起始数字驱动的,
这个数字是程序运行时通过校验值算出来的一个固定值,和用户输入无关。
有了起始数字就能一步步还原出打乱后的表——因为生成过程是确定的,同一个起始数字永远得到同一张表。
逐条解码
有了查找表之后就可以逐条解码了。
每个字节通过查找表映射到一个操作编号,
通过编号查操作数长度和跳转表,跳到对应的处理代码。
一共解码了 3572 条指令,全部有效。
发现密钥校验
解码出来发现程序前面一大段在做的事情非常简单:
不断地从内存的一个位置读一个字节,推一个期望值出来,比较两个值是否相等。
这个模式重复了 16 次,位置从 0 递增到 15。
按位置顺序对应的期望值:
位置 0 = 0x48 = 'H'
位置 1 = 0x41 = 'A'
位置 2 = 0x52 = 'R'
位置 3 = 0x44 = 'D'
位置 4 = 0x4C = 'L'
位置 5 = 0x56 = 'V'
位置 6 = 0x39 = '9'
位置 7 = 0x39 = '9'
位置 8 = 0x39 = '9'
位置 9 = 0x5F = '_'
位置 10 = 0x43 = 'C'
位置 11 = 0x4D = 'M'
位置 12 = 0x34 = '4'
位置 13 = 0x21 = '!'
位置 14 = 0x21 = '!'
位置 15 = 0x21 = '!'
拼起来就是 HARDLV999_CM4!!!
16 个字节全部比对通过之后,程序继续跑了三千多步做了一些计算,
最后把结果输出。
写出注册机
KEY = "***"
KEY_HEX = KEY.encode("utf-8").hex()
print(f"注册码: {KEY}")
print(f"命令行: cm4.exe --{KEY_HEX} {'0'*32}")
输出:
注册码: HARDLV999_CM4!!!
命令行: cm4.exe --484152444c563939395f434d34212121 00000000000000000000000000000000
后记
这个方案的问题在于校验逻辑和算法全在客户端,而且解密链的每一步都是确定的。
只要能在正常环境下跑一次,把解密后的指令数据截获,后面就是纯静态分析了。
算法指令集的逆向本身不难——44 种操作每种几条到几十条指令,
语义都很直白。这种活,思路对了,丢给豆包干,其他的 真正费时间的是搞清楚程序整体的调用流程和数据处理链,
以及那些环境校验失败时的影响。
如果要改进,最有效的做法是把校验结果和后续解密分开:
客户端只负责提交数据,服务端校验后返回结果,校验的时候多加点“脏东西”,对于逆向者而言,就是很恶心,要么加钱,要么回收站!!!
最后感谢各位阅读,如有说的不对的地方,请多多指教!!
如果你有搞不懂的程序,可以发我,抽空 帮你研究写个教程,记得给我加评分!