AST对JS代码进行混淆与还原

AST基本概念

中文名为抽象语法树,英文名称:Abstract Syntax Code,是源代码语法结构的一种抽象表示方式,具有明显的树状结构特征,原本紧密联系、结构紧凑的代码被切分成了不可再分的零碎词块,且语法不会表示出真实语法中的所有细节,因此带来了”抽象”的感觉。例如,编写一个简单的1+1==2,表达式在解析后得到AST抽象语法树如下所示。

{
  "type": "Program",
  "start": 0,
  "end": 6,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 6,
      "expression": {
        "type": "BinaryExpression",
        "start": 0,
        "end": 6,
        "left": {
          "type": "BinaryExpression",
          "start": 0,
          "end": 3,
          "left": {
            "type": "Literal",
            "start": 0,
            "end": 1,
            "value": 1,
            "raw": "1"
          },
          "operator": "+",
          "right": {
            "type": "Literal",
            "start": 2,
            "end": 3,
            "value": 1,
            "raw": "1"
          }
        },
        "operator": "==",
        "right": {
          "type": "Literal",
          "start": 5,
          "end": 6,
          "value": 2,
          "raw": "2"
        }
      }
    }
  ],
  "sourceType": "module"
}

其实,任何编程语言都需要一些软件来将源代码处理AST形式的数据结构以便让计算机能够理解,对于JS而言,可以使用Babel作为编译器将JS代码解构成AST。
AST在线编译

JS代码的混淆

JS代码如果不混淆,那么数据对于逆向者而言就如同探囊取物,下面对常见的混淆方式分别进行描述。大致可以分为三类,第一类是常量与标示符的混淆;第二类是代码块的混淆;第三类是执行逻辑的混淆。

以下面这段js代码为例,进行混淆处理。

Date.prototype.format = function (formatStr) {
    var str = formatStr;
    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()); //AsciiEncrypt
    return str;
}
console.log(new Date().format('yyyy-MM-dd'));

这段代码是在Date的原型链上新增一个实例方法,功能是以指定格式输出当前的日期,浏览器控制台执行结果如下。
执行结果

混淆前的预处理

在脚本文件中提前导入相关的依赖包,将混淆方案的相关实现封装成类,后续的混淆步骤都挂在这个类的原型链上。

const parser = require('@babel/parser') //解析
const traverse = require('@babel/traverse').default //遍历
const t = require('@babel/types') //类型判定与生成
const generator = require('@babel/generator').default //ast转code
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; //对象读写属性设置为true
            }
        }
    })
}

效果

内置对象处理

某些内置对象如Date,本来是标识符性质,可以转为window.['Date']

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) {
                //用window[name]成员表达式替换之
                path.replaceWith(t.MemberExpression(t.identifier('window'), t.stringLiteral(name), true));
            }
        }
    })
}

效果

常量与标识符混淆

常量一般是字符串或者数值,在上一步中已经通过改变对象属性访问方式产生了很多字符串,这些都可以混淆。

字符串常量处理

字符串加密

对全文中出现的字符串进行加密,加密方法可以是自带的base64也可自定义加密方法。

大数组分配

将加密后的字符串放入数组中统一分配,通过下标取值,当数组很大时,很容易让逆向者失去耐心,一般大数组和字符串加密同时处理。

ConfoundUtils.prototype.arrayConfound = function () {
    let bigArr = [];
    let encryptFunc = this.encryptFunc;
    traverse(this.ast, {
        StringLiteral(path) {
            let cipherText = encryptFunc(path.node.value); //获取字符串值
            let bigArrIndex = bigArr.indexOf(cipherText); //在大数组中查找是否有这个值,如有返回索引
            let index = bigArrIndex; // 索引赋值给index
            if (bigArrIndex === -1) { //若索引不存在
                let length = bigArr.push(cipherText); //将字符串推入大数组,并返回数组长度,push是推入最后
                index = length - 1 //index为大数组中最后一位的索引
            }
            //构造函数调用表达式(加密),内部成员表达式(数组混淆),从数组中用索引取值
            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); //大数组内的成员转ast代码
    });
    this.bigArr = bigArr; 
}
//插入大数组
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的最上层插入大数组
}

效果

数组乱序

通过将大数组打乱放置,后面插入一个自执行函数用于还原顺序,由于还原函数中的pushshift字符串含义比较明显,可以考虑用16进制编码表示,如此,逆向者必须找到还原函数,而还原函数中还可以埋坑进去,如格式化检测加内存爆破。

//现在外部新建一个astFront.js用于存放还原函数
!(function (myArr, num) {
    var outOrder = function (num) {
        while (--num) { //循环次数递减
            myArr.push(myArr.shift()); //数组开头取出末尾插入
        }
    };

})(arr, 0x10) //0x10表示移动16位,用16进制表示增加迷惑性
//对还原函数进行16进制处理
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; //取出主体名
                //16进制替换
                path.node.property = t.stringLiteral(hexEnc(name));
                path.node.computed = true;//16进制也是支持用.属性的形式访问的
            }
        }
    })
}
//把还原ast放到混淆ast的上面,主要注意的是这一步要放在大数组放置步骤的前面
ConfoundUtils.prototype.astConcatUnshift = function (ast) {
    this.ast.program.body.unshift(ast)
}
//混淆的代码中,如果有十六进制字符串加密,ast转成代码后会有多余的转义字符,需要替换掉
    code = code.replace(/\\\\x/g, '\\x')

效果

此时,数组提取中的数字就不再与大数组中一一对应。

格式化检测

浏览器中的网站js代码正常是压缩的状态,而逆向者在沙盒环境中执行往往是格式化状态,利用这点,在自执行函数中对某个函数利用正则校验其是否被格式化,如果被格式化,可以选择执行错误的流程分支,进入内存爆破的函数,让浏览器陷入崩溃。

内存爆破

当检测到当前环境不正常时执行,利用while或者for循环等,执行一个永不会结束的代码,这会持续消耗浏览器内存,直到浏览器崩溃。

