VMP加壳(三):VMP壳爆破实战-破解某编辑类软件&指令替换原理

时间:2024-03-18 09:35:10

  这次爆破的是某编辑类软件,版本是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这种指令被混淆和模拟,请看下面的推导过程:

      cmp指令本质上是减法,只不过结果不会写回操作数,所以模拟减法就很重要了!下面是减法的模拟过程:
  •       -a = ~a+1  => ~a = -a -1
  •       ~(~a+b) = ~(-a-1+b) = -(-a-1+b)-1 = a-b  => a-b = Not(NotT(a)+b)
        a-b最终可以由Not(NotT(a)+b)来表示,而Not(a)又可以用Nand(a,a)来表示,这就导致了VMP中not eax; not ecx; and eax,ecx代码大量出现,在trace的时候某些代码甚至执行了上千次
 
     (2.2)a和b比大小,大家第一想到的就是if(a>b),或则cmp a,b,这里可以用另一种方式替代:下面的函数也能返回a和b较大的数,但是没有任何大于符号,编译成汇编后会让逆向人员更加难以理解!这么做的本质是:把互斥条件的if else用+号替换
    //异或,这里相当于取反
    // 请确保,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;
    }
     (2.3)同理,加减乘除都可以“迂回”实现,而不是直接用+、-、*、/  这些明显的运算符号,比如:
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);
        }
    }
     (3)eflags位模拟

       前面做的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比较大小、加减乘除的实现