利用 AST 技术还原 JavaScript 混淆代码
原文作者:K哥爬虫 | 发布于 2022-04-28


一、先搞懂:AST 到底是什么?
AST(Abstract Syntax Tree,抽象语法树) 是源代码语法结构的树状表示——树上的每个节点,都对应着代码中的一个语法单元,比如变量声明、函数调用、if 条件判断等。
你可以把一段被混淆的 JavaScript 代码想象成一堆积木乱糟糟地堆在一起:通过 AST 解析,能精准定位每一块积木的类型和位置;通过修改 AST,我们可以按正确的逻辑顺序重新排列它们,最终拼回可读的代码。
AST 的广泛应用
AST 并不是逆向工程的专属玩具,你日常用到的很多开发工具,背后都在用它:
- IDE 功能:语法高亮、智能补全、自动格式化
- 代码转译:Babel 把 ES6+ 语法翻译成 ES5,核心就是操作 AST
- 代码压缩:UglifyJS、Terser 等工具删除无用代码、缩短变量名,同样依赖 AST
- 逆向解混淆:还原被打乱/加密的 JS 代码 ← 这就是本文要讲的重点
上手利器:AST 在线可视化工具
强烈推荐在 https://astexplorer.net/ 边改边看:
- 顶部栏:语言选 JavaScript,编译器选 @babel/parser(本文默认选择)
- 区域①:粘贴你想分析的混淆代码或正常代码
- 区域②:实时看到对应的 JSON 树形结构,点击节点可以高亮对应的源代码部分,反之亦然
- 区域③:编写 Babel 转换脚本(在这里对语法树增删改查)
- 区域④:预览转换后生成的代码

二、基础铺垫:AST 在编译过程中的定位
可能大多数前端同学平时不会去写编译器,但理解下面这三步,是玩转逆向解混淆的基本功:
源代码 → 词法分析 → 语法分析 → AST → [我们在这里修改] → 代码生成 → 目标代码