//在之前定义好的astFront代码中做补充
!(function (myArr, num) {
    var outOrder = function (num) {
        while (--num) { //循环次数递减
            myArr.push(myArr.shift()); //数组开头取出末尾插入
        }
    };
    //内存爆破
    var memBreak = function () {
        for (var i = 0, j = [1]; i < j.length; i++) {
            j.push(i) //每次都push,逻辑上i永远都小于j.length
        }
    }

    var formatFuc = function () {
        return 'debug review'
    }
    var formatReg = /[\r\n]/; //查找换行符或回车符

    var match = formatReg.exec(formatFuc.toString()); //格式化检测
    if (match) {
        memBreak(); //如果检测出来格式化就进入内存爆破的函数
    } else {
        outOrder(num); //否则执行正确的逻辑分支
    }
})(arr, 0x10) //0x10表示移动16位,用16进制表示增加迷惑性

如果检测到特定的代码被格式化


如果代码未被格式化

数值常量加密

一些数值,例如大数组的下标取值,可以利用异或操作增加复杂度,例如1,可以变成123457 ^ 123456表示。

ConfoundUtils.prototype.numericEncrypt = function () {
    traverse(this.ast, {
        NumericLiteral(path) {
            let value = path.node.value; //获取数值常量的值
            //生成100000~999999的随机10进制的字符串
            let key = parseInt(Math.random() * (999999 - 100000) + 100000, 10);
            let cipherNum = value ^ key;//真实数值异或这个随机值得到一个加密值,异或是两边二进制对位同为0,异为1
            //那么加密异或key也一定可以还原为真实值
            path.replaceWith(t.binaryExpression('^', t.numericLiteral(cipherNum), t.numericLiteral(key)));
            path.skip(); //这是因为替换后防止继续深度遍历numericLiteral,形成死循环
        }
    })
}

效果

标识符混淆

标识符名称在开发时往往定义成具有语义的名称,而这为逆向者提供了猜测代码逻辑的便利,通过将所有名称改成无意义的名称,使逆向者无法看词猜意。

随机生成办法:每个参数都有自己的作用域,因此每个不同作用域的标识符可以重名,这样逆向者搜索某个标识符的搜索结果就会变多,逆向者无法快速定位,此外,推荐使用oO0等高度相识的符号构成标识符名称,进一步增加混淆程度。

ConfoundUtils.prototype.renameIdentifier = function () {
    //标识符混淆之前先转成代码再解析,确保新生成的一些节点被解析到,因为type生成的节点并不会自动携带path,所以无法被遍历到
    let code = generator(this.ast).code;
    let newAst = parser.parse(code);

    //生成标识符
    function generatorIdentifier(decNum) {
        let arr = ['0', 'o', 'O']; //这3个字符长得比较像,用来替换3进制中012
        let retval = [];
        //十进制转三进制的算法
        while (decNum > 0) {
            retval.push(decNum % 3);
            decNum = parseInt(decNum / 3);
        }
        //除余法需要先事先翻转一下
        let Identifier = retval.reverse().map(function (v) {
            return arr[v]
        }).join('')
        //不足6位就补,开头是0也补(数字不可做标识符的开头)
        Identifier.length < 6 ? (Identifier = ('OOOOOO' + Identifier).substring(-6)) : Identifier[0] === '0' && (Identifier = 'O' + Identifier);
        return Identifier
    }

    function renameOwnBinding(path) {
        let OwnBindingObj = {}, globalBindingObj = {}, i = 0; //从十进制的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); //利用scope.rename对所有引用的变量批量修改
        }
    }

    traverse(newAst, {
        'Program|FunctionExpression|FunctionDeclaration'(path) {
            renameOwnBinding(path); //无论是全局还是匿名函数内还是函数声明内都查找标识符并改名
        }
    })
    this.ast = newAst; //把新ast重新赋值给this.ast
}

效果

至此第一部分完成,相较于原先代码,阅读难度显著增加。

代码块混淆

代码块一般指的是函数体内部代码,可以是匿名函数,也可以是函数表达式,通过可以用来混淆的操作有花指令膨胀、逗号表达式压缩、指定代码行加密等。为了增强前后的对比效果,第一部分的混淆不启用。

二项式转函数花指令

诸如a+b,可以用function x(a,b){return a+b}表示,利用函数调用的形式来表达,能够起到一行变十行的混淆效果。

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'); //新建标示符a
            let b = t.identifier('b'); //新建标示符b
            //生成唯一性的随机标示符作为函数名称,由于后期标识符会统一修改,这个仅做中转
            let funcNameIdentifier = path.scope.generateUidIdentifier('xxx');
            // 定义一个函数声明语句
            let func = t.functionDeclaration(
                funcNameIdentifier,
                [a, b],
                t.blockStatement([t.returnStatement(
                    t.binaryExpression(operator, a, b)
                )]));
            // 寻找当前最近的函数体表达式节点,这里用findParent
            let BlockStatement = path.findParent(
                function (p) {
                    return p.isBlockStatement()
                }
            );
            // 开头插入花指令函数
            BlockStatement.node.body.unshift(func);
            // 节点替换成函数调用表达式
            path.replaceWith(t.callExpression(funcNameIdentifier, [left, right]));
        }
    })
}  

效果

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;
  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'));

可以看到很多本来是+-<>等简单的二项式运算都独立成了函数调用,代码行数也显著上去,后期再配合上标识符混淆,可阅读性将显著降低。

指定代码行加密

一般用于对核心代码行单独加密,例如base64和ascii码加密,也可以是自定义加密方式。

ascii码加密

在待混淆的js文件指定行注明想要加密方式,这里用AsciiEncrypt,原理是ast识别到指定注释行后,对该行代码用ascii码加密然后转字符串,再利用eval特性在vm中执行代码。
加密行注释

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; //如果是返回语句则不修改
                //如果没有注释部分且注释部分非ASCIIEncrypt则不修改
                if (!(v.trailingComments && v.trailingComments[0].value === 'ASCIIEncrypt')) return v;
                //删除注释部分
                delete v.trailingComments;
                let code = generator(v).code; //将函数转化为code
                let codeAscii = [].map.call(code, function (v) {
                    return t.numericLiteral(v.charCodeAt(0)); //把字符串中每个字符串转化成ascii码形式变成一个列表
                });
                // 定义一个成员表达式,对象是String,成员是fromCharCode
                let decryptFunctionName = t.memberExpression(t.identifier('String'), t.identifier('fromCharCode'));
                //定义一个函数调用,函数是上面那个成员表达式,参数是ascii化的代码行
                let decryptFunc = t.callExpression(decryptFunctionName, codeAscii)
                //返回一个表达式,内嵌函数调用,调用方是eval,参数是fromCharCode解ascii码后的字符串,eval执行的代码会在eval里运行
                return t.expressionStatement(t.callExpression(t.identifier('eval'), [decryptFunc]))
            });
            //将当前func path的函数体替换掉
            path.get('body').replaceWith(t.blockStatement(Statements));
        }
    })
}  

