简介这是一份面向前端安全、逆向分析与代码审计方向的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把源码转成 ASTtraverse遍历所有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 21 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对应前面说的多轮遍历21把错误输出也重定向到日志方便排查。如果某个文件处理失败日志里会记录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% 以下、且能通过语法检查就认为这轮合格了。剩下的交给动态分析或者人工研判。工具是帮你省时间的不是让你跟混淆器死磕的。希望帮到你。本文还有配套的精品资源点击获取