AST自动化Javascript防护方案
AST自动化Javascript防护方案

AST自动化Javascript防护方案

AST自动化JavaScript防护方案

没有特别说明的情况下,都以处理以下代码为例:

Date.prototype.format = function (formatStr){
    var str = formatStr
    var Week = ['日', '一','二','三','四','五','六']
    str = str.replace(/yyyy|YYYY/, this.getFullYear())
    str = str.replace(/MM/, (this.getMonth()+1)>9 ? (this.getMonth()+1).toString() : '0' + (this.getMonth() + 1));
    str = str.replace(/dd|DD/, this.getDate() >9 ? this.getDate().toString() : '0' + this.getDate());
    return str;
}
console.log(new Date().format('yyyy-MM-dd'));

一、混淆前的代码处理

1.改变对象属性的访问方式

对象属性访间方式有两种,即类似 console.log 或者 console["log"]。在JS 混淆中通常会使用类似这种 console["log"]的对象属性访问方式。

image-20230901095107854

那么,两种访问方式在 AST 节点中又有什么区别呢?

下面介绍两种访问方式在AST节点中的区别,示例如下:

traverse(ast, {
    MemberExpression(path) {
        console.log(path.node)
    }
});

image-20230901100237688

可以看出主要有两处不一样:

console.log 这种访问方式的 property 为 Identifier,computed为false。

console["log"]这种访问式property为StringLiteral,computed为 true。

可以再看一个比较:

image-20230907154556280

因此,只要遍历MemberExpression (成员表达式),把对应的属性修改掉即可完成两种访间方式的转换。

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    MemberExpression(path){
        if(t.isIdentifier(path.node.property)){
            let name = path.node.property.name;
            path.node.property = t.stringLiteral(name);
        }
        //*这行代码必加
        path.node.computed = true;
    }, 
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});

注意,混淆之前的代码中,可能两种访问方式都有,不一定都是 console.log这种方式因此修改property 属性的时候要判断一下,只处理类型为 Identifier 的情况即可。

path.node.computed = true;不加后的结果:

image-20230907155613681

此刻船新版本:

Date.prototype.format = function (formatStr){
    var str = formatStr
    var Week = ['日', '一','二','三','四','五','六']
    str = str.replace(/yyyy|YYYY/, this.getFullYear())
    str = str.replace(/MM/, (this.getMonth()+1)>9 ? (this.getMonth()+1).toString() : '0' + (this.getMonth() + 1));
    str = str.replace(/dd|DD/, this.getDate() >9 ? this.getDate().toString() : '0' + this.getDate());
    return str;
}
console.log(new Date().format('yyyy-MM-dd'));
/*
变化如下:
*/
Date["prototype"]["format"] = function (formatStr) {
  var str = formatStr;
  var Week = ['日', '一', '二', '三', '四', '五', '六'];
  str = str["replace"](/yyyy|YYYY/, this["getFullYear"]());
  str = str["replace"](/MM/, this["getMonth"]() + 1 > 9 ? (this["getMonth"]() + 1)["toString"]() : '0' + (this["getMonth"]() + 1));
  str = str["replace"](/dd|DD/, this["getDate"]() > 9 ? this["getDate"]()["toString"]() : '0' + this["getDate"]());
  return str;
};
console["log"](new Date()["format"]('yyyy-MM-dd'));

加密代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
});
let ast = parser.parse(jscode);

// 1.改变对象属性的访问方式 console.log => console['log']
traverse(ast, {
    MemberExpression: function (path) {
        if(t.isIdentifier(path.node.property)){
            let name = path.node.property.name;
            path.node.property = t.stringLiteral(name);
        }
        path.node.computed = true
    }
})
// 更新AST ——似乎也不需要更新,已经被遍历过了
// ast = generatorObj(ast);

let code = generator(ast).code;
console.log(code)
fs.writeFile('./demoNew.js', code, (err)=>{});

2.JS标准内置对象的处理

在经过改变访问方式后的代码中,可以看到有两个 Date,这是JS 中的标准内置对象,读者们也可以理解成是系统函数。也就是想要使用就必须是这个名字,必须这么写,那是否就不能对Date进行加密了呢?在之前的章节中介绍过,内置对象一般都是 window 下的属性因此可以转成window["Date"]这种形式来访问Date,这样Date 就是一个字符串,就可以进行加密了。另外,还有一些系统自带的全局函数,它们也就是 window 下的方法,可以一并处理。

JS 发展到现在,标准内置对象已经有很多了。笔者在这里只演示其中一部分,读者们可自行百度“JS 标准内置对象”获取更全的。

JS 中的常见的全局函数有 eval、parseInt、encodeURIComponent。

JS 中常见的标准内置对象有 0bject、Function、Boolean、Number、Math、Date、String、RegExp、Array 等。

这些全局函数和内置对象在AST 中都是标识符,并且都是 window 对象下的。因此处理代码的思路就来了,遍历 Identifier 节点,把该节点替换成object 属性为 window 的MemberExpression 即可。

意思是,咱把这个成员表达式MemberExpression 再加一层,console.log变成了window.console.log,callee的左边,就是这个.的左侧的是object,右侧是property。如果把window.console看成一个整体,他们就作为一个MemberExpression在object里面。

image-20230907163056814

也就是需要生成MemberExpression,它在 ts 文件中的定义为:

export function memberExpression(object: Expression, property: any, computed?:boolean, optional?: true | false | null): MemberExpression;

传入 object、property、computed,返回 MemberExpression。内置对象的处理代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    Identifier(path){
        let name = path.node.name;
        if('console|eval|parseInt|encodeURIComponent|Object|Function|Boolean|Number|Math|Date|String|RegExp|Array'.indexOf(name) != -1){
            path.replaceWith(t.memberExpression(t.identifier('window'), t.stringLiteral(name), true));
        }
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});

上述代码中先获取到 Identifier 的 name 属性,然后到预先准备好的字符串中去搜索。如果是内置对象或全局函数,就把它替换成 object 属性为 window 的MemberExpression

效果如下:

window["Date"]["prototype"]["format"] = function (formatStr) {
  var str = formatStr;
  var Week = ['日', '一', '二', '三', '四', '五', '六'];
  str = str["replace"](/yyyy|YYYY/, this["getFullYear"]());
  str = str["replace"](/MM/, this["getMonth"]() + 1 > 9 ? (this["getMonth"]() + 1)["toString"]() : '0' + (this["getMonth"]() + 1));
  str = str["replace"](/dd|DD/, this["getDate"]() > 9 ? this["getDate"]()["toString"]() : '0' + this["getDate"]());
  return str;
};
window["console"]["log"](new window["Date"]()["format"]('yyyy-MM-dd'));

二、常量与标识符的混淆

1.实现数值常量加密

异或运算的规律(互为异或关系):

A ^ B = C
C ^ A = B
C ^ B = A

代码中的数值常量可以通过遍历 NumericLiteral 节点,获取其中的 value 属性来得到。

然后随机生成一个数值记为 key。接着把 value 与 key 进行异或得到加密后的数值记为cipherNum,即 cipherNum = value key。

此时,value = cipherNum^key。因此可以生成一个binaryExpression 节点来等价的替换 NumericLiteral 节点。

binaryExpression的operator 为,left为 cipherNum,right为 key。代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    NumericLiteral(path){
        let value = path.node.value;
        let key = parseInt(Math.random() * (999999 - 100000) + 100000, 10);
        let cipherNum = value ^ key;
        path.replaceWith(t.binaryExpression('^', t.numericLiteral(cipherNum), t.numericLiteral(key)));
        //替换后的节点里也有numericLiteral节点,会造成死循环,因此需要加入path.skip()
        path.skip();
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
Date.prototype.format = function (formatStr) {
  var str = formatStr;
  var Week = ['日', '一', '二', '三', '四', '五', '六'];
  str = str.replace(/yyyy|YYYY/, this.getFullYear());
  str = str.replace(/MM/, this.getMonth() + (983778 ^ 983779) > (474226 ^ 474235) ? (this.getMonth() + (365297 ^ 365296)).toString() : '0' + (this.getMonth() + (381808 ^ 381809)));
  str = str.replace(/dd|DD/, this.getDate() > (119081 ^ 119072) ? this.getDate().toString() : '0' + this.getDate());
  return str;
};
console.log(new Date().format('yyyy-MM-dd'));
*/

image-20230901104306098

2.实现字符串常量加密

原始代码经过前几个小节的处理,可以看出很多标识符都变成了字符串。但是这些字符串依然是明文,还是一眼就能看明白。因此这一小节就要对这些字符串进行加密,加密的思路很简单。比如,代码中明文是"prototype",加密后的值为"cHJvdG90exBI",解密函数名为atob。那么只要构建一下atob("cHJvdG90eXB1"),然后用它普换原先的"prototype"甲可。

当然解密函数也需要一起放入原始代码中。笔者在这里就用 Base64编码一下字符串,然后使用浏览器自带的atob 来解密。

那么在AST中操作的话,就要先遍历所有的 StringLiteral,取出其中的value 属性进行加密,然后把Stringliteral节点替换为callExpression (函数调用表达式)。如何生成callExpression 昵?来看一下callExpression在ts 文件中的定义:

export function callExpression(callee: Expression | V8IntrinsicIdentifiex, _arguments: Array<Expression | SpreadElement |  JSXNamespacedName | ArgumentPlaceholder>): CalIExpression;

callee表示函数名,传入一个Identifier 即可。_arguments表示参数,参数可以有多个,因此它是一个数组。函数最终返回 CallExpression。实现字符串常量加密的代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function base64Encode(e) {
    var r, a, c, h, o, t, base64EncodeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    for (c = e.length, a = 0, r = ''; a < c;) {
        if (h = 255 & e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4),
            r += '==';
            break
        }
        if (o = e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
            r += base64EncodeChars.charAt((15 & o) << 2),
            r += '=';
            break
        }
        t = e.charCodeAt(a++),
        r += base64EncodeChars.charAt(h >> 2),
        r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
        r += base64EncodeChars.charAt((15 & o) << 2 | (192 & t) >> 6),
        r += base64EncodeChars.charAt(63 & t)
    }
    return r
}