效果

执行逻辑的混淆

一般情况下,开发写代码逻辑是自上而下的,逆向者可以方便的一行一行阅读,此时可以通过流程平坦化打乱执行顺序,也可以通过逗号表达式将执行顺序变成从内到外。

流程平坦化

简单来说就是利用循环中switch...case配合一个规定了执行顺序的分发器执行,而这个分发器的执行顺序事前被随机成乱序。

ConfoundUtils.prototype.controlFlowFlat = function () {
    traverse(this.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]]
            }
            //定义一个分发器
            let dispenserArr = [];
            //定义一个存在case块的列表
            let cases = [];
            //对代码行列表进行映射,篡改一些对象的引用关系
            Statements.map(function (v, i) {
                //分发器记录真实顺序
                dispenserArr[v.index] = i;
                //构建swtich的case语句,包括一个判断条件,执行流程和一个continue语句
                let switchCase = t.switchCase(t.numericLiteral(i), [v.value, t.continueStatement()]);
                cases.push(switchCase)
            });
            //分发器数组用|连接成字符串
            let dispenserStr = dispenserArr.join('|');
            //构建不重名的标识符代表分发器
            let array = path.scope.generateUidIdentifier('array');
            //构建不重名的标识符代表起始索引
            let index = path.scope.generateUidIdentifier('index');
            //构建成员表达式,obj是分发器字符串,成员是split
            let callee = t.memberExpression(t.stringLiteral(dispenserStr), t.identifier('split'));
            //构建函数调用,传入参数'|'
            let arrayInit = t.callExpression(callee, [t.stringLiteral('|')]);
            //构建变量声明器,变量明是分发器标识符,初始化是上面那个
            let varArray = t.variableDeclarator(array, arrayInit);
            //构建初始索引,从0开始
            let varIndex = t.variableDeclarator(index, t.numericLiteral(0))
            //用let将上面2为构成完整的变量声明语句
            let dispenser = t.variableDeclaration('let', [varArray, varIndex]);
            //构建自增表达式
            let updExp = t.updateExpression('++', index);
            //构建成员表达式
            let memExp = t.memberExpression(array, updExp, true);
            //构建单项式表达式,这里用为了将字符串强转成字符串类型,因为case中用的是===全等,并不会自动转化类型
            let discriminent = t.unaryExpression('+', memExp);
            //将switch表达式和case块合成完整的switch语句块
            let switchSta = t.switchStatement(discriminent, cases);
            let unaExp = t.unaryExpression('!', t.arrayExpression());
            //!![]意思就是true,之所以不直接用当然是为了混淆视听
            unaExp = t.unaryExpression('!', unaExp);
            //构建while循环语句,需要循环条件和循环体,循环体需要一个switch块和一个break语句,因为需要保证到最后没有匹配时默认退出循环
            let whileSta = t.whileStatement(unaExp, t.blockStatement([switchSta, t.breakStatement()]));
            //将函数体节点替换成由分发器和while循环构成的函数体语句
            path.get('body').replaceWith(t.blockStatement([dispenser, whileSta]));
        }
    })
}

效果

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

可以看出该函数内部执行顺序已经不再是自上而下了,而是受一个分发器的控制let _array = "3|1|0|2|4".split("|"),这显然是乱序的。

逗号表达式压缩

如果平坦化是纵向打乱代码的话,那逗号表达式就是横向压缩代码,利用逗号表达式从左向右执行的特性,将多行代码置于一行内表达。

