逆向VMP壳的基本思路
发现需求
故事最早发生在2020年的8月份,我参加数学建模大赛,为了赛前准备购买了全套数学课,2021年5月份的时候我准备换台笔记本玩,在整理文件的时候看到了当时买的这个正版软件数学建模专用播放器.exe
犯了难,软件注册时使用一机一码的方式,换了电脑注册码就失效了,说白了就是白买了,虽然只需要提交电脑的购买凭据,就可以帮着更新激活码,然而这种一机一码绑定的方式还是太不痛快,尤其是数学算法这东西,老不用就容易遗忘,没用的时候又懒得看,说不准哪天就要翻出来看一下,到时候还得找对应的电脑才能看,烦。所以,搞起来。
注销软件激活码
激活成功的软件每次都自动登录。这样不行,不会到之前的状态,就无法逆向注册过程,因此必须要回滚到登录之前的状态。内存是动态的,想存储激活码一定会对硬盘进行读写,一般程序都会在AppData里做手脚,我们只需要找到配置文件并删除就可以了,然而茫茫文件海,如何找到对应的配置文件呢?
我们在新机子上打开一次数学建模专用播放器.exe
,然后记录一下系统时间2022-6-26 7:15,然后使用everything全局搜索日期为2022-6-26的文件变更,逐条时间一致的,找到目标文件夹DRMsoft(有一说一这文件名起的逆天,还以为装的是系统硬件驱动文件呢)。
直接到C盘目录下把这个文件夹直接删了,重启软件发现激活状态已消失。
架空注册服务器
软件上写着需要联网验证说明在激活时会想服务器发送网包,老鸟秒懂环节,直接就上recv断点(联网==recv)。系统dll有静态调用和动态调用两种方式,第一次搜索没找到,所以是动态调用,因此需要触发一下网包收发函数,这里随便输入一个账号和密码点击播放进行一下注册,管他注册成没成功,WS2_32.dll装载了就行,打开CE→附加进程→Memory View→View→枚举DLL和符号
找到WS2_32.dll,ctrl+F搜索recv,双击进入,直接在函数入口下个断点,再次点击注册。
程序断下来了,直接搂一眼堆栈,逐级回溯断点测试,回溯到49434F这个地址时,可以发现0089434A这是地址是处理网包接收的用户层函数,记下这个地址,之后会用到,很多人不知道怎么推测函数功能,其实就是看函数断点和软件功能的紧密度就可以,比如看0089434A这个call,它有如下特点:
- 断网的时候它不调用,联网才调用,很合理因为断网的时候收不到回显,socket连接直接抛异常去了。
- 只有在点注册的时候才调用,不点不调用,这也合理,因为只有注册的时候才需要发网包。
- 运行完了之后才会弹出注册失败这个BOX,这也合理,因为注册失败前会校验回显数据对不对。
根据上述三个特征可以发现,0089434A这个call,假设它是收网包用的,那么它在运行特征、功能关联性和运行顺序上都符合预期,那么它就是干这个的了。假设是怎么来的?只能靠经验,代码写多了就能假设了。
接下来就是关键,我们怎么才能知道软件访问的服务器是哪个呢,现在用的最多的就是http协议,除了个别肝帝(比如说我)才会用纯socket建服务器,单次数据量不大的时候,http是最万能的协议,那就先试着找一下http的url地址,不出意外软件会在内存里拼装http协议的url、header、body这些数据,最好找的无疑是url。
直接在CE中选择String类型数据,搜索关键字“http”就可以找到内存里的url,这里注意,一定要在网包send→recieve这段时间里加断点后再搜索关键字,否则一般程序不会在内存中拼装完整的url信息,导致搜不到结果。
一共搜到了8个结果,点击红色箭头加入address list里面逐一进行排查,这里把字符串长度都设置成100,即可查看完整的字符信息,这一对乱码其实是中文,CE解析不了,反正我觉得带中文的http请求肯定是闹着玩儿的,你们自己体会一下,我说的对不对,我压根没去看这几个中文url,直接能看到的网站有三个,分别是
- www.videolan.org
- weidian.com
- rz.protect-file.com
这域名一摆就是个单选题,太显然!weidian.com明显是个微店官网,vidiolan点击去是个播放器官网,最后一个域名带protect关键词,保护什么呢,肯定是注册码呀,明显就是它了。
然而跟rz.protect-file.com主机相关的有三个url,我们如何确定到底哪个链接是管注册码的呢? 总之,先试着把主机架空,直接到C:/Windows/System32/drivers/etc目录下面找到host文件,将rz.protect-file.com域名映射成自己的公网服务器(私网也行,我懒得搭了)
# Copyright (c) 1993-2009 Microsoft Corp.
#
# This is a sample HOSTS file used by Microsoft TCP/IP for Windows.
#
# This file contains the mappings of IP addresses to host names. Each
# entry should be kept on an individual line. The IP address should
# be placed in the first column followed by the corresponding host name.
# The IP address and the host name should be separated by at least one
# space.
#
# Additionally, comments (such as these) may be inserted on individual
# lines or following the machine name denoted by a '#' symbol.
#
# For example:
#
# 102.54.94.97 rhino.acme.com # source server
# 38.25.63.10 x.acme.com # x client host
# localhost name resolution is handled within DNS itself.
# 127.0.0.1 localhost
# ::1 localhost
127.0.0.1 www.xmind.net
49.234.72.215 rz.protect-file.com
再次点击注册按钮,指向rz.protect-file.com的网络请求就会发到我们自己的服务器上,咱们也没有验证码呀。肯定会出问题,但价值在于,我们可以拿到完整的url路径。为了方便对服务器进行debug、流量分析、保护服务器免受黑客攻击等,服务器都会有自己的日志系统,在里面会标明来自客户机的各种请求。
我们点击注册后,到服务器上查看最新的日志信息,就会发现这个不同寻常的信息,我的服务器根本没有这个密码文件,而且它访问的rzbfy.asp文件也和CE里扫描出来的一模一样,那么这个请求大概率是来自目标软件的。
据此我们获取到了完整的url请求路径,放到postMan里过一遍,方便分析请求参数。发现开发者已经给我们安排的明明白白了,pwd显然是password简写,uid是user id的简写,账号和密码都有了,v应该是version版本号,显示是2019,这个code不出意外和我们的机器码有关,所有的参数信息齐备,通过账号+密码+机器码计算出加密注册码,上网一搜.asp确实也是一种编程文件,所以秒懂,这显然是一个自动计算注册码的后端。
直接跑到真网站GET一下拿到注册码回显,一个朴素的想法就是自己搭一个服务器,然后不论机器码对不对都返回正确的注册码,就可以实现破解,说干就干。
AAAAAA474B052F13794348074E005A76B91618771B00CA7309664D|0|||b3642b893f8124d43fc5f0565b..
像这种“便宜的服务器需求”直接使用python搭最快,装一个Flask,实现如下代码,
import re
import json
import urllib.parse
from flask import Flask
from flask import request
import sys
app = Flask(__name__)
def kill():
raise KeyboardInterrupt('我自己想退出') # CTRL+C异常
# raise Exception('再来一个') 利用异常来退出程序
@app.route('/', methods=['GET', 'POST'])
def hello_world():
print("cookies:")
print(request.cookies)
for key in request.args.keys():
print(key)
if key.__eq__("keyWords"):
keySentence = request.args["keyWords"]
keySentence = urllib.parse.unquote(keySentence)
return getFinalJson(keySentence)
return 'hello world'
@app.route('/2019/u3492432002/rzbfy.asp', methods=['GET', 'POST'])
return "AAAAAA474B052F13794..."
if __name__ == "__main__":
app.run(host='0.0.0.0', port=80)
原则就是首页显示hello world方便debug,然后只要请求了rzbfy.asp文件就直接返回注册码。最后再改一次Hosts文件把 rz.protect-file.com映射到本地。直接注册看一下,淦,没成。
猜测应该使用机器码和注册码拼装后做了md5(或sha)把数据锁住了,防止篡改,再来个私钥签名,直接在客户端解一下签名,提取机器码和注册码重新做一次杂凑,比对一下结果,都对的话才通过。
制定破解方案
经过之前的猜测产生了另一个朴素的想法就是:把新机器的机器码改了。
硬要改肯定是有方法的,不过听同学说之前他的电脑送去维修被改了机器码,打LOL直接被认定为黑机器,封号3年。所以改机器码很有可能威胁到其他软件的正常工作,贻害无穷,为了一个听课软件,不值得做到这个地步。
于是Plan B,直接虚拟机,然后改虚拟机起码,可惜软件有虚拟机检测,在虚拟机里用不了。最后的Plan C就是破解这个注册校验过程。
对比Plan B和Plan C两者的难度差不多,都需要逆向,然后改关键的JCC,区别在于虚拟机检测应该运行的比较早需要使用调试器阻塞式启动软件,之前测过了,这个软件还有设有反调试,使用调试器启动会紧急退出。综合对比两者的难度、成本和收益如下。
Plan | 难点 | 调试时间成本 | 收益 |
---|---|---|---|
B | 不但需要逆向虚拟机检测函数,还需要破解反调试机制 | 需要虚拟机和真机双开调试,每次测试需要重启软件,调试速度慢 | 需要搭建私服、更改虚拟机机器码和CE脚本才能破解,仅限于在虚拟机使用 |
C | 网包收发→加密解密需要大量汇编代码,运行流程长,关键代码定位困难 | 可以启动软件后附加进程,绕过启动反调试,点击按钮即可触发函数,无需重启,所有操作在真机内完成,调试速度快 | 需要搭建私服、CE脚本才能破解,一次破解后可在真机上随意使用。 |
注意,这里我并没有对Vmp进行考量,主要是当时我还不知道这个不起眼的软件还设有vmp,确切的说,当时的我还不知道vmp是什么,因此对比结果是Plan C的成本低于B,收益高于B,果断选Plan C了。
当我用老方法开始测得时候,就开始发现各种不对味儿了,虽然键盘接听事件还算是一个正常函数,但是随着调用级数增大,软件的行为开始愈发古怪。这里放一下当时分析出的逆向树,大概体会一下。
两三级call调用后,很多call都变成阻塞式的,单步进到函数体里跑没多久call又阻塞了,而且还发生了各种循环式的jmp跳转,而且软件大量使用jmp ebp这种不受控的动态跳转,导致每个call都跟变色龙似的,什么事情都干,根本无法跟软件功能进行关联,此时我意识到了,我迎来了我逆向生涯的新挑战。
VMP逆向原则 - Trace原则
从最初2021年5月份破解这个软件开始,到2022年6月份,整整一年的时间里,我不定期的重新尝试破解这个软件,但总是被软件的VMP壳搞得焦头烂额。不过这期间我用Java实现了自己的matlab解释器、图形化语言编译器等项目,并多次在“看雪”上观摩前辈们的vmp逆向经验,这些工作让我对VMP的理解缓慢的加深着,知道2022年6月初的时候,我在逆向Android平台的时候,偶然发现了Profiler性能面板的CPU trace功能的妙用,启发了我逆向VMP的新思路。
VMP为了防止逆向分析的一个重要的干扰就是乱序,运行几行汇编就各种jump,VMP使用的jump方法是jxx指令和CALL,RET来进行。所以vmp里的call和ret已经不能当正常人来看待了,都是无差别跳转的疯子,同时vmp代码的堆栈的分析价值也约等于无,系统原生的程序运行套件堆栈+寄存器全都变成了工具人,真正的逻辑控制权全都交给内存里的伪堆栈和伪寄存器进行了。而且原生套件的数据你基本不能改,使用vmp会导致代码间高度耦合,根本不是覆盖了堆栈和寄存器能够解决的,这个之后细讲。
而且VMP也会大量引入垃圾指令,进一步增加逆向难度,拖IDA就可以看到,vmp0段的代码,每运行几行就填充一大串垃圾数据,这些数据除了占用硬盘空间以外,对程序运行也没什么影响,只是苦了那些读汇编代码的Hacker,将毫无价值的垃圾灌输进你的大脑,让你的耐心、身体状态、情绪都降低到冰点,从根本上突破你的心里防线,让你看见这软件就恶心,从此走向使用正版的“官道”,这就是信息安全心理学的应用。
想要快速逆向VMP要达成三个条件:
- 将有用的代码拼装起来
- 屏蔽垃圾指令
- 抹除call和ret的树状逻辑,将代码平面化
使用CE的指令trace功能可以帮我们达成前两个条件。trace可以将程序执行过程中的所有汇编指令都记录下来,同时保存指令的寄存器快照,这样我们就可以只专注于分析已执行的汇编,而不被乱七八糟的垃圾指令干扰。
打开CE→Memory View→File→Load Trace→点击取消,即可打开trace面板,点击左上角File→New Trace可以新建一个trace。
这里我们使用硬件断点,设置跳过系统模块,只记录exe的汇编运行情况,并最多记录300000条汇编指令,设置记录截至条件为:弹出“校验错误”Message Box的call。这个call地址怎么找我就不多说了,跟找recv一样,也是关键API断点+堆栈回溯法找。这个起始地址可以用我们之前找到的0x0089434A,也可以用我后来找的地址0x902B6F,这个地址是手动从0x0089434A单步调试出来的,也就是step into向下回溯法找到的,能比0x0089434A trace的快一点,也就一点吧,估计能少trace 几千行汇编,区别不大。
这里trace 30万条指令是有讲究的,根据前辈的经验,从校验一个注册码到激活大概需要最多30万行,因此直接按找trace上限来设置,宁可多,不可少。
但是,但是!千万别使用0x005DF7C0这个“键盘接听”函数来trace,因为键盘接听函数一定会调用0x0089434A网包收发,但是网包收发函数前的汇编都是垃圾指令,跟我们要破解的功能没有关系,我们的问题是校验过不去,只有拿到了回显数据才会运行校验检测函数,我们只关注校验部分,也就是从收到回显→校验失败这个过程之间的汇编实现。如果你从0x005DF7C0开始trace,就一定会trace网包收发函数,而像网包收发这种复杂功能少说需要10万行汇编才能完成,一下引入这么多垃圾指令,给自己找不痛快?
还有注意一点,这里虽然设置的了起始记录的地址,但CE并不会自动将断点设置在起始位置,需要自己鼠标点到对应Address的位置,点击ok才能打断点。
点击OK,等个10分钟就trace出结果了,前辈说他用了5个小时,我寻思可能电脑不太行该换了,或者x64dbg不太行CE比较快。反正我30万行10分钟就出来了,如果设立stop条件只需要trace 3000行左右,直接秒出。
这里我们trace两次,一次是注册成功的代码,起名叫success.cetrace,另一个是注册失败的起名叫fail.cetrace
trace文件分析
CE自带的tracer分析器太鸡肋了,功能太少了,不显示代码数量,也不支持字符串搜索,虽然支持lua搜索,但每次都写string.find(instruction,"edx")!=nil
这种东西太费事了,所以决定自己用Java写一个cetrace文件的分析器。
先用010Editor分析一下cetrace文件,然后用Java声明几个类做变量对齐,纯纯体力活。
这里可以看到success.cetrace一共收集了34994条汇编指令,fail.cetrace也差不多3万条
拿到数据后,设计一个算法将success.cetrace和fail.cetrace合并到一起,做一下关键branch预测,自动生成一份trace 报告。可以看到一共过滤出15个关键branch,这些branch节点附近,success与fail的运行存在差异。基本可以确定关键跳就在候选的这3万条trace里。
VMP逆向原则 - 改判不改跳原则
一个朴素的想法就是找到关键跳直接把目标地址改了,比如jz改成jnz之类的,这种方法在正常软件环境下可行,然而VMP环境下就痴人说梦了。我们之前就提到了,vmp环境下的代码耦合非常强,这是因为vmp汇编传递寄存器的方式是通过内存来实现的,内存的更新速度非常慢,往往10几条汇编过去了都不更新。
而汇编代码只是vmp的输出实现,vm虚拟机通过维护内存区的方式记录程序执行顺序,实现分支逻辑。也就是说给定计算结果(如elflags)vm虚拟机能够正常进行分支处理,但直接跳转到目标地址,将会导致虚拟机伪寄存器、伪堆栈、伪内存空间无法正常同步。
如果运行逻辑由系统控制,则可以通过手动同步寄存器和堆栈的方式维持程序正常执行,然而现在逻辑控制权掌握在vm手里,除非能改掉内存里vm伪寄存器的值,否则vm无法实时同步伪寄存器的值,它会以为程序执行了jmp到地址A,结果你啪一下把地址A变成地址B,vm也不知道,伪EIP直接走飞。
总结:系统寄存器和堆栈只是摆设罢了,纯纯工具人,不用来实现逻辑,能跑就行,修改vm分支的核心是改内存数据(直接或间接),而不是直接控制分支逻辑,和vm“对着干”。当然基本都是间接改啦,修改加法结果啊,更改与或非结果寄存器等,这些结果数据迟早会被提交给vm内存,就不用纠结于找vm地址在哪儿了。直接改?不会有人能直接靠汇编就看懂vm吧,那确实大佬!!!反正我不行。
爆破关键跳
结合前辈们的经验我们知道虚拟机关键跳是通过万能与门计算操作数与0x40的结果,来更新zf寄存器,然后实现关键跳转的,那么只需要找到3万条指令中所有寄存器值等于0x40的代码即可。直接实现如下java代码,注意比对寄存器,判断其值是否伪0x40,如果为0x40则收集该指令,否则舍弃。
public String toString() {
StringBuilder s = new StringBuilder("CETracer show:\n");
for(Instruction ins: traceList)
{
if(condition(ins))s.append(ins).append("\n");
}
return s.toString();
}
public boolean condition(Instruction ins)
{
Class<CERegInfo> c = CERegInfo.class;
for(Field field:c.getFields())
{
try {
if(field.getType().equals(long.class))
{
long b = (long) field.get(ins.getRegs());
if(b==0x40) return true;
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
return false;
}
运行脚本后,一共过滤出了246条代码,全局搜索和and相关的代码,一共找到两条,逐一测试即可,
最终定位到关键跳:
-24437 - 清风数学建模视频系列Windows电脑专用播放器.exe+77F147 - and edx,eax
CERegInfo{CS=23,SS=2B,DS=2B,FS=53,ES=2B,GS=2B,RAX=200346,RCX=FDF6B,RDX=40,RBX=156C24C,RSP=19EFE4,RBP=B7F124,RSI=179A68E,RDI=19F0C6,R8=2B,R9=77BE4CCC,R10=0,R11=200246,R12=359000,R13=9FDA0,R14=9ED10,R15=77B636E0,RIP=B7F147,}
在入口地址下一个断点,执行完and edx,eax后edx=0x0的选出来(相当于zf=0),人为把edx改成0x40,相当于把zf=1的数据提交给内存,原来判断为false的指令,就会判断为true。破解成功!
可以编一个lua脚本自动化地完成上述过程:
debug_setBreakpoint(0x00B7F149)
function debugger_onBreakpoint()
if EDX==0x0000 and EAX~=0x00 then
EDX=0x40;
return 0;
else
return 1;
end
end