查看原文
其他

代码混淆之我见

三十二变 看雪学院 2019-05-26


前言:


本人只是一个中学生,属于业余爱好者,没有经过系统学习,如有错误,望指正。



  • 混淆技术概论 


究竟是什么是混淆?如果仔细思考一下这个问题,就会发现混淆其实是打破思维惯性的一种方式。不可否认惯性思维带来的方便,但是同样有其局限性。我们一般对反汇编代码进行还原时,默认CALL就是对一个函数的调用,碰到RET就是函数返回,条件分支两侧的代码都有可能被执行。而代码混淆就是打破了这种思维惯性,让逆向工程变得更加复杂。


说到混淆,就不得不提到编译原理。编译器在把中间代码翻译为目标程序时,会先经过一个代码优化器来处理。


其过程可以表示为:


源程序 - > 前端 - > 中间代码 - > 代码优化器 - > 中间代码 - > 代码生成器 - > 目标程序


而混淆,就是代码优化器的逆过程。


相应的,混淆技术也可以分为两类,基于控制流的混淆,基于数据流的混淆。而实际应用中,这两类技术都会被紧密结合在一起。


所以如果一个人要进行解混淆操作,而这个人恰好对编译优化技术一窍不通 (   比如我   ),可以说,他能做到的最好的解混淆也就是搜一搜特征码,做模式替换。 



  • 基于数据流的混淆技术 


(1)常量展开(常量合并的逆操作)


编译器在编译时,会把那些在每次运行时总是得到相同常量值的表达式替换为该常量值。


int a;
a = 5 * 7 + 10;


比如上面这段代码,在现代编译器中是不可能把"5 * 7 + 10"这个计算过程编译进目标程序的,因为这个值在编译时就可以推算出来。


对代码进行混淆时,我们可以提取出一些指令的立即数,对其进行展开。  


push 2


push 1
inc dword ptr[esp],1


这两段代码是完全等价的,如果忽略标志位的变化的话。在VMProtect 1.X(早期版本)中,该混淆技术被大量运用。



(2)恒等运算


x - 1 == ~-x
x + 1 == -x
ror x,y == x >> y | (x << (lenbite(x) - y))
rol x,y ==x << y | (x >> (lenbite(x) - y))


lenbite指取位数,比如DWORD取位数是32 。


not reg32

xor reg32,-1


上面两条指令是完全等价的,但将not reg32替换为xor reg32,-1,足以使一些人摸不着头脑。


(3)模式替换(窥孔优化的逆操作)


push x

lea esp,[esp - 4]
mov [esp],x


lea esp,[esp - 4]


push reg32
mov reg32,esp
xchg [reg32],esp
pop esp


上述第1段代码与第2段代码是完全等价的,而第3段代码与第4段代码又是完全等价的。


这项技术的可怕在于,这个过程是可以不断重复的。混淆了一次代码,可能又会有新的可以混淆的代码出现。


这项技术在Themida的虚拟机里运用地非常成熟。


当然,尽管其变形后的代码往往让人读了之后有想吐的感觉,但其实去混淆非常简单。你放心,我绝不会编这么一个无聊的小孩子谎话来骗你,只要搜集到足够多的模板,对其替换回来就好了。唯一要考虑的难题就是,你的编码能力能不能支撑你写完一个高阶的模式匹配器。



  • 基于控制流的混淆技术


(1)插入死代码(消除死代码的逆操作)


如果一个变量在程序的某一点上的值以后可能会被用到,则该变量在改点上是活的,否则,它在该点上就是死的。与此相关的一个概念就是死代码,即永远不会被使用的语句。


基于该项技术,还可以延伸出伪造基本块等混淆技术。


其思路就是在两个基本块之间,或者我们可以在一个基本块里强行再随机选择首指令划出两个基本块,这两个新基本块本应该有一条无条件转移指令连接,我们可以伪造一条条件跳转指令,而有效的,永远是两侧分支中的一支。如果更进一步,对于那些永远不会执行的代码,我们可以进行巧妙构造来对抗静态反汇编工具。


如何不让破解者轻易的发现这是伪造的基本块呢?


我们可以引入一些必然成立的数学公式,或者在某个范围上必然成立的数学公式。


比如,贯穿整个高中函数题的基本不等式。


a^2 + b^2 >= 2(ab)^(1/2)


或者柯西不等式。


(a^2+b^2)(c^2 + d^2)≥(ac+bd)^2


再阴险一点的话,可以考虑去数论里面找一些灵感,比如费马小定理。



(2)流图展平


流图是用来表示中间代码的一种方式。流图就是一个有向图,基本块构成图的节点,基本块之间以转移指令构造出来的联系就是图的边。