ConfoundUtils.prototype.toSequenceConfuse = function () {
    //逗号表达式混淆
    traverse(this.ast, {
        FunctionExpression(path) {
            let blockStatement = path.node.body; //取出函数体
            let blockStatementLength = blockStatement.body.length; //得到函数体代码行数
            if (blockStatementLength < 2) return; //如果小于2直接返回不处理
            path.traverse({
                VariableDeclaration(p) { //遍历函数下的所有变量声明语句
                    let 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; //从第一行开始
            let secondSta; //定义第二行
            while (i < blockStatementLength) { //如果索引小于函数体的行数
                let tempSta = blockStatement.body[i++]; //i++先赋值再自增,暂存下一行
                t.isExpressionStatement(tempSta) ? //是否是表达式语句
                    secondSta = tempSta.expression : secondSta = tempSta; //是把里面的expreesion赋值给第二行,否则把本身赋值给第二行
                //处理返回语句
                if (t.isReturnStatement(secondSta)) { //第二行是否为return 语句
                    firstSta = t.returnStatement( //如果是就用return 包裹第一行和第二行return语句的参数部分
                        t.toSequenceExpression([firstSta, secondSta.argument])
                    )
                } else if (t.isAssignmentExpression(secondSta)) { //如果第二行是赋值语句
                    if (t.isCallExpression(secondSta.right)) { //是否赋值语句的右边是函数调用语句
                        let callee = secondSta.right.callee; //如果是就把函数调用obj提出来
                        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]); //如果既不是返回语句也非赋值语句,就简单的拼成逗号表达式
                }
            }
            if (t.isReturnStatement(firstSta)) { //如果最后形成的是一个return表达式
                path.get('body').replaceWith(t.blockStatement([firstSta])); //就穿上blockstatement的外衣替换掉原先的函数体
            } else {
                //如果没有return,就穿上表达式的外衣替换掉原先的函数体
                path.get('body').replaceWith(t.blockStatement([t.expressionStatement(firstSta)]));
            }
        }
    });
}

效果

Date.prototype.format = function (formatStr, str) {
  return str = (str = (str = (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'));

可以看出,format指向的函数原本5行的函数体被压缩成了一行,让人难以阅读。

JS代码的还原

前面对代码进行了一系列的混淆操作,让逆向者难以阅读;对逆向者而言,也可以利用ast进行还原,虽不能达到完全还原的程度(标识符随机命名这个就没办法还原),但也可大大提高代码阅读性。这里挑选上面提到的部分混淆方式,先形成一个混淆后js代码。

var OOOOOO=["MA==","Z2V0RGF0ZQ==","bG9n","eXl5eS1NTS1kZA==","RGF0ZQ==","cHJvdG90eXBl","Zm9ybWF0","cmVwbGFjZQ==","Z2V0RnVsbFllYXI=","Z2V0TW9udGg=","dG9TdHJpbmc="];!function(OOOOOO,OOOOOOo,OOOOOOO,OOOOOOo0,OOOOOOoo,OOOOOOoO,OOOOOOO0,OOOOOOOo,OOOOOOOO){OOOOOOO0=(OOOOOOoO=(OOOOOOoo=(OOOOOOo0=(OOOOOOO=function(OOOOOOo){while(--OOOOOOo){OOOOOO["\x70\x75\x73\x68"](OOOOOO["\x73\x68\x69\x66\x74"]())}},function(){for(OOOOOOOo=0,OOOOOOOO=[1];OOOOOOOo<OOOOOOOO["\x6c\x65\x6e\x67\x74\x68"];OOOOOOOo++){OOOOOOOO["\x70\x75\x73\x68"](OOOOOOOo)}}),function(){return"debug review"}),/[\r\n]/),OOOOOOoO)["\x65\x78\x65\x63"](OOOOOOoo["\x74\x6f\x53\x74\x72\x69\x6e\x67"]()),OOOOOOO0?OOOOOOo0():OOOOOOO(OOOOOOo)}(OOOOOO,16);window[atob(OOOOOO[784633^784633])][atob(OOOOOO[971616^971617])][atob(OOOOOO[935256^935258])]=function(OOOOOOo,OOOOOOO){return OOOOOOO=(OOOOOOO=(OOOOOOO=(OOOOOOO=OOOOOOo,OOOOOOO)[atob(OOOOOO[830960^830963])](/yyyy|YYYY/,this[atob(OOOOOO[993326^993322])]()),OOOOOOO)[atob(OOOOOO[460290^460289])](/MM/,this[atob(OOOOOO[219476^219473])]()+(675424^675425)>(177916^177909)?(this[atob(OOOOOO[624670^624667])]()+(107576^107577))[atob(OOOOOO[155125^155123])]():atob(OOOOOO[330811^330812])+(this[atob(OOOOOO[278369^278372])]()+(631088^631089))),OOOOOOO)[atob(OOOOOO[873048^873051])](/dd|DD/,this[atob(OOOOOO[815701^815709])]()>(402567^402574)?this[atob(OOOOOO[218780^218772])]()[atob(OOOOOO[674279^674273])]():atob(OOOOOO[433915^433916])+this[atob(OOOOOO[647323^647315])]()),OOOOOOO};console[atob(OOOOOO[813324^813317])](new window[atob(OOOOOO[944997^944997])]()[atob(OOOOOO[576679^576677])](atob(OOOOOO[927369^927363])));  

上面这个函数是可以被执行的
执行结果

分析混淆代码

压缩成一行的代码肯定是难以阅读的,格式化一下。

var OOOOOO = ["MA==", "Z2V0RGF0ZQ==", "bG9n", "eXl5eS1NTS1kZA==", "RGF0ZQ==", "cHJvdG90eXBl", "Zm9ybWF0", "cmVwbGFjZQ==", "Z2V0RnVsbFllYXI=", "Z2V0TW9udGg=", "dG9TdHJpbmc="];
!function(OOOOOO, OOOOOOo, OOOOOOO, OOOOOOo0, OOOOOOoo, OOOOOOoO, OOOOOOO0, OOOOOOOo, OOOOOOOO) {
    OOOOOOO0 = (OOOOOOoO = (OOOOOOoo = (OOOOOOo0 = (OOOOOOO = function(OOOOOOo) {
        while (--OOOOOOo) {
            OOOOOO["\x70\x75\x73\x68"](OOOOOO["\x73\x68\x69\x66\x74"]())
        }
    }
    ,
    function() {
        for (OOOOOOOo = 0,
        OOOOOOOO = [1]; OOOOOOOo < OOOOOOOO["\x6c\x65\x6e\x67\x74\x68"]; OOOOOOOo++) {
            OOOOOOOO["\x70\x75\x73\x68"](OOOOOOOo)
        }
    }
    ),
    function() {
        return "debug review"
    }
    ),
    /[\r\n]/),
    OOOOOOoO)["\x65\x78\x65\x63"](OOOOOOoo["\x74\x6f\x53\x74\x72\x69\x6e\x67"]()),
    OOOOOOO0 ? OOOOOOo0() : OOOOOOO(OOOOOOo)
}(OOOOOO, 16);
window[atob(OOOOOO[784633 ^ 784633])][atob(OOOOOO[971616 ^ 971617])][atob(OOOOOO[935256 ^ 935258])] = function(OOOOOOo, OOOOOOO) {
    return OOOOOOO = (OOOOOOO = (OOOOOOO = (OOOOOOO = OOOOOOo,
    OOOOOOO)[atob(OOOOOO[830960 ^ 830963])](/yyyy|YYYY/, this[atob(OOOOOO[993326 ^ 993322])]()),
    OOOOOOO)[atob(OOOOOO[460290 ^ 460289])](/MM/, this[atob(OOOOOO[219476 ^ 219473])]() + (675424 ^ 675425) > (177916 ^ 177909) ? (this[atob(OOOOOO[624670 ^ 624667])]() + (107576 ^ 107577))[atob(OOOOOO[155125 ^ 155123])]() : atob(OOOOOO[330811 ^ 330812]) + (this[atob(OOOOOO[278369 ^ 278372])]() + (631088 ^ 631089))),
    OOOOOOO)[atob(OOOOOO[873048 ^ 873051])](/dd|DD/, this[atob(OOOOOO[815701 ^ 815709])]() > (402567 ^ 402574) ? this[atob(OOOOOO[218780 ^ 218772])]()[atob(OOOOOO[674279 ^ 674273])]() : atob(OOOOOO[433915 ^ 433916]) + this[atob(OOOOOO[647323 ^ 647315])]()),
    OOOOOOO
}
;
console[atob(OOOOOO[813324 ^ 813317])](new window[atob(OOOOOO[944997 ^ 944997])]()[atob(OOOOOO[576679 ^ 576677])](atob(OOOOOO[927369 ^ 927363])));

此时运行:
格式化后报内存溢出

这说明代码中可能有格式化检测加内存爆破,仔细看代码结构,开头一个大数组,下面一个自执行函数,这是数组混淆和数组乱序的特征,而格式化检测很有可能在自执行函数里面。
开头大数组与自执行函数

还可以看到数值混淆和大量的同名函数调用,这是数值混淆和字符串加密的特征。
数值混淆和字符串加密

还有一个很长的return语句,这是逗号表达式压缩的特征。
很长的return语句

初步分析下来,可以断定这个js用了字符串混淆,常量混淆和逗号表达式混淆,其中字符串混淆中掺杂了数组混淆、数组乱序、格式化检测等。下面针对这三种情况一一做还原操作。

数值还原

这是最容易的,主要思路是遍历所有的二项式表达式节点,当leftright都是numericLiteral时,调用path.evaluate计算出值后直接替换。

ReductionUtils.prototype.numericDecrypt = function () {
    traverse(this.ast, {
        BinaryExpression(path) { //遍历所有的二项式表达式节点
            let left = path.node.left; //得到左边
            let right = path.node.right; //得到右边
            if (t.isNumericLiteral(left) && t.isNumericLiteral(right)) { //如果左右两边都是数值的话
                let {confident, value} = path.evaluate(); // 计算数表达式的值,这里用了对象解构写法
                confident && path.replaceWith(t.valueToNode(value)) //confident 布尔值表示是否有更多的上下文影响到计算
            }
        }
    })
}

可以看出所有数值都得到了还原

字符串还原

思路是,先从待还原的js代码中提取出前两个节点(第一个是大数组,第二个自执行还原函数),转为字符串后用eval执行,这样大数组的真实顺序就保存在了内存中了;ast中遍历所有的标识符节点(经过上一步的处理,现在的主要矛盾是处理形如atob(OOOOOO[9])的代码),如果标识符是atob,并且其父节点是成员表达式的话,就把节点转字符串后用eval执行,由于内存中已经存在一个还原好的大数组,并且nodejs环境也自带atob,无需另外准备,执行后atob(OOOOOO[9])会直接得到最后的字符串结果。

ReductionUtils.prototype.stringDecrypt = function () {
    let newAst = parser.parse('')
    newAst.program.body.push(this.ast.program.body[0]); //把大数组押入newAst
    newAst.program.body.push(this.ast.program.body[1]); //把数组还原函数押入newAst
    //把上面两部分代码转为字符串,由于存在格式化检测,需要指定选项来压缩代码
    let stringDecryptFunc = generator(newAst, {compact: true}).code;
    //将字符串形式的代码执行,这杨就可以在nodejs中运行解密函数了
    eval(stringDecryptFunc);
    //再次强调,由于原始代码中存在格式化检测和内存爆破的代码,所有上述代码在生成字符串代码时,需要指定选项,使用压缩后的代码来执行,否则会内存溢出
    //现在nodejs中已经有解密函数了,接下来可以去直接计算节点,并用结果替换它,这里我们用的解密函数中atob
    traverse(this.ast, {
        //遍历所有表识符
        Identifier(path) {
            //当变量名字与解密函数名相同时,判断其父路径是不是一个调用表达式如果是的话,将func字符串用eval执行再转ast节点替换,注意此时内存中的大数组的顺序已经被还原数组还原了,见上面的eval
            if (path.node.name === 'atob') {
                path.parentPath.isCallExpression() && path.parentPath.replaceWith(t.stringLiteral(eval(path.parentPath + '')));
            }
        }
    })
    //删除最上面两层
    this.ast.program.body.shift()
    this.ast.program.body.shift()
}  

效果

window["Date"]["prototype"]["format"] = function (OOOOOOo, OOOOOOO) {
  return OOOOOOO = (OOOOOOO = (OOOOOOO = (OOOOOOO = OOOOOOo, OOOOOOO)["replace"](/yyyy|YYYY/, this["getFullYear"]()), OOOOOOO)["replace"](/MM/, this["getMonth"]() + 1 > 9 ? (this["getMonth"]() + 1)["toString"]() : "0" + (this["getMonth"]() + 1)), OOOOOOO)["replace"](/dd|DD/, this["getDate"]() > 9 ? this["getDate"]()["toString"]() : "0" + this["getDate"]()), OOOOOOO;
};
console["log"](new window["Date"]()["format"]("yyyy-MM-dd"));  

经过这一步,很多逻辑都显现了出来,可以明显看出这是在向Date的原型链上增加方法。

逗号表达式还原

思路是,遍历所有的逗号表达式,取出表达式列表的最后一个节点也就是最后一步运算,然后将列表中节点都插入到逗号表达式所在return语句的前面,最后将当前这个逗号表达式替换成最后一步运算。

ReductionUtils.prototype.sequenceReduction = function () {
    traverse(this.ast, {
        SequenceExpression: { //遍历所有的逗号表达式节点
            exit(path) { //在退出节点时进行操作
                let expressions = path.node.expressions; //expressions一个包含着逗号表达式子式的列表,顺序执行
                let finalExpression = expressions.pop(); //取出最后一步运算
                let statement = path.getStatementParent(); //取到逗号表达式外层的语句节点,这里其实就是return语句节点
                expressions.map(function (v) {
                    statement.insertBefore(t.expressionStatement(v)) //在return前面挨个插入逗号表达式中表达式
                })
                path.replaceWith(finalExpression); //最后把这个逗号表达式直接替换成逗号表达式的最后一步
            }
        }
    })
}  

效果

window["Date"]["prototype"]["format"] = function (OOOOOOo, OOOOOOO) {
  OOOOOOO = OOOOOOo;
  OOOOOOO = OOOOOOO["replace"](/yyyy|YYYY/, this["getFullYear"]());
  OOOOOOO = OOOOOOO["replace"](/MM/, this["getMonth"]() + 1 > 9 ? (this["getMonth"]() + 1)["toString"]() : "0" + (this["getMonth"]() + 1));
  OOOOOOO = OOOOOOO["replace"](/dd|DD/, this["getDate"]() > 9 ? this["getDate"]()["toString"]() : "0" + this["getDate"]());
  return OOOOOOO;
};
console["log"](new window["Date"]()["format"]("yyyy-MM-dd"));  

至此,再看这段代码,逻辑就很清楚了,在Date的原型链上新增一个方法,传入一个参数,这个参数的/yyyy|YYYY/替换成date.getFullYear()的执行结果,也就是当前年份,月份部分替换逻辑是,如果date.getMonth()的月份+1 > 9的话就返回date.getMonth()的月份+1,否则在最左边补一个0位(这是因为date.getMonth()取到的月份默认是从0开始的),日期部分的逻辑和月份大同小异,最后函数调参打印输出结果。
控制台测试还原结果

结语

AST混淆与解混的过程就像是两个选手,一个出题,一个解题,题目千变万化,解法自然也无法做到一招吃遍天下,都是寻找破绽,见招拆招的过程,对于逆向者,熟悉常用的出题方式,总结一些定势是一方面,另外一方面更需要培养在一团乱麻中能让心保持巍然不动的定力,这才是应对无穷变化的终解。

附录

用于做案例的JS代码

Date.prototype.format = function (formatStr) {
    var str = formatStr;
    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()); //AsciiEncrypt
    return str;
}
console.log(new Date().format('yyyy-MM-dd'));  

用于还原数组乱序的代码

!(function (myArr, num) {
    var outOrder = function (num) {
        while (--num) { //循环次数递减
            myArr.push(myArr.shift()); //数组开头取出末尾插入
        }
    };
    //内存爆破
    var memBreak = function () {
        for (var i = 0, j = [1]; i < j.length; i++) {
            j.push(i) //每次都push,逻辑上i永远都小于j.length
        }
    }

    var formatFuc = function () {
        return 'debug review'
    }
    var formatReg = /[\r\n]/; //查找换行符或回车符

    var match = formatReg.exec(formatFuc.toString()); //格式化检测
    if (match) {
        memBreak(); //如果检测出来格式化就进入内存爆破的函数
    } else {
        outOrder(num); //否则执行正确的逻辑分支
    }
})(arr, 0x10) //0x10表示移动16位,用16进制表示增加迷惑性  

用于混淆JS的代码

const parser = require('@babel/parser') //解析
const traverse = require('@babel/traverse').default //遍历
const t = require('@babel/types') //类型判定与生成
const generator = require('@babel/generator').default //ast转code
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; //对象读写属性设置为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) {
                //用window[name]成员表达式替换之
                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; //获取数值常量的值
            //生成100000~999999的随机10进制的字符串
            let key = parseInt(Math.random() * (999999 - 100000) + 100000, 10);
            let cipherNum = value ^ key;//真实数值异或这个随机值得到一个加密值,异或是两边二进制对位同为0,异为1
            //那么加密异或key也一定可以还原为真实值
            path.replaceWith(t.binaryExpression('^', t.numericLiteral(cipherNum), t.numericLiteral(key)));
            path.skip(); //这是因为替换后防止继续深度遍历numericLiteral,形成死循环
        }
    })
}

//字符串加密与数组混淆
ConfoundUtils.prototype.arrayConfound = function () {
    let bigArr = [];
    let encryptFunc = this.encryptFunc;
    traverse(this.ast, {
        StringLiteral(path) {
            let cipherText = encryptFunc(path.node.value); //获取字符串值
            let bigArrIndex = bigArr.indexOf(cipherText); //在大数组中查找是否有这个值,如有返回索引
            let index = bigArrIndex; // 索引赋值给index
            if (bigArrIndex === -1) { //若索引不存在
                let length = bigArr.push(cipherText); //将字符串推入大数组,并返回数组长度,push是推入最后
                index = length - 1 //index为大数组中最后一位的索引
            }
            //构造函数调用表达式(加密),内部成员表达式(数组混淆),从数组中用索引取值
            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); //大数组内的成员转ast代码
    });
    this.bigArr = bigArr;
}

//数组乱序
ConfoundUtils.prototype.arrayShuffle = function () {
    !function (myArr, num) {
        var cthousand = function (num) {
            while (--num) {
                myArr.unshift(myArr.pop());
            }
        }
        cthousand(num)
    }(this.bigArr, 0x10) //默认16次
}

//二项式转函数花指令
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'); //新建标示符a
            let b = t.identifier('b'); //新建标示符b
            //生成唯一性的随机标示符作为函数名称,由于后期标识符会统一修改,这个仅做中转
            let funcNameIdentifier = path.scope.generateUidIdentifier('xxx');
            // 定义一个函数声明语句
            let func = t.functionDeclaration(
                funcNameIdentifier,
                [a, b],
                t.blockStatement([t.returnStatement(
                    t.binaryExpression(operator, a, b)
                )]));
            // 寻找当前最近的函数体表达式节点,这里用findParent
            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 () {
    //标识符混淆之前先转成代码再解析,确保新生成的一些节点被解析到,因为type生成的节点并不会自动携带path,所以无法被遍历到
    let code = generator(this.ast).code;
    let newAst = parser.parse(code);

    //生成标识符
    function generatorIdentifier(decNum) {
        let arr = ['0', 'o', 'O']; //这3个字符长得比较像,用来替换3进制中012
        let retval = [];
        //十进制转三进制的算法
        while (decNum > 0) {
            retval.push(decNum % 3);
            decNum = parseInt(decNum / 3);
        }
        //除余法需要先事先翻转一下
        let Identifier = retval.reverse().map(function (v) {
            return arr[v]
        }).join('')
        //不足6位就补,开头是0也补(数字不可做标识符的开头)
        Identifier.length < 6 ? (Identifier = ('OOOOOO' + Identifier).substring(-6)) : Identifier[0] === '0' && (Identifier = 'O' + Identifier);
        return Identifier
    }

    function renameOwnBinding(path) {
        let OwnBindingObj = {}, globalBindingObj = {}, i = 0; //从十进制的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); //利用scope.rename对所有引用的变量批量修改
        }
    }

    traverse(newAst, {
        'Program|FunctionExpression|FunctionDeclaration'(path) {
            renameOwnBinding(path); //无论是全局还是匿名函数内还是函数声明内都查找标识符并改名
        }
    })
    this.ast = newAst; //把新ast重新赋值给this.ast
}