traverse(ast, {
    StringLiteral(path){
        //生成callExpression参数就是字符串加密后的密文
        let encStr = t.callExpression(
                 t.identifier('atob'),
                 [t.stringLiteral(base64Encode(path.node.value))]);
        path.replaceWith(encStr);
        //替换后的节点里也有StringLiteral节点,会造成死循环,因此需要加入path.skip()
        path.skip();
  }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
Date.prototype.format = function (formatStr) {
  var str = formatStr;
  var Week = [atob("5Q=="), atob("AA=="), atob("jA=="), atob("CQ=="), atob("2w=="), atob("lA=="), atob("bQ==")];
  str = str.replace(/yyyy|YYYY/, this.getFullYear());
  str = str.replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : atob("MA==") + (this.getMonth() + 1));
  str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : atob("MA==") + this.getDate());
  return str;
};
console.log(new Date().format(atob("eXl5eS1NTS1kZA==")));
*/

3.实现数组混淆

来观察一下经过字符串加密以后的代码,可以看到字符串虽然加密了,但是字符串依然在原先的位置。数组混淆要做的事情,就是把这些字符串都提取到数组中,原先字符串的地方改为以数组下标的方式去访问数组成员。其实还可以提取到多个数组中,不同的数组处于不同的作用域中。笔者在这里就只实现一下提取到一个数组的情况。

举个例子,比如 Datelatob("cHJvdg90eXB1"),把"cHJvdG90eXB1"变成数组 arr 的下标为 0 的成员后,原先字符串的位置就变为,Date[atob(arr[0])],当然还需要额外生成一个数组,放入到被混淆的代码中。

那么AST 怎么来处理呢?首先肯定是遍历 StringLiteral 节点。既然这个也是遍历StringLiteral节点,其实就可以和字符串加密一起处理了。先来构造出这么一个结构
atob(arr[0]),代码如下:

//CallExpression:函数调用表达式
let encstr = t.cal1Expression(
        t.identifier('atob'),
        [t.memberExpression(t.identifier('arr'),t.numericLiteral(index),
true)]);

上述代码构建了一个 callExpression,函数名为atob,参数是 memberExpression。这个memberExpression 的 object 属性为 arr (就是待会要生成的数组名),property 属性为数组索引 index,这个值当前未知。

接着要把代码中的字符串放入数组中,然后得到对应的数组索引。这里有两种方案:

  1. 第一种是不管字符串有没有重复,遍历到一个就往数组里放,然后把新的索引赋值给上述代码中的 index。
  2. 第二种是字符串放入数组之前,先查询一下数组中有没有相同的,如果有相同的话,得到原先那个索引赋值给 index。笔者在这里采用第二种,代码如下:
function base64Encode(str) {
    ...
}
traverse(ast, {
    // StringLiteral 遍历字符串常量节点["aa"]["bb"]
    StringLiteral(path){
        let cipherText = base64Encode(path.node.value);
        //在JS 中,indexof 不只是可以用来搜索字符串,实际上它还可以用来搜索数组中某个成员是否存在,如果存在返回索引,如果不存在返 -1
        let bigArrIndex = bigArr.indexOf(cipherText);
        let index = bigArrIndex;
        if(bigArrIndex == -1){
            let length = bigArr.push(cipherText);
            index = length -1;
        }
        let encStr = t.callExpression(
                    t.identifier('atob'), 
                    [t.memberExpression(t.identifier('arr'), 
                                     t.numericLiteral(index), true)]);
        path.replaceWith(encStr);
    }
});

在JS 中,indexof 不只是可以用来搜索字符串,实际上它还可以用来搜索数组中某个成员是否存在,如果存在返回索引,如果不存在返 -1

在上述代码中,path.node.value 就是明文字符串,先进行加密得到 cipherText,再去数组中搜索是否存在与 cipherText 一样的成员。如果已经存在于数组中,那就把原先的索引赋值给 index,然后构建函数调用表达式,替换当前的 StringLiteral 节点。

用于替换的节点中没有 StringLiteral 节点,不会死循环,所以不需要加入停止遍历的代码。

如果不存在于数组中,也就是index0f 返回的结果为 -1,就把 cipherText 加入到数组中。

数组的push 方法,可以同时加入多个成员,除了加入成员到数组末尾以外,还会返回加入成员以后的数组长度,因此length - 1就是数组最末成员的索引。

当前bigArr中的成员只是JS中的字符串,并不是AST中需要的StringLiteral节点。因此还需要做进一步处理,把bigArr中的成员都转成stringLiteral节点。

代码如下:

bigArr = bigArr.map(function(v){
    return t.stringLiteral(v);
})

现在把bigArr加入到ast中,也就是要先用types组件生成一个数组声明,并且数组成员与bigArr一致,然后加入到被混淆代码的最上面。实现代码如下:

bigArr = t.variableDeclarator(t.indentifier('arr'), t.arrayExpression(bigArr));
bigArr = t.variableDeclarator('var', [bigArr]);

program节点相遇整个JS文件,它的body属性是一个数组,用unshift把bigArr加入到最前面。完整的数组混淆处理代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function base64Encode(e) {
    var r, a, c, h, o, t, base64EncodeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    for (c = e.length, a = 0, r = ''; a < c;) {
        if (h = 255 & e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4),
            r += '==';
            break
        }
        if (o = e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
            r += base64EncodeChars.charAt((15 & o) << 2),
            r += '=';
            break
        }
        t = e.charCodeAt(a++),
        r += base64EncodeChars.charAt(h >> 2),
        r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
        r += base64EncodeChars.charAt((15 & o) << 2 | (192 & t) >> 6),
        r += base64EncodeChars.charAt(63 & t)
    }
    return r
}

let bigArr = [];
traverse(ast, {
    StringLiteral(path){
        let cipherText = base64Encode(path.node.value);
        let bigArrIndex = bigArr.indexOf(cipherText);
        let index = bigArrIndex;
        if(bigArrIndex == -1){
            let length = bigArr.push(cipherText);
            index = length -1;
        }
        let encStr = t.callExpression(
                    t.identifier('atob'),
                    [t.memberExpression(t.identifier('arr'),
                                     t.numericLiteral(index), true)]);
        path.replaceWith(encStr);
    }
});
bigArr = bigArr.map(function(v){
  return t.stringLiteral(v);
});
bigArr = t.variableDeclarator(t.identifier('arr'), t.arrayExpression(bigArr));
bigArr = t.variableDeclaration('var', [bigArr]);
ast.program.body.unshift(bigArr);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
var arr = ["5Q==", "AA==", "jA==", "CQ==", "2w==", "lA==", "bQ==", "MA==", "eXl5eS1NTS1kZA=="];
Date.prototype.format = function (formatStr) {
  var str = formatStr;
  var Week = [atob(arr[0]), atob(arr[1]), atob(arr[2]), atob(arr[3]), atob(arr[4]), atob(arr[5]), atob(arr[6])];
  str = str.replace(/yyyy|YYYY/, this.getFullYear());
  str = str.replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : atob(arr[7]) + (this.getMonth() + 1));
  str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : atob(arr[7]) + this.getDate());
  return str;
};
console.log(new Date().format(atob(arr[8])));
*/

4.实现数组乱序

经过数组混淆以后的代码,引用处的数组索引与原先字符串还是一一对应的。这一小节就要打乱这个数组的顺序。代码很简单,传一个数组进去,并且指定循环次数,每次循环都把数组后面的成员放前面。代码如下:

(function (myArr, num){
    var xiaojianbang = function (nums){
        while(--nums){
            myArr.unshift(myArr.pop());
        }
    };
    xiaojianbang(++num);
}(bigArr, 0x10));

既然数组的顺序和原先不一样了,那么被混淆的代码在执行之前,肯定还是要还原的。因此还需要一段还原数组顺序的代码。代码也很简单,反着来即可,循环同样的次数,把数组前面的成员放后面。代码如下:

(function (myArr, num) {
    var xiaojianbang = function (nums) {
        while (--nums) {
            myArr.push(myArr.shift ());
        }
    };
    xiaojianbang(++num);
})(arr, 0x10);

还原顺序的代码肯定是要和被混淆的代码放一起的。如何放一起呢? 把还原数组顺序的代码保存到新文件 demoFront.js,读取该文件并解析成 astFront。由于还原数组顺序的代码最外层只有一个函数节点,所以取出其中的astFront.program.body[0]放入到被混淆代码中的body中。代码如下:

