AST反混淆JS还原工具2.2实战:Babel管线解析与控制流平坦化还原 简介这是一款面向JavaScript代码安全分析与逆向调试场景的AST反混淆还原工具基于丁仔大佬的还原工具进行二次开发新增功能达十余项并对原有功能做了兼容性优化与错误修复。工具版本2.2重点服务于OB混淆JS代码的还原需求可在尽量保证原文件可执行性的前提下将还原结果提升到接近源码的可读水平适合前端开发、爬虫逆向与安全研究人员使用。资源包采用ZIP压缩共9个文件核心为5个JavaScript脚本涵盖主还原流程、辅助函数、配置及演示用例另附4个Markdown文档分别记录更新说明、功能文档与待优化功能。脚本与文档搭配清晰压缩整包仅40KB结构精简、便于快速上手目前已有2542人学习下载。配合demo示例可直观验证还原效果是JS反混淆入门及日常逆向调试的实用工具。1. AST 反混淆 js 还原工具 2.2 是在解决什么问题从一段压缩到 20 行的脚本说起从某个站点拉回一段 JS打开一看二十行挤成一行变量全是_0x开头的十六进制名一个字符串数组加中转函数负责解出全部明文。想读业务逻辑先得把这个黑匣子拆开——这正是 AST 反混淆 js 还原工具 2.220230203 构建的主战场。它用 babel 把脚本解析成抽象语法树再按混淆器的套路逐层还原字符串数组、常量折叠、控制流平坦化、表达式美化最后产出一份能打断点、能改、能直接跑的代码。这版工具适合正在做 js 逆向、前端风控代码审阅和 js 反爬实战的人也适合想自己搭一条还原管线的同学当骨架参考。2. 还原工具的核心管线用 babel 把混淆 JS 拆成三层来处理这类工具架构相当固定parse 成 AST遍历节点做替换再 generate 回代码。2.2 版的底座就是 babel/parser、babel/traverse 和 babel/generator 三个包自己只写还原插件。选 babel 而不是自己写 parser 的理由很实际混淆产物语法不标准、容错要求高babel 的 errorRecovery 和插件生态能省下一半工作量traverse 自带的 path.evaluate() 还能直接做常量折叠不用自己实现表达式求值。管线顺序对还原质量影响很大2.2 固定成五步字符串数组还原 - 常量折叠与去死代码 - 控制流平坦化还原 - 表达式还原 - 输出美化。这个顺序不要乱动下面每节会把每一步为什么排在这个位置、参数怎么设、失败时看什么说清楚。2.1 解析阶段的容错配置parser 参数决定了还原下限解析这步看着不起眼但参数没配好后面所有插件的输入就是残缺的。我一般这样初始化const parser require(babel/parser); const ast parser.parse(obfuscatedCode, { sourceType: script, // 有 import/export 才改成 module allowReturnOutsideFunction: true, // 混淆包经常有顶层 return不放开直接 SyntaxError allowAwaitOutsideFunction: true, // 部分异步产物在非 async 作用域里写 await errorRecovery: true, // 单点语法错误不中断整批解析能收多少收多少 plugins: [bigInt, optionalChaining, nullishCoalescingOperator] });四个参数对应四类真实样本特征。sourceType 选错最常见的报错是 import outside module 和 with in strict mode前者是把 script 包当成 module 传了后者反过来判断标准很简单代码里有没有顶层的 import/export 语句没有就保持 script。allowReturnOutsideFunction 是给那种把整个逻辑塞进一个立即执行函数、却在最外层写 return 的产物准备的不放开就会在 parse 阶段丢整段代码。errorRecovery 是容错下限它不会修复语法只是把出错节点标记成 Error 节点继续往下走代价是丢掉的片段在还原后可能不完整。解析后我习惯先把 parser 返回的errors数组打印出来确认没有大面积语法错误再进下一步。如果错误超过 10 处说明样本不是标准混淆产物可能是语法压缩或从 HTML 里内联出来的脚本碎片这时候先做一次轻量修复——用正则把模板字符串里的反引号冲突处理好再重新 parse。这一步虽然土但在真实抓包样本里命中率很高。2.2 字符串数组还原定位数组、预计算解码函数、替换调用点字符串数组是最基础的混淆手段所有字符串集中到一个数组代码里通过_0x2b1f(0x12)这种调用取回明文。还原思路是把解码函数当成纯函数执行一遍用结果替换调用点不关心它内部是 charAt 拼接还是 radix 解码。常见做法是把解码函数克隆进一个 vm 沙箱里跑比静态分析函数体可靠得多const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types); const vm require(vm); function restoreStringArray(ast, cfg) { // cfg { arrayName, decodeName, arrayValues }由前置识别步骤给出 const sandbox { __arr: cfg.arrayValues }; vm.createContext(sandbox); // 从 AST 里克隆解码函数声明序列化成源码 let fnSource ; traverse(ast, { FunctionDeclaration(path) { if (path.node.id path.node.id.name cfg.decodeName) { fnSource generate(path.node).code; path.stop(); } } }); // 把函数体里对原数组变量名的引用全部换成沙箱内的 __arr vm.runInContext(fnSource.split(cfg.arrayName).join(__arr), sandbox); // 替换所有调用点只处理参数是字面量的嵌套调用交给下一轮 traverse(ast, { CallExpression(path) { const callee path.node.callee; if (!t.isIdentifier(callee) || callee.name ! cfg.decodeName) return; const args path.node.arguments; if (args.some(a !t.isLiteral(a))) return; const callSrc ${cfg.decodeName}(${args.map(a a.raw || JSON.stringify(a.value)).join(,)}); const decoded vm.runInContext(callSrc, sandbox); path.replaceWith(t.stringLiteral(String(decoded))); } }); }逻辑说明解码函数对工具来说是黑匣子执行它比分析它快且准。把原数组变量名整体替换成__arr是为了绕开解码函数内部对原全局变量的闭包依赖避免沙箱里变量名冲突。如果函数体里引用了Date.now()这类环境值替换会失真所以替换前先判断解码函数体里有没有非字符串运算符有就退回静态分析这个判断在真实混淆样本里命中率大约七成。参数说明cfg.arrayValues 必须在提取时把数组定义之后的reverse()、unshift()等变更语句一并执行掉否则后面每个调用点取到的都是初始值整段还原全错这是这个插件里最容易翻车的地方5.1 会展开讲。嵌套调用_0x2b(_0x1a(0x11))单遍处理不掉因为外层调用参数当时还不是字面量解决办法是让「字符串还原 常量折叠」组成循环跑 3 到 5 轮直到某轮替换数为 0 再退出。提示vm 只是执行环境不是安全沙箱。跑陌生解码函数前先确认函数体里没有require、process、globalThis这类访问必要的时候在vm.runInContext上加个 2 秒超时兜底。2.3 常量折叠与死代码剔除还原的第一轮瘦身字符串还原后代码里会冒出一堆_0x1f * 2、0x2b 0x11这样的表达式还有因为字符串被替换而变成恒真或恒假的条件分支。这一轮把它们折掉traverse(ast, { BinaryExpression(path) { const { confident, value } path.evaluate(); if (confident typeof value number) { path.replaceWith(t.valueToNode(value)); } }, IfStatement(path) { const test path.get(test); const { confident, value } test.evaluate(); if (!confident) return; if (value) { path.replaceWith(path.node.consequent); } else if (path.node.alternate) { path.replaceWith(path.node.alternate); } else { path.remove(); } } });逻辑说明path.evaluate() 是 babel-traverse 内置的表达式求值能力基于节点类型推导字面量能处理加减乘除、位运算、三元表达式等。它返回{ confident, value }confident 为 false 就代表它不确定这时必须跳过——这正是我们要的行为宁可少折不可错折。参数说明上面只折数字字符串拼接也可以折但a obj这种带隐式转换的不要强折IfStatement 分支只处理 test 能算出真值的if (a null)这类求不出来的保持原样。死代码剔除就藏在 IfStatement 替换里——折叠出 false 后 alternate 被顶上来原本永远不会执行的 consequent 自动丢掉了。执行上下文不同导致的坑也要在这轮兜住折叠时如果遇到typeof undeclaredVar这类结果依赖环境的操作babel 会保守地返回不 confident保留原节点这是正确的方向。顺序上的讲究常量折叠放在这里是为了给下一步控制流平坦化做铺垫。平坦化的 switch 分发值经常写成_0x9f 0x1、_0x9f ^ 0x2a这种不先折叠成字面量下一步的 collectFlow 就认不出状态转移边。不少人先做控制流后做折叠结果 switch 全部分发值都是表达式一步就翻车。3. 控制流平坦化还原从 switch-case 分发器回到线性代码控制流平坦化是混淆还原里收益最大、也最容易还原错的一步。它把原本顺序执行的代码切成小块塞进一个while(true) { switch(状态变量) {...} }里每个 case 结尾再把状态变量赋成下一个块的值。还原目标就是顺着状态变量的流转把代码块重新排成顺序语句。3.1 识别分发器与状态变量三种典型特征识别是否平坦化我只看三个特征全部命中基本可以确定特征判断方法还原含义循环体只有 switchWhileStatement 的 test 是布尔字面量 truebody 是单个 BlockStatement 且只有一条 SwitchStatement基本可以认定是平坦化分发器case 结尾是状态赋值加 breakswitch 分支最后一条有效语句形如_0x9f 字面量; break;这句赋值就是状态转移边case 里出现 continuecontinue 会回到 while 顶部重新执行 switch代表这里有循环不能直接展平第一个特征用来过滤普通 while 循环第二个特征用来建状态转移表第三个特征决定能不能展平。入口状态怎么找常见做法是找逻辑上最先被执行的那个 case——多数混淆器会把第一个 case 写成 switch 的 default或者把首个状态值设成数字 1 或字符串 0x0 开头。拿不准时就从 default 分支开始走走不通就逐个 case 试入口。3.2 按状态流转重组语句一个可落地的 visitor 写法识别之后就是重组。核心是把「状态值 - 语句块 - 下一状态」建成一张有向图从入口做一次深度优先展开遇到回边就说明有循环直接降级保留原结构function deFlatten(ast, opts { maxCases: 2000 }) { traverse(ast, { WhileStatement(path) { const test path.get(test); if (!test.isBooleanLiteral({ value: true })) return; // 不是 while(true) 跳过 const body path.get(body); if (!body.isBlockStatement() || body.node.body.length ! 1) return; const switchStmt body.get(body.0); if (!switchStmt.isSwitchStatement()) return; if (switchStmt.node.cases.length opts.maxCases) return; // 超大样本降级 // 这里假定 discriminant 是 Identifier如 switch(_0x9f) const stateName switchStmt.node.discriminant.name; const flowMap collectFlow(switchStmt, stateName); // 状态 - { body, next } const linear expandToLinear(flowMap); // 深度优先展开 if (!linear) return; // 有回边/冲突保留原节点 path.replaceWithMultiple(linear); } }); }逻辑说明switchStmt.node.discriminant.name直接给出状态变量名collectFlow 遍历 case 时把每个 case 里最后一条_0x9f x剥出来当 nextbreak表示出口continue表示回边。expandToLinear 用 visited 集合做展开展开过程中如果 next 指向已经访问过的状态说明这是循环返回 null外层判断后保留原 while 节点不还原。参数说明maxCases 用来兜内存真实样本里 case 数上千的不少展开成顺序代码后体积会成倍增长超过阈值直接不处理比爆内存强。这三个辅助函数合起来大概一百二十行核心逻辑就一句把 case 块按 next 指针排成链表。如果 discriminant 不是 Identifier而是_0x9f[x]这类表达式先回 2.3 把那一步折叠补齐再来。3.3 参数与降级策略循环、嵌套分发、状态变量被加密三种样本需要降级处理。第一种是 case 里出现 continue 的回边结构正确做法是把整套 while-switch 保留为循环只在循环体内还原语句块不要试图展平。第二种是嵌套平坦化——外层分发器套内层分发器2.2 的做法是先用一个队列收集所有可还原的 WhileStatement先处理内层再处理外层顺序反了内层结构乱掉外层就白还原了。第三种是状态变量被加密赋值写成_0x9f ^ 0x2a或_0x9f 1这种这种情况下先确认 2.3 的常量折叠已经跑过折叠后还是变量就让 collectFlow 对 next 也做一次 path.evaluate()求不出字面量就放弃该样本不要猜。提示遇到回边就整体保留是 2.2 的默认策略。宁可少还原一段也不生成一段行为不一致的假还原这是控制流插件的底线。还原完先用肉眼扫一遍以前全是while(true){switch}的地方现在应该是连续的函数调用和赋值语句如果还原后外面还套着空 while说明某个 case 没有被展开多半是 next 收集漏了回 collectFlow 里看是不是有赋值语句被当成了普通语句。4. 表达式还原与结果输出把中括号 key 和乱码中文一并修好前三步做完代码已经能跑、逻辑已经线性但可读性还是不达标属性访问全是a[_0x2b]中文全被转义成\uXXXX变量名依然全是_0x头。这一步的任务是让人能读懂而不是让机器跑得更快。很多人做 js 逆向时想绕开 js 判定真正落地的第一步永远是先把混淆代码还原成能下断点、能看变量值的明文读不懂后面全是白费功夫。4.1 成员表达式还原computed 转点号的边界条件a[b]转成a.b看起来是纯语法替换但有几个边界必须守住否则行为会变traverse(ast, { MemberExpression(path) { const { computed, property } path.node; if (!computed || !t.isStringLiteral(property)) return; const key property.value; if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) return; // 不是合法标识符不转 if ([__proto__, constructor, prototype].includes(key)) return; // 危险 key 保留 if (path.node.optional /^\d$/.test(key)) return; // a?.[0] 不转 path.node.computed false; path.node.property t.identifier(key); } });逻辑说明正则把不符合变量命名规则的 key 挡在门外a[0]这种数字 key 转成a.0直接就是语法错误所以也要挡。危险 key 那条是血泪经验——a[__proto__]和a.__proto__在赋值场景语义不同中括号形式走普通属性写点号形式会触发原型 setterconstructor和prototype牵涉原型链能不动就不动。参数说明这个 visitor 不依赖运行时信息可以对整棵 AST 无脑跑但它只改 computed 标志不改 object 的求值顺序所以不会引入副作用。变量名美化比属性还原难得多常见做法是别动它。2.2 默认保留_0x原名另输出一份变量出现次数排序表帮人肉在还原报告里定位热点函数硬要重命名的话优先重命名每个函数体内的局部变量永远不要全局重排——作用域一交叉就是一场灾难。4.2 generator 输出与中文转义最后一步别毁掉还原成果生成代码这一步翻车率远比想象高常见输出全是\u4e00\u5fc3等于还原了个寂寞const generate require(babel/generator).default; const result generate(ast, { compact: false, // 不要压缩保留换行和缩进 comments: true, // 混淆器注释里偶尔藏着 sourceMappingURL 线索 jsescOption: { minimal: true }, // 关键中文和可打印字符不要转成 \uXXXX retainLines: false // 需要对照原文件行号时才开 }, obfuscatedCode);逻辑说明混淆器把中文存成\u4e00\u5fc3字符串数组还原后拿到的还是转义形态generator 默认会根据 ASCII-only 规则再转义一次输出全成\u串。jsescOption.minimal: true的意思是只转义必要字符中文、标点、emoji 尽量原样输出——这一步不设前面所有还原在阅读体验上直接归零。参数说明comments 建议保留部分混淆产物会在注释里留版本号或函数入口的提示retainLines 平时关掉只有在对原文件行号排查时才开开了之后输出格式会明显变差。4.3 还原报告用三个指标判断还原是否到位还原效果不能靠感觉我会在每次处理后生成一份量化报告function report(ast) { let hexName 0, stringArrayCall 0, totalIdent 0; traverse(ast, { Identifier(path) { totalIdent; if (/^_0x/.test(path.node.name)) hexName; }, CallExpression(path) { if (t.isIdentifier(path.node.callee) /^_0x/.test(path.node.callee.name)) { stringArrayCall; } } }); return { totalIdent, hexName, stringArrayCall, whileTrue: countWhileTrue(ast) // 统计 while(true) 分发器残留 }; }逻辑说明三个指标分别对应三个还原目标——十六进制变量名占比、字符串数组调用残留、while(true) 分发器残留。我一般以 hexName 占总标识符比例小于 5%、stringArrayCall 为 0、whileTrue 相比原始样本大幅减少作为「还原到位」的及格线。参数说明这套报告每次处理都落盘成 JSON版本迭代时直接 diff 两个 JSON能看出一次改动到底提升了什么、有没有把以前还原好的样本弄坏。这个习惯帮我挡住了好几次「新插件修了 A 样本、坏了 B 样本」的回归。5. AST 反混淆还原工具避坑最容易翻车的 5 个现场2.2 这版20230203里大部分改动都不是加新还原规则而是在修这 5 个坑。每一条按现象、原因、解决写方便你对照自己的样本排查。5.1 字符串数组还原三个高频现场现场 1还原后到处报 _0xabc is not defined。现象替换完 decode 调用一运行就 ReferenceError报错的变量正是数组名。原因数组定义在某个 IIFE 里而 decode 调用在外层作用域提取时拿不到顶层定义或者数组定义之后被_0xabc.reverse()、_0xabc.unshift(...)改过内容提取时拿到的是初始值。解决提取数组前先扫描「定义之后、首次 decode 调用之前」这段区间收集所有对数组的变更调用在沙箱里按顺序执行一遍再取值拿不准时干脆保留原数组声明只替换 decode 调用点不做整体内联——内联是优化不是必需别为了它牺牲正确性。现场 2字符串还原后全是转义叠罗汉。现象中文字符变成双重转义或纯\u串。原因解码回来的是转义字面量替换节点时用了path.replaceWithSourceString()它会把源文本里的一层转义带进新节点再到 generator 又转一次。解决一律用t.stringLiteral(value)构造节点value 直接传解码后的明文再配合 4.2 的jsescOption.minimal两处同时改才干净。这个坑我至少踩过三次每次都是两行配置的事。现场 3一层还原完还有一层 _0x 调用。现象跑完字符串还原代码里仍有 decode 调用而且参数变成了另一组字面量。原因嵌套调用_0x2b(_0x1a(0x11))单遍 visitor 只处理参数是字面量的调用点外层调用要等内层替换完才满足条件。解决把「字符串还原 常量折叠」包成一个循环跑 3 到 5 轮直到某一轮替换数为 0 再退出。这比写递归逻辑省事也不容易栈溢出。5.2 控制流还原与产物输出两个现场现场 4展平后代码能跑但循环次数不对。现象行为对比时发现某个循环少跑了几次或者直接死循环。原因case 尾部是continue而不是break。continue 的语义是回到 while 顶部重新分发展平后这条边被当成了顺序下一块循环就丢了。解决在 collectFlow 里显式区分 break 出口和 continue 回边遇到回边就把整套 while-switch 保留为循环只还原内部语句块。简单说只展平无回边的状态图有回边一律降级。现场 5还原后体积暴增、中文乱码。现象一个 500KB 的混淆包还原出来变成 8MB打开全是\u和展开的大数组。原因字符串还原把所有调用点都内联了数组本身体积没减加上重复字符串爆炸成多份中文乱码就是 generator 默认转义。解决内联加阈值maxInlineStringLength 12这类配置——短字符串直接替换长字符串保留arr[idx]访问形式乱码按现场 2 的双处修复处理。提示还原工具必须有降级机制。遇到吃不准的结构就保留原样输出一份 warn 列表比硬着头皮还原出一份错代码靠谱得多。6. 给还原工具 2.2 加一道行为对比双跑验证脚本6.1 用双跑脚本守住还原底线样本集与回归策略还原得再漂亮行为不一致就是废稿。我现在每个样本都要配一个「同一输入、原包与还原包双跑、输出逐字对比」的回归脚本这是 2.2 后期补上的、对我帮助最大的一道工序const vm require(vm); const assert require(assert); function runCapture(code, input) { const logs []; const sandbox { input, console: { log: (...a) logs.push(a.join( )) } }; vm.createContext(sandbox); vm.runInContext(code, sandbox, { timeout: 3000 }); // 超时防死循环 return logs.join(\n); } // 原包与还原包喂同一份输入输出必须完全一致 assert.strictEqual( runCapture(obfuscated, {a:1}), runCapture(restored, {a:1}) );逻辑说明timeout 是保命符——还原出错最常见症状就是死循环不兜底会把你的分析进程一起拖死。参数说明输入数据要挑能覆盖分支的纯字符串样本给一个 JSON 就够带业务逻辑的样本至少要覆盖正常路径和异常路径两条。样本集我固定放 5 个纯字符串数组型、平坦化加数组型、嵌套平坦化型、带 try/catch 型、带正则和模板字符串型。每次改完还原插件先把这 5 个全跑绿才敢碰新样本新样本过了再加进集子里。这套对比脚本是我做 2.2 这版时最后加上的因为肉眼看代码永远看不出行为差异机器对比才说了算。样本集越滚越大工具反而越改越谨慎——每一次插件改动都先过回归过了才敢往真实样本上放。配合 js 补环境这类场景还原产物要在 node 里独立跑通验证双跑脚本就是那个「能不能用」的裁判。希望帮到你。本文还有配套的精品资源点击获取