//指定代码行base64加密
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 = btoa(code);
                let decryptFunc = t.callExpression(t.identifier('atob'),
                    [t.stringLiteral(cipherText)]);
                return t.expressionStatement(
                    t.callExpression(t.identifier('eval'), [decryptFunc])
                )
            })
            path.get('body').replaceInline(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; //如果是返回语句则不修改
                //如果没有注释部分且注释部分非ASCIIEncrypt则不修改
                if (!(v.trailingComments && v.trailingComments[0].value === 'AsciiEncrypt')) return v;
                //删除注释部分
                delete v.trailingComments;
                let code = generator(v).code; //将函数转化为code
                let codeAscii = [].map.call(code, function (v) {
                    return t.numericLiteral(v.charCodeAt(0)); //把字符串中每个字符串转化成ascii码形式变成一个列表
                });
                // 定义一个成员表达式,对象是String,成员是fromCharCode
                let decryptFunctionName = t.memberExpression(t.identifier('String'), t.identifier('fromCharCode'));
                //定义一个函数调用,函数是上面那个成员表达式,参数是ascii化的代码行
                let decryptFunc = t.callExpression(decryptFunctionName, codeAscii)
                //返回一个表达式,内嵌函数调用,调用方是eval,参数是fromCharCode解ascii码后的字符串,eval执行的代码会在eval里运行
                return t.expressionStatement(t.callExpression(t.identifier('eval'), [decryptFunc]))
            });
            //将当前func path的函数体替换掉
            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的最上层插入大数组
}