//构建数组声明语句
bigArr = t.variableDeclarator(t.identifier('arr'), t.arrayExpression(bigArr));
bigArr = t.variableDeclaration('var', [bigArr]);

//读取还原数组顺序的函数,并解析成astFront
const jscodeFront = fs.readFileSync("./demoFront.js", {
    encoding: "utf-8"
  });
let astFront = parser.parse(jscodeFront);
//先把还原数组顺序的代码,加入到被混淆代码的ast中
ast.program.body.unshift(astFront.program.body[0]);
//把数组放到被混淆代码的ast最前面
ast.program.body.unshift(bigArr);

从上述代码中可以看出,数组混淆中生成的数组,需要放到还原数组顺序的代码前面。

全部代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function base64Encode(e) {
    var r, a, c, h, o, t, base64EncodeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    for (c = e.length, a = 0, r = ''; a < c;) {
        if (h = 255 & e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4),
            r += '==';
            break
        }
        if (o = e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
            r += base64EncodeChars.charAt((15 & o) << 2),
            r += '=';
            break
        }
        t = e.charCodeAt(a++),
        r += base64EncodeChars.charAt(h >> 2),
        r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
        r += base64EncodeChars.charAt((15 & o) << 2 | (192 & t) >> 6),
        r += base64EncodeChars.charAt(63 & t)
    }
    return r
}

let bigArr = [];
traverse(ast, {
    StringLiteral(path){
        let cipherText = base64Encode(path.node.value);
        let bigArrIndex = bigArr.indexOf(cipherText);
        let index = bigArrIndex;
        if(bigArrIndex == -1){
            let length = bigArr.push(cipherText);
            index = length -1;
        }
        let encStr = t.callExpression(
                    t.identifier('atob'), 
                    [t.memberExpression(t.identifier('arr'), 
                                     t.numericLiteral(index), true)]);
        path.replaceWith(encStr);
    }
});
bigArr = bigArr.map(function(v){
  return t.stringLiteral(v);
});
//构建数组声明语句
bigArr = t.variableDeclarator(t.identifier('arr'), t.arrayExpression(bigArr));
bigArr = t.variableDeclaration('var', [bigArr]);

//读取还原数组顺序的函数,并解析成astFront
const jscodeFront = fs.readFileSync("./demoFront.js", {
    encoding: "utf-8"
  });
let astFront = parser.parse(jscodeFront);
//先把还原数组顺序的代码,加入到被混淆代码的ast中
ast.program.body.unshift(astFront.program.body[0]);
//把数组放到被混淆代码的ast最前面
ast.program.body.unshift(bigArr);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    var arr = ["5Q==", "AA==", "jA==", "CQ==", "2w==", "lA==", "bQ==", "MA==", "eXl5eS1NTS1kZA=="];
    (function (myArr, num) {
      var xiaojianbang = function (nums) {
        while (--nums) {
          myArr.push(myArr.shift());
        }
      };
      xiaojianbang(++num);
    })(arr, 0x10);
    Date.prototype.format = function (formatStr) {
      var str = formatStr;
      var Week = [atob(arr[0]), atob(arr[1]), atob(arr[2]), atob(arr[3]), atob(arr[4]), atob(arr[5]), atob(arr[6])];
      str = str.replace(/yyyy|YYYY/, this.getFullYear());
      str = str.replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : atob(arr[7]) + (this.getMonth() + 1));
      str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : atob(arr[7]) + this.getDate());
      return str;
    };
    console.log(new Date().format(atob(arr[8])));
*/

5.实现十六进制字符串

实现数组顺序还原的代码是没有经过混淆的,代码中标识符的混淆,可以在最后一起处理。

其中像 push、shift 这些方法可以转为字符串,由于这些代码处于还原数组顺序的代码中,因此没法把它们提取到大数组中。笔者在这里就简单地把它们编码成十六进制字符串。

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demoFront.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function hexEnc(code) {
    for (var hexStr = [], i = 0, s; i < code.length; i++) {
        s = code.charCodeAt(i).toString(16);
        hexStr += "\\x" + s;
    }
    return hexStr
}
traverse(ast,{
    MemberExpression(path){
        if(t.isIdentifier(path.node.property)){
            let name = path.node.property.name;
            path.node.property = t.stringLiteral(hexEnc(name));
        }
        path.node.computed = true;
    }
});
let code = generator(ast).code;
console.log(code);
/*
    (function (myArr, num) {
      var xiaojianbang = function (nums) {
        while (--nums) {
          myArr["\\x70\\x75\\x73\\x68"](myArr["\\x73\\x68\\x69\\x66\\x74"]());
        }
      };
      xiaojianbang(++num);
    })(arr, 0x10);
*/

上述代码遍历 MemberExpression 节点,将property的值进行 hex 编码,然后用stringLiteral节点替换 property,并把 computed 改为 true 最后输出的结果有两个转义字符,其实是 Babel 在处理的节点的时候,自动转义了反斜杠。在最后转成代码以后,把它们替换掉即可

let code = generator(ast).code;
code = code.replace(/\\\\x/g, '\\x');
console.log(code);

另外,unicode字符申的实现方式与本小节一致,把hex编码的函数换成unicode 编码的函数即可。

6.实现标识符混淆

程序员在开发的时候,标识符一般都是有语义的,根据标识符名就可以猜测出代码在做什么,因此标识符混淆很有必要。

有一个很简单的标识符混淆方案,遍历binding.scope.block,找到相关表达式的值进行替换。

这个方案的缺点是整个文件中不同标识符的名称都是不一样的。但实际上可以让各个函数之间的局部标识符名相同,函数内的局部标识符名还可以与没有引用到的全局标识符名相同。这样做更具迷惑性。这一小节就来实现一下这种方案。

要实现这种方案,需要用到一个方法 scope.getOwnBinding。该方法可以用来获取属于当前节点的自己的绑定。

比如,在 Program 节点下,使用 getOwnBinding 就可以获取到全局标识符名,而函数内局部标识符名不会破获取到。

那么要获取到局部标识符名,可以遍历函数节点,在FunctionExpression与FunctionDeclaration 节点下,使用gotOwmBinding会获取到函数自身定义的局部标识符名,而不会获取到全局标识符名。

遍历三种节点,执行同一个重命名方法,代码如下:

traverse(ast, {
    'Program|FunctionExpression|FunctionDeclaration'(path) {
        renameOwnBinding(path);
    }
});

那么renameOwnBinding函数如何实现呢?来看下面这段代码:

function renameOwnBinding(path) {
    let OwnBindingObj = {}, globalBindingObj = {}, i = 0;
    path.traverse({
        Identifier(p)  {
            let name = p.node.name;
            let binding = p.scope.getOwnBinding(name);
            binding ? (OwnBindingObj[name] = binding) : (globalBindingObj[name] = 1);
        }
    });
}

上述代码先遍历当前节点中所有的 Identifier,得到Identifier 的 name 属性,通过getOwnBinding判断是否当前节点自己的绑定。

如果 binding 为undefined,则装示是其父级函数的标识符或者全局的标识符,就将该标识符名作为属性名,放入到 globalBindingObj对象中。

如果 binding 存在,则表示是当前节点自己的绑定,就将该标识符作为属性名,binding 作为属性值,放入到 OwnBindingObj 对象中。

这里有四点需要注意一下:

  1. globalBindingObj 中存放的不是所有的全局标识符,而是当前节点引用到的全局标识符。因为重命名标识符的时候,不能与引用到的全局标识符重名,需要进行判断。至于没有引用到的全局标识符名,就是要重名才更具迷惑性,反正最后使用的还是当前节点定义的局部标识符。
  2. OwnBindingObj中存储对应标识符的 binding。因为重命名标识符的时候,需要使用 binding.scope.rename 方法。
  3. 把标识符名作为对象的属性名。因为一个 Identifier 有多处引用就会遍历到多个,但实际上只需要调用一次 scope.rename 即可完成所有引用处的重命名。而对象属性名具有唯一性,就可以只保留最后一个同名标识符。
  4. 最好把 ast 先转成代码,再次进行解析后再进行标识符混淆。之前章节中介绍过修改AST 节点的时候,使用 Path 对象的方法的话,Babel 会更新 Path 信息。但是实际应用中并不能做到全部使用 Path 对象的方法。比如,用 types 组件生成新节点由于 types 组件生成的是节点,并不是 Path 对象,那么 binding 从何而来?

接下去肯定是要遍历 OwnBindingObj 对象中的属性,来进行重命名了,代码如下:

for(let oldName in OwnBindingObj) {
    do {
        var newName = '_0x2ba6ea' + i++;
    } while(globalBindingObj[newName]);
    OwnBindingObj[oldName].scope.rename(oldName, newName);
}

