项目地址 :包括待破解的代码和操作语法树的代码
前言
AST学名抽象语法树,Babel代码的转换其实都是操作的语法树,而拥有操作语法树的能力就是拥有了操作代码的能力,这对于破解还原或者正向混淆js代码是很重要的能力。 本文通过还原某网站用到的混淆js来介绍ast的作用
在看此文之前,希望你对js语法树和babel有基本的认识 下面是我搜到的一些文章
从babel讲到AST 主要介绍了如何把代码解析成语法树
AST in Modern JavaScript 既有介绍也有部分ast的操作
JavaScript抽象语法树AST 关于每个语法树节点的介绍很详细,甚至可以当文档用
如果你对js语法树还不了解,希望你阅读完上面的文章再来看这篇。
这篇文章对于没怎么接触过语法树的同学会比较硬,建议先思考然后带着目标如何把项目中的source.js还原成可阅读的代码?
正文
见到这种js以前我是拒绝看下去的,但现在通过我们一步步分析并通过操作ast是可以完全还原并看懂的(代码在项目地址 source.js, 把代码拉下来分析一下会清楚一点)

首先我们先进行静态分析, 源码有四部分:
- 一个包含字符串的数组
- 一个自执行函数
- 一个有两个输入和输出的函数
- 一个有5000行的自执行函数

字符串替换
可以看到第一个数组(我们暂称为strArr)出现在了第一二三部分中,第二个自执行函数只是对strArr顺序进行了转换。