借助IDA等工具,我们可以清楚地看到一段代码的流图,而这对我们逆向工程的进行是很有益处的。有了控制流,我们就知道往哪里入手,如果走控制流行不通,我们就只能走数据流,比如常见的我们对内存中的关键信息下内存断点。


如果数据流和控制流都走不通,那么唯有设计一个解混淆工具,这是背水一战。 


所谓流图展平,其实和虚拟机是差不多的一项技术。


如下图所示,就是一个非常典型的虚拟机。



    而流图展平,和虚拟机一样,都是对程序的控制流进行了处理。唯一不同的就是,虚拟机里的Handle,变成了基本块。



    (3)虚拟机 


    没什么多说的,大名鼎鼎的一项技术。


    简单来说就是将部分代码重新编译一次,并打包解释器进程序,运行的时候动态解释执行。


    但这种技术会带来很多额外的CPU开销,不能用来,或者说不会有人用来保护算法性质的代码。 


    (4)打乱代码顺序局部性 


    同一基本块中的指令基本都是按顺序排列的,而不同基本块之间也基本上是按照一定顺序排列的,因为这可以减少跳转指令的数量。


    在十年前,或者说更早的时候,用JMP IMM32来打乱代码顺序这种技术的运用可以说是数不胜数。


    但以现在的眼光来看,这种技术完全是幼儿园五岁小孩的把戏。有太多办法可以把它KILL掉,这里不多赘述。 



    (5)特殊的控制流模糊化 


    我们可以采取异常处理机制,设置程序的下一步状态。但这也是基于系统的,你不能指望在Linux上面还能使用VEH。


    实现思路就很简单了,现在也还有一些壳会用到这种技术。


    比较常见的应用就是,安装一个SEH,然后触发一个异常,在异常处理Handle里面将程序设置回正常的状态。


    如果说阴险的话,我首推PESpin壳,该壳的v1.33版本会使用Debug机制接管程序,并转移一部分指令进虚拟机,异常触发时由调试程序来执行。



    • 总结


    如果把上述的混淆技术,单独提出一两种来运用,则其效果是非常差的。一个好的混淆引擎,应该设计成可重用的,支持多次轮环加密的。


    尝试实现一个混淆器之流图展平 


    源程序里面用到的PE操作库,反汇编引擎,汇编引擎,我都是直接在Github上面找的,它们分别是,PEFile、BeaEngine、XDEParser。


    如果要实现流图展平的话,首先我们得理出程序的基本块。


    基本块的定义如下:


    (1)控制流只能从基本块的第一条指令进入该块。也就是说,没有跳转到该基本块中间的指令。

    (2)除了基本块尾指令,否则不会有机会离开基本块。 

    如果要划分基本块的话,首先需要确定首指令。


    首指令的选择规则如下:


    (1)待混淆的所有代码的第一条指令是首指令。

    (2)任意一条转移指令的目标指令是一个首指令。

    (3)紧跟在一条转移指令后的指令是一个首指令。 


    每个首指令对应的基本块包括了从它开始,直到下一个首指令(不含)或者结尾指令的所有指令。 


    以一段汇编代码为例。如下图所示:



    首先,我们需要选择首指令,根据首指令的选择规则,标出首指令。



    接着选择基本块。


    按照基本块划分规则,我们可以划分出。


    1>
    mov eax,2018h
    cmp eax,7DFh
    jbe Label1

    2>

    mov ecx,1
    jmp Label2

    3>

    mov ecx,0

    4>

    ret


    实际处理的时候,如果尾指令是流程转移指令,我们应该把这条指令抹掉,因为每个代码块之间的联系不再是由流程转移指令维护了,而是隐藏在对Dispatch的调用中,而对于尾指令不是流程转移指令的,我们可以向其尾部附加一条无条件转移指令,当做尾指令是流程转移指令的情况来处理。


    Dispatch的设计如下:


    pushad
    pushfd
    mov     edx,dword ptr[esp]         ;edx - > eflags
    mov     ecx,dword ptr[esp + 02Ch]    ;ecx - > BranchType
    cmp     ecx,0                       ;JO
    jnz     @F
    push    edx
    popfd
    mov     edx,0
    mov     eax,1
    cmovo   edx,eax
    jmp     end5
    @@:
    cmp     ecx,1                       ;JC | JB
    jnz     @F
    push    edx
    popfd
    mov     edx,0
    mov     eax,1
    cmovc   edx,eax
    jmp     end5
    @@:
    cmp     ecx,2                       ;JE
    jnz     @F
    push    edx
    popfd
    mov     edx,0
    mov     eax,1
    cmove   edx,eax
    jmp     end5
    @@:
    cmp     ecx,3                       ;JS
    jnz     @F
    push    edx
    popfd
    mov     edx,0
    mov     eax,1
    cmovs   eax,edx
    jmp     end5
    @@:
    cmp     ecx,4                       ;JP
    jnz     @F
    push    edx
    popfd
    mov     edx,0
    mov     eax,1
    cmovp   eax,edx
    jmp     end5
    @@:
    cmp     ecx,5                       ;JL
    jnz     @F
    mov     eax,1
    push    edx
    popfd
    mov     edx,0
    cmovl   edx,eax
    jmp     end5
    @@:
    cmp     ecx,7                       ;JG
    jnz     @F
    mov     eax,1
    push    edx
    popfd
    mov     edx,0
    cmovg   edx,eax
    jmp     end5
    @@:
    cmp     ecx,6                       ;JA
    jnz     @F
    mov     eax,1
    push    edx
    popfd
    mov     edx,0
    cmova   edx,eax
    @@:
    mov     eax,dword ptr[esp + 01Ch]    ;JECXZ
    cmp     eax,0
    mov     ebx,1
    cmovz   edx,ebx
    jmp     end5
    end5:
    push    dword ptr[esp + edx * 4 + 024h]
    add     esp,4
    popfd
    popad
    sub     esp,28h
    ret     30h


    调用方式很简单。其实是我为我自己的懒惰找了个借口 :) 


    push    BranchType
    push    Branch2     //跳转成立的目标代码块
    push    Branch1     //跳转不成立的目标代码块


    对于JX和JNX这种类型的跳转,我只实现了JX一种。


    其实这两者是可以进行转换的。


    如下。


    JNX Label1

    Label2:


    JX Label2

    Label1:


    这两段代码其实是完全等价的。因为我们设计的Dispatch可以同时指明Label1与Label2,所以把JNX转化为JX是一件很方便的事,只需要把两个目标互换一下即可。


    而对于无条件转移指令,可以随便用一条条件转移指令来替代,只要把两个目标设置成一样的即可。在ZProtect里面就大量运用到了这一技巧。


    for (unsigned int i = 0; i < vAsm.size(); i++)  //对所有跳转指令的下一条指令,跳转指令目的地,以及最后一条指令做标记
    {
       if (IsBranch(vAsm[i].stAsm.Instruction.BranchType) && vAsm[i].stAsm.Instruction.AddrValue != 0)
       {
           vAsm[i + 1].states = true;
           for (unsigned int x = 0; x < vAsm.size(); x++)
           {
               if (vAsm[x].stAsm.VirtualAddr == vAsm[i].stAsm.Instruction.AddrValue)
               {
                   vAsm[x].states = true;
               }
           }
       }
    }
    vAsm[vAsm.size() - 1].states = true;

    CodeBlock stBlock;
    for (unsigned int i = 0; i < vAsm.size(); i++)
    {
       if (vAsm[i].states == true)
       {
           stBlock.Entry = (int)stBlock.vAsm[0].VirtualAddr;
           if (!IsBranch(vAsm[i - 1].stAsm.Instruction.BranchType) || vAsm[i - 1].stAsm.Instruction.AddrValue == 0) //如果尾指令不是跳转指令
           {
               stBlock.iBranch1 = (int)vAsm[i].stAsm.VirtualAddr;
               stBlock.iBranch2 = (int)vAsm[i].stAsm.VirtualAddr;
               srand(unsigned int(time(NULL)));
               stBlock.nBrType = rand() % 8;   //当成JMP指令来处理
           }
           else if (IsBranch(vAsm[i - 1].stAsm.Instruction.BranchType) && vAsm[i - 1].stAsm.Instruction.AddrValue != 0) //如果尾指令是跳转指令
           {
               stBlock.nBrType = tranbr(vAsm[i - 1].stAsm.Instruction.BranchType);
               stBlock.iBranch1 = (int)vAsm[i].stAsm.VirtualAddr;
               stBlock.iBranch2 = (int)vAsm[i - 1].stAsm.Instruction.AddrValue;
               if (stBlock.nBrType == 10)   //如果是JMP指令,随便找一条跳转指令模拟JMP
               {
                   srand(unsigned int(time(NULL)));
                   stBlock.nBrType = rand() % 8;
                   stBlock.iBranch1 = stBlock.iBranch2;
               }
               if (stBlock.nBrType < 0)  //对于JNC Label \ Label2: 指令,将其转换为JC Label2 \ Label1:
               {
                   stBlock.nBrType = -stBlock.nBrType;
                   int k;
                   k = stBlock.iBranch1;
                   stBlock.iBranch1 = stBlock.iBranch2;
                   stBlock.iBranch2 = k;
               }
               stBlock.vAsm.pop_back();    //删除尾指令
           }
           vBlocks.push_back(stBlock);
           stBlock.vAsm.clear();
           stBlock.vAsm.push_back(vAsm[i].stAsm);
       }
       else
       {
           stBlock.vAsm.push_back(vAsm[i].stAsm);
       }
    }  
    vBlocks[vBlocks.size() - 1].vAsm.push_back(vAsm[vAsm.size() - 1].stAsm);    //将代码切割成基本块


    接下来,因为要把代码全部移到新添加的区块,所以要正所有基本块的后继区块的地址(VA地址)。


    for (unsigned int i = 0; i < vBlocks.size() - 1; i++)
    {
       srand(unsigned int(time(NULL)));
       chanblock(vBlocks, i, rand() % (unsigned int)vBlocks.size());
    }
    //先将基本块随机交换位置,打乱代码空间局部性

    int pBase = (int)pe.getaddr(pCopy, vt_offset, vt_va);
    vector<int> vOldEntry;
    const int jmpsize = 17; //每个基本块后都会添加一段指令,以跳转到Dispatch,需要用到该字段计算新的Entry
    for (unsigned int i = 0; i < vBlocks.size(); i++)
    {
       vOldEntry.push_back(vBlocks[i].Entry);
    }
    //记录所有基本块的原入口

    vBlocks[0].Entry = pBase;
    for (unsigned int i = 1; i < vBlocks.size(); i++)
    {
       static int len = 0;
       for (unsigned int x = 0; x < vBlocks[i - 1].vAsm.size(); x++)
       {
           memcpy(xde.instr, vBlocks[i - 1].vAsm[x].CompleteInstr, 64);
           xde.cip = 0;
           XEDParseAssemble(&xde);
           len += xde.dest_size; //这里出现设计失误了,本来应该记录一下指令长度的
       }
       len += jmpsize; //基本块尾部跳转到Dispatch的指令长度
       vBlocks[i].Entry = pBase + len;
    }
    //更新所有基本块的入口

    for (unsigned int i = 0; i < vBlocks.size(); i++)
    {
       for (unsigned int x = 0; x < vOldEntry.size(); x++)
       {
           if (vBlocks[i].iBranch1 == vOldEntry[x])
           {
               vBlocks[i].iBranch1 = vBlocks[x].Entry;
           }
           
           if (vBlocks[i].iBranch2 == vOldEntry[x])
           {
               vBlocks[i].iBranch2 = vBlocks[x].Entry;
           }
       }
    }
    //修正所有区块的后继区块


    然后就是让原来的代码跳转到混淆后的代码,同时抹掉原代码了,或者说,也可以用一些小技巧实现流程转移。比如,利用栈溢出,不易让攻击者轻易察觉。同时,原来的代码所占的空间也不用浪费,可以随便填一点进去。至于填进去的代码究竟是什么意图?Who TM cares?


    参考代码源代码打包已打包。但是据朋友反映说,用的那个PE库(PEFile)操作过的PE文件,在WIN7上打不开,初步排查是资源出了问题。如果确实无法打开,请使用LordPE把经过混淆的程序的.lcz区块保存到硬盘上,然后再打开原程序,手工把区块附加到尾部。找到开始的基本块(注意,是第一个开始执行的基本块,因为把基本块给随机打乱了,不是按照原来的顺序写入的),手工修改一下源代码,跳转过去即可。


    以下是一段示例程序,以及混淆前与混淆后的代码的对比。


    int _tmain(int argc, _TCHAR* argv[])
    {
       int i = 20;
       scanf("%d", &i);
       if (i > 50)
       {
           printf("i > 50\n");
       }
       else
       {
           printf("i < 50\n");
       }
       for (i = 0; i < 20; i++)
       {
           printf("i = %d\n", i);
       }
       getchar();
       getchar();
       return 0;
    }


    混淆前:





    混淆后:







    - End -




    看雪ID:三十二变            

    https://bbs.pediy.com/user-783210.htm




    本文由看雪论坛 三十二变 原创

    转载请注明来自看雪社区




    开工第二天,给自己充充电



    热门技术文章推荐:




    最近微信又双叒改版了,想第一时间get到最新、最in的技术干货嘛?

    按照下面的步骤把看雪学院设为“星标”吧~





    公众号ID:ikanxue
    官方微博:看雪安全

    商务合作:wsc@kanxue.com



    戳原文,看看大家都是怎么说的?

      您可能也对以下帖子感兴趣

      文章有问题?点此查看未经处理的缓存