上述代码中使用 do...while 循环来随机取一个标识符名,直到与当前节点引用到的全局标识符名不一样的时候,进行重命名。上述代码看似完美,但实际上 getOmnBinding 会取到当前函数中的子函数的参数名。虽然不影响最后结果,因为在当前函数中对子函数的参数进行重命名了,当遍历到子函数的时候,还会再次重命名。笔者在这里使用比较标识符作用域的方法来避免这种情况,完整的混淆标识符的代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function renameOwnBinding(path) {
    let OwnBindingObj = {}, globalBindingObj = {}, i = 0;
    path.traverse({
        Identifier(p)  {
            let name = p.node.name;
            let binding = p.scope.getOwnBinding(name);
            binding && generator(binding.scope.block).code == path + '' ?
            (OwnBindingObj[name] = binding) : (globalBindingObj[name] = 1);
        }
    });
    for(let oldName in OwnBindingObj) {
        do {
            var newName = '_0x2ba6ea' + i++;
        } while(globalBindingObj[newName]);
        OwnBindingObj[oldName].scope.rename(oldName, newName);
    }
}
traverse(ast, {
    'Program|FunctionExpression|FunctionDeclaration'(path) {
        renameOwnBinding(path);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});

单独解释下binding && generator(binding.scope.block).code == path + '' ,这段代码中 binding.scope,block 表示 binding 的作用域,path + ''只表示把当前节点转代码。注意,这里path 指的是Program|FunctionExpression|FunctionDeclaration其中一个。子函数的参数作用域转代码后是子函数本身,肯定与当前节点转代码后不一致。

7. 标识符的随机生成

在刚刚的小节中,重命名标识符时使用固定的_0x2aea 加上一个自增的数字来作为新的标识符名。这一节将使用大写字母 O小写字母o和数字0这三个字符来组成标识符名,把var newName='_0x2ba6ea' + i++;这句代码改成var newName=generatorIdentifier(i++);,以下是 generatorIdentifier 的实现代码:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function generatorIdentifier(decNum){
    let flag = ['O', 'o', '0'];
    let retval = [];
    while(decNum > 0){
        retval.push(decNum % 3);
        decNum = parseInt(decNum / 3);
    }
    let Identifier = retval.reverse().map(function(v){
        return flag[v]
    }).join('');
    Identifier.length < 6 ? (Identifier = ('OOOOOO' + Identifier).substr(-6)):
    Identifier[0] == '0' && (Identifier = 'O' + Identifier);
    return Identifier;
}

function renameOwnBinding(path) {
    let OwnBindingObj = {}, globalBindingObj = {}, i = 0;
    path.traverse({
        Identifier(p)  {
            let name = p.node.name;
            let binding = p.scope.getOwnBinding(name);
            binding && generator(binding.scope.block).code == path + '' ?
            (OwnBindingObj[name] = binding) : (globalBindingObj[name] = 1);
        }
    });
    for(let oldName in OwnBindingObj) {
        do {
            var newName = generatorIdentifier(i++);
        } while(globalBindingObj[newName]);
        OwnBindingObj[oldName].scope.rename(oldName, newName);
    }
}
traverse(ast, {
    'Program|FunctionExpression|FunctionDeclaration'(path) {
        renameOwnBinding(path);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    Date.prototype.format = function (OOOOOO) {
      var OOOOOo = OOOOOO;
      var OOOOO0 = ['日', '一', '二', '三', '四', '五', '六'];
      OOOOOo = OOOOOo.replace(/yyyy|YYYY/, this.getFullYear());
      OOOOOo = OOOOOo.replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1));
      OOOOOo = OOOOOo.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
      return OOOOOo;
    };
    console.log(new Date().format('yyyy-MM-dd'));
*/

它本质上就是将十进制转为三进制,然后把 0、1、2 分别用大写字母O 、小写字母o、和数字0来换。

retval 数组中存的是余数,经过数组的 reverse 方法倒序后,就得到十进制为三进制的结果,只是这时里面的数字是 012。接着通过数组的 map 遍历数组成员012,分别替换成大写字母 O,小写字母o和数字 0。最后用数组的 join 把数组拼接成字符串。

为了使生成的标识符整齐,对于长度小于 6的标识符,可以用大写字母O 或者小写字母o补全位数;对于长度大于或等于 6并且第一个字符是数字0的标识符,则往前面补一个大字母O或者小写字母o(标识符不能以数字开头)。

三、代码块的混淆

1.二项式转函数花指令

花指令用来尽可能地隐藏原代码的真实意图,并且有多种实现方案。本节要介绍如何把二项式转为函数。

例如,把 c+d 转换为如下形式:

function xxx(a,b){
    return a + b;
}
xxx(c,d);

不止二项式,代码中的函数调用表达式也可以处理成类似的花指令,例如 c(d)可以转换成如下形式:

function xxx(a,b) {
    return a(b);
}
xxx(c,d);

在这里只实现二项式的情况。从 AST 的角度去考虑如何实现这个方案,分为以下四步:

  1. 遍历 BinaryExpression 节点,取出operator、left 和 right。
  2. 生成一个函数,函数名不能与当前节点中的标识符冲突,参数可以固定为a和b,返回语句中的运算符与 operator 一致。
  3. 找到最近的 BlockStatement 节点,将生成的函数加入到 body 数组中的最前面。
  4. 把原先的 BinaryExpression 节点替换为 CallExpression,callee 是函数名,_arguments 是一项式的 let 和right。

上述实现方案中,标识符名可以随机设置,因为最后进行标识符混淆时,会处理成相似的名字。花指令的目的是增加代码量,隐藏代码的真实意图。因此每遍历到一个 operator,都可以生成不同名字的函数,不需要去判断是哪种operator,因为并不是一种operator生成一个函数。实现的代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    BinaryExpression(path){
        let operator = path.node.operator;
        let left = path.node.left;
        let right = path.node.right;
        let a = t.identifier('a');
        let b = t.identifier('b');
        let funcNameIdentifier = path.scope.generateUidIdentifier('xxx');
        let func = t.functionDeclaration(
            funcNameIdentifier, 
            [a, b], 
            t.blockStatement([t.returnStatement(
                    t.binaryExpression(operator, a, b)
                )]));
        let BlockStatement = path.findParent(
                    function(p){return p.isBlockStatement()});
        BlockStatement.node.body.unshift(func);
        path.replaceWith(t.callExpression(funcNameIdentifier, [left, right]));
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    Date.prototype.format = function (formatStr) {
      function _xxx7(a, b) {
        return a + b;
      }
      function _xxx6(a, b) {
        return a > b;
      }
      function _xxx5(a, b) {
        return a + b;
      }
      function _xxx4(a, b) {
        return a + b;
      }
      function _xxx3(a, b) {
        return a + b;
      }
      function _xxx2(a, b) {
        return a + b;
      }
      function _xxx(a, b) {
        return a > b;
      }
      var str = formatStr;
      var Week = ['日', '一', '二', '三', '四', '五', '六'];
      str = str.replace(/yyyy|YYYY/, this.getFullYear());
      str = str.replace(/MM/, _xxx(_xxx2(this.getMonth(), 1), 9) ? _xxx3(this.getMonth(), 1).toString() : _xxx4('0', _xxx5(this.getMonth(), 1)));
      str = str.replace(/dd|DD/, _xxx6(this.getDate(), 9) ? this.getDate().toString() : _xxx7('0', this.getDate()));
      return str;
    };
    console.log(new Date().format('yyyy-MM-dd'));
*/

2.代码的逐行加密

这种方案的原理跟字符串加密是一样的,不过需要先把代码转化为字符串,再把字符申加密后的密文传入解密函数、解密出明文,然后通过 eval 执行代码。实现代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);
function base64Encode(e) {
    var r, a, c, h, o, t, base64EncodeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    for (c = e.length, a = 0, r = ''; a < c;) {
        if (h = 255 & e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4),
            r += '==';
            break
        }
        if (o = e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
            r += base64EncodeChars.charAt((15 & o) << 2),
            r += '=';
            break
        }
        t = e.charCodeAt(a++),
        r += base64EncodeChars.charAt(h >> 2),
        r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
        r += base64EncodeChars.charAt((15 & o) << 2 | (192 & t) >> 6),
        r += base64EncodeChars.charAt(63 & t)
    }
    return r
}
traverse(ast, {
    FunctionExpression(path){
        let blockStatement = path.node.body;
        let Statements = blockStatement.body.map(function(v){
            if(t.isReturnStatement(v)) return v;
            let code = generator(v).code;
            let cipherText = base64Encode(code);
            let decryptFunc = t.callExpression(t.identifier('atob'), [t.stringLiteral(cipherText)]);
            return t.expressionStatement(t.callExpression(t.identifier('eval'), [decryptFunc]));
        });
        path.get('body').replaceWith(t.blockStatement(Statements));
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    Date.prototype.format = function (formatStr) {
      eval(atob("dmFyIHN0ciA9IGZvcm1hdFN0cjs="));
      eval(atob("dmFyIFdlZWsgPSBbJ+UnLCAnACcsICeMJywgJwknLCAn2ycsICeUJywgJ20nXTs="));
      eval(atob("c3RyID0gc3RyLnJlcGxhY2UoL3l5eXl8WVlZWS8sIHRoaXMuZ2V0RnVsbFllYXIoKSk7"));
      eval(atob("c3RyID0gc3RyLnJlcGxhY2UoL01NLywgdGhpcy5nZXRNb250aCgpICsgMSA+IDkgPyAodGhpcy5nZXRNb250aCgpICsgMSkudG9TdHJpbmcoKSA6ICcwJyArICh0aGlzLmdldE1vbnRoKCkgKyAxKSk7"));
      eval(atob("c3RyID0gc3RyLnJlcGxhY2UoL2RkfERELywgdGhpcy5nZXREYXRlKCkgPiA5ID8gdGhpcy5nZXREYXRlKCkudG9TdHJpbmcoKSA6ICcwJyArIHRoaXMuZ2V0RGF0ZSgpKTs="));
      return str;
    };
    console.log(new Date().format('yyyy-MM-dd'));
*/

这段代码处理步骤如下:

  1. 遍历 FunctionExpression 节点。其中path,node.body 即 BlockStatement 节点。

    blockStatement.body 是一个数组,里面是函数的代码行,它的每一个成员分别对应函数的每一行语句,然后使用数组的 map 方法对每一行语句分别处理。

  2. 如果是返回语句,就不做处理,直接返回原语句。单行加密时不能加密返回语句;整个函数加密时,函数中可以有返回语句。

  3. 使用 let code = generator(v).code; 将语转变成字符串。

  4. 使用 let cipherText = base64Encode(code);对字符串进行加密。

  5. 构建 atob('xxxx')形式的代码,atob 是解密函数名。解密函数如果不是系统自带的,还需要把解密函数一起放入代码中。需要生成 CallExpression,callee 为解密函数名,参数为加密后的字符串常量,赋值给 decryptFunc。

  6. 构建 eval(atob('xxxx'))形式的代码,需要生成 CallExpression,callee为 eval,参数为上一步生成的 decryptFunc,最后用expressionStatement 包裹。

  7. 当函数中所有语句处理完后,构建新的 BlockStatement 替换原有的即可。

代码逐行加密的方案不建议大规模应用,会导致标志太明显。可以只隐藏其中几句代码或某些变量的关键赋值位置。自动化处理过程中无法知道一段代码是否为关键代码,除非用户在代码中加人标记,表示要隐藏哪几句代码。这个标记可以是注释,例如,可以在原始代码中某一句代码后面加入注释 Base64Encrypt。解析以下节点,查看发生的变化:

str = str.replace(/yyyy|YYYY/,this.getFullYear());//Base64Encrypt

Node{
    type:'ExpressionStatement',
    expression: Node{
        type:'AssignmentExpression',
        ...
        operator:'=',
        left: Node{type: 'Identifier', ...name:'str'}
        right: Node{type:'CallExpression',...}
   },
    trailingComments: [{
        type:'CommentLine',
        value:'Base64Encrypt',
        ...
        }
    ]
}

从上述结构中可以看出,生成了一个 trailingComments 节点,它是行尾注释。

因此只要在逐行解析代码时,添加判断语句,如果有注释且为 Base64Encrypt,就进行加密,

...
if(t.isReturnStatement(v)) return v;
if(!(v.trailingComments && v.trailingComments[0].value == 'Base64Encrypt')) return v;
delete v.trailingComments;
let code = generator(v).code;
...

delete是JS 自带的,用来删除对象属性,加密之前把注释删掉。使用JS 自带的 delete而不是 path.remove,是因为v和 v. trailingComments 都是 Node 对象,不是 Path 对象。

如下所示:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionExpression(path){
        let blockStatement = path.node.body;
        let Statements = blockStatement.body.map(function(v){
            if(t.isReturnStatement(v)) return v;
            if(!(v.trailingComments && v.trailingComments[0].value == 'Base64Encrypt')) return v;
            delete v.trailingComments;
            let code = generator(v).code;
            let cipherText = base64Encode(code);
            let decryptFunc = t.callExpression(t.identifier('atob'), [t.stringLiteral(cipherText)]);
            return t.expressionStatement(t.callExpression(t.identifier('eval'), [decryptFunc]));
        });
        path.get('body').replaceWith(t.blockStatement(Statements));
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});

3.代码的逐行ASCII码混淆

这种方案的原理与代码的逐行加密没有太大差别,只不过去掉了字符串加密函数,改用charCodeAt将字符串转到ASCII码,把解密函数换成 String.fromCharCode,最后使用eval函数来执行字符串代码。具体实现代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionExpression(path){
        let blockStatement = path.node.body;
        let Statements = blockStatement.body.map(function(v){
            if(t.isReturnStatement(v)) return v;
            if(!(v.trailingComments && v.trailingComments[0].value == 'ASCIIEncrypt')) return v;
            delete v.trailingComments;
            let code = generator(v).code;
            let codeAscii = [].map.call(code, function(v){
                return t.numericLiteral(v.charCodeAt(0));
            });
            let decryptFuncName = t.memberExpression(t.identifier('String'), t.identifier('fromCharCode'));
            let decryptFunc = t.callExpression(decryptFuncName, codeAscii);
            return t.expressionStatement(t.callExpression(t.identifier('eval'), [decryptFunc]));
        });
        path.get('body').replaceWith(t.blockStatement(Statements));
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
Date.prototype.format = function (formatStr) {
  var str = formatStr;
  var Week = ['日', '一', '二', '三', '四', '五', '六'];
  str = str.replace(/yyyy|YYYY/, this.getFullYear());
  str = str.replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1));
  str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
  return str;
};
console.log(new Date().format('yyyy-MM-dd'));
*/

字符串在JS 中也是数组,不过是只读的。但是它不能直接调用数组的 map,所以上述代码中用[].map.call来间接调用,作用是一样的。把字符串中的每个成员转成 ASCII码之后,用 NumericLiteral 节点来包裹,然后生成一个 MemberExpression 节点,String.fromCharCode作为解密函数名。

不管是代码逐行加密还是代码逐行 ASCII混淆,都要在标识符混淆之后。由此可见,
在做标识符混淆时,应该处理原始代码中的eval和 Function。

四、完整的代码处理后的效果

本章前三节介绍了多种混淆方案的实现方式。其中有些方案联合使用时,需要修改部分代码,本节介绍完整的代码以及完整处理后的效果。

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

//把混淆方案的相关实现方法封装成类
function ConfoundUtils(ast, encryptFunc){
    this.ast = ast;
    this.bigArr = [];
    //接收传进来的函数,用于字符串加密
    this.encryptFunc = encryptFunc;
}
//改变对象属性访问方式 console.log 改为 console["log"]
ConfoundUtils.prototype.changeAccessMode = function (){
    traverse(this.ast, {
        MemberExpression(path){
            if(t.isIdentifier(path.node.property)){
                let name = path.node.property.name;
                path.node.property = t.stringLiteral(name);
            }
            path.node.computed = true;
        }, 
    });
}
//标准内置对象的处理
ConfoundUtils.prototype.changeBuiltinObjects = function (){
    traverse(this.ast, {
        Identifier(path){
            let name = path.node.name;
    if('eval|parseInt|encodeURIComponent|Object|Function|Boolean|Number|Math|Date|String|RegExp|Array'.indexOf(name) != -1){
                path.replaceWith(t.memberExpression(t.identifier('window'),                                                  t.stringLiteral(name), true));
            }
        }
    });
}
//数值常量加密
ConfoundUtils.prototype.numericEncrypt = function (){
    traverse(this.ast, {
        NumericLiteral(path){
            let value = path.node.value;
            let key = parseInt(Math.random() * (999999 - 100000) + 100000, 10);
            let cipherNum = value ^ key;
            path.replaceWith(t.binaryExpression('^',                                                                  t.numericLiteral(cipherNum),                                                t.numericLiteral(key)));
            path.skip();
        }
    });
}
//字符串加密与数组混淆
ConfoundUtils.prototype.arrayConfound = function (){
    let bigArr = [];
    let encryptFunc = this.encryptFunc;
    traverse(this.ast, {
        StringLiteral(path){
            let bigArrIndex = bigArr.indexOf(encryptFunc(path.node.value));
            let index = bigArrIndex;
            if(bigArrIndex == -1){
                let length = bigArr.push(encryptFunc(path.node.value));
                index = length -1;
            }
            let encStr = t.callExpression(
                t.identifier('atob'), 
                [t.memberExpression(t.identifier('arr'),                                   t.numericLiteral(index), true)]);
            path.replaceWith(encStr);
      }
    });
    bigArr = bigArr.map(function(v){
      return t.stringLiteral(v);
    });
    this.bigArr = bigArr;
}
//数组乱序
ConfoundUtils.prototype.arrayShuffle = function (){
    (function(myArr, num){
        var xiaojianbang = function(nums){
            while(--nums){
                myArr.unshift(myArr.pop());
            }
        };
        xiaojianbang(++num);
    }(this.bigArr, 0x10));
}
//二项式转函数花指令
ConfoundUtils.prototype.binaryToFunc = function (){
    traverse(this.ast, {
        BinaryExpression(path){
            let operator = path.node.operator;
            let left = path.node.left;
            let right = path.node.right;
            let a = t.identifier('a');
            let b = t.identifier('b');
            let funcNameIdentifier = path.scope.generateUidIdentifier('xxx');
            let func = t.functionDeclaration(
                funcNameIdentifier, 
                [a, b], 
                t.blockStatement([t.returnStatement(
                    t.binaryExpression(operator, a, b)
                    )]));
            let BlockStatement = path.findParent(
function(p){return p.isBlockStatement()});
            BlockStatement.node.body.unshift(func);
            path.replaceWith(t.callExpression(
funcNameIdentifier, [left, right]));
        }
    });
}
//十六进制字符串
ConfoundUtils.prototype.stringToHex = function (){
    function hexEnc(code) {
        for (var hexStr = [], i = 0, s; i < code.length; i++) {
            s = code.charCodeAt(i).toString(16);
            hexStr += "\\x" + s;
        }
        return hexStr
    }
    traverse(this.ast, {
        MemberExpression(path){
            if(t.isIdentifier(path.node.property)){
                let name = path.node.property.name;
                path.node.property = t.stringLiteral(hexEnc(name));
            }
            path.node.computed = true;
        }
    });
}
//标识符混淆
ConfoundUtils.prototype.renameIdentifier = function (){
    //标识符混淆之前先转成代码再解析,才能确保新生成的一些节点也被解析到
    let code = generator(this.ast).code;
    let newAst = parser.parse(code);
    //生成标识符
    function generatorIdentifier(decNum){
        let arr = ['O', 'o', '0'];
        let retval = [];
        while(decNum > 0){
            retval.push(decNum % 3);
            decNum = parseInt(decNum / 3);
        }
        let Identifier = retval.reverse().map(function(v){
            return arr[v]
        }).join('');
        Identifier.length < 6 ? (Identifier = ('OOOOOO' + Identifier).substr(-6)):
        Identifier[0] == '0' && (Identifier = 'O' + Identifier);
        return Identifier;
    }
    function renameOwnBinding(path){
        let OwnBindingObj = {}, globalBindingObj = {}, i = 0;
        path.traverse({
            Identifier(p){
                let name = p.node.name;
                let binding = p.scope.getOwnBinding(name);
                binding && generator(binding.scope.block).code == path + '' ? 
                (OwnBindingObj[name] = binding) : (globalBindingObj[name] = 1);
            }
        });
        for(let oldName in OwnBindingObj){
            do{
                var newName = generatorIdentifier(i++);
            }while(globalBindingObj[newName]);
            OwnBindingObj[oldName].scope.rename(oldName, newName);
        }
    }
    traverse(newAst, {
        'Program|FunctionExpression|FunctionDeclaration'(path) {
            renameOwnBinding(path);
        }
    });
    this.ast = newAst;
}
//指定代码行加密
ConfoundUtils.prototype.appointedCodeLineEncrypt = function (){
    traverse(this.ast, {
        FunctionExpression(path){
            let blockStatement = path.node.body;
            let Statements = blockStatement.body.map(function(v){
                if(t.isReturnStatement(v)) return v;
                if(!(v.trailingComments && v.trailingComments[0].value == 'Base64Encrypt')) return v;
                delete v.trailingComments;
                let code = generator(v).code;
                let cipherText = base64Encode(code);
                let decryptFunc = t.callExpression(t.identifier('atob'),                                                   [t.stringLiteral(cipherText)]);
                return t.expressionStatement(
t.callExpression(t.identifier('eval'), [decryptFunc]));
            });
            path.get('body').replaceWith(t.blockStatement(Statements));
        }
    });
}
//指定代码行ASCII码混淆
ConfoundUtils.prototype.appointedCodeLineAscii = function (){
    traverse(this.ast, {
        FunctionExpression(path){
            let blockStatement = path.node.body;
            let Statements = blockStatement.body.map(function(v){
                if(t.isReturnStatement(v)) return v;
                if(!(v.trailingComments && v.trailingComments[0].value == 'ASCIIEncrypt')) return v;
                delete v.trailingComments;
                let code = generator(v).code;
                let codeAscii = [].map.call(code, function(v){
                    return t.numericLiteral(v.charCodeAt(0));
                })
                let decryptFuncName = t.memberExpression(
t.identifier('String'), t.identifier('fromCharCode'));
                let decryptFunc = t.callExpression(decryptFuncName, codeAscii);
                return t.expressionStatement(
t.callExpression(t.identifier('eval'),[decryptFunc]));
            });
            path.get('body').replaceWith(t.blockStatement(Statements));
        }
    });
}
//构建数组声明语句,加入到ast最前面
ConfoundUtils.prototype.unshiftArrayDeclaration = function(){
    this.bigArr = t.variableDeclarator(t.identifier('arr'), t.arrayExpression(this.bigArr));
    this.bigArr = t.variableDeclaration('var', [this.bigArr]);
    this.ast.program.body.unshift(this.bigArr);
}
//拼接两个ast的body部分
ConfoundUtils.prototype.astConcatUnshift = function(ast){
    this.ast.program.body.unshift(ast);
}
ConfoundUtils.prototype.getAst = function(){
    return this.ast;
}
//Base64编码
function base64Encode(e) {
    var r, a, c, h, o, t, base64EncodeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
    for (c = e.length, a = 0, r = ''; a < c;) {
        if (h = 255 & e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4),
            r += '==';
            break
        }
        if (o = e.charCodeAt(a++), a == c) {
            r += base64EncodeChars.charAt(h >> 2),
            r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
            r += base64EncodeChars.charAt((15 & o) << 2),
            r += '=';
            break
        }
        t = e.charCodeAt(a++),
        r += base64EncodeChars.charAt(h >> 2),
        r += base64EncodeChars.charAt((3 & h) << 4 | (240 & o) >> 4),
        r += base64EncodeChars.charAt((15 & o) << 2 | (192 & t) >> 6),
        r += base64EncodeChars.charAt(63 & t)
    }
    return r
}
function main(){
    //读取要混淆的代码
    const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
    //读取还原数组乱序的代码
    const jscodeFront = fs.readFileSync("./demoFront.js", {
        encoding: "utf-8"
    });
    //把要混淆的代码解析成ast
    let ast = parser.parse(jscode);
    //把还原数组乱序的代码解析成astFront
    let astFront = parser.parse(jscodeFront);
    //初始化类,传递自定义的加密函数进去
    let confoundAst = new ConfoundUtils(ast, base64Encode);
    let confoundAstFront = new ConfoundUtils(astFront);
    //改变对象属性访问方式
    confoundAst.changeAccessMode();
    //标准内置对象的处理
    confoundAst.changeBuiltinObjects();
    //二项式转函数花指令
    confoundAst.binaryToFunc()
    //字符串加密与数组混淆
    confoundAst.arrayConfound();
    //数组乱序
    confoundAst.arrayShuffle();

    //还原数组顺序代码,改变对象属性访问方式,对其中的字符串进行十六进制编码
    confoundAstFront.stringToHex();
    astFront = confoundAstFront.getAst();
    //先把还原数组顺序的代码,加入到被混淆代码的ast中
    confoundAst.astConcatUnshift(astFront.program.body[0]);

    //再生成数组声明语句,并加入到被混淆代码的最开始处
    confoundAst.unshiftArrayDeclaration();
    //标识符重命名
    confoundAst.renameIdentifier();
    //指定代码行的混淆,需要放到标识符混淆之后
    confoundAst.appointedCodeLineEncrypt();
    confoundAst.appointedCodeLineAscii();
    //数值常量混淆
    confoundAst.numericEncrypt();
    ast = confoundAst.getAst();
    //ast转为代码
    code = generator(ast).code;
    //混淆的代码中,如果有十六进制字符串加密,ast转成代码以后会有多余的转义字符,需要替换掉
    code = code.replace(/\\\\x/g, '\\x');
    console.log(code);
}
main();

对上述代码进行以下三点说明。
(1) demo.js 中存放的代码用来测试混淆方案,代码为原始代码。
(2) demoFront.js 中存放的代码用于还原数组顺序,2.4 节中。
(3) 如果不使用浏览器自带的 atob 作为解密函数,就需要实现对应的 Base64 解码函数,然后与还原数组顺序的代码一起加入。

用上述代码处理原始代码,输出结果为:

var OOOOOO = ["Zm9ybWF0", "5Q==", "AA==", "jA==", "CQ==", "2w==", "lA==", "bQ==", "cmVwbGFjZQ==", "Z2V0RnVsbFllYXI=", "Z2V0TW9udGg=", "dG9TdHJpbmc=", "MA==", "Z2V0RGF0ZQ==", "bG9n", "eXl5eS1NTS1kZA==", "RGF0ZQ==", "cHJvdG90eXBl"];
(function (OOOOOO, OOOOOo) {
  var OOOOO0 = function (OOOOOo) {
    while (--OOOOOo) {
      OOOOOO["\x70\x75\x73\x68"](OOOOOO["\x73\x68\x69\x66\x74"]());
    }
  };
  OOOOO0(++OOOOOo);
})(OOOOOO, 142584 ^ 142568);
window[atob(OOOOOO[640621 ^ 640621])][atob(OOOOOO[333872 ^ 333873])][atob(OOOOOO[534827 ^ 534825])] = function (OOOOOo) {
  function OOOoOo(OOOOOO, OOOOOo) {
    return OOOOOO + OOOOOo;
  }
  function OOOoOO(OOOOOO, OOOOOo) {
    return OOOOOO > OOOOOo;
  }
  function OOOO00(OOOOOO, OOOOOo) {
    return OOOOOO + OOOOOo;
  }
  function OOOO0o(OOOOOO, OOOOOo) {
    return OOOOOO + OOOOOo;
  }
  function OOOO0O(OOOOOO, OOOOOo) {
    return OOOOOO + OOOOOo;
  }
  function OOOOo0(OOOOOO, OOOOOo) {
    return OOOOOO + OOOOOo;
  }
  function OOOOoo(OOOOOO, OOOOOo) {
    return OOOOOO > OOOOOo;
  }
  var OOOOO0 = OOOOOo;
  var OOOOoO = [atob(OOOOOO[889992 ^ 889995]), atob(OOOOOO[199115 ^ 199119]), atob(OOOOOO[393974 ^ 393971]), atob(OOOOOO[915745 ^ 915751]), atob(OOOOOO[302842 ^ 302845]), atob(OOOOOO[560371 ^ 560379]), atob(OOOOOO[624550 ^ 624559])];
  OOOOO0 = OOOOO0[atob(OOOOOO[449616 ^ 449626])](/yyyy|YYYY/, this[atob(OOOOOO[445092 ^ 445103])]());
  OOOOO0 = OOOOO0[atob(OOOOOO[314857 ^ 314851])](/MM/, OOOOoo(OOOOo0(this[atob(OOOOOO[568473 ^ 568469])](), 666066 ^ 666067), 271298 ^ 271307) ? OOOO0O
(this[atob(OOOOOO[251391 ^ 251379])](), 425338 ^ 425339)[atob(OOOOOO[357360 ^ 357373])]() : OOOO0o(atob(OOOOOO[391630 ^ 391616]), OOOO00(this[atob(OOOOOO[391880 ^ 391876])](), 812408 ^ 812409)));
  OOOOO0 = OOOOO0[atob(OOOOOO[666436 ^ 666446])](/dd|DD/, OOOoOO(this[atob(OOOOOO[189039 ^ 189024])](), 863695 ^ 863686) ? this[atob(OOOOOO[777680 ^ 777695])]()[atob(OOOOOO[542837 ^ 542840])]() : OOOoOo(atob(OOOOOO[250977 ^ 250991]), this[atob(OOOOOO[785111 ^ 785112])]()));
  return OOOOO0;
};
console[atob(OOOOOO[348315 ^ 348299])](new window[atob(OOOOOO[247633 ^ 247633])]()[atob(OOOOOO[400280 ^ 400282])](atob(OOOOOO[406239 ^ 406222])));

他妈的效果确实炸裂,亲妈不认。

五、代码执行逻辑的混淆

1.实现流程平坦化

把以下代码放到AST Explorer 网站中解析,对照网站来实现相应的流程平坦化,如下所示:

Date.prototype.format = function (formatStr){
    let array="0|1|4|5|3|2".split("|"),
        index =0;
    while(!![]){
        switch ( + _array[_index++]){
            case 0:
                var str = formatStr;
                continue;
            case 1:
                var Week = ['日','一', '二','三','四','五','六'];
                continue;
            case 2:
                return str;
                continue;
            case 3:
                str = str.replace(/dd|DD/,this.getDate() > 9 ? this.getDate().toString():'0' + this.getDate());
                continue;
            case 4:
                str = str.replace(/yyyy|YYYY/, this.getFullYear());
                continue;
            case 5:
                str = str.replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1));
                continue;
        }
        break;
    }
};
console.log(new Date().format( 'yyyy -MM- dd'));