1. 词法分析:拆成一个个「单词」
从左到右逐字符扫描代码,把它们切分成有独立意义的 Token(记号)流。比如 log('Hi') 会被拆成:
log → 标识符
( → 左圆括号
'Hi' → 字符串字面量
) → 右圆括号
2. 语法分析:拼出完整的「句子」
把 Token 序列按照语法规则组合起来,建立嵌套、先后等关系,最终生成一棵 AST。例如 log('Hi') 的 AST 大致长这样:
ExpressionStatement(表达式语句)
└── CallExpression(函数调用表达式)
├── Identifier(标识符):log
└── [Arguments]
└── StringLiteral(字符串字面量):“Hi”
3. 代码生成:把修改后的树还原成代码
在 AST 层完成修剪、替换、排序等操作后,再生成可执行的代码字符串。我们做解混淆的所有工作,都集中在这中间的 AST 层上。
三、核心武器:Babel 全家桶快速上手
Babel 是目前最主流的 JavaScript 编译器,内部提供了一套成熟易用的 AST 操作 API,完全能满足入门级逆向需求。
安装方式
npm install @babel/core @babel/parser @babel/traverse @babel/generator @babel/types
核心包速览
@babel/parser:解析代码为 AST
最常用的是 parse 方法:
const parser = require("@babel/parser");
const code = "const a = 1;";
const ast = parser.parse(code, {
sourceType: "module" // 支持 ES Module 语法,可选
});
console.log(ast); // 输出嵌套的 JSON,直接在 astexplorer 里看更直观
@babel/traverse:批量处理节点
如果只是单点修改,可以直接用路径访问(如 ast.program.body[0]),但处理大批量节点时,必须依靠 traverse + visitor。
基础示例:批量修改数字和字符串
const parser = require("@babel/parser");
const generate = require("@babel/generator").default;
const traverse = require("@babel/traverse").default;
const code = `
const a = 1500;
const b = "hi";
const c = 787;
`;
const ast = parser.parse(code);
// visitor 对象:键是节点类型,值是处理函数
const visitor = {
NumericLiteral(path) {
path.node.value = (path.node.value + 100) * 2;
},
StringLiteral(path) {
path.node.value = "I Love JavaScript!";
}
};
traverse(ast, visitor);
console.log(generate(ast).code);
输出结果:
const a = 3200;
const b = "I Love JavaScript!";
const c = 1774;
visitor 的几种常见写法
以下四种方式效果完全相同,可以根据习惯选用:
// 1. 简写方法(最常用)
const visitor = { NumericLiteral(path) {...} };
// 2. 统一入口 + 类型判断(适合需要统一处理的情况)
const visitor = {
enter(path) {
if (path.node.type === "NumericLiteral") {...}
}
};
// 3. 多个类型共享一个处理函数
const visitor = {
"NumericLiteral|StringLiteral"(path) {...}
};
提示:enter 是进入节点时调用的钩子,exit 是离开节点时调用的钩子(一般用于后序处理)。
@babel/types:构建新节点
当需要向 AST 中新增代码时,必须使用 @babel/types,它的方法名通常是节点类型的「首字母小写」。
高效技巧
如果不确定方法需要哪些参数,在支持 TypeScript 的编辑器里 按住 Ctrl + 点击方法名 跳转到类型定义,比翻官方文档快得多。
四、实战修炼:五种典型混淆的还原方案
4.1 Unicode / 十六进制字符串还原
混淆工具常常把普通字符串变成编码形式,比如 console['\u006c\u006f\u0067']。
关键发现
在 AST 中,这类字符串虽然 extra.raw 里是乱糟糟的转义序列,但 value 属性已经被 Node.js 自动处理成了正常的文字!
还原代码
const visitor = {
StringLiteral(path) {
// 删除 raw 属性,生成代码时会基于 value 重新输出
delete path.node.extra?.raw;
delete path.node.extra?.rawValue;
}
};
4.2 静态表达式求值还原
混淆代码会把简单的常量替换成一长串运算,例如 const a = !![]+!![]+!。
核心方法
使用 Babel 内置的 path.evaluate(),它会自动判断表达式是否可以在不执行的情况下计算出结果(即静态可计算)。
还原代码
const types = require("@babel/types");
const visitor = {
"BinaryExpression|CallExpression|ConditionalExpression"(path) {
const { confident, value } = path.evaluate();
if (confident) {
// 将计算结果值转换成 AST 节点并原地替换
path.replaceInline(types.valueToNode(value));
}
}
};
注意:如果表达式中引用了外部变量,confident 会返回 false,此时不要强行替换,否则会丢失逻辑。
4.3 清除未使用的变量和函数
混淆代码里经常会塞入大量无用的声明,纯属干扰分析。
核心方法
使用 path.scope.getBinding() 检查变量/函数的引用情况:
referenced:是否被其他代码引用
constant:是否为常量(考虑过是否被重新赋值)
还原代码
const visitor = {
VariableDeclarator(path) {
const binding = path.scope.getBinding(path.node.id.name);
// 未找到绑定 or 被重新赋值过 or 被引用过 → 保留
if (!binding || binding.constantViolations.length > 0 || binding.referenced) return;
// 否则是“僵尸代码”,直接删除
path.remove();
}
};
4.4 删除永真/永假的 if-else 分支
混淆代码中常常出现 if (false) 或者 if (1) 这类分支,其中一些分支永远不会执行,一些则是必然执行。
还原代码
const types = require("@babel/types");
const visitor = {
IfStatement(path) {
const test = path.node.test;
// 只处理条件值是布尔或数字字面量的情况(变量条件无法静态判断)
if (!types.isBooleanLiteral(test) && !types.isNumericLiteral(test)) return;
if (test.value) {
// 条件为真 → 用 if 分支的语句直接替换 if 语句
path.replaceInline(path.node.consequent.body);
} else if (path.node.alternate) {
// 条件为假且有 else → 用 else 分支替换
path.replaceInline(path.node.alternate.body);
} else {
// 条件为假且没有 else → 删除整个 if
path.remove();
}
}
};
4.5 switch-case 反控制流平坦化(入门版)
控制流平坦化是最常用的混淆手段之一,它通过 while-switch-case 结构加上一个乱序的数组,把原本顺序执行的代码切成片,再按数组顺序重新“播放”。
核心思路
- 定位控制顺序的数组(比如
'3,4,0,5,1,2'['split'](','))
- 按索引顺序依次取出对应的
case 块内容
- 去掉每个
case 末尾的 continue 语句
- 用拼接好的正常顺序代码替换整个
while 结构
还原代码(前置依赖查找版)
const visitor = {
WhileStatement(path) {
const switchNode = path.node.body.body[0];
// 拿到控制流数组的变量名
const arrayName = switchNode.discriminant.object.name;
let controlFlowArr = [];
// 向 while 前面的兄弟节点查找数组定义
path.getAllPrevSiblings().forEach(prevPath => {
const { id, init } = prevPath.node.declarations?.[0] || {};
if (id?.name === arrayName) {
// 简化版 split 模拟,实际中可能遇到更复杂的生成方式
const str = init.callee.object.value;
const separator = init.arguments[0].value;
controlFlowArr = str.split(separator);
// 删除已经用过的数组定义
prevPath.remove();
}
});
// 按顺序拼接 case 内容
let replaceNodes = [];
controlFlowArr.forEach(index => {
const caseBody = switchNode.cases[index].consequent;
// 如果最后一条语句是 continue,就删掉
if (types.isContinueStatement(caseBody.at(-1))) caseBody.pop();
replaceNodes = replaceNodes.concat(caseBody);
});
// 替换掉整个 while
path.replaceWithMultiple(replaceNodes);
}
};
五、要点速查与学习资源
核心技术点速查
推荐学习资源
解混淆没有一成不变的万能公式,核心思路就是:根据目标混淆工具的具体输出,配合 AST 工具一点一点还原真实的逻辑。掌握本文的基础知识和套路之后,90% 的入门级 JavaScript 混淆都挡不住你了。