AST反混淆JS还原工具2.2:从混淆代码到可读源码的实战指南
简介AST反混淆js还原工具2.2面向需要处理混淆JavaScript代码的前端开发者、逆向分析人员与安全研究人员核心目标是在尽量保持原文件可执行性的前提下让反混淆结果更接近源码的可读性。该版本基于丁仔大佬的js还原工具进行二次开发新增功能十余项并对已有功能做了优化与兼容性扩展同时修复了1.0版本遗留的错误与已知bug兼容更多混淆场景。资源包共9个文件以5个js脚本和4个md文档为主js文件承担反混淆主流程与配置逻辑md文档则记录功能说明、更新内容与待优化方向压缩包整体约40KB体积轻便易于集成。目前已有2542人学习下载适合希望快速上手AST反混淆、理解还原思路并在此基础上继续扩展的读者参考使用。1. AST 反混淆 js 还原工具 2.2从混淆代码到可读源码的那条路手上拿到一段被混淆过的 JavaScript变量名全是_0x4a2b字符串被拆成数组再靠下标取控制流被切成switch-case加死循环——这是做 js 逆向、js 反爬实战时最常见的开局。AST 反混淆 js 还原工具 2.220230203这个标题说的就是拿抽象语法树AST当手术台把这类混淆一层层剥掉还原出人能读、能改、能复用的源码。它解决的不是跑不跑得起来而是看不看得懂。适合谁做前端安全、爬虫对抗、第三方脚本审计的从业者以及被js 格式化之后依然一头雾水的同学。下面按是什么 → 怎么搭 → 怎么改 → 坑在哪讲透。2. 为什么必须用 AST 而不是正则去还原2.1 正则替换在混淆代码上为什么必然翻车很多人第一反应是写正则把_0x[a-f0-9]{4}全局替换成var1、var2。这个思路在小脚本上偶尔能跑通但一遇到真实混淆就崩。原因有三层。第一层混淆器会把字符串集中到一个大数组里取值靠_0x1234(0x1a)这种函数调用正则根本不知道0x1a对应数组里哪个元素替换出来的名字毫无语义。第二层标识符会重复出现在字符串字面量、注释、正则表达式里正则无差别替换会误伤把user_0x4a2b这种业务字符串也改掉。第三层控制流平坦化把顺序执行的代码打散成状态机正则改不动结构改完还是看不懂。AST 的思路完全不同它先把源码解析成树每个节点有明确类型Identifier、CallExpression、StringLiteral……你在树上按类型精确匹配、精确改写最后再生成代码。改的是结构不是文本所以不会误伤也能处理控制流。2.2 一套可复用的还原流水线长什么样我一般把还原拆成固定几段顺序不能乱解析用解析器把源码转成 AST同时保留loc位置信息方便回溯。规范化统一处理js 格式化把压缩成一行的代码展开方便后续定位。字符串还原把字符串数组 取值函数还原成字面量。标识符重命名给_0x开头的变量、函数起可读名字。控制流还原把平坦化的switch-case状态机还原成顺序结构。死代码消除删掉永远不执行的分支和未引用变量。生成把 AST 重新打印成代码再做一次格式化。这条流水线的每一步都对应一类混淆手法工具 2.2 版本通常就是把这几步封装成可配置的插件。理解这条线比记住某个工具按钮重要得多。2.3 最小可跑环境装依赖、解析第一段代码先搭环境。核心依赖是解析和生成库社区主流是babel/parser、babel/traverse、babel/generator、babel/types这一套。装好之后先跑通解析 → 打印确认环境没问题。# 初始化项目并安装 AST 三件套 npm init -y npm install babel/parser babel/traverse babel/generator babel/types// parse-demo.js把一段混淆代码解析成 AST 并统计节点类型 const parser require(babel/parser); const traverse require(babel/traverse).default; // 一段典型的字符串数组混淆片段 const code var _0x1a2b [log, hello, world]; function _0x3c4d(i) { return _0x1a2b[i]; } console[_0x3c4d(0)](_0x3c4d(1) _0x3c4d(2)); ; // sourceType 用 script混淆代码一般不是 ESM const ast parser.parse(code, { sourceType: script }); const counter {}; traverse(ast, { enter(path) { counter[path.node.type] (counter[path.node.type] || 0) 1; } }); console.log(counter);逻辑说明parser.parse把字符串变成 ASTtraverse深度优先遍历每个节点path.node.type是节点类型。跑完你会看到Identifier、CallExpression、StringLiteral等类型的数量分布这个分布就是判断混淆类型的第一个信号——如果StringLiteral集中在数组里、CallExpression大量出现且 callee 是同一个函数名基本可以确定是字符串数组混淆。参数说明sourceType选script还是module会影响import/export的解析混淆代码九成是script如果解析报错先试sourceType: unambiguous让解析器自己判断。plugins里按需加jsx、typescript不加会解析失败。3. 字符串数组还原把 _0x 取值函数打回字面量3.1 字符串数组混淆的三种常见形态字符串数组混淆不是一种是三种处理方式不同形态特征还原难度静态数组数组元素全是字面量取值函数只做下标访问低直接替换位移数组数组在初始化后被push/splice打乱顺序中要先还原顺序加密数组数组元素是密文取值函数里带解密逻辑高要提取解密函数工具 2.2 一般能自动识别前两种第三种需要手动补解密逻辑。判断方法看取值函数体如果只有return arr[i]就是静态如果有arr.push或arr arr.slice(...)就是位移如果有atob、decodeURIComponent或自定义异或运算就是加密。3.2 用 Babel 插件还原静态字符串数组下面这段代码把数组 取值函数还原成字面量。核心是两步先收集数组内容再把所有_0x3c4d(数字)调用替换成对应字符串。// restore-string.js还原静态字符串数组 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 _0x1a2b [log, hello, world]; function _0x3c4d(i) { return _0x1a2b[i]; } console[_0x3c4d(0)](_0x3c4d(1) _0x3c4d(2)); ; const ast parser.parse(code, { sourceType: script }); // 第一步找到字符串数组和对应的取值函数名 let strArray null; let getterName null; traverse(ast, { VariableDeclarator(path) { const { id, init } path.node; if (t.isIdentifier(id) t.isArrayExpression(init)) { // 数组元素全是字符串字面量才认 const allString init.elements.every(e t.isStringLiteral(e)); if (allString) { strArray init.elements.map(e e.value); // 记录数组变量名稍后找引用它的函数 path.stop(); } } } }); // 第二步找到取值函数确认它只做下标访问 traverse(ast, { FunctionDeclaration(path) { const { id, body } path.node; if (!t.isIdentifier(id)) return; const stmts body.body; if (stmts.length ! 1) return; const ret stmts[0]; if (!t.isReturnStatement(ret)) return; const arg ret.argument; // 匹配 return arr[i] 形态 if (t.isMemberExpression(arg) t.isIdentifier(arg.object)) { getterName id.name; } } }); // 第三步把所有 getter(数字) 调用替换成字符串字面量 if (strArray getterName) { traverse(ast, { CallExpression(path) { const { callee, arguments: args } path.node; if (t.isIdentifier(callee, { name: getterName }) args.length 1 t.isNumericLiteral(args[0])) { const idx args[0].value; if (idx 0 idx strArray.length) { path.replaceWith(t.stringLiteral(strArray[idx])); } } } }); } console.log(generate(ast).code);逻辑说明第一遍遍历找数组声明用every确认元素全是字符串避免误判业务数组第二遍找取值函数用函数体只有一条 return、return 的是arr[i]这个特征锁定比按函数名匹配更稳因为混淆器会随机化函数名第三遍把所有调用替换成字面量replaceWith会同时更新父节点引用不会留下悬空节点。参数说明path.stop()在找到第一个数组后停止遍历避免大文件里遍历过久idx 0 idx strArray.length是边界保护混淆代码里偶尔有越界下标不判断会拿到undefined污染结果。如果数组是位移型需要在第一遍之后加一步模拟执行 push/splice还原顺序这一步建议用vm模块沙箱跑不要手写模拟。3.3 位移数组怎么还原顺序位移数组的典型写法是数组声明后紧跟一个自执行函数里面用while循环配合push和shift打乱顺序。还原方法是把这段自执行函数单独抽出来在沙箱里执行拿到执行后的数组再替换。// restore-shift.js用 vm 沙箱还原位移数组 const vm require(vm); // 从 AST 里提取出的数组声明 打乱函数源码 const shuffleCode var arr [a, b, c, d]; (function(a, n) { while (--n) { a.push(a.shift()); } })(arr, 3); arr; ; // 在沙箱里执行最后一行表达式的值就是还原后的数组 const result vm.runInNewContext(shuffleCode, {}, { timeout: 1000 }); console.log(result); // [d, a, b, c]逻辑说明vm.runInNewContext在独立上下文里执行代码最后一行arr;作为表达式返回结果。这样不用手写push/shift的模拟逻辑混淆器怎么打乱沙箱就怎么还原准确率最高。参数说明timeout: 1000是必须的混淆代码里可能有死循环不设超时会卡死进程沙箱上下文传空对象{}不要传global否则混淆代码可能污染主进程。如果打乱函数里引用了外部变量需要把这些变量也注入沙箱。4. 标识符重命名与控制流还原让代码重新像人写的4.1 标识符重命名的命名策略字符串还原后代码里还剩一堆_0x4a2b变量名。重命名的难点不是改名字是起什么名字。我的策略是分三类函数调用如果函数体里出现console、document、window等宿主对象按行为命名比如logWrapper、domQuery。变量按初始化表达式类型命名init是ObjectExpression就叫configObj是ArrayExpression就叫listArr。参数按在函数体里的用途命名参与运算的叫numArg参与字符串拼接的叫strArg。不要追求语义完美能区分就行。工具 2.2 一般提供按类型前缀 递增序号的默认策略够用。// rename.js给 _0x 开头的标识符批量重命名 const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types); const ast parser.parse( function _0x1a2b(_0x3c4d) { var _0x5e6f _0x3c4d 1; return _0x5e6f; } , { sourceType: script }); let counter 0; const renamed new Map(); traverse(ast, { Identifier(path) { const name path.node.name; // 只处理 _0x 开头的且跳过属性名obj._0x1 不该改 if (!/^_0x[0-9a-f]$/i.test(name)) return; if (path.parentPath.isMemberExpression() path.key property) return; if (!renamed.has(name)) { renamed.set(name, var_${counter}); } path.node.name renamed.get(name); } }); console.log(generate(ast).code);逻辑说明用Map保证同一个原名映射到同一个新名避免_0x1a2b在不同位置被改成不同名字导致引用断裂。跳过MemberExpression的property位置是因为obj._0x1里的_0x1是属性名改了会破坏对象结构。参数说明正则/^_0x[0-9a-f]$/i里的i是忽略大小写混淆器有时用大写十六进制如果你的目标代码里_0x前缀不同改这个正则即可。counter从 0 开始递增如果想按类型区分可以维护多个计数器。4.2 控制流平坦化还原的基本思路控制流平坦化把代码改成这样一个while(true)循环里面一个switch(状态变量)每个case执行一段原代码后修改状态变量跳到下一个case。还原的核心是追踪状态变量的取值序列把switch拆回顺序执行。思路分三步第一找到状态变量和它的初始值第二按case里的赋值语句推导执行顺序第三按顺序把case体拼成顺序语句。这一步比字符串还原复杂得多工具 2.2 通常只处理线性平坦化状态变量每次只加固定值遇到条件平坦化状态变量依赖运行时判断就还原不了需要人工介入。// flatten-demo.js识别线性平坦化的 switch 结构 const parser require(babel/parser); const traverse require(babel/traverse).default; const t require(babel/types); const ast parser.parse( var state 0; while (true) { switch (state) { case 0: console.log(a); state 1; break; case 1: console.log(b); state 2; break; case 2: return; } } , { sourceType: script }); traverse(ast, { SwitchStatement(path) { const cases path.node.cases; // 检查每个 case 是否以 state 常量; break; 结尾 const linear cases.every(c { const stmts c.consequent; if (stmts.length 2) return false; const last stmts[stmts.length - 1]; const prev stmts[stmts.length - 2]; return t.isBreakStatement(last) t.isExpressionStatement(prev) t.isAssignmentExpression(prev.expression); }); console.log(线性平坦化:, linear, 分支数:, cases.length); } });逻辑说明这段代码只做识别不做还原。判断标准是每个case末尾都是break倒数第二条是赋值语句。识别出来之后才能按赋值的目标值排序case再拼接。真正的还原需要构建状态转移图代码量在两百行以上建议直接读工具 2.2 的controlFlow插件源码不要从零写。参数说明cases.length是分支数超过 50 的平坦化建议放弃自动还原人工成本更低linear为false说明有条件分支需要先做常量折叠再判断。4.3 死代码消除与未引用变量清理还原到最后一步代码里会剩下一堆永远不执行的分支和没人引用的变量。清理方法是先做一次作用域分析统计每个标识符的引用次数引用为 0 的声明直接删再对if语句做常量折叠条件恒为false的分支删掉。// dce.js删除未引用变量 const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const ast parser.parse( var unused 1; var used 2; console.log(used); , { sourceType: script }); traverse(ast, { Program(path) { // scope.globals 里是全局绑定检查每个绑定的引用次数 Object.keys(path.scope.globals).forEach(name { const binding path.scope.globals[name]; if (binding.references 0) { binding.path.remove(); } }); } }); console.log(generate(ast).code);逻辑说明path.scope.globals拿到全局作用域的所有绑定binding.references是引用次数为 0 说明没人用binding.path.remove()删掉声明。Babel 的 scope 系统会自动处理引用计数不用自己遍历。参数说明只删references 0的不要删constantViolations非空的那说明变量被重新赋值过可能有副作用。删除前建议先generate一份备份删错了能回滚。5. 避坑与排查还原过程中最容易翻车的五件事5.1 解析阶段就报错定位不到行号现象parser.parse抛SyntaxError但报的行号是压缩后的行号整段代码就一行根本找不到位置。原因混淆代码压缩成一行解析器报的行号没有意义另外有些混淆器会插入非法字符或故意破坏语法。解决先用js 格式化工具如prettier把代码展开成多行再解析报错行号就能对上。如果格式化也失败用parser.parse的errorRecovery: true选项让解析器跳过错误继续先拿到部分 AST。5.2 字符串还原后代码反而更长现象还原完字符串代码行数翻倍可读性没提升。原因把_0x3c4d(0)替换成log后原本紧凑的调用变成console[log](...)方括号访问比点访问长另外数组声明和取值函数没删成了死代码。解决字符串替换后加一步方括号转点访问把console[log]改成console.log再跑一遍死代码消除把数组和取值函数删掉。这两步做完代码才会真正变短。5.3 重命名后引用断裂运行报 undefined现象重命名后代码能生成但一跑就报某个变量 undefined。原因混淆器会用同一个名字表示不同作用域的变量你的Map按名字全局映射把两个不同变量改成了同一个新名或者把属性名也改了。解决重命名必须基于作用域不能用全局Map。用path.scope.rename(oldName, newName)让 Babel 按作用域处理它会自动区分不同作用域的同名变量。属性名判断用path.parentPath.isMemberExpression() path.key property跳过。5.4 控制流还原后逻辑顺序错乱现象switch拆成顺序语句后执行结果和原代码不一致。原因状态变量的赋值不是简单的state 常量而是state state 3或state fn(state)你按case顺序拼接没按实际状态转移顺序。解决还原前先模拟执行状态变量记录case的实际访问顺序按访问顺序拼接而不是按case的字面顺序。如果状态转移依赖运行时值放弃自动还原手动标注。5.5 沙箱执行位移数组时卡死现象vm.runInNewContext跑了很久不返回进程 CPU 占满。原因混淆器在打乱函数里埋了死循环或者while(--n)的n是负数导致无限循环。解决vm.runInNewContext必须传timeout单位毫秒建议 1000 到 3000同时给沙箱注入一个假的console避免混淆代码往真实控制台刷日志。如果超时说明这段打乱逻辑有反调试需要先 patch 掉再执行。6. 进阶把还原流程固化成可复用的 CLI 与验证习惯做到这里你已经能手动跑通整条流水线。但每次拿到新样本都手敲一遍不现实我的习惯是把它固化成 CLI参数化输入输出再配一套验证方法。先看 CLI 的骨架用commander收参数把前面几段还原逻辑串起来// cli.js还原工具的命令行入口 const fs require(fs); const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types); function restore(code, options) { const ast parser.parse(code, { sourceType: script, errorRecovery: true }); // 按开关依次执行还原步骤 if (options.string) { // 字符串数组还原见第 3 章逻辑 } if (options.rename) { // 标识符重命名见第 4 章逻辑 } if (options.dce) { // 死代码消除见第 4 章逻辑 } return generate(ast, { comments: true, // 保留注释方便对照 compact: false, // 不压缩输出可读 retainLines: false }).code; } // 用法node cli.js input.js output.js --string --rename --dce const [,, input, output] process.argv; const code fs.readFileSync(input, utf8); const result restore(code, { string: true, rename: true, dce: true }); fs.writeFileSync(output, result, utf8); console.log(还原完成输出到, output);逻辑说明restore函数把各步骤用开关控制方便对比只还原字符串和全还原的差异。generate的comments: true保留原注释compact: false输出多行这两个参数是给人工阅读用的不要为了省事设成压缩。参数说明errorRecovery: true让解析器遇到错误继续适合处理有轻微语法问题的样本retainLines: false是因为还原后行号已经对不上保留反而干扰阅读。CLI 参数建议用--string、--rename、--dce三个开关而不是一个--all方便定位是哪一步出的问题。再说验证。还原完不能只看代码变好看了要验证行为一致。我的做法是把原混淆代码和还原后代码分别在沙箱里跑喂同一组输入对比输出。如果输出一致说明还原没改变语义如果不一致说明某一步改错了回退到上一步逐步排查。// verify.js对比还原前后的执行结果 const vm require(vm); function run(code, input) { const sandbox { input, output: null, console }; vm.runInNewContext(code, sandbox, { timeout: 2000 }); return sandbox.output; } const original require(fs).readFileSync(input.js, utf8); const restored require(fs).readFileSync(output.js, utf8); const cases [1, 2, 3, 100]; cases.forEach(n { const a run(original, n); const b run(restored, n); console.log(input${n}, a b ? PASS : FAIL: ${a} vs ${b}); });逻辑说明run在沙箱里执行代码把结果写到sandbox.output对比两次结果。cases覆盖边界值0、负数、大数不要只测一个正常值。参数说明timeout: 2000防止死循环沙箱里注入console是为了让代码里的日志能输出方便调试。如果原代码有网络请求或 DOM 操作沙箱里跑不了需要 mock 掉这些依赖再验证。最后说一个我踩过的坑不要迷信一键还原。工具 2.2 能自动处理静态字符串数组和线性平坦化但遇到加密数组、条件平坦化、反调试就歇菜。我的习惯是先用工具跑一遍看哪一步输出不对再针对那一步手写插件。还原的本质是理解混淆器的套路工具只是把重复劳动自动化。每次拿到新样本先花十分钟看它的混淆特征比直接上工具快得多。希望帮到你。本文还有配套的精品资源点击获取