因为采用的是一行语句对应一条 case 的方案,所以需要先获取每一行语句,处理方法与之前介绍的代码逐行混淆一致。遍历 FunctionExpression 节点,获取 path.node.body即BlockStatement。该节点的 body 属性是一个数组,通过 map 遍历数组,即可操作其中的每一行语句。代码如下:

traverse(ast,{
    FunctionExpression(path) {
        let blockStatement = path.node.body;
        let Statements = blockStatement.body.map(function (v, i) {
            return {index: i, value: v}
        });
    }});

在流程平坦化的混淆中,要打乱原先的语句顺序,但是在执行时又要按原先的顺序执行,因此在打乱语句顺序之前,要对原先的语句顺序做映射。

从上述代码中可以看出,采用{index:i,value:v}这种方式来做映射。

index 是语句在 blockStatement.body 中的索引也就是原先的顺序。value 是语句本身。有了对应的关系后,就可以方便地建立分发器,来控制代码在执行时跳转到正确的 case 语句块。

接着要打乱语句顺序,算法很简单,遍历数组每一个成员,每一次循环随机取出一个索引为j的成员,与索引为i的成员进行交换。代码如下:

//打乱语句顺序
let i = Statements.ength;
while(i){
    let j = Math.floor(Math.random() * i--);
    [Statements[j],Statements[i]] = [Statements[i], Statements[j]];
}