// 拼接两个ast的body部分
ConfoundUtils.prototype.astConcatUnshift = function (ast) {
    this.ast.program.body.unshift(ast)
}
ConfoundUtils.prototype.getAst = function () {
    return this.ast
}

// 控制流平坦化
ConfoundUtils.prototype.controlFlowFlat = function () {
    traverse(this.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]]
            }
            //定义一个分发器
            let dispenserArr = [];
            //定义一个存在case块的列表
            let cases = [];
            //对代码行列表进行映射,篡改一些对象的引用关系
            Statements.map(function (v, i) {
                //分发器记录真实顺序
                dispenserArr[v.index] = i;
                //构建swtich的case语句,包括一个判断条件,执行流程和一个continue语句
                let switchCase = t.switchCase(t.numericLiteral(i), [v.value, t.continueStatement()]);
                cases.push(switchCase)
            });
            //分发器数组用|连接成字符串
            let dispenserStr = dispenserArr.join('|');
            //构建不重名的标识符代表分发器
            let array = path.scope.generateUidIdentifier('array');
            //构建不重名的标识符代表起始索引
            let index = path.scope.generateUidIdentifier('index');
            //构建成员表达式,obj是分发器字符串,成员是split
            let callee = t.memberExpression(t.stringLiteral(dispenserStr), t.identifier('split'));
            //构建函数调用,传入参数'|'
            let arrayInit = t.callExpression(callee, [t.stringLiteral('|')]);
            //构建变量声明器,变量明是分发器标识符,初始化是上面那个
            let varArray = t.variableDeclarator(array, arrayInit);
            //构建初始索引,从0开始
            let varIndex = t.variableDeclarator(index, t.numericLiteral(0))
            //用let将上面2为构成完整的变量声明语句
            let dispenser = t.variableDeclaration('let', [varArray, varIndex]);
            //构建自增表达式
            let updExp = t.updateExpression('++', index);
            //构建成员表达式
            let memExp = t.memberExpression(array, updExp, true);
            //构建单项式表达式,这里用为了将字符串强转成字符串类型,因为case中用的是===全等,并不会自动转化类型
            let discriminent = t.unaryExpression('+', memExp);
            //将switch表达式和case块合成完整的switch语句块
            let switchSta = t.switchStatement(discriminent, cases);
            let unaExp = t.unaryExpression('!', t.arrayExpression());
            //!![]意思就是true,之所以不直接用当然是为了混淆视听
            unaExp = t.unaryExpression('!', unaExp);
            //构建while循环语句,需要循环条件和循环体,循环体需要一个switch块和一个break语句,因为需要保证到最后没有匹配时默认退出循环
            let whileSta = t.whileStatement(unaExp, t.blockStatement([switchSta, t.breakStatement()]));
            //将函数体节点替换成由分发器和while循环构成的函数体语句
            path.get('body').replaceWith(t.blockStatement([dispenser, whileSta]));
        }
    })
}