代码
首先看我们需要的babel依赖
// 把js源码转成语法树
const parser = require("@babel/parser")
// 遍历语法树中的节点
const traverse = require("@babel/traverse").default
// 提供对语法树中Node的一系列方法比如判断Node类型,辅助创建Node等
const t = require("@babel/types")
// 根据语法树生成js代码
const generator = require("@babel/generator").default
接下来是对文件的读写流程
// 读取source文件
fs.readFile(path.resolve(__dirname, './source.js'), { "encoding": 'utf-8' }, function (err, data) {
// 转换成语法树
const ast = parser.parse(data)
// 我们要转换的代码
decrypt(ast)
// 转换完后放到generator生成新的js
let { code } = generator(ast)
// 针对代码中!![]/![] 直接进行通过字符串替换
code = code.replace(/!!\[\]/g, 'true').replace(/!\[\]/g, 'false')
// 写到新文件中
fs.writeFile(path.resolve(__dirname, './result.js'), code, function (err) {
if (!err) {
console.log('finished')
} else {
console.log(err)
}
})
})
主要的代码就在decrypt函数中了
function decrypt(ast) {
traverse(ast, {
CallExpression: {
enter: [callToStr]
},
StringLiteral: {
enter: [removeExtra]
}
NumericLiteral: removeExtra
})
我们直接把ast传到函数中,然后调用@babel/traverse来进行节点的遍历。 如果不知道某个节点的名称,推荐一个网站AST Explorer查询,左边放入js代码,右边就可以显示对应的树节点(不要忘了选上面的解析器babylon7)。


我们在CallExpression的enter里面放了callToStr函数,也就是说每次遍历进入到一个CallExpression时,会执行callToStr并传入当前path作为参数
function callToStr(path) {
let node = path.node
// decryptStrFnName即_0xd1a5
if (t.isIdentifier(node.callee) && node.callee.name === decryptStrFnName) {
// decryptStr就是我们拷贝到我们项目中作为Node运行的strFn
const result = decryptStr(node.arguments[0].value)
// t.stringLiteral可以为我们生成一个StringLiteral节点,只要我们传入必须的值, path.replaceWith直接替换掉当前节点
path.replaceWith(t.stringLiteral(result))
return
}
}
上面代码的作用相当于把代码中所有_0xd1a5('0x1c8')直接转换成'0.02'
我们还看到代码里的数字和部分字符串转换成了16进制表示,我们转换成好阅读的,通过babel实现很简单


function removeExtra(path) {
delete path.node.extra
}
这一步做完以后,前三部分的代码(strArr, strFn)就没有作用了,我们可以手动把他们删掉。后面的步骤就以此为源码

结构转换


var idxArr = "0|1|2|6|4|3|5"["split"]("|"),
idx = 0;
while (true) {
switch (idxArr[idx++]) {
case "0":
// expression
continue
case "1":
// expression
continue
}
break;
}
我们所要做的就是根据idxArr的顺序依次找到case中的语句然后按顺序拼起来。 我个人习惯会先找到while节点, 然后判断包含是个switch和break,这时就可以获取到idxArr的变量名,再通过while的siblings找到前一个节点,获取idx的顺序,最后拼case。当然其实有很多种路径都可以实现,比如先找switch,再判断父节点等。
function decrypt2(ast) {
traverse(ast, {
WhileStatement: replaceWhile
})
}
function replaceWhile(path) {
let node = path.node
// 判断基础的结构 while(true) {}
if (!t.isBooleanLiteral(node.test) || node.test.value !== true) return
if (!t.isBlockStatement(node.body)) return
const body = node.body.body
// 判断包含一个switch和一个break
if (!t.isSwitchStatement(body[0]) || !t.isMemberExpression(body[0].discriminant) || !t.isBreakStatement(body[1])) return
const switchStm = body[0]
// switch (idxArr[idx++]) 找到idxArr变量的名称
const arrName = switchStm['discriminant'].object.name
// 找到sibling前一个Node
let varKey = path.key - 1
let varPath = path.getSibling(varKey)
// 找到idxArr这个Node
let varNode = varPath.node.declarations.filter(declarator => declarator.id.name === arrName)[0]
// 把值取出来分割成数组 ["0", "1", "3", "6", "2" ...]
let idxArr = varNode.init.callee.object.value.split('|')
// 所有的case
const runBody = switchStm.cases
let retBody = []
idxArr.map(targetIdx => {
// 根据顺序找到对应的语句
let targetBody = runBody[targetIdx].consequent
// 把continue删除
if (t.isContinueStatement(targetBody[targetBody.length - 1])) {
targetBody.pop()
}
retBody = retBody.concat(targetBody)
})
// 如果是一个Node替换为多个,要使用replaceWithMultiple
path.replaceWithMultiple(retBody)
// remove idxArr var/index
varPath.remove()
}

下一步, 发现定义了一些包含很多方法的对象,而这些方法基本就是一些基本操作符,逻辑运算符,位运算符或者函数调用这些封装到函数内部,形成了类似函数式的写法。


var obj = {
"add": function(a, b) {
return a + b
},
"equal": function(a, b) {
return a === b
}
}
var c = obj.add(1,2) // 源码
var c = 1 + 2 // 我们要替换成的
我们来分析一下源码的结构: 一个大的VariableDeclaration中包含一个VariableDeclarator,其id属性是变量名,type为Identifier, init属性是一个对象字面量ObjectExpression。


function replaceFns(path) {
// 遍历VariableDeclarator
let node = path.node
// 变量右边是不是一个对象字面量
if (!t.isObjectExpression(node.init)) return
let properties = node.init.properties
try {
// 这里简单的判断了对象第一个属性值是不是个函数,并且函数只有一条return语句
// 看起来有些不严谨,但是对于这份代码没有问题,没有出现和这个结构一样但后面的值不满足的情况。。
if (!t.isFunctionExpression(properties[0].value)) return
if (properties[0].value.body.body.length !== 1) return
let retStmt = properties[0].value.body.body[0]
if (!t.isReturnStatement(retStmt)) return
} catch (error) {
console.log('wrong fn arr', properties)
}
// 存储一下变量名,后面调用都是objName[key],所以需要匹配它
let objName = node.id.name
// 一个一个函数进行查找
properties.forEach(prop => {
// key
let key = prop.key.value
// 需要替换成的语句
let retStmt = prop.value.body.body[0]
// path.getFunctionParent可以方便的帮我们找出最近的一个包含此path的父function, 这样我们就可以在此作用域遍历了
const fnPath = path.getFunctionParent()
fnPath.traverse({
// 找所有函数调用 fn()
CallExpression: function (_path) {
// 确保是obj['key'] 或 obj.add等相似的调用
if (!t.isMemberExpression(_path.node.callee)) return
let node = _path.node.callee
// 第一位是上面定义的objName
if (!t.isIdentifier(node.object) || node.object.name !== objName) return
// key值是我们当前遍历到的
if (!t.isStringLiteral(node.property) || node.property.value !== key) return
// 参数
let args = _path.node.arguments
/* 其实定义的函数总共分三类
* 1. function _0x3eeee4(a, b) {
* return a & b; // BinaryExpression
* }
* 2. function _0x3eeee4(a, b) {
* return a === b; // LogicalExpression
* }
* 3. function _0x3eeee4(a, b, c) {
* return a(b, c) // CallExpression
* }
* 下面的代码就是对调用的代码做一个转换。这里可以看到t.Node并传入对应的参数可以帮助我们生成相应的节点, t.isNode是判断是否* 为某个type的Node
*/
if (t.isBinaryExpression(retStmt.argument) && args.length === 2) {
_path.replaceWith(t.binaryExpression(retStmt.argument.operator, args[0], args[1]))
}
if (t.isLogicalExpression(retStmt.argument) && args.length === 2) {
_path.replaceWith(t.logicalExpression(retStmt.argument.operator, args[0], args[1]))
}
if (t.isCallExpression(retStmt.argument) && t.isIdentifier(retStmt.argument.callee)) {
_path.replaceWith(t.callExpression(args[0], args.slice(1)))
}
}
})
})
// 最后删掉这些定义的函数 已经没有用了
path.remove()
}


代入参数

我们先看这个自执行函数的语法树结构
(function () {
// codes
})();

这里有个破解方面的选择,因为在那段操作arguments代码之前的参数是可以直接替换的,而下面的需要转换完再带进去。 简单一点的话可以全都先带进去然后把arguments那一段放Node里运行得到转换后的参数再带进去。但为了更多展示如何操作语法树,决定尝试用语法树去自动转换。
我的思路是先遍历CallExpression,直接粗暴的判断是不是参数大于10,可以直接定位到最外层。然后把前面转换arguments的参数带进去。 这样我们得到了一段转换arguments的语法树,然后把它转成js放到一个文件中(transferArgs.js),通过require引入。
如果是里面的自执行函数,直接跳过。 然后在外面的自执行函数阶段require('transferArgs.js'),并运行转换arguments,这样得到转换后的arguments。 再然后从外面的函数遍历里面自执行函数找到变量替换成这些基本值。

// 转换完的transfer.js
function transfer() {
if ("\u202E" !== "\u202E") {
return;
}
var _0x3f0c99 = arguments;
var _0x83b777;
for (_0x83b777 = 0; _0x83b777 < 760; _0x83b777++) {
// typeof node[peoperty] === 'string' => node[property].type === StringLiteral
if (_0x3f0c99[_0x83b777].type === "StringLiteral") {
// 加上.value
_0x3f0c99[_0x83b777].value = _0x3f0c99[_0x83b777].value["split"]("")["reverse"]()["join"]("");
}
}
for (_0x83b777 = 0; _0x83b777 < 760 / 2; _0x83b777++) {
var _0x1f2bc2 = _0x3f0c99[_0x83b777];
_0x3f0c99[_0x83b777] = _0x3f0c99[760 - _0x83b777 - 1];
_0x3f0c99[760 - _0x83b777 - 1] = _0x1f2bc2;
}
return _0x3f0c99;
}
module.exports = transfer;
下面看看如何转化的
function generateTransferFile(path, filePath) {
let node = path.node
const argValues = node.arguments
const paramIdentifiers = node.callee.params.map(n => n.name)
// 找到var _0x3f0c99 = arguments;中变量名_0x3f0c99,我们改造最后要return出去
let argVarNode
path.traverse({
enter: function (_path) {
if (_path.node.name === 'arguments') {
const varPath = _path.find(parentPath => {
return parentPath.isVariableDeclarator()
})
if (varPath) {
argVarNode = varPath.node.id
_path.stop()
}
}
}
})
// node.callee.body是BlockStatement, node.callee.body.body是函数体, 由于最后一个是内部的自执行函数,我们先去掉
const body = node.callee.body.body.slice(0, node.callee.body.body.length - 1)
// 里面的代码主体
const mainBody = node.callee.body.body[node.callee.body.body.length - 1]
// 把转换完的arguments return出去
const retStatement = t.returnStatement(argVarNode)
// 套个function的壳子 function transfer(){codes}
const fn = t.functionDeclaration(t.identifier('transfer'), [], t.blockStatement(body.concat(retStatement)))
// 因为需要生成一个完整的js,所以我们要补上最外面的program节点 并把函数导出, babel/template可以协助我们把代码的字符串转为ast
const program = t.file(t.program([fn, template.ast('module.exports = transfer')]))
traverse(program, {
Identifier: {
enter: (path) => {
// 由于这个转换的时候字符串还是在参数的变量中,我们直接替换
const node = path.node
const idIdx = paramIdentifiers.indexOf(node.name)
if (idIdx > -1) {
let valueNode = argValues[idIdx]
path.replaceWith(valueNode)
}
}
},
StringLiteral: {
// 替换完变量为字符串才好运行, 所以这一步转判断ast的方法在exit的时候执行
exit: path => {
// 代码太长了不贴了..方法都差不多 有兴趣去github看吧
}
}
})
let { code } = generator(program)
path.get('callee.body').replaceWith(t.blockStatement([mainBody]))
fs.writeFileSync(filePath, code, { encoding: 'utf-8' })
}
这样我们就把对arguments的转化放到了transfer.js文件中,并且改造成了对ast的判断 最后引用调用一下再把传进来的基本值节点根据变量名替换掉即可。
我们可以看到node操作js ast有着先天的优势,不仅js用起来方便,而且代码可以直接运行。 后面我打算写一点使用babel时用到的path, scope, binding等可以帮助我们更好操作语法树的概念