接着构建 case 语句块,在 AST 中它的类型为 SwitchCase。

下面介绍 SwitchCase 的结构:

{
    type:'SwitchCase',
    test:{ type: 'NumericLiteral', value: 5},
    consequent:[
        Node { ...},
        {type: 'ContinueStatement',label: null}
    ]
}

test 是 case 后的值,consequent 是一个数组,存放 case 语块中具体的语句,所以 case语句块的生成很容易。遍历打乱顺序后的语句数组 Statements。SwitchCase 中的 test 用从0开始递增的 NumericLiteral, consequent 存放两条语句,一条是原始代码中的语句,另一条是 continue 语句。最后把包装好的 SwitchCase 放入 cases 数组中。实现代码如下:

let cases = [];
Statements.map(function(v,i){
    let switchCase = t.switchCase(t.numericLiteral(i), [v.value, t.continueStatement()]);
    cases.push(switchCase);
]);

为了控制 switch 每一次都跳转到正确的 case 语句块上,需要构建分发器。分发器中的"0|1|4|5|3|2"来源于之前做的映射。调整上面用来生成 case 语句块的代码,改为如下形式:

let dispenserArr = [];
let cases =[];
Statements.map(function(v, i){
    dispenserArr[v.index] = i;
    let switchCase = t.switchCase(t.numericLiteral(i), [v.value, t.continueStatement()]);
    cases.push(switchCase);
});
let dispenserStr = dispenserArr.join('|');

