
简介这份AST反混淆JS还原工具2.0是针对JavaScript混淆代码的逆向分析辅助包专为JS逆向工程师与爬虫开发者设计可高效处理当前主流obfuscator.io混淆规则覆盖常见混淆还原场景辅助快速定位关键逻辑。压缩包共8个文件包含5个JavaScript核心脚本与3个Markdown说明文档包体仅54KB其中JS脚本覆盖配置解析、主还原逻辑与演示示例MD文档提供功能说明、更新记录与使用指引脚本与文档分工明确可快速定位所需模块整体轻量易用。该工具在丁仔开源还原方案基础上进行二次开发新增功能达10余项修复了1.0版本遗留错误并针对2022年4月20日最新版obfuscator.io规则做了专项适配同时优化原有功能兼容性新增三元表达式转if-else能力解决了常见作用域问题使还原后的代码更贴近人工书写习惯。资源包已有4602人学习浏览是处理JS混淆、提升前端逆向与爬虫分析效率的实用工具可配合逆向分析流程使用。1. 拿到一个加密混淆过的JS别再靠肉眼扒代码了AST反混淆到底在还原什么做 js 逆向和前端对抗的人手里多少都接过几个被人打包加密过的脚本。有些是用 OB 混淆器处理过的有些是自定义加密函数配控制流平坦化还有的是把整个逻辑揉进字符串数组里再拆散。这时候最怕的不是读不懂而是读了半天发现变量全叫_0x3f2a字符串全变成_0x4d[xyz]函数名也没了。AST反混淆js还原工具2.0.zip这类包解决的正是这个问题把压缩、改名、加密过的 JS 源码还原成能读、能改、能复用的近似源码。和正则是两套思路。正则看到的是“文本长什么样”AST 看到的是“代码怎么执行”。混淆改的是文本A ST 还原改的是结构所以对重命名、字符串拼接、控制流平坦化这类混淆手段AST 还原的效率比正则高一个量级。这篇文章不讲原理书直接按我平时在 js 逆向实战里用这套工具链的方式来讲适合已经能看懂混淆源码、想把“反混淆”做成稳定流程的人也适合刚接触 js 反爬、js格式化之后发现代码仍然难读的新手。这里没有银弹但有可复用的套路。2. 为什么 AST 反混淆是 js 逆向里的硬通货先弄清 AST 对 JS 做了什么2.1 混淆只改“怎么读”AST 幫你看的是“怎么执行”先还原一个常见误区混淆不是加密。混淆后的 JS 仍然可以直接被浏览器执行只是可读性差。常见的混淆手段就那几类标识符重命名function test()变成function _0x3f2a()、字符串拼接与数组取值hello变成_0x1234[c](he,llo)、数字加密0xff变成(0x1a, 0x0f, 0x05)的运算结果、控制流平坦化把if / else变成while switch、死代码注入。你去看这些手法的共同点它们全是在“文本表示”层面做手脚而代码的执行结果没有变。AST 全称是 Abstract Syntax Tree抽象语法树。JS 引擎在执行前会把源码经过词法分析变成 token再做语法分析生成 AST最后解释执行或编译成字节码。AST 还原工具做的事情是绕过“文本”这一层直接在这棵树上操作把_0x3f2a改回test把字符串拼接调用改回字面量把while switch改回条件分支。正则再强也做不到在不知道结构的情况下处理嵌套的大括号和括号运算符。也因此AST 方案的判断标准很硬还原后代码的执行结果必须和混淆前完全一致。正因为 AST 里保存的是“逻辑关系”而不是“排版格式”所以改名、合并、拆分这些改动只要不破坏求值顺序和作用域绑定还原就是安全的。这一点是老手和新手最大的分水岭新手盯着源码一行行看老手先让工具生成语法树再对树上节点做局部手术。2.2 工具 2.0 这类包的真实工作方式解析、变换、重新生成三步AST反混淆js还原工具2.0.zip 这个叫法是很多打包好的本地工具的总称。它的本质是一个可执行的 JS 处理链通常基于 Babel 的babel/parser、babel/traverse、babel/generator或者同类库如 acorn、esprima封装而成。2.0 相对 1.x 的意义一般体现在三处解析器支持更新的 ES2020 语法空值合并??、可选链?.不能只靠老版 babel 处理插件化架构允许你写自定义 visitor批量处理能力和异常恢复机制更稳不会因为某个文件语法错误就整体崩掉。整个还原流程固定是三步。第一步解析Parse读入源码字符串生成 AST。第二步变换Transform遍历 AST 节点按规则修改节点结构。第三步生成Generate把改好的 AST 重新输出成 JS 文本。这三步里第二步是可编程的你可以写自己的还原插件。比如我要去掉所有console.log就注册一个CallExpression的 visitor判断callee是不是console.log然后path.remove()。这也是它能对付 js 散度问题的原因不同混淆器生成的 AST 结构差异很大但只要你能把“某种混淆的特征”描述成“一个 visitor 规则”就能批量处理一类样本。很多人拿到工具 2.0 只会点一下“一键还原”然后抱怨结果不全其实是不理解这个三步模型。真正好用的还原工作流一定是基于自定义 visitor 的内置规则负责通用混淆自定义规则处理目标站点自己的独家褶皱。3. 把还原跑通以 AST 反混淆工具为模板的最小实践路径3.1 先判断你的 JS 文件值不值得还原识别四种主流混淆打法拿到一个混淆 JS别急着还原。先花三分钟做特征识别决定你要写哪几种 render 规则以及预期能还原到什么程度。我一般直接在终端先跑这段检测脚本// detect.js快速识别混淆特征帮助决定还原策略 const fs require(fs); const code fs.readFileSync(process.argv[2], utf-8); const features { hexVariable: /\b(_0x[0-9a-f]{4,6})\b/gi, // 16进制命名的变量 arrayString: /\[\s*[^]{1,20}\s*\]/g, // 字符串数组下标取值 whileSwitch: /\bwhile\s*\(\s*1\s*\)\s*\{/, // 控制流平坦化特征 evalOrFunction: /\beval\s*\(|\bnew\sFunction\s*\(/, // 动态执行特征 unicodeEscape: /\\u[0-9a-fA-F]{4}/g, // unicode转义 }; for (const [name, pattern] of Object.entries(features)) { const match code.match(pattern); console.log(name, match ? match.length : 0); }这段脚本的逻辑是拿正则做“数值体检”不是用正则做还原。参数上需要注意正则的贪婪性_0x[0-9a-f]{4,6}会匹配到 4 到 6 位十六进制数但真实的混淆变量名可能更长或带数字后缀。如果匹配数量异常大先怀疑是不是误伤了普通变量名再决定后续使用哪些替换规则。整数和字符串的内容不做检测因为它们不会干扰重命名策略的制定。根据检测结果你可以对号入座。全是_0x开头的变量名说明只是重命名混淆最易还原。命中arrayString且数值很高说明有字符串提取成数组需要还原字符串引用。命中whileSwitch属于控制流平坦化难度最高得另配专门的分析脚本。命中eval或者new Function本质上是运行时生成代码静态分析只能还原到“解密函数调用”这一步更深的逻辑要动态执行后才能拿到。3.2 从 ZIP 中拉出源码后用一条 AST 转换链完成初次还原工具解压后典型的做法是把目录放进一个 Node 项目。第一步先安依赖注意版本要匹配我用的是 babel 全家桶的 7.x 版本npm init -y npm install babel/parser babel/traverse babel/generator babel/types四个依赖各司其职parser 负责把源码字符串变成 ASTtraverse 负责递归遍历节点允许你注册 visitortypes 里的每个工厂函数都对应一种 AST 节点类型比如t.stringLiteral(hello)会返回一个字符串字面量节点generator 负责任务最后把 AST 生成回 JavaScript 代码。这里唯一要注意的是babel/traverse的默认导出方式在 7.x 版本中是const traverse require(babel/traverse).default直接require整个包拿到的是一个对象而不是函数很多新人在这一步翻车。接着写第一个还原脚本目标是把混淆后的变量名统一重命名为可读形式。这个脚本要解决的本质问题是混淆工具把变量名变成了_0x3f2a这种没意义的值我们要把它们改回有语义的名字同时不能破坏作用域绑定。// rename.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); const code fs.readFileSync(obfuscated.js, utf-8); const ast parser.parse(code, { sourceType: unambiguous, plugins: [jsx], }); const map new Map(); // 存的映射原混淆名 - 新语义名 let counter 0; traverse(ast, { // 这两类节点承载变量/函数声明先收集再看使用 Identifier(path) { const node path.node; // 跳过对象属性的 key避免误改属性名 if (path.parent.type MemberExpression path.parent.property node !path.parent.computed) { return; } if (path.parent.type Property path.parent.key node) { return; } // 跳过未绑定到当前作用域的引用比如全局 window if (!path.scope.hasBinding(node.name) node.name ! undefined) { return; } let name map.get(node.name); if (!name) { name v (counter); map.set(node.name, name); } path.node.name name; }, }); fs.writeFileSync(renamed.js, generate(ast, { compact: false }).code);这段代码里最关键的判断是那两条return。Object 的 key 和MemberExpression的非计算属性名虽然 Token 类型也是 Identifier但它们不是变量绑定不能替换。比如obj._0x3f2a里的_0x3f2a可能是后端下发的数据字段名改了就会破坏接口对接。path.scope.hasBinding这个调用是 Babel 给的作用域查询 API它会顺着当前节点的父级链去查绑定关系用来确认这个标识符确实是一个可改的变量。参数上compact: false一定要带否则生成器会把换行和缩进全删掉。运行这步后代码已经从_0x3f2a变成v0、v1可读性大幅提升。接下来的工作是把字符串从解密函数调用还原回字面量。3.3 字符串解密也交给 Visitor还原拼接与数组取值最常见的字符串混淆有两种打法一种是把字符拆开后再用函数拼接另一种是字符串数组加下标取值。前者的还原逻辑比较简单找到解密函数然后 hardcode 替换后者需要先把数组内容读出来再逐个替换引用。下面这段脚本兼顾了这两种情况而且只处理“可静态求值”的调用// deobfuscate-string.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); const code fs.readFileSync(renamed.js, utf-8); const ast parser.parse(code, { sourceType: unambiguous }); // 第一步把字符串数组的定义收集起来var arr [a,b,...] let strArray null; traverse(ast, { VariableDeclarator(path) { const init path.node.init; if (init t.isArrayExpression(init) init.elements.every(el t.isStringLiteral(el))) { strArray init.elements.map(el el.value); path.stop(); // 只取第一个数组避免误伤 } }, }); // 第二步遇到 arr[i] 或 fn(xx,yy) 这类调用能用已知信息算就算 traverse(ast, { CallExpression(path) { const { callee, arguments } path.node; // 处理拼接函数_0x2ab(he,llo) - hello if (t.isIdentifier(callee) !path.scope.hasBinding(callee.name)) { const allStrings arguments.every(a t.isStringLiteral(a)); if (allStrings) { path.replaceWith(t.stringLiteral(arguments.map(a a.value).join())); } } }, MemberExpression(path) { // 处理字符串数组下标arr[3] - some const { object, property } path.node; if (t.isIdentifier(object) strArray t.isNumericLiteral(property)) { const index property.value; if (strArray[index] ! undefined) { path.replaceWith(t.stringLiteral(strArray[index])); } } }, }); fs.writeFileSync(deobfuscated.js, generate(ast, { compact: false, comments: true }).code);这里有两个参数值得记住。第一个是path.stop()它表示在遍历器里提前结束不再往下深入。我在这里只取第一个字符串数组是因为多数混淆器只用一个大数组存字符串第二个数组往往是注入的垃圾代码取了反而引入错误。第二个参数是generate里的comments: true默认情况下 Babel 会丢弃注释但对还原工作而言保留注释可以帮助你把“这一步是哪个混淆器的特征”记在代码里。如果你不想要这些注释改成false即可。写完这两段脚本一个最小可用的 AST 反混淆工具链就成型了先跑detect.js再跑rename.js最后跑deobfuscate-string.js。这三步只能处理最简单的情景真正生产环境还会再加控制流、布尔表达式、对象拼接等还原插件。但有了骨架之后后续所有插件都只是注册新的 visitor 而已这就是 2.0 版工具“插件化”的含金量所在。4. 避坑AST 反混淆常见翻车现场与排查套路4.1 还原后变量名撞车脚本报“Identifier already declared”现象rename 脚本跑完代码能生成但浏览器里执行直接报SyntaxError: Identifier v5 has already been declared。原因我前面给的 rename 脚本是一个简化版它只把“混淆名”统一重命名成v0、v1但没有考虑两个不同函数作用域里的_0x3f2a其实是两个互不相关的变量。当两个无关变量被重命名成v5它们会同时出现在同一个作用域链里触发重名冲突。解决换一种思路先收集整个文件里所有_0x开头的标识符用path.scope.getBinding(name)判断它们是否属于同一个 binding如果是同一绑定则统一命名否则使用不同的新名字。你还需要保留原始名称到新名称的映射Map而不是用counter直接递增。正确做法的核心是不要按“变量名字”去映射要按“变量绑定”去映射。Babel 的scope模块提供了getOwnBinding和getBinding两个 API前者只查当前作用域后者会往上找。修改时可优先使用path.scope.rename(oldName, newName)这个 API 会把所有引用一并更新避免漏改。4.2 字符串解密函数带eval或new Function静态还原怎么都还原不动现象代码里的字符串都是通过一个_0x4d函数调出来的但我的脚本替换了引用后发现解密函数本身里还藏着一个拼接数组的eval()运行时才能拿到真正的字符串静态分析根本算不出值。原因这是一种“轻量虚拟化”混淆思路。解密函数在被调用时执行eval把一段拼接后的字符串当作代码执行并返回结果。AST 层面能看到的是_0x4d函数的定义、_0x4d(xx)的调用但_0x4d内部逻辑依赖运行时数据所以静态遍历无解。解决改用“混合模式”。先用 Node 的vm模块跑一遍解密函数把_0x4d(xx)的入参和返回值全部记录成一张字典再回到 AST 里把所有_0x4d(xx)调用替换成对应的真实字符串值。这个思路利用了一个常见事实解密函数通常没有副作用可以重复执行。你可以参考这个模板const vm require(vm); const sandbox {}; vm.createContext(sandbox); vm.runInContext(code, sandbox); // 先跑完整代码让解密函数挂到全局 traverse(ast, { CallExpression(path) { const { callee, arguments } path.node; if (t.isIdentifier(callee) callee.name _0x4d) { const key arguments.map(a a.value).join(,); const val vm.runInContext(_0x4d(${key}), sandbox); if (typeof val string) path.replaceWith(t.stringLiteral(val)); } } });判断条件是当val不是字符串时坚决不替换。混淆工具偶尔会用同一个解密函数做逻辑判断返回布尔值如果你不加类型校验把布尔值当字符串塞回 AST后续代码会因为true被写成true而出现不可预测的行为。这种混合模式在 js 逆向实战里是常态静态 AST 负责结构调整动态 vm 负责补全信息两者并不冲突。4.3 控制流平坦化还原后逻辑没错但性能反而退化现象有的还原工具强行把while (1) { switch(_0xab) { case 1: ... } }拆成一个个if (x) {...}结果是代码确实能读了但从执行路径上看原本 5 个分支的跳转被展开成 40 个顺序 if 判断耗时不降反升甚至在低版本浏览器上直接白屏。原因控制流平坦化的核心是把分散的条件判断压缩成一个分发变量然后靠switch跳转。还原的难点在于“分发变量的取值路径”不在本地而在其他 case 块里。简单粗暴地展开必然带来分支膨胀和顺序判断开销。解决不要急着展开先把分发变量还原成常量。你需要找到控制 flow 的入口比如var _0xab abc.indexOf(...)或者_0xab x;然后顺着 case 之间的赋值关系做常量传播最后才把switch折叠成if。具体到工具里优先用专门写“控制流平坦化还原”的插件而不是用通用 rename 脚本。还原后一定要做性能打点测试跑一个密集循环对比混淆前后执行耗时如果差距超过 10%就说明有些分支没折叠干净需要检查case体最后一行是不是还带_0xab ...的赋值。4.4 还原后的代码出现大量undefined排查后发现是枚举或for...in被改坏现象代码里本来有for (var key in obj)还原脚本把每个key都当成普通变量替换成v7结果obj[v7]在浏览器里全是undefined。原因for...in循环里的key虽然是变量绑定但它的值在运行时取自对象的属性名不是由代码文本决定的。如果把这个变量硬命名成v7你无法从源码看出它对应的是对象里的哪些字段排查问题就变得极困难。更危险的是如果你的重命名脚本把key与另一个同作用域变量合并会导致运行时属性名被覆盖。解决在 rename 脚本里加一个条件凡是ForInStatement的left或ForOfStatement的left保留原名或者加上前缀如kForIn1不要走通用重命名。同理catch(e)里的e也建议保留原始名很多源码审计工具依赖这个标识符定位异常分支。把这四种保留情况写进 visitor 的白名单能避免大量不明原因的undefined。4.5 unicode 转义字符串还原后变中文乱码提交代码时被格式化工具“误杀”现象还原后源代码里出现\u4e2d\u6587这类转义浏览器可以正常显示但是代码提交到仓库后团队的 Prettier 配置把字符串强行转成中文导致 diff 变得巨大且不易审查。原因Babel 生成器默认对非 ASCII 字符做 unicode 转义这是为了防止源码文件编码不一致导致某些旧运行时出错。团队格式化工具则会优先输出可读的原文两者策略冲突。解决在generate(ast, { compact: false, comments: true, jsescOption: { minimal: true } })参数里加入jsescOption强制生成器保留原始可读字符。参数minimal: true的意思是只有碰到必须转义的字符才转义其余一律原样输出。另一个躲不开的习惯是还原后的文件头部固定加一行// ts-nocheck或/* eslint-disable */避免工具链把还原结果当手工代码重排。这个习惯我在多项目里验证过能少吵很多架。5. 还原完了拿什么证明没改坏逻辑三种验证手法与常用判据还原工具输出的代码能不能用不是看变量名好不好读而是看它跑起来对不对。我常用的验证手法有三种按成本从低到高排。第一种是 vm 沙箱对比。分别用vm.runInNewContext跑原始混淆代码和还原代码传入同一样本数据对比输出 JSON 是否一致。适合没有任何 DOM 和网络依赖的纯计算逻辑。第二种是浏览器环境注入。用 Chrome DevTools 的Local Overrides或者 Puppeteer 的page.addScriptTag把还原后的代码替换到页面里看功能交互是否正常。这种方式适合依赖 DOM 和 ajax 请求的站点坑在于周边环境不干净报错不一定来自还原代码。第三种是 AST 差异比对。用 acorn 把两份代码重新解析成 AST做结构化 diff。它能发现“文本不同但逻辑一致”的情况也能捕捉到“文本一致但语义已变”的高危改动。我个人的固定流程是先跑 vm 对比再做一次语法树 diff最后用一次浏览器手工点击。如果你也在做 js 逆向或反爬实战建议把 vm 对比脚本直接留档它能在你每次改还原规则后立刻反馈结果。// verify.js对比原始混淆代码和还原代码的输出 const vm require(vm); function run(code, input) { const sandbox { input, result: null }; vm.createContext(sandbox); vm.runInContext(code, sandbox); return sandbox.result; } const sampleInput { page: 1, size: 20 }; const originalOutput run(originalCode, sampleInput); const deobfuscatedOutput run(deobOutput, sampleInput); console.log(JSON.stringify(originalOutput) JSON.stringify(deobfuscatedOutput));最后一件事值得做把还原后的代码再过一遍 Lint。用npx eslint --no-eslintrc deobfuscated.js检查未定义变量、重复声明、不可达代码这些报错往往就是还原脚本漏处理的位置。不要太迷信工具的自动还原率我见过不少工具宣称 95% 还原率实际跑下来能把控制流平坦化完整折叠的不到一半。工具负责把重复工作自动化你负责判断哪里不能动——AST 给了我们改代码的精度也希望你保留一份敬畏改动后永远先验证再上线。希望这篇实战拆解能帮到你少走一点弯路。本文还有配套的精品资源点击获取