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的最上层插入大数组
}
数组乱序
通过将大数组打乱放置,后面插入一个自执行函数用于还原顺序,由于还原函数中的push
和shift
字符串含义比较明显,可以考虑用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语句,这是逗号表达式压缩的特征。
初步分析下来,可以断定这个js用了字符串混淆,常量混淆和逗号表达式混淆,其中字符串混淆中掺杂了数组混淆、数组乱序、格式化检测等。下面针对这三种情况一一做还原操作。
数值还原
这是最容易的,主要思路是遍历所有的二项式表达式节点,当left
和right
都是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。