这段代码又定义了一个数组 dispenserArr,并且该数组在最后使用 join,以“|”作为连接符连接数组所有成员。可以看出,dispenserStr 就是分发器中的"0|1|4|5|3|2",而014532 是代码执行的真实顺序,因此 dispenserArr 中的成员应当是 case 后面的值。假设case 后面的值是 5,即i为5,再取出当前语句的真实索引 v.index,假设它是 3,也就是原始代码中该语句是第4行语句(数组从0开始)。那么就把 dispenserArr 中索引为3的成员改为5。对应上述代码中的 dispenserArr[v.index]=i,即可生成分发器中的字符串。
核心部分已经介绍完毕,接下来用 types 组件生成节点。完整的代码如下:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionExpression(path){
        let blockStatement = path.node.body;
        //逐行提取语句,按原先的语句顺序建立索引,包装成对象
        let Statements = blockStatement.body.map(function(v, i){
            return {index: i, value: v};
        });
        //洗牌,打乱语句顺序
        let i = Statements.length;
        while(i){
            let j = Math.floor(Math.random() * i--);
            [Statements[j], Statements[i]] = [Statements[i], Statements[j]];
        }
        //构建分发器,创建switchCase数组
        let dispenserArr = [];
        let cases = [];
        Statements.map(function(v, i){
            dispenserArr[v.index] = i;
            let switchCase = t.switchCase(t.numericLiteral(i),  [v.value, t.continueStatement()]);
            cases.push(switchCase);
        });
        let dispenserStr = dispenserArr.join('|');
         //生成_array和_index标识符,利用BabelAPI保证不重名
        let array = path.scope.generateUidIdentifier('array');
        let index = path.scope.generateUidIdentifier('index');
        //生成 '...'.split 这是一个成员表达式
        let callee = t.memberExpression(t.stringLiteral(dispenserStr),  t.identifier('split'));
        //生成 split('|')
        let arrayInit = t.callExpression(callee, [t.stringLiteral('|')]);
        //_array和_index放入声明语句中,_index初始化为0
        let varArray = t.variableDeclarator(array, arrayInit);
        let varIndex = t.variableDeclarator(index, t.numericLiteral(0));
        let dispenser = t.variableDeclaration('let',[varArray, varIndex]);
        //生成switch中的表达式 +_array[_index++] 
        let updExp = t.updateExpression('++', index);
        let memExp = t.memberExpression(array, updExp, true);
        let discriminant = t.unaryExpression("+", memExp);
        //构建整个switch语句
        let switchSta = t.switchStatement(discriminant, cases);
        //生成while循环中的条件 !![]
        let unaExp = t.unaryExpression("!", t.arrayExpression());
        unaExp = t.unaryExpression("!", unaExp);
        //生成整个while循环
        let whileSta = t.whileStatement(unaExp,  t.blockStatement([switchSta, t.breakStatement()]));
        //用分发器和while循环来构建blockStatement,替换原有节点
        path.get('body').replaceWith(t.blockStatement([dispenser, whileSta]));

    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
Date.prototype.format = function (formatStr) {
  let _array = "0|3|5|1|4|2".split("|"),
    _index = 0;
  while (!![]) {
    switch (+_array[_index++]) {
      case 0:
        var str = formatStr;
        continue;
      case 1:
        str = str.replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1));
        continue;
      case 2:
        return str;
        continue;
      case 3:
        var Week = ['日', '一', '二', '三', '四', '五', '六'];
        continue;
      case 4:
        str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate());
        continue;
      case 5:
        str = str.replace(/yyyy|YYYY/, this.getFullYear());
        continue;
    }
    break;
  }
};
console.log(new Date().format('yyyy-MM-dd'));
*/