//逗号表达式混淆
ConfoundUtils.prototype.toSequenceConfuse = function () {
    //逗号表达式混淆
    traverse(this.ast, {
        FunctionExpression(path) {
            let blockStatement = path.node.body; //取出函数体
            let blockStatementLength = blockStatement.body.length; //得到函数体代码行数
            if (blockStatementLength < 2) return; //如果小于2直接返回不处理
            path.traverse({
                VariableDeclaration(p) { //遍历函数下的所有变量声明语句
                    let 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; //从第一行开始
            let secondSta; //定义第二行
            while (i < blockStatementLength) { //如果索引小于函数体的行数
                let tempSta = blockStatement.body[i++]; //i++先赋值再自增,暂存下一行
                t.isExpressionStatement(tempSta) ? //是否是表达式语句
                    secondSta = tempSta.expression : secondSta = tempSta; //是把里面的expreesion赋值给第二行,否则把本身赋值给第二行
                //处理返回语句
                if (t.isReturnStatement(secondSta)) { //第二行是否为return 语句
                    firstSta = t.returnStatement( //如果是就用return 包裹第一行和第二行return语句的参数部分
                        t.toSequenceExpression([firstSta, secondSta.argument])
                    )
                } else if (t.isAssignmentExpression(secondSta)) { //如果第二行是赋值语句
                    if (t.isCallExpression(secondSta.right)) { //是否赋值语句的右边是函数调用语句
                        let callee = secondSta.right.callee; //如果是就把函数调用obj提出来
                        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]); //如果既不是返回语句也非赋值语句,就简单的拼成逗号表达式
                }
            }
            if (t.isReturnStatement(firstSta)) { //如果最后形成的是一个return表达式
                path.get('body').replaceWith(t.blockStatement([firstSta])); //就穿上blockstatement的外衣替换掉原先的函数体
            } else {
                //如果没有return,就穿上表达式的外衣替换掉原先的函数体
                path.get('body').replaceWith(t.blockStatement([t.expressionStatement(firstSta)]));
            }
        }
    });
}


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, btoa);
    let confoundAstFront = new ConfoundUtils(astFront);
    // 改变对象属性访问方式
    confoundAst.changeAccessMode();
    // 标准内置对象的处理
    confoundAst.changeBuiltinObjects();

    // 字符串加密与数组混淆
    confoundAst.arrayConfound();
    // 数组乱序
    confoundAst.arrayShuffle()

    // 标识符重命名
    // confoundAst.renameIdentifier()
    // //指定代码行的base64混淆,需要放到标识符混淆之后
    // confoundAst.appointedCodeLineEncrypt();
    // confoundAst.appointedCodeLineAscii();
    // 数值常量混淆
    confoundAst.numericEncrypt();
    // //控制流平坦化
    // confoundAst.controlFlowFlat();
    //
    //还原数组顺序代码,改变对象属性访问方式,对其中的字符串进行十六进制编码
    confoundAstFront.stringToHex();
    astFront = confoundAstFront.getAst();
    //先把还原数组顺序的代码,加入到被混淆代码的ast中
    confoundAst.astConcatUnshift(astFront.program.body[0]);
    //再生成数组声明语句,并加入到被混淆代码的最开始处
    confoundAst.unshiftArrayDeclaration()
    // confoundAst.renameIdentifier() //可以在每次新的标识符生成后用一次

    //二项式转函数花指令
    // confoundAst.binaryToFunc()
    // 逗号表达式混淆
    confoundAst.toSequenceConfuse() //一般不与花指令+控制流平坦化混用

    confoundAst.renameIdentifier() //可以在每次新的标识符生成后用一次

    ast = confoundAst.getAst()
    //ast转为代码
    let code = generator(ast, {
        comments: false,
        minified: true
    }).code;
    //混淆的代码中,如果有十六进制字符串加密,ast转成代码后会有多余的转义字符,需要替换掉
    code = code.replace(/\\\\x/g, '\\x')
    //将代码保存到文件中
    fs.writeFile('./demoNew.js', code, (err) => {
    })
}

