
简介面向JS逆向与爬虫工程师的AST反混淆还原工具包基于丁仔大佬的js还原方案二次开发新增十余项功能并优化原有逻辑兼容性更强可处理2021年9月最新版obfuscator.io生成的混淆代码是应对复杂JS混淆的实用利器。压缩包约29KB共7个文件其中5个js脚本覆盖主还原流程、加解密处理与演示用例2个markdown文档提供功能说明与使用指南目录结构紧凑方便直接配置运行。已有4310人学习下载适合具备一定JS逆向基础、希望提升反混淆效率的中高级开发者。借助内置的config.js与多种还原函数可快速拆解OB混淆器生成的控制流平坦化、字符串数组移位等典型特征配合demo脚本上手实践能显著减少手动调试时间。该工具还针对原版缺陷进行修正并扩展兼容性可作为日常逆向工作台中的常备组件。1. AST反混淆不是解密它到底在还原什么为什么商业混淆依然难啃拿到一个叫“AST反混淆js还原工具.zip”的包时你多半正在干js逆向的活面前是一段被压缩成 a、b、c 变量的代码函数名全丢字符串藏进数组外层还套了两层自执行函数。很多人以为反混淆是“解密”工具一点就出原始源码。实际上 AST 反混淆做的是形态还原把代码重新解析成抽象语法树再按照模式改写成接近人写的可读状态。它解决的是“看得懂、能下断点、能改逻辑”而不是变回你丢掉的源文件。这套东西适合 JS 逆向调试、爬虫脚本维护、前端安全审计的工程师。下面按我自己的实操顺序展开先认清混淆套路再跑通最小还原链路接着啃控制流硬骨头最后说怎么验货。2. 从混淆到 AST四种常见混淆套路和一套能落地的还原思路2.1 变量名压缩最廉价但也最挡路的混淆用 UglifyJS 或 Terser 跑过一遍的代码变量名会变成 a、b、c函数名变成 o、n、r。这类混淆不改变代码逻辑纯粹把标识符压短但阅读体验会断崖式下降。在 AST 层面它只是VariableDeclarator节点的id.name被替换了结构本身没有变化。还原变量名时要注意一个现实原名已经丢失不可能靠 AST 变回去。常见做法是按作用域重新命名成_str、_fn、_arr这类带语义前缀的名字再结合字符串调用点做推断。比如a(log)这种调用点能猜到 a 大概率对应某个全局对象的访问器。这个步骤优先级最低因为变量名不影响运行结果只影响人读代码的效率。2.2 字符串数组化混淆工具的“第一节课”现在大多数混淆工具默认都会做字符串收集把代码里的字符串抽到一个数组里再用一个解密函数按下标取回。典型形态是var _0xkeys [console, log, hello]; function _0xdec(i) { return _0xkeys[i]; } _0xdec(0)[_0xdec(1)](_0xdec(2));AST 层面的特征非常明显FunctionDeclaration的函数体里只有一个ReturnStatement返回的是一个MemberExpression并且object是数组变量名property是形参名。只要数组元素是字面量、下标是常量结果就能静态算出来还原脚本可以直接拿到真实字符串。边界情况是带位移的版本比如return _0xkeys[i ^ 0x1f]或者return _0xkeys[(_0xkey 1) - 1]这种就不能只做映射替换函数体内本身带状态。遇到这种我一般先把解密函数单独拎出来执行一遍再用执行结果去填调用点具体在第 4 章讲。2.3 自执行函数和控制流平坦化真正的可读性杀手字符串数组化好歹还能用“查找替换”对付自执行函数和控制流平坦化是另一档的难度。自执行函数常见两类用途一是包裹初始化逻辑把数组解密、内置函数定义藏进闭包二是做反调试比如setInterval检测开发者工具。还原时如果直接删掉 IIFE副作用就丢了代码行为会变。控制流平坦化则是把 if/else、for 的顺序逻辑压扁成一个while switch分发器。原始代码里if (a 0) { b(); } else { c(); }会变成一个状态变量_state和 switch 的多个 case每个 case 末尾给_state赋下一个值。AST 特征是WhileStatement内部包一个SwitchStatementcase 的consequent里基本都是表达式和赋值语句。这种混淆看着吓人但执行顺序是确定的只要还原出状态流转路径就能把 case 按顺序拼接回去。2.4 还原思路的统一写法识别模式、AST 改写、重新打印所有 AST 反混淆工具走的都是同一条路没有玄学。第一步用解析器把源码变成 AST第二步写 visitor 去匹配目标模式第三步对匹配到的节点做替换、删除或重排第四步用生成器把 AST 打印成代码。整个过程不涉及“解密”只是在做语法树层面的等价变换。混淆手法AST 特征还原策略工作量变量名压缩Identifier.name 被替换作用域分析后重命名低字符串数组化CallExpression 数组下标收集数组替换调用点低字符串编码String.fromCharCode / 位移静态折叠或执行解码函数中自执行函数CallExpression FunctionExpression提取内部声明或整体计算中高控制流平坦化While Switch 状态变量分发表 顺序复原高做这套东西最忌讳一上来就写“万能还原器”。我在实际处理打包混淆文件时都是先统计代码里出现哪几种模式再按模式逐个写小的还原脚本。能自动化的自动化不能自动化的就退化成“人肉读 工具辅助”这是 js反爬实战里的常态。3. 跑通第一个还原脚本用 Babel 解析、改写、生成的最小链路3.1 环境准备与依赖选型Babel 的 parser、traverse、generator 三件套是目前做 AST 还原最顺手的组合文档全、社区案例多而且本身就是 JS 写的处理 JS 代码没有任何跨语言隔阂。新建一个目录项目里装好依赖mkdir ast-deobf cd ast-deobf npm init -y npm install babel/parser babel/traverse babel/generator babel/types需要先保证本机有 Node.js 环境建议 14 以上太老的 V8 对部分语法解析会报错。安装完成后写脚本时统一这样引入const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types);注意traverse在 Babel 7 里默认导出是对象要取.default才能当函数用这是新手最容易翻车的第一处。3.2 一段常见的混淆代码作为还原样本要验证链路先造一个覆盖多种典型模式的样本。下面这段代码同时包含字符串数组、解密函数、String.fromCharCode编码、以及字符串形式的属性访问var _0xkey [console, log, hello , world]; function _0xget(i) { return _0xkey[i]; } var _0xstr String.fromCharCode(104, 105); _0xget(0)[_0xget(1)](_0xget(2) _0xget(3) _0xstr);这段代码执行结果是console.log(hello worldhi)但人眼已经很难一眼看出来。还原的目标就是把它变回接近原始的形态。3.3 第一步解析成 AST先看清节点长什么样解析只需要一行const ast parser.parse(code); console.log(ast.program.body[0].type); console.log(ast.program.body[1].type);输出会是VariableDeclaration和FunctionDeclaration。这里要强调的是不要把 AST 当黑匣子。拿到一段混淆代码先用JSON.stringify打印某个节点的一个片段看它的type、id、init、body长什么样再写 visitor。我用过很多现成反混淆脚本一旦path.node的结构和脚本里假设的不一致运行时代码直接崩所以自己动手前先观察结构是省时间的关键。3.4 第二步收集数据数组和解密函数数组收集的逻辑是遍历所有VariableDeclarator如果初始化值是数组表达式就记下变量名到元素列表的映射。const arrMap {}; traverse(ast, { VariableDeclarator(path) { const node path.node; if (t.isArrayExpression(node.init) t.isIdentifier(node.id)) { arrMap[node.id.name] node.init.elements.map(e e.value); } } }); console.log(arrMap);这里有个参数需要说明e.value只对字面量元素有效如果数组里嵌了函数表达式或对象e.value是undefined后续替换要跳过。所以判断时应该确认node.init.elements里每个元素都是字面量不满足就直接不收集安全第一。解密函数的识别稍微严格一些函数体只能有一个ReturnStatement返回的是_0xkey[i]这种方括号访问形式。const fnMap {}; traverse(ast, { FunctionDeclaration(path) { const node path.node; if (node.body.body.length ! 1) return; const stmt node.body.body[0]; if (!t.isReturnStatement(stmt)) return; const arg stmt.argument; const pName node.params[0] node.params[0].name; if (t.isMemberExpression(arg) arg.computed t.isIdentifier(arg.property) arg.property.name pName) { fnMap[node.id.name] arg.object.name; } } }); console.log(fnMap);判断里有个细节arg.computed为 true 才表示_0xkey[i]这种访问如果为 false 就说明是_0xkey.i那就不符合预期模式直接忽略。3.5 第三步替换调用点、折叠编码、归一属性访问收集完映射开始改写。先把_0xget(0)这类调用替换成真实字符串traverse(ast, { CallExpression(path) { const callee path.node.callee; const fnName callee.name; const arg path.node.arguments[0]; if (!fnMap[fnName] || !t.isNumericLiteral(arg)) return; const arrName fnMap[fnName]; const idx arg.value; const val arrMap[arrName] arrMap[arrName][idx]; if (val ! undefined) { path.replaceWith(t.stringLiteral(val)); } } });这一步替换完成后AST 里_0xget(2)变成了字符串字面量hello 。但这里有个坑traverse不会因为你在回调里替换了节点就自动重新访问已经路过的父节点所以属性访问归一化必须放在下一轮遍历里做否则可能替换不到。然后处理String.fromCharCodetraverse(ast, { CallExpression(path) { const callee path.node.callee; if (!t.isMemberExpression(callee)) return; if (callee.object.name ! String || callee.property.name ! fromCharCode) return; const nums path.node.arguments.map(a a.value); if (nums.every(n typeof n number)) { path.replaceWith(t.stringLiteral(String.fromCharCode(...nums))); } } });这段的意图很直接只有所有参数都是数值字面量时才折叠避免把含变量的参数提前执行导致出错。如果参数里混着_0xget(0)这种还没替换的调用那就等下一轮处理。最后归一化属性访问。现在代码里已经是console[log](...)这种形态了我们要把它变成console.log(...)。这里必须加白名单字符串转标识符只对确实存在的全局对象做否则abc.def()变成abc.def()会直接ReferenceError。const GLOBALS new Set([console, window, document, String, Math, JSON, Array, Object]); traverse(ast, { MemberExpression(path) { const { object, property, computed } path.node; if (!computed || !t.isStringLiteral(property)) return; const pName property.value; if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pName)) return; if (t.isStringLiteral(object) GLOBALS.has(object.value)) { path.node.object t.identifier(object.value); } if (t.isIdentifier(object) || t.isStringLiteral(object)) { path.node.property t.identifier(pName); path.node.computed false; } } });参数说明正则限制了属性名必须是合法标识符log可以通过my-method这种只能保留方括号形式白名单限制了 object 不能乱转。这个取舍非常重要宁可少还原几处也不能把代码改坏。3.6 第四步清理未引用声明并重新生成代码到这一步_0xkey数组和解密函数_0xget已经没人引用了留着只会干扰阅读。需要一轮清理traverse(ast, { VariableDeclarator(path) { const id path.node.id; if (t.isIdentifier(id) !path.scope.getBinding(id.name).referenced) { path.remove(); } }, FunctionDeclaration(path) { if (!path.scope.getBinding(path.node.id.name).referenced) { path.remove(); } } });path.scope.getBinding(name).referenced是 Babel 做作用域分析后给出的引用计数判断为 false 说明当前绑定没有被引用。清理必须放在所有替换完成之后这一点放在这里强调是因为很多还原脚本卡在“替换一套、清理一套”的顺序错乱上。最后生成代码const result generate(ast, { compact: false, comments: true, jsescOption: { minimal: true } }); console.log(result.code);输出是接近理想的结果var _0xstr hi; console.log(hello world hi);compact: false让代码按多行展开jsescOption.minimal: true告诉生成器字符串尽量不转义只转义必须转义的字符。_0xstr之所以还在是因为它被引用了清理逻辑不会误删这是合理的保留不需要再强行内联。到这里一个最简还原链路已经跑通。整个过程没有依赖任何“黑匣子”工具全是看得见的 AST 变换这也是我建议新手自己搭一遍的原因你不用猜工具干了什么每一步都可在 AST 上验证。4. 进阶还原字符串解密泛化、自执行函数展开与控制流平坦化4.1 字符串解密升级让还原脚本学会“执行”第 3 章的映射替换只能处理_0xkey[i]这种纯取值的解密函数。遇到带异或、位移、加法混淆的解密函数比如function _0xdec(i) { return _0xarr[i % 4] ^ 0x2a; }静态替换就废了。常见做法是把这个解密函数从 AST 里抽出来放进一个模拟的 JS 上下文里执行再回填结果。下面这个函数可以处理大部分的“纯函数”解密逻辑const vm require(vm); function buildDecoder(fnNode, depsCode) { const fnName fnNode.id.name; const sandbox { String, Math, Number, console }; vm.createContext(sandbox); const code depsCode \n generate(fnNode).code \n; globalThis.__dec fnName ;; vm.runInContext(code, sandbox); return (idx) sandbox.__dec(idx); }逻辑说明depsCode是解密函数依赖的数组声明比如var _0xarr [...]需要先用generate把对应的VariableDeclaration节点重新生成成代码拼接在函数前面否则函数执行时会引用未定义变量。sandbox只放了String、Math这些解码可能用到的全局对象不要放window、document这是为了逼出潜在的浏览器环境依赖——如果解密函数本身还依赖 DOM API说明它不是纯解密你这个还原姿势要改。参数说明vm.runInContext执行完代码后sandbox.__dec就是可调用的解密函数返回值可以直接拿去替换调用点。这个取巧路径在 js反爬实战里非常实用。很多时候混淆工具的解密函数不是给你读的是给浏览器执行的那我们就让它在模拟器里执行把结果拿出来。当然要注意性能一个文件里几千次调用每次都重复创建 sandbox 会慢应该先把解密函数构建好循环复用。4.2 自执行函数展开先判断是不是“纯初始化”再决定要不要整体执行IIFE 分两种。一种是纯初始化型函数体里没有副作用只执行一段算法然后返回结果另一种是有副作用的比如注册事件、挂了定时器、修改了全局变量。对前一种可以直接把 IIFE 的返回值算出来替换整个调用表达式。判断“纯初始化”的规则我一般收得很紧函数体内没有this、没有外部函数调用、没有try/catch、没有赋值给未声明变量。四条全过才敢用 vm 执行。执行代码和 4.1 一样只是把fnNode换成FunctionExpression并把(function(){...})()换成一个普通调用。function evalPureIIFE(path) { const node path.node; const callee node.callee; if (!t.isFunctionExpression(callee)) return false; const hasThis traverse(callee.body, { ThisExpression() { throw new Error(has this); } }); // 这里用 try/catch 包住遇到 this 直接放弃 try { hasThis; const sandbox { String, Math, JSON }; vm.createContext(sandbox); const code ( generate(callee).code )(); const result vm.runInContext(code, sandbox); path.replaceWith(t.valueToNode(result)); return true; } catch (e) { return false; } }这段代码里t.valueToNode会把 JS 值还原成 AST 节点字符串、数字、数组都能转。遇到不能转的值时会抛错比如返回了一个函数表达式所以替换前也要 try/catch 包一层失败就维持原样。带副作用的 IIFE 不要硬展开保留原样人工读的时候多花点时间但运行行为不会变。这是典型的“宁可不还原不能改坏”。4.3 控制流平坦化分发表加顺序复原别想着一次到位控制流平坦化是所有反混淆工具的重灾区。它的核心是一个while套switchswitch 的分支顺序被状态变量打乱。第一步要做的不是“复原”而是建分发表看每个 case 的test值以及 case 末尾把状态变量改成了什么值。function extractDispatcher(path) { const whileNode path.node; const switchNode whileNode.body.body.find(n t.isSwitchStatement(n)); if (!switchNode) return null; const table {}; switchNode.cases.forEach(c { const id c.test.value; table[id] c.consequent; }); // 找初始状态值 const initState extractInitState(path); return { table, initState }; }extractInitState做的事情是查找进入 while 之前的最后一次状态变量赋值。这个看起来简单实际很坑混淆工具会把状态变量藏进对象属性、参与位运算字符串匹配根本找不到。我自己的做法是加一层“执行台账”在循环里插入日志语句让代码在真实环境跑一遍记录每个 case 的执行顺序然后按顺序把 case 的语句重新拼接成顺序代码。这种做法比纯静态分析快得多也更稳。function patchLogBeforeCase(switchNode) { switchNode.cases.forEach(c { const logCall t.expressionStatement( t.callExpression( t.memberExpression(t.identifier(__log), t.identifier(push)), [t.numericLiteral(c.test.value)] ) ); c.consequent.unshift(logCall); }); }这样在还原后的代码里注入__log.push(caseId)运行一次就能拿到真实执行顺序。拿到顺序后按顺序把所有 case 的语句拼接成一个顺序块替换掉整个while。注意拼接时要处理break因为 switch 的break不是给顺序代码用的直接去掉。状态变量赋值语句如果只是控制流转也一并删除如果状态变量还参与了业务逻辑不能删要保留。这是控制流平坦化的核心思路。完整实现一个通用还原器至少上千行代码而且必须处理跳转、嵌套、循环多维度的问题。实战里我的建议是先用动态台账拿到顺序再用脚本半自动拼接不要指望一个函数搞定所有。4.4 死代码清理与表达式收敛还原后的最后一公里替换完字符串、展开完 IIFE 后AST 里会残留大量无引用的声明、常数表达式、恒真条件。清理未引用声明在第 3 章已经做过了这一节补两个常用的表达式收敛手段。二元表达式折叠traverse(ast, { BinaryExpression(path) { const { left, right, operator } path.node; if (!t.isNumericLiteral(left) || !t.isNumericLiteral(right)) return; let val; switch (operator) { case : val left.value right.value; break; case -: val left.value - right.value; break; case *: val left.value * right.value; break; case : val left.value right.value; break; default: return; } path.replaceWith(t.valueToNode(val)); } });参数说明只处理左右都是数值字面量的节点避免a 1这种带变量的表达式被误判。字符串拼接也可以加进去但要注意1 2的结果是字符串12需要单独处理。恒真条件收缩把if (true)直接替换成其consequenttraverse(ast, { IfStatement(path) { if (t.isBooleanLiteral(path.node.test, { value: true })) { path.replaceWithMultiple(path.node.consequent.body || [path.node.consequent]); path.skip(); } } });这里有个细节consequent是BlockStatement时要用replaceWithMultiple把块里的语句展开进父级如果是单条语句直接替换会破坏结构。path.skip()防止替换后再进入已经删除节点的子树能避免一部分奇怪的运行时报错。死代码清理做得越干净后续人工阅读的成本越低这也是还原工具“值不值得用”的评判标准之一。5. 避坑手册还原脚本常见的五类翻车点与排查路径5.1 还原后代码反而更膨胀清理时机错了现象字符串替换都成功了但生成的新代码比原混淆代码体积还大里面一大堆var _0xarr [...]和没人调用的解密函数。原因清理未引用声明跑在了替换之前。替换前解密函数还被调用着referenced判断为 true自然删不掉等替换完了又没再跑清理逻辑。解决所有模式替换完成之后再跑一轮清理顺序不能乱。可以把清理写成独立的traverse调用反复多跑两遍因为删掉一层声明后下一层声明可能就变成无引用的了。5.2 字符串还原出一堆乱码和转义现象还原出来的字符串里全是\x和\u转义或者多出\n打断逻辑编辑器里看着像乱码。原因babel/generator默认会对部分字符做转义尤其是非 ASCII 字符和特殊控制字符。如果不调jsescOption原字符串里的中文、换行全被转成\uXXXX人眼根本无法校对。解决生成代码时设置jsescOption: { minimal: true }让生成器只转义必须转义的字符。另外要注意字符串内容本身包含引号的情况优先用单引号风格并在生成参数里把quotes设为single可以减少转义层级。遇到确实无法靠配置解决的就保留转义别强行解码。5.3 变量重命名导致作用域污染现象还原后代码能跑但结果不对某个变量在执行到一半时变成了undefined定位发现是两个不同函数里的同名变量被改成了一样的名字。原因在做变量重命名时用了简单的全局正则替换没有按作用域隔离。JS 的函数级作用域和块级作用域里同名变量在不同函数内是互不干扰的全局替换会把这些独立的绑定合并。解决不要自己拼名字用path.scope.generateUidIdentifier(tmp)生成唯一标识符或者先对每个函数作用域单独做引用分析再决定替换。generateUidIdentifier会保证生成的标识符不跟当前作用域里任何已有名字冲突这是 Babel 给的最安全的后悔药。5.4 控制流平坦化还原把 JS 引擎跑死现象还原后代码放进 Node 或浏览器里执行进程直接挂死控制台卡在某个循环里。原因状态变量赋值分析漏了分支导致拼接后的顺序块里出现了环。比如 case A 末尾把状态改成 2case B 末尾又改回 1两个 case 死循环互跳。解决给执行台账加一个 case 执行次数上限。__log数组在收集时应限制最大长度比如超过 10000 就抛错终止。分析阶段发现同一个 case 出现超过 3 次就说明状态流转有环不要硬拼退回去保留原始的 while 结构。控制流平坦化本来就是最难啃的部分做不动就圈出来人工处理硬来不如不还。5.5 大文件 OOM 或 Node 进程崩溃现象还原一个 5MB 的混淆脚本Node 进程内存涨到 2GB 以上最后 OOM 被杀。原因AST 本身是内存大户几万行代码解析出来节点数轻松上百万。如果 visitor 里频繁调用path.get(xxx)获取深层子节点每次都会产生新的路径对象内存会持续膨胀。更常见的诱因是一次性把整个文件解析了没有分段。解决大文件先做分块预处理比如按顶层var声明或函数级节点先切分成多个片段各自还原最后再合并。写 visitor 时尽量避免在回调里做深链路径查询优先用path.node直接访问对象属性。另外要关掉 parser 的注释收集parser.parse(code, { attachComment: false })能省不少内存。遇到实在太大的文件把重复的字符串模式先用正则粗筛一遍再做 AST 还原能省掉不少节点。6. 还原结果怎么验语法、行为、体积三道关还原工具写出来判断做没做对的唯一标准是“行为不变”。AST 变换再漂亮只要执行结果和原代码不一致就是失败的。第一道关是语法检查。还原后的代码至少得能通过 Node 的语法解析node --check restored.js有语法错误会直接报行号和错误类型。接着做行为对拍这是最硬的一道关。把原代码和还原代码分别放进两个 vm 沙箱里收集它们对console.log的输出然后对比const vm require(vm); function runCapture(code) { const logs []; const ctx { console: { log: (...a) logs.push(a.join( )) } }; vm.createContext(ctx); vm.runInContext(code, ctx); return logs; } const a runCapture(originCode); const b runCapture(restoredCode); console.log(a.join(|) b.join(|) ? PASS : FAIL); console.log(origin:, a); console.log(restored:, b);说明这个对拍脚本只 mock 了console.log如果原代码里用了window、document、setTimeout需要把这些 API 也 mock 进沙箱否则两边都会报错导致对比没有意义。进阶一点的做法是用jsdom模拟浏览器环境跑完整的前端逻辑但成本高一般只在关键路径上做。第三道关是体积预期。还原后的代码比混淆代码大是正常的因为变量名变长了、无用的包装展开了。可以再压缩一次看量级npx terser restored.js -m -c restored.min.js ls -lh origin.js restored.js restored.min.js检查项方法合格标准语法正确node --check无报错行为一致vm 沙箱对拍输出输出完全一致体积量级还原后再次压缩对比压缩后体积接近原混淆前量级关键字符串grep 检索业务关键词字符串可读且完整这套验证流程也可以反过来用还原脚本每改一版就跑一遍对拍回归成本很低。我最早做反混淆时上来就写整套控制流还原结果连着三天跑出来的代码全不能在真实环境执行后来学乖了先做最小链路数组解密、属性归一、常量折叠这三样跑通验完再往里面加复杂规则。做 AST 还原最重要的不是一把梭而是每一步都留一个可执行的验证开关。这个方向值不值得投入看你的场景里混淆代码出现的频率如果每周都要碰花一个周末把最小链路搭起来后续收益很快就能回来。希望帮到你。本文还有配套的精品资源点击获取