VMP加壳(三):VMP壳爆破实战-破解某编辑类软件&指令替换原理
这次爆破的是某编辑类软件,版本是32位绿色版本:V4.3.1
1、OD打开后发现了VMP0段,这里下个内存访问断点:
又来到这里了,非常明显的VMP入口特征:
一大堆push指令又开始保存物理寄存器;同时让esi指向虚拟指令集;和上面一篇文章分析的混淆手法一模一样,个人猜测用的VMP也是3.5.0版本的;
分配虚拟栈空间:
、
这里就不再重复分析整个VMP过程了,感兴趣的小伙伴建议看看之前的VMP系列介绍;为了快速定位关键位置,来到注册地方,随便输入一个辨识度较高的注册码:
同时记得继续在内存视图给VMP0段下断点后点击确认按钮,断到这里了,继续F7:
单步F7走着,同时在栈中回溯,找到了自己输入的sn,这个地址就很重要了,果断取消VMP0段的断点,对这个地址下读的断点:
这里也下访问断点;继续回溯栈,在栈下方凡是这个exe本身的调用都下断点:
这里有messageBox,栈里面也有sn和“该注册码不正确!”的关键提示,说明这里已经走到了注册错误的分支,需要回溯栈,看看在哪调用了这个方法(这里的返回地址是0x5E01E9)!
这里的地址是0x3E6000,text段的基址是0x381000,偏移=0x3E6000-0x381000=0x65000,记住这个偏移,后续可能有用;
继续F7,执行完messbaox弹窗后,居然回到了某个VM的入口:正常情况下,ret会回到call的下一行,但这里回到了push(或则说vm的入口),说明ret的的地址因该是人为故意加上的,也说明这段代码是关键代码,作者故意不想让逆向人员破解,真是此地无银三百两啊!
上面这个地方会不会是验证码检测的入口了?我没仔细分析,不过既然断到这里了,就有可能是,不管那么多了,先NOP这些代码试试,结果直接崩掉,说明不能这么粗暴,重新来!
上面是爆破软件的传统思路:通过字符串找到各种关键提示(sn、注册不正确之类的)的内存,通过访问断点定位到关键代码,然后逐步往上回溯找到关键的JCC指令,改变JCC指令的跳转方向达到爆破的目的;由于被VMP加了很多混淆指令,直接这样简单粗暴找JCC难度不小,这条路暂时放弃,得换个思路和打法!
2、既然3E6000是弹窗的,那么只要找出是哪个函数调用了这个函数距离JCC指令就更进一步了,上面就是这种思路。但ret后发现是VM的入口,并不是我们传统意义上的函数调用。既然动态分析行不通,那就静态查找试试;打开IDA,默认的base是401000,函数偏移是0x65000,绝对地址是466000,正好是目标函数:
为了方便,可以把base改成exe运行的base,也就是0x380000,能在3E6000这里直接看到函数了:
通过function calls能找到所有调用这个函数的函数:注意,这里的call用的是相对地址,不是绝对地址,所以直接用硬编码找是不行的!
一共有24个,如果挨个看代码难度非常大,只能继续接着动态调试:每个地方都下断点,然后继续操作注册的流程,满以为能找到调用点,结果一个都没断下来,说明注册失败弹窗的函数不是这么调用了,要么是jmp到这里,要么是call 寄存器这种间接调用;
3、静态分析也没找到关键的调用点,继续动态分析;VMP最大的特点就是混淆:明明一个简单的指令,非要用复杂的多行指令替代,那么这次就trace一下,看看从VM入口一直到弹窗,这中间究竟执行了哪些代码!
(1)既然5E01E9是关键代码,那么先trace一下,看看都有哪些代码执行过!先在0x5E01E9下个断点,然后开启trace功能,接着上面输入sn验证的操作重新做一遍,run trace界面就能看到指令执行的记录了,如下(这里顺便吐槽一下log to file的功能,只能看到地址和寄存器的值,看不到执行的指令,WTF.......):
接下来就是个体力活了:挨个找寄存器里面的值等于00000040的行(后续会解释原因),挨个下断点,比如下面这样:
这里不得不吐槽一下OD的trace功能不好用:OD内部没法搜索关键词,保存到文件后又看不到执行的指令,很不方便,果断弃坑,换成x32dbg;trace的步骤:
- 在内存布局那找到VMP0段下个一次性的访问断点,然后操作注册的流程,正常情况下会断到VM入口。这时单步步进,进入VM
- 在“跟踪”页面右键选择“启动追踪”,最后再在菜单栏选择“追踪->自动步进”,或则直接CTRL+F7
此时x32dbg会自动开始单步步进,直到注册界面弹框;整个过程我花了一上午+中午,超过5小时,终于看到弹框;在跟踪界面查到一共执行了0x3F617=259607行代码;
(2)这么多行代码,该怎么分析才能找到关键的JCC指令?在“跟踪”界面,右键选择搜索->常数,表达式这里输入00000040(后面会详细解释为啥是这个数),点击确定;
和0x40相关的指令有近100条,逐条筛选and指令(后续会详细解释为什么要重点查找and指令)。
可以看到除最后一条,所有的指令都是and eax,ecx; 第1、2条分别执行一次,第3条执行了30次;
接下来就是纯体力活了:
- 选中某条,右键 复制->索引;(虚拟机不好截图,用手机拍的,读者请多担待)
- 回到跟踪窗口,ctrl+g后粘贴刚才复制的索引,定位到那行and代码;然后 右键->信息 查看寄存器的值
对于eax=0x40、eflags=0x246或0xFFFFxxxxx开始的值,都下个断点(其实一共只有3个,也不用挨个检查,直接下断点也行);
这三个and eax,ecx都有个共同点:之前都执行了not eax和not ecx,原理后续再介绍!3个断点下来后,继续操作注册的流程,3个断点都成功断下,然后挨个过滤,把执行完and eax,ecx后eax=0x0的选出来,人为把eax改成0x40或0x246;
除此以外,由于影响eax的是ecx,所以把ecx=FFFFFDB9(或者是~276,因为刚好让ZF位=0,和eax与后也会让ZF=0)找出来,挨个下断点查看:
关键的两个and指令夹杂在jmp中间,如果不是trace,根本找不到这些关键点:
凡是eax不等于0x40的全都改成0x40
终于,在改了好多次eax=0x40后,成功爆破!
输入注册码购买之类的也没了!
4、(1)VMP的万用门
学过逻辑电路的朋友们都知道有一种门电路,叫与非门(俗称万用门),表示为: Nand(a,b) = ~a & ~b,就是两个数取反后再与;这是一个很普通的表达式,为啥要专门拿出来介绍?用名字就能看出来:万用门!汇编里面最基本的4种逻辑运算,都能用万用门表示,推导过程如下:
- Not(a) = ~a = ~a & ~a = Nand(a,a)
- Or(a,b) = a | b = ~(~a & ~b) = Nand(Nand(a,b),Nand(a,b))
- And(a,b) = a & b = ~~a & ~~b = Nand(Nand(a,a),Nand(b,b))
- Xor(a,b) = (~a & b) | (a & ~b) = (0 | (a & ~b)) | (0 | (b & ~a)) = (a & (~a | ~b)) | (b & (~a | ~b)) = (~a | ~b) & (a | b) = ~(a & b) | ~(~a & ~b) = Nand(And(a,b),Nand(a,b)) =Nand(Nand(Nand(a,a),Nand(b,b)),Nand(a,b))
这里感觉就有点饶了: ~a表示a取反,用Nand(a,a)表示时是~a&~a,表达式里面又嵌套了取反,感觉有点像盗梦空间............
再VMP 3.5.0版本中,大量使用了Nand运算来表示其他的各种逻辑,真实地隐藏了原本的各种逻辑运算,有效地加大了逆向分析的难度!所以上面
(2)指令模拟
(2.1)JCC跳转要依赖efalgs的标志位,而标志位又收到sub/cmp等指令的影响,如果逆向人员顺着sub/cmp等指令找JCC,会很容易暴露关键的JCC指令(我第一次就是用这种思路分析的),但找了很久都没找到关键的JCC指令,原因就是sub、cmp这种指令被混淆和模拟,请看下面的推导过程:
- -a = ~a+1 => ~a = -a -1
- ~(~a+b) = ~(-a-1+b) = -(-a-1+b)-1 = a-b => a-b = Not(NotT(a)+b)
//异或,这里相当于取反 // 请确保,n不是0,就是1 // 0 -> 1 1 -> 0 public static int flip(int n) { return n ^ 1; } // 如果n是非负数,返回1 // 如果n是负数,返回0 public static int sign(int n) { // (n >> 31) & 1: 非负数结果是0,负数结果是1 // >>> >> //通过flip反转: 非负数结果是1,负数结果是0 return flip((n >> 31) & 1); } //整个局部变量,只有c是自然数,其他取值都是0或1; public static int getMax2(int a, int b) { //这里任然可能溢出 int c = a - b; //分别求出三个值的符号状态已判断正负 int sa = sign(a); int sb = sign(b); int sc = sign(c); //a和b的符号一样吗?异或试试了 int difSab = sa ^ sb; int sameSab = flip(difSab);//a和b符号相同就是1,否则是0,这样才符合常识和直觉 // 返回a的条件 returnA == 1 应该返回a; // returnA == 0,不应该返回a // difSab和sameSab是互斥的; // a和b的符号不同,并且a非负,返回a,也就是difSab * sa=1; // 或则a和b符号相同,此时a-b绝对不会溢出,并且c符号是正的(说明a>b),返回a;中间的+相当于或, +前后是互斥的 int returnA = difSab * sa + sameSab * sc; // a和b只能返回1个 int returnB = flip(returnA); return a * returnA + b * returnB; }
public static int add(int a, int b){ int sum = a; while(b!=0){ sum = a^b; b = (a&b)<<1; a = sum; } return sum; } public static int negNum(int a){ return add(~a,1); } public static int minus(int a,int b){ return add(a,negNum(b)); } public static int multi(int a,int b){ int rest = 0; while(b!=0){ if((b&1)!=0){//最右位是1,需要相加 rest = add(rest,a); } a=a<<1; //配合b逐位检查是否为1 b=b>>>1;//b逐位检查是否为1,如果是1就要加a } return rest; } public static boolean isNeg(int a){ return a<0; } /* * a/b就是不停地循环a-b,直到a-b<0; * */ public static int div(int a, int b){ //先转成正数 int x = isNeg(a)?negNum(a):a; int y = isNeg(b)?negNum(b):b; int rest = 0; for(int i =31;i>-1;i=minus(i,1)){ if((x>>i)>=y){ rest = rest | (1<<i); x = minus(x,y<<i); } } return isNeg(a)^isNeg(b)?negNum(rest):rest; } public static int divide(int a, int b){ if(b==0){ System.out.println("divisor is 0"); return 0; } if(a == Integer.MIN_VALUE&&b==Integer.MIN_VALUE){ return 1; }else if(b==Integer.MIN_VALUE){ return 0; }else if(a == Integer.MIN_VALUE){ int rest = div(add(a,1),b); return add(rest,div(minus(a,multi(rest,b)),b)); }else{ return div(a,b); } }
前面做的CMP、sub等指令,结果都会反馈到eflags的ZF位,不考虑其他位,当eflags=0x40时,ZF=1,JCC指令才会根据实际情况跳转, 所以要想办法改变eflags的ZF位。很不幸的是:x86汇编指并未提供可以直接修改ZF位的指令(只有STC、CTC、CMC、bt等少数指令可以修改CF位),这个该怎么修改ZF位了?
VM的一大特点:寄存器都是虚拟的,存放在栈中,所以VM的eflags是可以随便改的!那么vm的eflags值是怎么计算得到的了? 计算方式如下:
- eflags = and( eflags1, 0x815) + and( eflags2, not(0x815)) ,其中eflag1和eflags2都是Nand(sn,sn)+随机数得到的,不过这两个数不重要,只要eflags的ZF=0就行;下面标红的这段就是前半段and(eflags1,0x815),eflags1原值0x286,保存在ecx;0x815在eax,and eax,ecx后把结果保存在eax=0x4,然后写入epb+4的位置(也即是虚拟eflags的位置)
-
zf = and(0x40, eflags) ZF取决于原eflags的值。具体到汇编代码层面,用的还是and eax, ecx;,所以上面要重点对这行代码下断点调试;eflags保存在ecx(应该是0x246或0xFFFFxxxx形式),0x40保存在eax,所以断点可以根据这两个条件筛选;执行完后的结果保存在eax,然后写回栈上面的VM_CONTEXT中的eflags位置,这样代码执行完后如果eax=0,要手动改成0x40;
参考: 1、https://www.52pojie.cn/thread-1304279-1-2.html 爆破vm代码关键点之某文本编辑辑xxxxEdit4.3.1(4480)的分析与爆破
2、https://bbs.pediy.com/thread-224732.htm 谈谈vmp的爆破
3、https://www.52pojie.cn/thread-1036956-1-1.html VMP学习笔记之万用门
4、https://www.bilibili.com/video/BV1444y187AC?p=6 a和b比较大小、加减乘除的实现