简介:这是一份面向前端安全、逆向分析与代码审计方向的AST反混淆JS还原工具,基于丁仔大佬的js还原工具进行二次开发,新增功能十余项,并对原有功能做了优化与兼容性扩展,同时修复了1.0版本遗留的错误与已知bug。工具在保证原JS文件可执行性的前提下,尽量提升反混淆后代码的可读性,使其更接近源码结构,适合需要处理混淆脚本、还原逻辑的中高级开发者使用。压缩包共9个文件,包含5个js脚本与4个md文档,前者承载核心还原逻辑与配置入口,后者提供功能说明、更新记录及待优化事项,整体约40KB,轻量易部署。目前已有2542人学习下载,读者可借此快速搭建反混淆流程,理解AST还原思路,并参考文档排查兼容性问题、按需扩展功能。
1. AST反混淆js还原工具2.2:为什么手工抠混淆代码迟早会让你崩溃
如果你做过前端逆向或者爬虫对抗,一定遇到过那种打开就让人头皮发麻的 JS 文件:变量名全是_0x4a2b,字符串被塞进一个巨大的数组里,控制流被拆成switch-case嵌套几十层,还夹杂着永远走不到的死代码。手工改?改到第三行你就忘了第一行是什么。AST 反混淆 js 还原工具 2.2 这类方案,解决的就是这个问题——它不靠正则硬匹配,而是把 JS 源码解析成抽象语法树,在树结构上做变换,再把干净的代码生成回来。适合谁用?做前端安全分析、爬虫对抗、恶意脚本研判的工程师,以及需要批量处理混淆样本的团队。核心价值一句话:把「人眼找规律」变成「程序按规则重写」,一次写好规则,后面全是自动化。
2. 从源码到干净代码:AST 反混淆的四个核心变换怎么落地
2.1 为什么必须用 AST 而不是正则替换
正则替换在混淆代码面前基本是玄学。原因很简单:混淆器会刻意制造大量相似但不完全相同的模式,正则要么匹配不全,要么误伤正常代码。更致命的是,正则不理解作用域——你把_0x4a2b全局替换成a,结果把字符串里的_0x4a2b也换了,代码直接跑不起来。
AST 方案的核心优势在于:解析器已经把代码的结构、作用域、引用关系都分析清楚了。你在树上操作,改的是「变量声明节点」而不是「文本片段」,天然避开了字符串误伤。常见做法是用 Babel 的解析和生成能力,配合@babel/traverse做节点遍历和替换。
一个最小可跑的解析流程长这样:
const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const t = require('@babel/types'); // 读取混淆代码 const code = ` var _0x4a2b = ['log', 'hello', 'world']; function _0x3c1d(i) { return _0x4a2b[i]; } console[_0x3c1d(0)](_0x3c1d(1) + ' ' + _0x3c1d(2)); `; // 第一步:解析成 AST const ast = parser.parse(code, { sourceType: 'module', compact: false }); // 第二步:在 AST 上做变换 traverse(ast, { // 匹配函数调用节点,识别 _0x3c1d(数字) 这种模式 CallExpression(path) { const callee = path.node.callee; const args = path.node.arguments; // 判断是否是对 _0x3c1d 的调用且参数是数字字面量 if ( t.isIdentifier(callee, { name: '_0x3c1d' }) && args.length === 1 && t.isNumericLiteral(args[0]) ) { // 这里需要结合数组内容做替换,实际工具会先收集数组映射 // 简化演示:直接替换成对应字符串 const map = ['log', 'hello', 'world']; const idx = args[0].value; if (map[idx] !== undefined) { path.replaceWith(t.stringLiteral(map[idx])); } } } }); // 第三步:生成干净代码 const output = generate(ast, { comments: false, compact: false }); console.log(output.code);这段代码的逻辑说明:parser.parse把源码转成 AST;traverse遍历所有CallExpression节点,识别出对_0x3c1d的调用且参数是数字的情况;path.replaceWith把整个调用节点替换成字符串字面量;最后generate把修改后的 AST 还原成代码。参数上,sourceType根据样本是模块还是脚本调整,compact控制输出是否压缩,调试阶段建议设为false方便看结构。
2.2 字符串数组还原:把散落的引用收拢回字面量
字符串数组混淆是最常见的一层。混淆器把所有字符串抽到一个数组里,然后通过一个解码函数按索引取用。还原的关键是两步:先静态求值出数组内容,再把所有解码函数调用替换成对应的字符串。
我一般会先写一个「数组收集器」,遍历 AST 找到那个大数组的声明,用path.evaluate()尝试静态求值。如果数组元素全是字面量,evaluate能直接算出结果;如果元素本身是表达式,就需要递归处理。
// 收集字符串数组映射 const stringMap = {}; traverse(ast, { VariableDeclarator(path) { const init = path.node.init; // 匹配 var _0x4a2b = ['log', 'hello', ...] 这种结构 if (t.isArrayExpression(init)) { const evaluated = path.get('init').evaluate(); if (evaluated.confident) { // 把数组变量名和值存起来 stringMap[path.node.id.name] = evaluated.value; } } } }); // 第二遍遍历:替换解码函数调用 traverse(ast, { CallExpression(path) { const callee = path.node.callee; const args = path.node.arguments; // 匹配 _0x3c1d(0) 这种形式 if ( t.isIdentifier(callee) && stringMap[callee.name] === undefined && // 排除数组本身 args.length === 1 && t.isNumericLiteral(args[0]) ) { // 这里需要知道 callee 对应哪个数组,实际工具会维护函数到数组的映射 // 简化处理:假设只有一个解码函数 const arr = Object.values(stringMap)[0]; if (arr && arr[args[0].value] !== undefined) { path.replaceWith(t.stringLiteral(arr[args[0].value])); } } } });参数说明:path.get('init').evaluate()返回{ confident, value },confident为true时才可信。如果数组里有函数调用或外部引用,confident会是false,这时候就得手动构造求值逻辑,或者用vm模块在沙箱里跑一遍。注意,替换时一定要确认解码函数只做纯索引访问,如果它内部还有异或、加减等运算,得先把运算逻辑也静态算出来。
2.3 控制流平坦化还原:把 switch-case 打回顺序执行
控制流平坦化是混淆里最恶心的一层。原始代码的顺序执行被拆成一个while循环加switch,每个case执行一小段,然后修改一个状态变量决定下一步跳哪。还原思路是:追踪状态变量的赋值链,把case块按实际执行顺序重新排列,最后去掉switch和状态变量。
常见做法是先用traverse找到WhileStatement或ForStatement,判断其内部是否是SwitchStatement,且switch的判别式是一个变量。然后收集所有case,分析每个case末尾对状态变量的赋值,构建执行顺序图。
// 简化版控制流平坦化还原思路 traverse(ast, { WhileStatement(path) { const body = path.node.body; // 判断循环体是否是单个 switch 语句 if ( t.isBlockStatement(body) && body.body.length === 1 && t.isSwitchStatement(body.body[0]) ) { const switchNode = body.body[0]; const discriminant = switchNode.discriminant; // 收集所有 case 及其对状态变量的赋值 const cases = switchNode.cases; const order = []; let current = null; // 找到初始状态:通常是 switch 前对状态变量的赋值 // 这里简化:假设第一个 case 是入口 // 实际工具会分析 switch 前的变量声明 // 按状态变量赋值链排序 case // 伪代码:从初始值出发,找对应 case,记录,再找该 case 末尾赋的新值 // 直到回到初始值或没有更多 case // 排序后替换整个 while 语句为顺序执行的语句块 // path.replaceWith(t.blockStatement(orderedStatements)); } } });这段代码只给了骨架,因为完整的控制流还原需要处理嵌套、条件分支、多个状态变量等复杂情况。参数上,关键是识别状态变量的「初始赋值」和每个case末尾的「跳转赋值」。如果混淆器用了多个状态变量或者加了不透明谓词,还原难度会指数上升,这时候建议先做常量折叠和死代码消除,把干扰项清掉再处理控制流。
2.4 死代码消除与常量折叠:把混淆器的烟雾弹清掉
死代码和不透明谓词是混淆器用来干扰分析的手段。典型模式是if (false) { ... }或者if (!![]) { ... }这种恒真恒假的判断。AST 层面处理很简单:用path.evaluate()判断条件表达式的真假,然后直接替换成对应的分支。
traverse(ast, { IfStatement(path) { const test = path.get('test'); const evaluated = test.evaluate(); if (evaluated.confident) { if (evaluated.value) { // 条件为真,保留 consequent,去掉 alternate path.replaceWith(path.node.consequent); } else { // 条件为假,保留 alternate(如果有) if (path.node.alternate) { path.replaceWith(path.node.alternate); } else { path.remove(); } } } } });常量折叠则是把var a = 1 + 2;变成var a = 3;。Babel 的path.evaluate()对纯字面量运算能直接求值,但遇到函数调用或外部变量就会返回confident: false。这时候可以配合babel-plugin-minify-constant-folding这类现成插件,或者自己写一个递归求值器。注意,折叠时要小心NaN、Infinity和-0这些边界情况,evaluate的结果和实际运行时行为可能有细微差异。
3. 工具 2.2 版本的参数配置与批量处理流水线
3.1 核心配置项:哪些参数决定还原成功率
这类工具通常有一个配置文件或命令行参数入口。根据我踩过的坑,下面几个参数最影响结果:
| 参数名 | 作用 | 建议值 | 踩坑点 |
|---|---|---|---|
sourceType | 指定源码类型 | module或script | 设错会导致解析直接报错 |
compact | 输出是否压缩 | false | 调试时设true会看不清结构 |
comments | 是否保留注释 | false | 混淆代码的注释多半是干扰 |
maxIterations | 最大遍历轮数 | 5到10 | 设太小还原不彻底,设太大可能死循环 |
evalTimeout | 沙箱求值超时 | 2000毫秒 | 恶意样本可能故意拖慢求值 |
maxIterations这个参数特别关键。因为很多变换是连锁的:字符串还原后可能暴露出新的常量表达式,常量折叠后可能让死代码判断变得可求值。所以工具需要多轮遍历,直到 AST 不再变化。我一般会设8轮,然后观察每轮后的代码长度变化,如果连续两轮长度不变就提前退出。
3.2 批量处理脚本:一次跑完一个目录的样本
单个文件处理用命令行就够了,但实际工作中往往是几十上百个样本。这时候需要写一个批量脚本,把目录下所有.js文件跑一遍,输出到指定目录,并记录每个文件的还原状态。
#!/bin/bash # 批量反混淆脚本 INPUT_DIR="./samples" OUTPUT_DIR="./output" LOG_FILE="./deobfuscate.log" mkdir -p "$OUTPUT_DIR" # 遍历所有 js 文件 for file in "$INPUT_DIR"/*.js; do filename=$(basename "$file") echo "[$(date)] Processing: $filename" >> "$LOG_FILE" # 调用反混淆工具,假设命令是 deobf # --input 指定输入,--output 指定输出,--iterations 指定轮数 node deobf.js --input "$file" --output "$OUTPUT_DIR/$filename" --iterations 8 >> "$LOG_FILE" 2>&1 if [ $? -eq 0 ]; then echo "[$(date)] Success: $filename" >> "$LOG_FILE" else echo "[$(date)] Failed: $filename" >> "$LOG_FILE" fi done echo "Batch done. Check $LOG_FILE for details."逻辑说明:脚本遍历samples目录下所有.js文件,逐个调用反混淆工具,输出到output目录,同时把处理日志追加到deobfuscate.log。参数上,--iterations 8对应前面说的多轮遍历,2>&1把错误输出也重定向到日志,方便排查。如果某个文件处理失败,日志里会记录Failed,后续可以单独分析。
3.3 还原效果验证:怎么判断代码真的干净了
还原完不能直接信,得验证。我一般用三个指标:代码长度变化、语法可解析性、运行时行为一致性。
代码长度变化最直观:混淆代码通常又长又重复,还原后长度会明显下降。但要注意,有些混淆器会故意插入大量无意义代码,还原后长度可能只降 30% 到 50%,这不一定代表失败。
语法可解析性用node --check就能测:
# 检查还原后的代码语法是否正确 node --check ./output/sample.js如果报语法错误,说明还原过程中产生了非法 AST,需要回退到上一轮或者调整变换规则。
运行时行为一致性最难验证,因为很多混淆样本本身就有环境依赖。常见做法是构造一个沙箱,把还原前后的代码都跑一遍,对比关键函数的输出。如果样本是纯计算逻辑,可以直接对比结果;如果涉及 DOM 或网络请求,就得 mock 掉外部依赖。
4. 避坑指南:AST 反混淆里最容易翻车的五个地方
4.1 现象:替换后代码跑不起来,报ReferenceError
原因:字符串数组还原时,把解码函数调用替换成了字符串,但解码函数本身还被其他地方引用,或者替换后的字符串里包含了原本的转义字符,导致语义变化。
解决:替换前先确认解码函数的所有引用点都被覆盖。对于转义字符,用t.stringLiteral构造节点时,Babel 会自动处理转义,但如果你手动拼接字符串再解析,就可能出问题。稳妥做法是始终用t.stringLiteral(value)而不是t.identifier('"' + value + '"')。
4.2 现象:控制流还原后代码顺序错乱,逻辑完全不对
原因:状态变量的赋值链分析错了。混淆器可能用了多个状态变量,或者case内部有条件修改状态,导致执行顺序不是线性的。
解决:先做数据流分析,把状态变量的所有赋值点找出来,构建有向图。如果图里有环或者分支,说明不是简单的线性平坦化,需要保留部分switch结构,只还原确定的部分。别硬还原,宁可留一点混淆也别改错逻辑。
4.3 现象:工具跑着跑着内存爆了,或者卡死不动
原因:多轮遍历时,某条规则产生了循环替换——A 节点被替换成 B,下一轮 B 又被替换回 A。或者沙箱求值时遇到了死循环。
解决:给每轮遍历加一个「变更计数」,如果某轮变更数为 0 就提前退出。沙箱求值一定要设超时,用vm模块的timeout选项,别用eval裸跑。另外,遍历时用path.skip()跳过已经处理过的子树,能大幅减少重复计算。
4.4 现象:还原后的代码里还有大量_0x开头的变量名
原因:只做了字符串还原,没做变量名重命名。混淆器通常会把变量名也混淆掉,但重命名需要作用域分析,不能简单全局替换。
解决:用 Babel 的scope.rename方法,在Program退出时统一重命名。注意要避开全局变量和内置对象,只重命名函数作用域内的局部变量。重命名后代码可读性会大幅提升,但别指望变成原始变量名——能变成a、b、c就已经很好了。
4.5 现象:某些样本还原后代码变短了,但运行结果和原来不一样
原因:常量折叠时把NaN、undefined、-0这些特殊值处理错了,或者把有副作用的表达式当成了纯表达式求值。
解决:折叠前先判断表达式是否有副作用。path.isPure()可以帮你判断,但它不是万能的。对于函数调用、new表达式、赋值表达式,一律不折叠。特殊值方面,evaluate返回的NaN和Infinity要单独处理,别直接当数字字面量塞回去。
5. 进阶技巧:用插件化架构把还原率再提一截
工具用久了你会发现,固定的变换规则总有覆盖不到的样本。这时候与其改主流程,不如把每条变换规则做成独立插件,按需加载。我现在的做法是:主流程只负责解析、遍历调度和生成,所有具体变换都注册成插件,每个插件声明自己关心的节点类型和优先级。
// 插件注册示例 const plugins = [ { name: 'string-array-restore', priority: 10, visitor: { CallExpression(path) { /* 字符串数组还原逻辑 */ } } }, { name: 'control-flow-restore', priority: 20, visitor: { WhileStatement(path) { /* 控制流还原逻辑 */ } } }, { name: 'dead-code-eliminate', priority: 30, visitor: { IfStatement(path) { /* 死代码消除逻辑 */ } } } ]; // 按优先级排序后依次应用 plugins.sort((a, b) => a.priority - b.priority); plugins.forEach(plugin => { traverse(ast, plugin.visitor); });优先级的设计有讲究:字符串还原要最先做,因为它能暴露出更多常量;控制流还原其次,因为它会改变代码结构;死代码消除放最后,用来清理前两步产生的垃圾。如果顺序反了,比如先做死代码消除,可能因为字符串还没还原导致条件判断无法求值,白白漏掉一批可消除的代码。
另一个提效技巧是「差分测试」。准备一批已知行为的样本,每次调整插件后跑一遍,对比还原前后代码的运行输出。如果输出一致,说明这次调整没引入回归;如果不一致,立刻定位是哪个插件的问题。这个习惯帮我省了无数个 debug 的夜晚。
还有一个血泪经验:别追求 100% 还原率。有些样本用了自定义虚拟机或者加密壳,AST 层面根本还原不了,硬刚只会浪费时间。我一般设一个阈值,比如还原后代码长度降到原来的 40% 以下、且能通过语法检查,就认为这轮合格了。剩下的交给动态分析或者人工研判。工具是帮你省时间的,不是让你跟混淆器死磕的。
希望帮到你。
本文还有配套的精品资源,点击获取