• 使用模拟器混淆前端代码


    前言

    很多时候,我们都会觉得混淆脚本程序是件困难的事,效果远不及传统程序的混淆力度。毕竟,脚本的初衷就是简单易用。诸多先天不足的特征,使得混淆难以深入实施。

    然而从理论上这似乎也说不通,只要是图灵完备的语言,解决问题的能力都是相同的。举个最简单的例子,网上有使用 JavaScript 实现的 x86 模拟器,我们抛开性能不说,单论功能,它和本地系统是一样的。因此使用传统工具混淆的程序,同样也是能在浏览器中运行的!

    当然,这个代价不免有些太大。为了保护一段逻辑,还得加载一个庞大的模拟器和操作系统,显然是难以接受的。但是这个思路还是很有意义的 —— 将需要保护的代码逻辑,放入模拟器中执行。

    事实上类似的方案也早已存在,例如大名鼎鼎的 VMProtect。在浏览器端同样也有应用的案例,例如 Google 曾经开发的 reCaptcha 验证系统,也用到了模拟器来保护重要逻辑。

    如何将前端脚本程序,变成可被模拟器运行的指令?我们从最简单的案例开始讲解。

    字节码

    和传统的编译型程序不同,脚本程序始终是带语法的文本代码。如何将一段充满各种可读单词的代码,尽可能多得使用数字来描述?例如这段代码:

    var el = document.createElement('script');
    el.text = 'alert(123)';
    document.body.appendChild(el);
    

    其中就有变量名 el、字符串 'script'、全局变量 document、属性 body 等可读单词。

    对于变量名来说,普通的压缩工具就能很好处理,变成诸如 a、b、c 这样的短名字;但是字符串和属性,又该如何处理?

    熟悉 JS 的都知道 obj.keyobj['key'] 是相等的。而且全局变量都是 window 下的属性。因此,我们可把全局变量和属性都变成字符串的形式:

    var el = window['document']['createElement']('script');
    el['text'] = 'alert(123)';
    window['document']['body']['appendChild'](el);
    

    这时,整个代码中除了 window 之外,都是字符串了。

    既然我们的目标是将代码数字化,那就将数字以外的常量都提取出来,放到一个单独的数组里:

    var MEM = [
        window, 'document', 'createElement', 'script',
        'text', 'alert(123)', 'body', 'appendChild'
    ];
    

    这样,就可以用 MEM[数字] 代替一切了:

    var el = MEM[0][ MEM[1] ][ MEM[2] ]( MEM[3] );
    el[ MEM[4] ] = MEM[5];
    MEM[0][ MEM[1] ][ MEM[6] ][ MEM[7] ](el);
    

    看起来有些眼花缭乱了吧。不过这只是对常量进行替换,语法仍然存在,因此还是能推测出大致的逻辑。不少基于语法树的混淆工具,大多就到这一步。

    下面我们进一步,将语法展开:

    var A, X, Y, Z
    
    A = MEM[0]       //  window
    X = MEM[1]       //  'document'
    X = A[X]         //  X = window['document']
    
    A = MEM[2]       //  'createElement'
    Y = MEM[3]       //  'script'
    A = X[A](Y)      //  A = document['createElement']('script')
    
    Y = MEM[4]       //  'text'
    Z = MEM[5]       //  'alert(123)'
    A[Y] = Z         //  A['text'] = 'alert(123)'
    
    Y = MEM[6]       //  'body'
    X = X[Y]         //  X = document['body']
    
    Y = MEM[7]       //  'appendChild'
    X[Y](A)          //  body['appendChild'](A)
    

    由于失去了语法,因此需要一些临时变量来保存中间值,这里使用 A、X、Y、Z 四个变量来暂存。

    这时的每一步,都是一个基本操作。我们到了脚本层面最低级的形式。(可以试着粘到控制台,仍能正常运行~ 或者点击 jsfiddle.net/qLtojr5z/ 演示)

    观察上述代码,其中有大量相似操作,我们尝试用代号来进行替换。例如读取 MEM[i] 操作,使用 LDR(Load Reg)来描述:

    r = MEM[i]      =>    LDR r, i
    

    同样的,属性读写操作,也进行类似替换:

    r1 = r2[r3]     =>    GET r1, r2, r3
    r1[r2] = r3     =>    SET r1, r2, r3
    

    对于方法调用操作,暂且用 CAL 来表示参数正好为 1 个的情况,并且返回值统一存放在 A 中:

    A = r1[r2](r3)  =>    CAL r1, r2, r3
    

    现在,我们用这个几个虚拟代号,重新描述上述逻辑:

    LDR A, 0
    LDR X, 1
    GET X, A, X
    
    LDR A, 2
    LDR Y, 3
    CAL X, A, Y
    
    LDR Y, 4
    LDR Z, 5
    SET A, Y, Z
    
    LDR Y, 6
    GET X, X, Y
    
    LDR Y, 7
    CAL X, Y, A
    

    这是不是有一种汇编指令的感觉!之后的处理过程自然就很明确了,我们将这些可读的文本汇编码,转换成二进制字节码。

    例如用 1 代表 LDR 指令,2 代表 GET 指令。。。同样的,暂存器也可以用数字表示,例如用 0 代表 A ,1 代表 X。。。

    汇编码 字节码
    LDR A, 5 01 00 00 05
    GET X, Y, Z 02 01 02 03
    SET Z, Y, X 03 03 02 01
    ... ...

    于是之前那段程序逻辑,最终就能用纯数字表示了:

    01 00 00 00 01 01 00 01 02 01 00 01 01 00 00 02
    01 02 00 03 04 01 00 02 01 02 00 04 01 03 00 05
    03 00 02 03 01 02 00 06 02 01 01 02 01 02 00 07
    04 01 02 00
    

    注意,这部分只是程序逻辑的指令数据,那些字符串等常量数据并不在此,需要另外存储。

    模拟器

    我们的字节码在浏览器看来,只是一堆数据而已,并无实际意义。因此需要一个模拟器,来解释执行这些数据。

    模拟器听起来高大上,其实原理是非常简单的 —— 根据指令数据,做相应操作而已。例如遇到 1,执行读取存储操作;遇到 2,执行访问属性操作。。。

    REG = [];   // 暂存器
    
    do {
        opcode = MEM[pc++];
    
        switch (opcode) {
            case 1:     // LDR
                ...
            case 2:     // GET
                ...
            case 3:     // SET
                r1 = MEM[pc++];
                r2 = MEM[pc++];
                r3 = MEM[pc++];
    
                obj = REG[r1];
                key = REG[r2];
                val = REG[r3];
    
                obj[key] = val;
            ...
        }
    } while (...)
    

    [运行演示]

    我们将字节码当做二进制数据加载到存储中,然后使用一个计数器,指向当前指令所在的存储位置,暂且称之 pc(program counter)。每执行一条指令,pc 进行相应增加,指向下一条指令。周而复始。

    这样,一个模拟器的雏形就出现了。

    我们可以添加更多的指令,例如算数、位运算等等,使模拟器变得更完善。同一个指令,也可以有多种模式。例如 LDR 指令,地址可以是立即数、暂存器,或是 暂存器+立即数、暂存器+暂存器 等多种模式,方便各种寻址操作。

    指令越丰富,相应的逻辑实现就越简单。相反,指令越少,同样的操作就需要多个指令组合才能完成。一个极端的例子就是 Brainfuck 程序,它只提供极少的指令,因此即便非常简单的功能,也需要大量冗长的组合才能完成。

    当然,指令越丰富模拟器也会越庞大,因此得根据实际需求折中考虑。

    跳转指令

    程序不可能永远都是顺着执行的,否则一下就执行完了。因此还需跳转操作,可反复执行先前指令。最简单的跳转,就是无条件跳转,我们暂且用 JMP(Jump)来表示:

    Label:
      ...
      JMP Label
    

    和传统语言 BASIC 或 C 的 goto 一样,在汇编文本层面,可以使用 label 作为跳转的目标。当然 label 只是个标记而已,并不存在于最终的字节码中。最终存储的,只是目标指令所在的位置。

    因此当模拟器解释 JMP 指令时,仅仅是修改 pc 而已:

    ...
    switch (opcode) {
        ...
        case OP_JMP:
            ...
            pc = r;
        ...
    }
    

    有跳转指令,我们就可以灵活操控流程,完全不必按照 JS 那死板的流程控制了。

    事实上,这个指令集和 JS 源码已经毫无关系。我们完全可以使用其他语言,编译出相应的虚拟指令。最终的字节码,显然也是无法还原出 语义化 的 JS 代码的。

    分支指令

    除了无条件跳转,还有带条件的。例如这段代码:

    var str = prompt('password'); 
    
    if (str == 'hello') {
        alert('OK');
    } else {
        alert('Fail');
    }
    

    按照先前的方式,我们将其转换成最低级的 JS 代码:

    var MEM = [window, 'prompt', 'password', 'hello', 'alert', 'OK', 'Fail']
    var A, X, Y, Z
    
    X = MEM[0]      // X = window
    A = MEM[1]
    Y = MEM[2]
    A = X[A](Y)     // A = window['prompt']('password')
    
    Y = MEM[3]      // Y = 'hello'
    
    if (A == Y)
        A = MEM[5]  // A = 'OK'
    else
        A = MEM[6]  // A = 'Fail'
    
    Y = MEM[4]
    X[Y](A)         // window['alert'](A)
    

    相比之前,现在多了判断操作。因此,我们再添加一个带条件的跳转指令。例如当 r1 != r2 时执行跳转:

    JNE r1, r2, label
    

    这样,我们就能和 JMP 指令组合,来表达上述逻辑了:

      ...                 ; 注释
      LDR Y, 3            ; Y = 'hello'
    
      JNE A, Y, L_ELSE    ; if (A != Y) goto L_ELSE
    
      LDR A, 5            ; A = 'OK'
      JMP L_END
    L_ELSE:
      LDR A, 6            ; A = 'Fail'
    L_END:
      ...
      CAL X, Y, A         ; alert(A)
    

    有了 != 判断,自然也可实现 == 判断。不过为了方便使用,我们可提供更丰富的分支操作。例如 JS 中的各种判断:

    跳转指令 条件 备注
    JE r1 == r2 Jump if Equal
    JNE r1 != r2 Jump if Not Equal
    JES r1 === r2 Jump if Equal Strict
    JNES r1 !== r2 Jump if Not Equal Strict
    JG r1 > r2 Jump if Greater
    JGE r1 >= r2 Jump if Greater or Equal
    JL r1 < r2 Jump if Less
    JLE r1 <= r2 Jump if Less or Equal
    JIN r1 in r2 Jump if IN
    JINSOF r1 instanceof r2 Jump if INStanceOF

    甚至对于一些常见情况,还可再进一步封装:

    跳转指令 条件
    JTRUE r1 === true
    JFALSE r1 === false
    JZERO r1 === 0
    JNULL r1 === null
    JUNDEF r1 === undefined
    ... ...

    不过,有时我们只想判断,未必要跳转。例如:

    isOK = (stat == 200);
    

    对于这种情况,使用跳转指令也能满足,只是显得略为累赘。如果想更精简,则可添加纯粹的判断指令,例如:

    A = (r1 != r2)    =>    TEST_NE r1, r2
    A = (r1 in r2)    =>    TEST_IN r1, r2
    ...
    

    当然,其本质都是一样的。

    JS 操作

    既然我们的模拟器是用于浏览器环境,显然应该提供完善的 JS/DOM 操作。因此我们再添加几个脚本相关的指令,例如:

    指令 功能 备注
    CONCAT r1, r2, r3 r1 = r2 + r3 字符拼接
    OBJECT r1 r1 = {} 创建对象
    TYPEOF r1, r2 r1 = typeof r2 typeof
    DELETE r1, r2 delete r1[r2] delete
    NEWCAL r1, ... A = new r1(...) new

    这里提一下 JS 的 + 操作符:它既可以用于数字加法,也可用于字符串拼接。为了不和 ADD 指令混在一起,我们可单独提供一个字符串拼接的指令。

    现在来思考一个问题:如何提供回调函数?

    从理论上说,我们可实现一个完全兼容 JS 的字节码模拟器,但事实上这是相当复杂的。JS 有众多灵活的特征,例如闭包、with、eval 等等,要实现这些,相当于得重新造一个 JS 引擎,显然是不现实的。

    因此,我们只需提供一些常用的操作就可以了。闭包之类的特性,就可以不考虑了。不过回调函数还是需要支持的,例如这段代码:

    button.onclick = function() { ... };
    

    我们可设计一个指令,将相应的 label 封装成一个函数对象:

    FUN  r, label      ; r = makeCallback(...)
    
    label:
      ...
    

    这样,就能提供给 DOM 使用了:

    L_CLICK:
      ...
    
    L_MAIN:
      ...                ; A = button, X = 'onclick'
    
      FUNC  Y, L_CLICK   ; Y = makeCallback(...)
      SET   A, X, Y      ; A['onclick'] = Y
    

    至于封装的细节,大致就这样:

    function makeCallback(pc) {
        return function() {
            return vm.run(pc);
        };
    }
    

    在回调函数里,让模拟器从 pc 的位置开始解释,这样就让某些指令异步执行了。

    这里简单的演示一下。例如这个回调函数:

    var i = 0;
    
    function render() {
        txt.value = i++;
        if (i <= 255) {
            requestAnimationFrame(render);
        }
    }
    render();
    

    将其转换成字节码:

    0000    05 03 00 00             MOV Z, 0
    0004    01 00 00 00  L_TIMER:   LDR A, 0
    0008    01 01 00 01             LDR X, 1
    000C    02 02 00 01             GET Y, A, X
    0010    01 01 00 02             LDR X, 2
    0014    03 02 01 03             SET Y, X, Z
    0018    06 03 00 00             INC Z
    001C    05 01 00 ff             MOV X, 255
    0020    07 03 01 10             JG  Z, X, L_END
    0024    01 01 00 03             LDR X, 3
    0028    08 02 00 04             FUN Y, L_TIMER
    002C    04 00 01 02             CAL A, X, Y
    0030    00 00 00 00  L_END:     BRK
    

    运行演示


    在脚本层面上还有个特殊流程,那就是错误捕获。例如这样的 JS 逻辑:

    try {
        // safe
    } catch (...) {
        // handler
    }
    

    这使用指令并不难描述。我们可定义两个指令,分别用于捕获的开启和关闭:

      CATCH L_ERR
      ...           ; safe
      ...
      UNCATCH
      ...
    L_ERR:
      ...           ; handler
    

    当模拟器遇到 CATCH 指令时,使用 try 解释后续指令,若有错误发生,则进入 label 的位置;当遇到 UNCATCH 指令时,则退出当前递归,返回上一层的捕获:

    function run(...) {
        ...
        case OP_CATCH:
            try {
                run(...);     // 安全模式 递归
            } catch (e) {
                pc = ...      // 错误处理流程
            }
            ...
        case OP_UNCATCH:
            return;
    

    这样,就能放心地执行一些可能报错的操作了。

    类似的逻辑实现还有很多,这里就不详细介绍了。关于模拟器的基本原理简介,就到此为止。不过我们的目标并非只是为了实现一个模拟器,而是利用模拟器来保护代码逻辑。

    逻辑保护

    相比过去那些基于 AST(抽象语法树)的混淆方案,使用模拟器可以实施得更深入。大致可以在这几点上对抗:

    • 编译过程

    • 指令编码

    • 指令混淆

    编译过程

    从源程序到字节码,需要一个编译的过程。这个过程本身就有一定的混淆效果,例如一些优化工作会对逻辑进行调整。和传统的编译型语言一样,这个过程是不可逆的。反编译的代码,是很难回到原始语义的。(不知大家是否见过那些自称能把 exe 程序还原成 c 代码的工具,结果当然是惨不忍睹)

    由于模拟器难以完全兼容 JS 所有的特性,因此不能直接用于现有的脚本。需混淆的代码必须遵循一定的规范编写,例如不能使用 with、eval 等高级特性。所以,不推荐对整个程序都进行混淆,而是只针对一些核心逻辑。

    如果核心部分只是算法,甚至完全可以不用 JS 编写,而是选择 C 这种更适合计算的语言。我们可以使用 clang 编译出 LLVM 中间码,然后开发一个 LLVM Backend 插件,将中间码编译成我们模拟器的目标指令。

    LLVM 是个非常有意义的系统。它不仅可用于程序的优化,同样也可实现程序的「劣化」,让逻辑变得更乱更难分析。例如在计算过程中,插入大量的中间步骤,干扰逻辑的分析。

    指令编码

    因为模拟器的指令是我们自创的,所以对方在逆向分析之前,必须了解指令的编码格式,才能成功反编译。因此,在编码上又可以进行一些对抗。

    传统的指令编码大多都有规律,因为那是从解码复杂度以及性能上考虑。例如:

    switch (opcode) {
        ...
        case OP_SET:
            r1 = MEM[pc++];
            r2 = MEM[pc++];
            r3 = MEM[pc++];
            ...
    

    这么简单明了的解码过程,显然是很容易分析的。而我们最终目标是混淆,性能并非是第一位。因此可使出各种千奇百怪的编码格式,来增加解码的复杂度。

    例如,使用各种逻辑位运算,并且不同的指令格式也各不相同,没有任何规律。在性能损失可接受的范围内,将解码过程变得极其复杂,使分析变得更困难。

    a = MEM[pc++]
    b = MEM[pc++]
    if (a & 128)
        if (a & 64)    // OP_SET
            r1 = (a >> 4) & 16
            r2 = (b & 16) ^ ~r1
            r3 = (b >> 4 & 16) ^ r1
            ...
    

    当然再复杂的格式也有破解的时候。因此我们不能永远使用一种格式,而必须不定期的进行升级。不过,每次升级都得重新设计一遍,会不会很麻烦?

    如果编码格式由人工制定,那显然是很麻烦的。因此必须借助工具,自动化生成「编码器」和「解释器」。我们只需设计一些策略就可以了,让工具将这些套路随机组合,生成千奇百怪的格式。最终格式是什么样的,我们自己都不需要了解:)

    总之,用最简单的正向设计达到最困难的逆向分析,这就符合对抗的意义了。

    指令混淆

    指令本身也是内存中的数据。因此和普通数据一样,指令数据也能被修改,例如当前指令可以修改即将执行的下一条指令,这样就可以在运行时动态调整程序行为了。

    利用这个特征,我们可对程序的大部分指令事先进行加密,然后在运行时再逐步解密。假如程序有 a、b、c、d 几个部分,我们事先将 b、c、d 部分进行简单加密,只保留明文的 a 部分。

    当程序执行 a 部分时,将 b 部分的二进制数据进行解密,还原出明文指令;执行到 b 部分时,还原 c 部分,同时再将 a 部分加密回去。。。这样变执行边释放,就能避免一出来就能看到所有指令,从而增加分析成本。

    另外,在字节码的层面上,跳转是以字节为单位的,因此可跳到某个指令的中间:

    位置     字节码          汇编码
    0000    02 01 02 03    GET X, Y, Z
    0004    05 00 01       JMP 0001
    

    这样就能执行 01 02 03 05 这串字节码,即 LDR Y, 0x0305 了。利用这个方法,就可以将一些指令伪装起来,实现花指令的效果。

    类似的对抗思路还有很多,这里就不详细讨论了。事实上,这些大多是传统程序的混淆方案,之所以能用到 JS 上,得益于模拟器消除了平台间的差距,从而使得前端脚本也能享受到前人积累的对抗技术,完全不必自创一些看似炫酷实则毫无意义的混淆方案。

  • 相关阅读:
    shop--11.前端展示系统--店铺列表(后端)
    shop--11.前端展示系统--首页展示(前端)
    shop--11.前端展示系统--首页展示(后端)
    shop--10.商品--解除商品与某商品类别的关联的实现
    shop--10.商品--商品管理(前端)
    shop--10.商品--商品管理(后端)--dao层--Service层--Controller层
    shop--10.商品--商品编辑(前端)及调试注意
    shop--10.商品--商品编辑(后端)--Controller层
    shop--10.商品--商品编辑(后端)--Service层
    shop--10.商品--商品编辑(后端)--dao层
  • 原文地址:https://www.cnblogs.com/index-html/p/use_vm_protect_js.html
Copyright © 2020-2023  润新知