对上述代码做以下 3 点说明:
(1) 为了便于理解,代码没有使用其他混淆方案。但在实际使用中,该混淆方案必须和之前介绍的其他混淆方案配合使用,才能加强混淆效果。例如,case 后面跟的值可以用数值常量加密(在JS 中 case 后面可以跟一个表达式,而不仅是数值或字符),分发器中的字符串可以用字符串加密,然后一起提取到大数组中。对于原始代码部分,可以进行生成花指令逐行加密等各种操作。
(2) switch中的表达式为+_array[_index++],最前面的加号代表强转成数值类型,因为分发器中它是字符串类型。虽然JS 是一门弱类型的语言,解释器会在适当的时机完成类型的自动转换,但是在 case 中,全等(===)的匹配不会自动转换类型。
(3)由于语句顺序被打乱,因此生成的语句顺序每一次都是不同的。在测试时,并不一定与上述顺序相同。

2.实现逗号表达式混淆

逗号表达式混淆的核心,是用逗号连接多个表达式。但是有一些语句在连接时,需要进行特别处理。

1.声明语句之间的连接

示例代码如下

vara=100;
var b = 200;
// vara=100,b=200;
// vara = 100,var b = 200;不能这样操作

可以看出,如果要连接两个声明语句,需要取出 VariableDeclaration 中的 declarations数组,该数组里是声明语句中定义的变量,然后将它处理成一条声明语句。这里最方便的处理方式是把所有的标识符声明都提取到参数中。

2.普通语句与return 语句连接

示例代码如下:

function test(a){
    a = a+100;
    return a;
}
/*
    function test(a){
        return a = a +100,
        a;
        //a = a+100, return a;不能这样操作
    }
*/

可以看出,普通语句与return 语句连接时,需要提取出return语句的argument部分然后重新构建return 节点,把整个逗号表达式作为 argument 部分。接着介绍如何把函数中所有声明的变量提取到参数列表中,实现代码如下:

traverse(ast, {
    FunctionExpression(path){
        let blockStatement = path.node.body;
        let blockStatementLength = blockStatement.body.length;
        if(blockStatementLength < 2) return;
        //把所有声明的变量提取到参数列表中
        path.traverse({
            VariableDeclaration(p){
                declarations = p.node.declarations;
                let statements = [];
                declarations.map(function(v){
                    path.node.params.push(v.id);
                    v.init && statements.push(t.assignmentExpression('=',v.id,v.init));
                });
                p.replaceInline(statements);
            }
        });
    }
});

遍历FunctionExpression 节点,取出 blockStatement,body 节点,该节点里存放函数中所有的语句。如果语句少于两条,就不做任何处理。接着,遍历当前函数下所有的VariableDeclaration 节点,其中 declarations 数组为声明的具体变量。然后通过map 遍历整个数组,通过 path.node.params.push(v.id)将变量提取到函数的参数列表中。

要考虑两种情况:

  • 变量没有初始化,那么它不加入到 statements 数组中,最后替换节点时相当于被移除;
  • 变量初始化,那么就将 VariableDeclarator 改为赋值语句。

有时候会在函数体中的语句外面包裹一层 ExpressionStatement 节点,这会影响语句类型的判断。所以先把这类语句外层的 ExpressionStatement 节点去掉,代码如下:

let firstSta = blockStatement.body[0],i = 1;
while(i< blockStatementLength){
    let tempSta = blockStatement.body[i++];
    t.isExpressionStatement(tempSta) ? secondSta = tempSta.expression : secondSta = tempSta;
}

首先取出函数体中的第一条语句,并且定义一个初始值为 1的计数器 i。接着对函数体中的其他语句进行循环,取出来的语句先赋值给 tempSta,然后判断是否为ExpressionStatement 节点。如果是,就取出expression 属性赋值给 secondSta,否则直接赋值给 secondSta。

然后要把这两个语句改为逗号表达式,可以使用toSequenceExpression 来完成。对于不同的语句,需要使用不同的处理方案。本节只处理其中的赋值语句和函数调用语句,返回语句也需要处理,如下所示:

//处理返回语句
if(t.isReturnStatement(secondSta)){
    firstSta = t.returnStatement(
        t.toSequenceExpression([firstSta,secondSta.argument]));
//处理赋值语句)
}else if(t.isAssignmentExpression(secondSta)){
    secondSta.right = t.toSequenceExpression([firstSta, secondSta.right]);
    firstSta = secondSta;
}else{
    firstSta = t.toSequenceExpression([firstSta, secondSta]);
}

首先介绍这段代码中的赋值语句的处理方法。判断语句如果是赋值表达式,就取出其中的right 节点,与 firstSta 组成逗号表达式后,再替换原有的 right 节点,最后把 secondSta赋值给 firstSta,进行后续的语句处理。接着介绍返回语句,取出其中的argument 节点,与firstSta组成逗号表达式后,重新生成一个returnStatement 节点。到这一步就可以跳出循环,因为后续即使再有语句,也不会被执行到。如果既不是返回语句,也不是赋值语句,那么就直接将 firstSta 与 secondSta 组成逗号表达式,这是一种最没有混淆力度的组成方式。

最后再处理函数调用表达式。在原始代码中,str.replace(...)可以处理成(firstSta,str.replace)(...)或者(firstSta,str.replace)(...),本节来实现第一种情况,如下所示:

if(t.isCallExpression(secondSta.right)){
    let callee = secondSta.right.callee;
    callee.object = t.toSequenceExpression([firstSta, callee.object]);
    firstSta = secondSta;
}

这段代码中,赋值语句的右边是函数调用表达式。取出函数名 callee,str.replace(...)的函数名为 str.replace。这是一个 MemberExpression 节点。接着取出 object 部分,即将str 与 firstSta 组成逗号表达式,再替换掉原先的 object 节点。

下面展示完整的处理代码:

const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const fs = require('fs');

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionExpression(path) {
        let blockStatement = path.node.body;
        let blockStatementLength = blockStatement.body.length;
        if (blockStatementLength < 2) return;
        //把所有声明的变量提取到参数列表中
        path.traverse({
            VariableDeclaration(p) {
                declarations = p.node.declarations;
                let statements = [];
                declarations.map(function (v) {
                    path.node.params.push(v.id);
                    v.init && statements.push(t.assignmentExpression('=', v.id, v.init));
                });
                p.replaceInline(statements);
            }
        });
        //处理赋值语句 返回语句 函数调用语句
        let firstSta = blockStatement.body[0],
        i = 1;
        while (i < blockStatementLength) {
            let tempSta = blockStatement.body[i++];
            t.isExpressionStatement(tempSta) ?
            secondSta = tempSta.expression : secondSta = tempSta;
            //处理返回语句
            if (t.isReturnStatement(secondSta)) {
                firstSta = t.returnStatement(
                    t.toSequenceExpression([firstSta, secondSta.argument]));
                //处理赋值语句
            } else if (t.isAssignmentExpression(secondSta)) {
                if (t.isCallExpression(secondSta.right)) {
                    let callee = secondSta.right.callee;
                    callee.object = t.toSequenceExpression([firstSta, callee.object]);
                    firstSta = secondSta;
                } else {
                    secondSta.right = t.toSequenceExpression([firstSta, secondSta.right]);
                    firstSta = secondSta;
                }
            } else {
                firstSta = t.toSequenceExpression([firstSta, secondSta]);
            }
        }
        path.get('body').replaceWith(t.blockStatement([firstSta]));
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
Date.prototype.format = function (formatStr, str, Week) {
  return str = (str = (str = (Week = (str = formatStr, ['日', '一', '二', '三', '四', '五', '六']), str).replace(/yyyy|YYYY/, this.getFullYear()), str).replace(/MM/, this.getMonth() + 1 > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1)), str).replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate()), str;
};
console.log(new Date().format('yyyy-MM-dd'));
*/

这段代码只适用于原始代码的案例,实际应用中,还需要考虑更多情况。可以看出,逗号表达式混淆的实现是比较复杂的,但是逗号表达式的还原很容易处理。后续章节会给出相应的还原方案。

六、小结

本章介绍了很多混淆的实现方案,这些方案之间需要配合使用才能达到更好的混淆效果。

但是在配合使用的过程中,有些方案需要注意顺序,如代码逐行加密,由于会把代码中的标识符一起加密,所以需要放在标识符混淆之后;有些方案不需要注意顺序,如数值常量混淆。

在代码执行逻辑的混淆中,流程平坦化的实现方案也有很多种,大体原理差不多。

此外,本章不少小节只写了FunctionExpression 的情况,FunctionDeclaration 的处理方法也是一样的。

发表回复

您的电子邮箱地址不会被公开。