main()

用于解混JS的代码

const parser = require('@babel/parser') //解析
const traverse = require('@babel/traverse').default //遍历
const t = require('@babel/types') //类型判定与生成
const generator = require('@babel/generator').default //ast转code
const fs = require('fs') //文件读写


function ReductionUtils(ast) {
    this.ast = ast;
}

//数值还原
ReductionUtils.prototype.numericDecrypt = function () {
    traverse(this.ast, {
        BinaryExpression(path) { //遍历所有的二项式表达式节点
            let left = path.node.left; //得到左边
            let right = path.node.right; //得到右边
            if (t.isNumericLiteral(left) && t.isNumericLiteral(right)) { //如果左右两边都是数值的话
                let {confident, value} = path.evaluate(); // 计算数表达式的值,这里用了对象解构写法
                confident && path.replaceWith(t.valueToNode(value)) //confident 布尔值表示是否有更多的上下文影响到计算
            }
        }
    })
}
ReductionUtils.prototype.getAst = function () {
    return this.ast
}
//我们都知道解密函数是atob,下面进入字符串还原
ReductionUtils.prototype.stringDecrypt = function () {
    let newAst = parser.parse('')
    newAst.program.body.push(this.ast.program.body[0]); //把大数组押入newAst
    newAst.program.body.push(this.ast.program.body[1]); //把数组还原函数押入newAst
    //把上面两部分代码转为字符串,由于存在格式化检测,需要指定选项来压缩代码
    let stringDecryptFunc = generator(newAst, {compact: true}).code;
    //将字符串形式的代码执行,这杨就可以在nodejs中运行解密函数了
    eval(stringDecryptFunc);
    //再次强调,由于原始代码中存在格式化检测和内存爆破的代码,所有上述代码在生成字符串代码时,需要指定选项,使用压缩后的代码来执行,否则会内存溢出
    //现在nodejs中已经有解密函数了,接下来可以去直接计算节点,并用结果替换它,这里我们用的解密函数中atob
    traverse(this.ast, {
        //遍历所有表识符
        Identifier(path) {
            //当变量名字与解密函数名相同时,判断其父路径是不是一个调用表达式如果是的话,将func字符串用eval执行再转ast节点替换,注意此时内存中的大数组的顺序已经被还原数组还原了,见上面的eval
            if (path.node.name === 'atob') {
                path.parentPath.isCallExpression() && path.parentPath.replaceWith(t.stringLiteral(eval(path.parentPath + '')));
            }
        }
    })
    //删除最上面两层
    this.ast.program.body.shift()
    this.ast.program.body.shift()
}

//逗号表达式的还原
//逗号表达式的还原相对于混淆的实现来说,要容易的多
ReductionUtils.prototype.sequenceReduction = function () {
    traverse(this.ast, {
        SequenceExpression: { //遍历所有的逗号表达式节点
            exit(path) { //在退出节点时进行操作
                let expressions = path.node.expressions; //expressions一个包含着逗号表达式子式的列表,顺序执行
                let finalExpression = expressions.pop(); //取出最后一步运算
                let statement = path.getStatementParent(); //取到逗号表达式外层的语句节点,这里其实就是return语句节点
                expressions.map(function (v) {
                    statement.insertBefore(t.expressionStatement(v)) //在return前面挨个插入逗号表达式中表达式
                })
                path.replaceWith(finalExpression); //最后把这个逗号表达式直接替换成逗号表达式的最后一步
            }
        }
    })
}

function main() {
    //读取要还原的代码
    const jsCode = fs.readFileSync('./demoNew.js', {
        encoding: 'utf-8'
    })
    //把要还原的代码解析成ast
    let ast = parser.parse(jsCode);
    //初始化类,传递自定义的加密函数进去
    let ReductionAst = new ReductionUtils(ast);
    //数值常量还原
    ReductionAst.numericDecrypt()
    //字符串解密
    ReductionAst.stringDecrypt()
    //逗号表达式还原
    ReductionAst.sequenceReduction()

    ast = ReductionAst.getAst()
    //ast转为代码
    let code = generator(ast, {
        comments: false,
        // jsescOption: {minimal: true},
        // minified: true
    }).code;
    ast = parser.parse(code)
    code = generator(ast).code;
    //将代码保存到文件中
    fs.writeFile('./demoReduction.js', code, (err) => {
    })
}

main()

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达,如有问题请邮件至2454612285@qq.com。
跃迁主页