本文深入讲解在前端与爬虫JS逆向场景中,如何基于Babel编译器利用抽象语法树(AST)技术系统化还原高度混淆的JavaScript代码,涵盖大数组字符串解密、代理对象与函数内联、控制流平坦化重构以及死代码消除等核心实战步骤。
JS逆向实战:使用AST技术还原高度混淆的代码逻辑
时间:2026-11-03
浏览:477
文章目录
一、混淆防御的本质与 AST 解决方案
在爬虫与反爬虫、前端加密对抗的实际场景中,商业级混淆工具(如 Obfuscator.io、Terser 等)对源码进行的形变并不会改变其底层图灵完备的运行结果,而是破坏人类程序员的认知模型。常见的混淆手段包括四种协同机制:
- 字符串及标识符混淆:所有常量字符串被提取到全局大数组中,辅以自执行移位函数与解密包装函数,原代码处全部替换为
_0x4f1b('0x12')样式的十六进制索引调用; - 代理函数与对象运算扩散:将普通的加减乘除、逻辑比较或函数调用包装在临时散列表对象内,例如将
a + b包装为_0x3a['add'](a, b); - 控制流平坦化(Control Flow Flattening):将原本具备明确层次的
if-else、循环或顺序语句,拆碎并塞进while(true) + switch(state)的状态机中,通过类似"3|1|0|2".split('|')的数组驱动执行; - 死代码与自卫代码注入:插入大量恒假条件分支,甚至加入检测
Function.prototype.toString格式的自卫闭包,一旦代码被格式化就会触发无限递归崩溃。
面对此类代码,正则匹配会因为词法嵌套和作用域重名引发灾难性的替换错误。抽象语法树(Abstract Syntax Tree, AST)是源代码语法结构在内存中的树状表示形式。通过将 JavaScript 代码解析为符合 ESTree 规范的 AST 节点树,我们能够精确识别节点类型、作用域绑定关系及父子层级,以程序化遍历的方式进行节点增删改查,完成严密可靠的逻辑还原。
二、开发环境准备与 Babel 核心工具链
在 Node.js 环境下,Babel 官方生态提供了极其成熟的 AST 操作工具套件。我们需要在本地工作目录中安装以下核心依赖包:
npm init -y
npm install @babel/core @babel/parser @babel/traverse @babel/types @babel/generator --save-dev
各个模块在反混淆管线中的分工如下:
@babel/parser:负责将混淆后的 JS 文本解析为 AST 树,支持现代 ESNext 语法及宽松模式配置;@babel/traverse:维护整棵语法树的遍历状态,提供访问者模式(Visitor Pattern)接口,并管理各层级作用域(Scope)和路径引用(NodePath);@babel/types:提供各类 AST 节点的断言(如t.isIdentifier(node))与构建方法(如t.binaryExpression());@babel/generator:将处理完毕的 AST 重新序列化输出为格式规整的 JavaScript 源代码。
三、第一步:字符串加密大数组提取与常量折叠
还原高度混淆代码的第一要务是让“代码说人话”。只要代码中充斥着 _0x1a2b(0x3c) 这类未解析的解密函数调用,后续的代理函数分析与控制流提取就无法判断真实的目标键名。
典型的混淆器通常在文件最开头声明三样东西:一个存储所有密文字符串的大数组 _0x1a2b、一个负责对该数组元素进行循环移位(Rotate)的自执行闭包,以及一个或多个负责根据偏移量解密的函数 _0x4f1b。
1. 内存加载解密函数并替换调用点
最稳健且高效的还原方式,是将混淆代码前部的“大数组声明 + 移位闭包 + 解密函数”这三段 AST 节点提取出来,在本地 Node.js 内存或隔离的 vm 上下文中执行。之后,遍历业务代码中的所有 CallExpression,计算其实际解密值并替换为纯字面量节点:
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const generator = require('@babel/generator').default;
// 步骤一:提取前部解密函数并在环境内执行
function restoreStrings(ast, decryptCodeSnippet) {
// 在局部 eval 中执行解密环境,暴露全局解密函数 _0x4f1b
eval(decryptCodeSnippet);
traverse(ast, {
CallExpression(path) {
const { callee, arguments: args } = path.node;
// 匹配形如 _0x4f1b('0x12') 的调用
if (t.isIdentifier(callee, { name: '_0x4f1b' }) && args.length === 1) {
if (t.isNumericLiteral(args[0]) || t.isStringLiteral(args[0])) {
// 调用内存中真正的解密逻辑
const decryptedVal = _0x4f1b(args[0].value);
// 生成标准 StringLiteral 节点并替换原始调用节点
path.replaceWith(t.stringLiteral(decryptedVal));
}
}
}
});
}
2. 静态常量表达式折叠
混淆器往往会把数字与布尔值写得极其冗长,例如用 1 * 0x12 + 0x5 替代 23,用 ![] 替代 false,用 !![] 替代 true。Babel 的 NodePath 提供了强大的 evaluate() 方法,能在编译期安全求取无副作用表达式的值:
traverse(ast, {
"BinaryExpression|UnaryExpression"(path) {
const evaluation = path.evaluate();
if (evaluation.confident) {
path.replaceWith(t.valueToNode(evaluation.value));
}
}
});
eval 执行带有自卫检测的代码,可能导致 Node.js 进程卡死在死循环中。在实际逆向时,应优先截取前三个初始化节点,并在干净的 Node vm.createContext 沙箱中运行,同时配置合理的超时限制(如 2000ms)。
四、第二步:对象代理函数与成员调用的内联还原
当字符串被全部解密后,代码中会暴露大量的代理散列表。混淆器通过构造形如 var _0x51ab = { 'add': function(a, b) { return a + b; }, 'str': "action" }; 的临时对象,把原本平铺直叙的计算逻辑打碎。
如果直接分析控制流,由于每个运算都套着对象属性查询与函数调用,代码读起来异常晦涩。我们需要识别这类代理对象,并将 _0x51ab['add'](x, y) 还原为原生的 x + y。
通过编写一个专门针对 VariableDeclarator 的 Visitor,我们可以捕获所有属性均为常数字符串或简单返回语句的对象,建立方法映射表,并在对应作用域内递归完成内联替换:
traverse(ast, {
VariableDeclarator(path) {
const { id, init } = path.node;
if (!t.isIdentifier(id) || !t.isObjectExpression(init)) return;
const objName = id.name;
const scope = path.scope;
const proxyMap = new Map();
// 提取对象中的所有代理方法和属性值
init.properties.forEach(prop => {
if (!t.isObjectProperty(prop)) return;
const key = prop.key.value || prop.key.name;
proxyMap.set(key, prop.value);
});
// 在当前作用域内查找该代理对象的所有使用点
const binding = scope.getBinding(objName);
if (!binding) return;
binding.referencePaths.forEach(refPath => {
const parent = refPath.parentPath;
// 场景 1:代理调用 _0x51ab['add'](a, b)
if (parent.isMemberExpression() && parent.parentPath.isCallExpression()) {
const propKey = parent.node.property.value || parent.node.property.name;
const callPath = parent.parentPath;
const targetFunc = proxyMap.get(propKey);
if (t.isFunctionExpression(targetFunc)) {
const returnStmt = targetFunc.body.body[0];
if (t.isReturnStatement(returnStmt)) {
const expr = returnStmt.argument;
// 二元运算还原
if (t.isBinaryExpression(expr)) {
callPath.replaceWith(t.binaryExpression(
expr.operator,
callPath.node.arguments[0],
callPath.node.arguments[1]
));
}
// 嵌套调用还原
else if (t.isCallExpression(expr)) {
callPath.replaceWith(t.callExpression(
callPath.node.arguments[0],
callPath.node.arguments.slice(1)
));
}
}
}
}
});
// 如果代理对象的所有引用均已替换完毕,移除该无用的对象声明
scope.crawl();
if (scope.getBinding(objName)&& !scope.getBinding(objName).referenced) {
path.remove();
}
}
});
五、第三步:核心攻坚——控制流平坦化还原
控制流平坦化是现代混淆技术中最具迷惑性的防御层。混淆器故意将线性的业务逻辑切分成若干小代码片段,放入一个 switch 的各个 case 分支中,外层套一个恒真 while 循环,由一个状态分发索引数组驱动程序跳转。
1. 控制流平坦化的典型 AST 结构特征
观察被平坦化的代码,其在 AST 层次具备高度一致的模式特征:
- 外层节点为一个
WhileStatement,测试条件为常量真(如while (true)或while (!![])); - 循环体的
body包含一个SwitchStatement,其判别式(discriminant)是一个状态变量; - 在
WhileStatement的同级前序兄弟节点中,存在一个分发数组声明,典型写法为var _0xarr = "3|1|0|2"['split']('|'),以及一个用于遍历数组的指针索引变量var _0xidx = 0。
2. 控制流还原 Visitor 实现
还原的核心算法是:提取出字符串分发数组所声明的真实执行顺序,从 switchCases 中提取每个 case 的有效业务语句(剔除用于阻断或重跳的 continue 与 break),按照数组顺序将语句平铺,最后直接替换掉整个 WhileStatement。
traverse(ast, {
WhileStatement(path) {
const { test, body } = path.node;
// 1. 判定是否为 while(true) / while(!![])
if (!t.isBooleanLiteral(test, { value: true }) &&
!(t.isUnaryExpression(test, { operator: '!' }) && t.isArrayExpression(test.argument))) {
return;
}
// 2. 检查循环体首节点是否为 SwitchStatement
const switchStmt = body.body ? body.body[0] : null;
if (!t.isSwitchStatement(switchStmt)) return;
// 3. 获取前一个同级声明节点,提取分发器顺序序列
const prevSibling = path.getPrevSibling();
if (!prevSibling || !prevSibling.isVariableDeclaration()) return;
let orderArray = null;
prevSibling.node.declarations.forEach(decl => {
// 匹配 "3|1|0|2".split('|')
if (t.isCallExpression(decl.init) &&
t.isMemberExpression(decl.init.callee) &&
t.isStringLiteral(decl.init.callee.object)) {
orderArray = decl.init.callee.object.value.split('|');
}
});
if (!orderArray) return;
// 4. 将所有 case 的 consequent 映射到 Map 中
const caseMap = new Map();
switchStmt.cases.forEach(c => {
const key = c.test.value;
// 过滤掉末尾的 break 和 continue 语句
const cleanStmts = c.consequent.filter(stmt =>
!t.isBreakStatement(stmt) && !t.isContinueStatement(stmt)
);
caseMap.set(key, cleanStmts);
});
// 5. 按照提取到的顺序重构线性语句列表
const linearStatements = [];
orderArray.forEach(step => {
const stmts = caseMap.get(step);
if (stmts) linearStatements.push(...stmts);
});
// 6. 替换整个 While 循环,并移除此前定义的分发器状态变量
path.replaceWithMultiple(linearStatements);
prevSibling.remove();
}
});
六、第四步:死代码消除、标识符规范化与作用域刷新
在经历了解密、内联与控制流拉平之后,代码的语义已经基本透明,但仍残存不少混淆器注入的垃圾代码与难读的十六进制变量名。
1. 裁剪永假条件分支 (Dead Code Elimination)
混淆器经常注入形如 if ('a' === 'b') { ... } else { realCode(); } 的分支结构。经过第一步的常量折叠,该测试表达式会被求值为布尔常量 false。此时只需用 alternate(即 else 块)替换整个 IfStatement,即可消除干扰:
traverse(ast, {
IfStatement(path) {
const { test, consequent, alternate } = path.node;
if (t.isBooleanLiteral(test)) {
if (test.value === true) {
path.replaceWith(consequent);
} else if (alternate) {
path.replaceWith(alternate);
} else {
path.remove();
}
}
}
});
2. 作用域绑定检测与未引用变量删除
通过调用 path.scope.crawl(),Babel 会完整重新爬取并更新语法树的引用计数。如果某个变量的 binding.referenced === false,说明该变量在后续逻辑中完全没有被读取,可以直接安全删除:
traverse(ast, {
VariableDeclarator(path) {
const { id } = path.node;
if (t.isIdentifier(id)) {
const binding = path.scope.getBinding(id.name);
if (binding && !binding.referenced && binding.path === path) {
path.remove();
}
}
}
});
七、完整还原验证案例与代码实测
为了让整套还原流程能够切实运行并验证,我们构造一个典型的、集成了十六进制调用、代理对象与控制流平坦化的小型目标代码,并运行由上述规则集成的还原脚本。
1. 混淆前的测试代码片段
// 待逆向分析的目标混淆代码
var testObfuscatedCode = `
var _0xarr = ["a|c|b", "split", "token_value", "admin", "log"];
var _0xidx = 0;
var _0xflow = _0xarr[0][_0xarr[1]]('|');
var _0xproxy = {
'calc': function(_0xa, _0xb) { return _0xa + _0xb; }
};
while (true) {
switch (_0xflow[_0xidx++]) {
case 'a':
var user = _0xarr[3];
continue;
case 'b':
console[_0xarr[4]](result);
break;
case 'c':
var result = _0xproxy['calc'](user, "_verified");
continue;
}
break;
}
`;
2. 执行还原后的输出代码
// 经过 Babel AST 脚本自动化还原后的纯净代码
var user = "admin";
var result = user + "_verified";
console.log(result);
可以看到,原本打散在 switch 中的 user 声明、加号拼接以及 console.log 输出,不仅完全消除了外层多余的跳转循环和状态变量,而且变量被自动按正确的时间顺序排布,复杂的字符串索引与散列表调用也直接回归为原生操作符。
八、常见陷阱与防御规避要点
在处理真实业务或商业级加固代码时,读者需要格外留意以下边界条件,防止逆向脚本出现误伤:
| 潜在陷阱 | 产生原因 | 稳健规避方案 |
|---|---|---|
| 代码自卫防篡改崩溃 | 混淆器内置了类似 (() => { if (!/\bfunction\b/.test(fn.toString())) debugger; })() 的校验函数。 |
在进行 AST 转换前,定位此类格式化探测闭包,直接用 AST 插件将其声明与调用整段删除,阻断其运行时校验。 |
| 作用域同名遮蔽 (Shadowing) | 内层函数存在与外层同名的混淆变量 _0x12,盲目全局替换会导致变量含义错乱。 |
始终通过 path.scope.getBinding() 判断引用的归属,严禁脱离作用域直接按变量名字符串全局替换。 |
| 动态状态跳转计算 | 部分混淆器在 switch 中使用 status = nextStatusMap[status] 动态跳跃,而非静态切分。 |
对动态状态转移采用符号执行(Symbolic Execution)或建立状态转移有向图拓扑,根据控制流可达性依序导出节点。 |
| 属性名简写引发语法歧义 | 使用 generator 导出代码时,部分对象可能出现属性简写错乱或保留关键字报错。 |
配置 @babel/generator 选项参数 jsescOption: { minimal: true },并开启规范缩进与分号强制保留。 |
总结
使用 AST 技术还原混淆 JavaScript 代码的核心逻辑,并不在于编写一个包打天下的万能脚本,而在于建立模块化、高解耦的规则流水线。从最基础的字符串解密与常量折叠起步,扫除代码表面的符号障碍;继而通过代理对象与函数调用内联抹平语法树层级;随后以控制流平坦化重构拉直逻辑流程;最后辅以作用域清理与死代码剪枝。掌握这套系统化 AST 逆向方法论后,面对复杂对抗代码时便能抽丝剥茧,快速透视其底层的真实业务逻辑。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。