AST反混淆JS还原工具2.0:从原理到实践,把天书代码恢复成人话 简介面向JavaScript逆向与爬虫工程师的AST反混淆js还原工具2.0压缩包深度适配目前最新的obfuscator.io混淆规则可处理截至2022年4月20日的新式混淆代码。该版本基于丁仔大佬的还原工具二次开发新增十余项功能优化原有处理逻辑并修复1.0版本的已知错误还加入三元表达式转if-else的能力改善了作用域处理兼容性和稳定性均有明显提升。压缩包共8个文件包括5个JavaScript脚本和3个Markdown文档整体只有54KB非常轻量。压缩包内文件分工明确主脚本负责整体还原流程配置文件用于调节处理参数两个demo示例可帮助快速验证效果3个Markdown文档分别提供功能说明、更新日志与使用说明方便上手和二次修改。目前已有4602人学习下载适合有一定AST基础的JS逆向爱好者在实战中应对混淆加固、还原核心逻辑十分顺手。1. AST反混淆js还原工具2.0把“天书”变回人能读懂的JavaScript在做 JavaScript 逆向时最头疼的就是打开混淆过的前端代码满屏十六进制字符串、双层自执行数组、被拍平的控制流根本没法下断点。我最近拆了一份 AST反混淆js还原工具2.0它是基于原版还原工具做的二次开发新增了十多项功能针对目前主流在线混淆服务的规则做了专门适配还原后的代码能直接读、能重新跑对爬虫工程师和前端安全研究者来说相当顺手。这份资源适合两类人一类是刚入门的 JS 逆向新手想从还原好的代码里学混淆套路另一类是天天和 ECMAScript 反爬对抗的老手需要快速把混淆代码恢复成可分析的形态。2. 先理解AST反混淆的原理混淆器做了什么还原器怎么下手2.1 混淆的本质从源码到AST再对AST动手每一个 JS 文件在浏览器执行之前都必须经过“解析”这一步把字符串形式的源码变成计算机能理解的结构化数据也就是抽象语法树AST。混淆器做的所有操作都不是在文本层面对源码做替换而是在 AST 这棵树上做变换。比如把字符串宣传“hello world”拆成一个字符数组再通过一个位移函数在运行时还原把原本清晰的 if/else 流程拍平成 switch-case 状态机把函数调用通过数组索引绕上好几圈再往代码里塞一堆永远不会执行的死代码。经过这些变换后代码的语义没有变但是结构完全面目全非人眼很难直接看出它在干什么。还原器要做的事情就是把这些变换逐一反向操作。这就是为什么 AST 反混淆不能靠正则替换完成正则只能匹配文本无法理解代码的嵌套层级而 AST 层面可以精确知道哪一段是函数体、哪一个标识符是变量声明、哪一次CallExpression其实是数组位移函数所以能在正确的节点类型上做反向重写。这也是工具包选择“解析 → 变换 → 重新生成代码”这一路线的原因而不是简单地把混淆代码抄进编辑器里做文本替换。说到 AST就得先记住几个最基础的节点类型Program是整个文件的根节点VariableDeclaration表示变量声明FunctionExpression表示函数表达式CallExpression表示函数调用Literal表示字符串或数字。混淆器最常用的套路是把var a hello转换成var a _0x3b9[b1]再从加密数组里取真正的字符串还原器要做的就是识别这个数组读取操作把_0x3b9对应的内容查出来再用Literal(hello)替换掉原来的读取表达式。除了字符串替换另一个高频操作是“立即执行函数折叠”。混淆器喜欢把简单的计算写成var x (function(a, b) { return a b; })(1, 2);还原器看到CallExpression内部的FunctionExpression参数全部是字面量并且函数体里没有外部依赖就可以直接计算得出结果把整段替换成var x 3;。这个折叠看起来简单但关键在“判断参数是否都是字面量”否则一旦参数里有变量引用强行计算就会改变作用域绑定关系导致还原后报错。2.2 工具包的文件结构与主流程解压“AST反混淆js还原工具2.0.zip”之后里面是一组可以直接用 Node 环境运行的 JS 文件。文件并不算多按功能可以分三类配置与控制config.js主处理脚本ObDecryMain.js、ObDecryFuMain.js示例与文档demo.js、demoNew.js、README.md、更新说明.md、功能说明文档.md第一次拆这个工具的时候我建议先打开README.md和功能说明文档.md因为里面会说明当前版本支持哪些混淆特性、哪些开关有副作用、哪些功能只对特定代码形态有效。不要直接跑主脚本否则遇到报错你会分不清是环境问题还是配置问题很容易白折腾。在主流程上ObDecryMain.js是入口文件它负责读取config.js读取目标 JS 内容然后交给 AST 解析器生成语法树接着按配置里的开关顺序调用还原逻辑。ObDecryFuMain.js则是辅助方法集比如字符串数组的解密函数、控制流状态机的识别、作用域分析、死代码判定等都被拆成独立模块方便单独调试或者二次开发。执行还原时整个流程大致是“解析 → 逐条变换 → 生成代码”。每一步变换之后工具都会重新检查一遍 AST 节点的合法性如果某个节点变成空块或者出现孤立标识符就需要回到上一步重新处理。这个“边变换边验证”的思路很重要因为混淆代码里有很多自我防护机制比如检测函数名字符串、检测代码运行环境一旦某一步处理激进后续步骤可能全部跑偏。主流程里最容易忽略的一点是“还原顺序”。工具默认的顺序一般是先解字符串数组再处理控制流平坦化然后做变量名整理最后删除死代码。为什么是这个顺序因为字符串数组还原出来的内容可能被控制流跳转地址引用只有先把字符串变成明文才能进一步识别哪些 switch 分支是真实可用的哪些是无限循环的诱饵。顺序一旦颠倒还原结果就是一堆乱码加一堆执行不到的逻辑。3. 使用与配置让还原工具跑起来并控制还原深度3.1 环境准备与第一次运行处理工具本身是用 JavaScript 写的所以不需要编译环境只要机器上装了 Node 环境版本建议在 v14 以上。版本太低的话解析库可能会挂因为新式异步语法、可选链这些 ES2020 以后的特性需要较高的 runtime 支持。把 zip 解压到某个干净目录然后先拿自带的demo.js试运行。常见的调用方式有两种一种是直接运行主脚本并把结果打印到控制台另一种是通过-o参数把结果写入新文件。命令大概长这样# 进入工具目录 cd ast-decry-2.0 # 直接打印还原结果到控制台 node ObDecryMain.js demo.js # 将还原结果写入文件 node ObDecryMain.js demo.js -o demo_output.js如果你拿到的那份版本没有-o参数那就用管道重定向或者从主脚本内部的读取逻辑里找到输出位置。跑完之后先看demo_output.js正常情况下应该看到可读的格式化代码而不是压缩成一行的密文。第一次运行如果报错优先检查三件事第一当前目录是不是工具根目录配置文件在不在第二目标 JS 能不能被 Babel 解析如果样本本身就包含语法错误AST 解析阶段就会直接抛出异常第三配置里是不是开启了某个还没适配的开关可以先全部关掉只保留最基础的解码再逐步打开。跑通 demo 以后不要急着扔真目标进去。先把demo.js和demoNew.js两个样例的还原结果做一次 diff这一个用来测基础字符串解码另一个用来测新版控制流平坦化。两个都能正常还原说明环境没问题再上真实页面脚本。3.2 config.js 参数说明与常见组合config.js是控制还原行为的核心文件里面的开关直接决定还原深度和风险。下面是一份典型的配置内容示例不同版本字段名可能略有差异但思路一致// config.js module.exports { // 是否解密字符串数组 decodeStringArray: true, // 是否还原控制流平坦化 restoreControlFlow: true, // 是否将三元表达式转为 if-else convertTernaryToIfElse: true, // 是否删除死代码 removeDeadCode: true, // 标识符重命名策略none | readable | compact identifierRename: none, // 最大处理轮数防止还原过程死循环 maxIterations: 500, // 保留的全局变量名列表避免误改全局绑定 preserveGlobalNames: [$, _, __], };每个开关都不是独立起作用的。比如decodeStringArray开启后工具需要定位字符串数组变量和对应的解密函数所以它依赖内部一个“数组使用次数”的统计逻辑如果数组变量被几十个函数引用直接替换有可能把别的非混淆代码也替换掉。这时就要借助preserveGlobalNames把外部注入的全局变量名保住。restoreControlFlow开启后工具会把 switch-case 状态机识别出来并尝试还原成原来的 if/else 结构。这个开关开销很大因为状态机里一般有一个state变量循环体内部不断更新状态需要做“可达性分析”才知道哪些 case 能落到。对复杂样本建议把maxIterations调到 500 以上否则还原到一半就中断输出代码会出现大量未处理状态。identifierRename这个参数决定还原后变量名怎么处理。默认是none也就是保留混淆器生成的名字这样最安全因为不会引入重名冲突如果选readable工具会尝试根据上下文生成类似arr、temp、flag这样的可读变量名但前提是作用域分析足够准不然就翻车。我的建议是第一次跑永远用none确认还原结果没问题后再尝试readable。下面是一张参数组合速查表对应不同场景的推荐设置场景decodeStringArrayrestoreControlFlowconvertTernaryToIfElseremoveDeadCodeidentifierRename快速看逻辑truefalsefalsefalsenone完整还原truetruetruefalsenone分析后整理truetruetruetruereadable极端混淆truetruefalsefalsenone3.3 针对最新混淆规则的还原顺序现在主流的在线混淆服务会做一些“分步编码”操作比如先把字符串转成十六进制再塞进数组然后再用 RC4 加密一层。针对这种规则单一开关是不管用的必须按照正确的顺序多次递归处理。我一般会在还原前先做一次静态观察打开目标 JS搜索字符串数组变量名的赋值语句看它附近有没有解密函数调用。如果发现解密函数本身也经过了控制流平坦化那么直接开启decodeStringArray可能会卡住因为解密函数里的 switch-case 没有被还原。正确做法是先用restoreControlFlow把解密函数还原出来再执行一次字符串解码之后再对主代码做控制流还原。这个顺序可以类比成“先拆锁再开门”。混淆器会把钥匙藏在门后的盒子里你要先通过某种方式从盒子里拿到钥匙才能开门。工具包里的还原步骤虽然叫“还原”但其实很多时候是在模拟代码执行比如直接在 AST 节点上调用解密函数把结果算出来。遇到加密分层就得用“还原一部分 → 模拟执行 → 再还原一部分”的思路反复迭代。针对代码自防护规则还需要注意Function.prototype.toString被改写的情况。混淆器会检测Math、Function这些内置对象有没有被修改一旦还原后的代码结构变化toString输出跟原始定义不一致程序就会走反调试分支。遇到这种情况单靠这几个开关还不够得在输出代码的前置位置重写一个稳定的toString实现或者用沙箱运行还原结果绕过环境检测。4. 避坑指南还原过程中最容易翻车的五个场景4.1 还原后代码出现大量未定义变量现象还原完的代码一运行控制台直接报ReferenceError: xxx is not defined而且报错位置在原本根本没有变量的地方。原因混淆器经常用数组索引 位移来替代变量名引用还原器在解析时如果漏了某个成员表达式的绑定关系就会把标识符换成错误的名字导致变量名错位。另一个常见原因是作用域判断过于保守把跨函数引用的私有变量当成了全局变量。解决先把identifierRename设为none让原变量名保留再看是否还有报错。如果仍然报错优先检查decodeStringArray是否生效因为字符串数组没有完全解码的话数组读取表达式还留在代码里对应的变量自然找不到。我习惯把preserveGlobalNames加上页面自行注入的全局函数列表这样能避免误改。4.2 字符串数组解密后是乱码现象还原后字符串长这样_0x3b9[a1]或者decodeURIComponent(%E4%B8%AD)输出中文和预期完全对不上。原因混淆器生成数组时下标指向的顺序并不是明文顺序而是依赖一个密钥数组做二次映射。如果只拆了第一层数组没处理位移函数还原出来的就是位移前的初始数据看上去全是乱码。解决在还原前先定位解密函数并单独执行一遍把执行结果记录下来作为字典然后强制用该字典回填所有数组索引访问。可以在辅助模块里加一个方法专门提取数组变量和对应的解密函数先静态执行一次再把结果传给后续所有还原步骤。这个“先模拟执行再回填”的思路很重要能绕过很多位移逻辑。4.3 死代码删除把活代码一起删了现象开启removeDeadCode之后函数直接少了一段逻辑而且少掉的部分正好是控制流转换后的“真实分支”。原因混淆器把真分支放在if (0)或while (0)后面把假分支放在正常逻辑里。还原器在删除不可达节点时如果错误地把if (1)当成不可达或者把被Getter劫持的属性访问当成无副作用表达式就会把关键调用抹掉。解决不要一开始就开死代码删除。先还原字符串和控制流再做一次代码对比最后才开启removeDeadCode。如果发现某个分支被误删可以用配置文件里的排除规则把该段代码单独跳过去或者先把restoreControlFlow关闭看是否因为这个开关导致流程判断错乱。4.4 三元表达式转 if-else 后作用域泄漏现象开启convertTernaryToIfElse后代码多出ReferenceError: node_xxx is not defined尤其在嵌套三元表达式中。原因原版工具处理三元表达式时是把整个条件表达式替换成 if/else 语句但是如果分支里出现了let/const声明或者分支内调用了一个只在分支内定义的箭头函数转换后的块级作用域没有被正确包裹声明就泄漏到了外层。解决新版工具增加了块级作用域包装自动为每个分支加上{...}。如果你用的版本还是旧逻辑可以在转换时把分支体内的变量声明提升到外层做重命名或者直接给每个分支加一层大括号块。重点在于“带作用域处理”而不是简单地把条件表达式拆成两条赋值语句。4.5 还原后能读但运行结果不对现象代码可读性很好但运行结果和混淆前不一致接口参数加密结果不同页面行为完全变样。原因混淆器使用了“自校验”机制比如检测函数toString是否被修改或者代码内部使用了Function(return this)()来检测环境。还原器把函数压缩、改名甚至格式化之后函数的toString输出变了触发了反调试分支。解决在还原时保留原有的函数注释或文件头部标记或者在最终输出的代码开头重新定义Function.prototype.toString让它返回一个固定字符串来干扰自校验。但这种做法要视目标而定不能一刀切。我的经验是先看还原后代码里有没有类似toString检测的语句再去决定要不要做这一步。5. 进阶把三元表达式转成 if-else并固化自己的还原流程三元表达式转 if-else 这个功能属于这次二次开发里最实用的几个点之一。原版工具在转换时往往会踩作用域的坑新版做的最主要改进是转换前检查分支内部有没有VariableDeclaration如果有就把整个分支用块级作用域包裹起来避免变量泄漏到外部。举一个典型例子// 混淆前的源码可能是这样的 var result flag ? (condition ? 1 : 2) : 3; // 经过普通的三元转 if-else会变成 var result; if (flag) { result condition ? 1 : 2; } else { result 3; }如果三元表达式里不是简单赋值而是包含函数调用就需要更谨慎。比如// 分支里声明了临时变量 var val flag ? (function() { let temp 1; return temp; })() : 0;转换后的 if/else 必须给分支加块级作用域否则temp会污染外部的变量作用域。新版工具的做法是对每个分支体做一次“作用域判定”如果检测到let/const自动包一层大括号。这个处理思路同样适用于你手动去改其他还原工具可以作为一个通用模块。除了这个具体功能我更想分享的是“固化还原流程”这件事。每次拿到新的混淆 JS我现在的操作顺序固定是先跑自带 demo 确认环境再用最小配置跑真实样本最后逐步打开控制流、死代码、三元转换。每开一个开关都会把还原前后的代码放到node --check里做语法验证再用几个测试输入跑一遍行为对比。如果某个开关导致输出差异变大立刻定位到对应规则去查原因。# 语法检查 node --check restored.js # 行为验证跑一个已知输入看输出是否一致 node restored.js --inputtest_case这个过程看起来繁琐但它能把还原工具的“黑匣子”变成可信任的流程。我从那以后每次处理混淆代码都强制走一遍这个验证循环不再直接开全量还原然后指望输出一次性正确。希望帮到你。本文还有配套的精品资源点击获取