☰
AST反混淆实战:从字符串解密到控制流还原,2.2版本工具详解
2026/9/26 4:32:20 网站建设 项目流程

简介:一套面向JavaScript逆向与前端安全分析人员的AST反混淆还原工具,基于丁仔大佬的早期版本二次开发,新增功能十余项,并针对原有功能进行优化与错误修复,同时提升了对不同混淆脚本的兼容性;在处理OB混淆等常见场景时,能在尽量维持脚本可执行性的前提下,让还原后代码更贴近人工编写习惯。资源包共9个文件,包括5个JavaScript脚本与4个Markdown文档:脚本负责抽象语法树解析、反混淆处理与配置加载,文档则覆盖更新说明、功能说明、当前待优化事项和使用指引,便于快速上手和二次开发。整个压缩包仅40KB,轻量易部署,无需复杂环境即可使用。目前已有2542人学习下载,适合具备一定JavaScript基础、希望提升混淆代码分析效率的逆向工程师或前端开发者。借助该工具,使用者可获得可直接运行的还原脚本与完整配套文档,既能快速完成常用反混淆任务,也能根据说明扩展自定义处理逻辑,省去从零编写解析代码的时间。

1. 从正则硬刚到 AST 还原:2.2 版本工具为什么值得你换一个思路

上周调试一个被混淆过的 JS 文件,我拿着正则替换了俩小时,最后发现混淆器把字符串数组的顺序打乱了两次,正则方案直接崩盘。这也是多数人在 js 逆向实战里反复踩的坑:你面对的混淆脚本根本不是给人看的代码,而是一棵被打乱的语法树。AST 反混淆的思路就是先把 JS 解析成抽象语法树,再在树上做还原操作,绕开文本层面的正则死磕。网上流传的“AST反混淆js还原工具2.2(20230203)”,本质上就是一套封装好的解析、还原、输出流程,面向的是做 js 反爬实战、前端安全分析和脚本调试的人。这篇笔记把 AST 反混淆的落地细节拆开讲透,从原理到参数再到翻车现场,尽量一步不落。

2. 混淆器都藏了什么:先认识你要还原的对象

2.1 常见混淆花样:字符串、数组、控制流、变量名

在动手写还原工具之前,得先弄清楚混淆器到底做了哪些手脚。常见的混淆维度有四类,每一类对应 AST 上的不同节点类型。

第一类是字符串加密。原代码里的明文常量串,有的转成了十六进制\x68\x65\x6c\x6c\x6f,有的变成了String.fromCharCode(104, 101, 108, 108, 111),还有的直接塞进一个大数组,运行时按下标取出来。这类混淆在 AST 里表现为StringLiteral节点被替换成了CallExpression或MemberExpression。

第二类是数组乱序。工具会维护一个字符串数组,比如var _0xabc = ["hello", "world"],然后通过一个解密函数_0xabc[_0x56f(1)]去取字符串。要还原这类混淆,除了把解密函数跑出来得到真实字符串值,还要处理数组的乱序逻辑——这就是 2.2 工具迭代里的重头戏之一。

第三类是控制流扁平化。这是最头疼的一类。混淆器会把原本顺序执行的代码块拆成很多小段,塞进一个switch-case分发器里,用变量_0x1控制哪段先执行。还原的时候要做的是把这个分发器拍平成原来的顺序结构。第四类是变量名混淆,把name改成_0x1x2y,同时插入大量从不执行的死代码来干扰阅读。

这四类混淆会叠加使用,处理顺序直接影响最终还原质量。常见做法是:先做数组解密还原字符串,再做控制流扁平化,最后做死代码清除。顺序反了的话,字符串表没还原就看控制流,分析结果全是噪音。

2.2 最小可用的 AST 解析链路:两行代码看见语法树

AST 还原的第一步是把 JS 源码变成语法树。主流的解析器是@babel/parser,它能把几乎所有 ES 语法解析成标准化的 AST。下面这个代码块是最小解析链路:

const parser = require("@babel/parser"); const traverse = require("@babel/traverse").default; const generate = require("@babel/generator").default; const code = ` var _0xabc = ["hello", "world"]; function _0x56f(idx) { return _0xabc[idx]; } console.log(_0x56f(1)); `; const ast = parser.parse(code, { sourceType: "script" }); traverse(ast, { CallExpression(path) { console.log("发现函数调用:", path.node.callee.name); } });

这段代码做了两件事:用parser.parse把源码字符串解析成 AST,再用traverse遍历语法树,碰到函数调用节点就打印被调用的函数名。对于还原工具来说,这个遍历机制是插件的骨架。Babel 的traverse基于 visitor 模式,每个节点类型对应一个回调函数,你在回调里修改节点,改动会直接反映到 AST 上。

参数说明:sourceType有两档,script和module。如果你要解析的代码里有import/export就得用module,否则 Babel 会直接报错。这个参数是新手最容易忽略的坑之一。@babel/generator负责把改完的 AST 还原成 JS 代码,你不需要自己写代码生成器。

3. 手写一个还原插件:从字符串解密到控制流拍平

3.1 搭工程:先保证代码能跑起来

还原工具本质上是一个应用了多个自定义 Babel 插件的处理管线。先建工程、装依赖:

mkdir ast-restore && cd ast-restore npm init -y npm install @babel/parser @babel/traverse @babel/generator @babel/types

这里没有用@babel/core,是因为它太重了,而且parse -> traverse -> generate这套链路足够覆盖反混淆的需求。把整个还原逻辑拆成多个独立插件传进一个流水线里,每个插件只处理一种混淆,代码结构会清晰很多。后面所有还原逻辑都写在一个入口文件里,形如:

const restorePipeline = [ require("./plugin-array-restore"), require("./plugin-call-restore"), require("./plugin-control-flow"), require("./plugin-dead-code"), ];

每个插件导出的是一个 visitor 对象,Babel 会把同一个 AST 依次交给这些插件处理。参数说明:plugin-array-restore负责数组解密,plugin-call-restore负责函数调用化简,plugin-control-flow负责控制流拍平,plugin-dead-code负责清除死代码。实际项目里还可能加上plugin-eval-replace和plugin-string-unescape。

3.2 数组解密:字符串表还原的标准操作

最典型的混淆模式是这样的:所有字符串字面量被抽到一个数组里,调用点通过索引访问。

var _0x42b = ["\x68\x65\x6c\x6c\x6f", "\x77\x6f\x72\x6c\x64"]; function _0x1f(idx, offset) { return _0x42b[idx - offset]; } console.log(_0x1f(2, 1));

还原思路是先理解_0x1f这个函数的作用:idx - offset算出真实数组下标,然后返回数组元素。要还原所有调用点,必须把解密函数的值域算出来。常见做法有两个:静态求值或运行时求值。

静态求值的思路是主动去识别数组和解密函数,如果解密函数只包含一个索引运算,就把调用点替换成常量。代码如下:

module.exports = function() { return { visitor: { VariableDeclarator(path) { const { id, init } = path.node; if (t.isArrayExpression(init)) { // 记录数组值和数组名 const bindings = path.scope.getBinding(id.name); if (!bindings) return; bindings.referencePaths.forEach(ref => { if (ref.parentPath.isCallExpression()) { const callee = ref.parentPath.node.callee; if (callee.name === decryptedName) { // 将调用节点替换为字符串字面量 } } }); } } } }; };

这段代码的核心逻辑是:先找数组声明,拿到数组名字;再找所有引用这个数组的调用点;最后把调用点替换成字符串常量。注意这里省略了匹配解密函数的细节,真实项目里解密函数可能藏在变量名混淆后的函数里,需要先扫描函数体。

参数说明:path.scope.getBinding是 Babel 的作用域 API,它能找到某个变量名在何处声明、在哪些地方被引用。这是反混淆的关键——你不需要靠正则去搜名字,AST 早就替你把引用关系建好了。如果解密函数带偏移量参数,比如_0x1f(2, 1)里面的- offset,你还要解析这层运算关系,把偏移量算进去。

更省力的方案是运行时求值。把解密函数从混淆代码里抽出来,放到vm沙箱里执行,建一个“字符串表”,再反向替换调用点。这种方式对复杂解密函数更有效,但要注意沙箱的安全边界。很多实战工具同时保留两种模式,遇到简单函数用静态求值,遇到复杂情况降级到动态求值。2.2 这个版本值得关注的点,就是它在这两种策略之间做了均衡,默认先用静态求值,失败再走动态,避免动态执行带来的环境和安全风险。

3.3 控制流扁平化:把 Switch-Case 分发器拍回顺序结构

控制流扁平化的还原是所有混淆还原里最难的一环。先看混淆器生成的结构长什么样:

var _0x1 = 2; while (true) { switch (_0x1) { case 2: console.log("a"); _0x1 = 3; continue; case 3: console.log("b"); _0x1 = 5; continue; case 5: console.log("c"); _0x1 = 0; continue; } break; }

直观上看执行顺序是a -> b -> c,因为_0x1从 2 开始经过 3、5 回到 0 就跳出循环。还原思路就是模拟这个状态机的跳转,把每个分发器变成一个顺序块。实现要点如下:

module.exports = function() { return { visitor: { WhileStatement: function(path) { const { test, body } = path.node; // 确认是 while(true) 结构 if (!t.isBooleanLiteral(test, { value: true })) return; const switchNode = findSwitch(body); if (!switchNode) return; // 模拟状态机跳转顺序 const seq = []; let current = findStartCase(switchNode); while (current && !visited.has(current)) { seq.push(current.consequent.filter(n => !isBreak(n))); current = getNextCase(switchNode, current); } // 把原始代码块的语句替换成 seq 展开的语句 path.replaceWithMultiple(seq.flat()); path.skip(); } } }; };

这段代码的处理流程是:判断是不是while(true),在循环体里找到switch节点,从起始 case 开始模拟执行,按照分发变量取值把 case 块串起来,最后用还原后的顺序语句替换整段循环。

参数说明:findStartCase要找的是第一个执行的分支,通常就是分发变量初始值对应的 case。visited集合用来防死循环——如果混淆器里藏了个反向跳转的 case,不标记会无限循环。path.skip()是 Babel 的 API,告诉遍历器不要再进入这个节点内部,因为已经被整体替换了。

单层控制流还好处理,实际看到的多是嵌套控制流:一个大分发器里某个 case 内部又藏了一个小分发器。这时候的处理策略是从内往外逐层拍平:先内层后外层,每次替换完后再跑一次插件,直到没有可拍平的while(true) + switch结构为止。这就是还原工具里所谓的“递归折叠”逻辑,也是 2.2 版本里耗时占比最大的计算模块。

3.4 死代码清除与变量名还原

控制流拍平之后,代码里通常残留大量没用的赋值和分支。死代码清除插件的核心是识别“无法到达”或“从不使用”的语句:

module.exports = function() { return { visitor: { "IfStatement|ConditionalExpression": function(path) { const test = path.node.test; const evaluated = path.evaluate(); if (evaluated.confident) { if (evaluated.value) { path.replaceWith(path.node.consequent); } else { path.replaceWith(path.node.alternate); } } }, VariableDeclarator(path) { const name = path.node.id.name; if (!path.scope.getBinding(name).referenced) { path.remove(); } } } }; };

这里的重点在path.evaluate(),它是 Babel 内置的常量折叠器,能确认一个表达式是不是恒定值。比如if (1 > 0)会被折叠成if (true),然后被上面这段逻辑替换成 consequent 分支。死代码在 AST 里典型特征是:定义的变量从来没有被引用,或者被引用了但引用节点本身也被删了。

变量名还原阶段,如果项目里有 sourceMap 或原始命名规则可以恢复;没有的话,至少把_0x开头的变量名改成可读名,比如var _0x4f2a = 1改成var count = 1。这个改法并不通用,多数反混淆工具直接跳过改变量名,保留了_0x前缀,原因是改了名字可能会导致代码语义变化。2.2 版本提供的“变量名恢复”选项,本质上是基于使用场景猜测语义,比如经常被.length调用的变量,大概率是个数组或字符串。这部分是启发式逻辑,不保证完全准确。

4. 把还原能力封装成工具:2.2 版的核心设计思路与参数配置

4.1 输入与批处理:单文件模式和多文件模式

一款真正的还原工具不能只写一个插件脚本,它需要把上面这些还原能力组织成一条可配置的流水线。2.2 版本的核心设计是:输入一段混淆代码,输出一段经过多个阶段的还原代码,并保留原始代码中的注释信息供分析比对。

命令行模式是通用做法。工具通常这样被调用:

ast-restore -i obfuscated.js -o restored.js --mode advanced --remove-dead-code --formatted

-i指定输入文件,-o指定输出文件。--mode决定走哪套还原策略:basic只还原字符串,advanced跑完整的五阶段流水线。批处理模式下传入目录:

ast-restore -i ./obfuscated/ -o ./restored/ --batch --ext .js --threads 4

--batch开启目录扫描,遍历所有.js文件并对每个文件独立执行还原流程。这里有个关键取舍:多线程处理时,每个文件的还原过程是隔离的,不会互相污染,但会带来更高的内存开销。处理大批量混淆文件时,建议一次别超过 200 个文件,设置--threads 4比较均衡。

4.2 输出选项:控制还原结果的样式

还原结果的质量不只看逻辑对不对,还要看人能不能读下去。工具的--formatted开关控制代码格式,不开这个选项输出的代码是一行的。做 js 逆向分析的人拿到还原代码后第一件事就是格式化,这个选项直接省了一步。

--keep-comments用于保留原混淆代码中的注释。有的混淆器会在注释里藏版本标记,留着有助于确认混淆器型号;但多数情况下注释是干扰信息,不推荐开。--debug选项决定是否输出还原日志,日志会详细记录每个插件处理了多少节点、跳过多少次、失败多少次,例如:

[plugin-array-restore] 处理 128 处数组引用,成功替换 121 处,失败 7 处 [plugin-control-flow] 折叠 23 个分发器,剩余 1 个(含嵌套未分解)

这份日志是排错的第一手信息。如果还原结果不理想,先看日志看看哪个插件的成功率偏低。

支持输出 .map 源码映射表是一个很有用的进阶功能。开启--source-map后,工具会输出一份 JSON 映射,把还原后代码的每一行映射回原代码的行号。这对调 bug 来说几乎等于后悔药——还原后的代码报错了,可以通过映射找到原混淆代码中的对应位置。

4.3 参数对照表:每个配置项是干什么的

参数取值作用建议
--modebasic / advanced / expert控制插件集合的范围新手上 advanced 足够,expert 会启用变量名还原
--remove-dead-code开关启用死代码清除真项目里混淆器塞的死代码极多,建议开
--eval-replace开关把不可解析的 eval 结构转为变量调用遇到eval(atob(...))时必须开
--strict-mode开关对错误情况更敏感,失败即停止批量处理时不要开,会中断整体任务
--timeout数字(秒)单文件还原超时上限处理超大文件时建议设置 30 秒,防止死循环
--max-iterations数字控制流折叠的最大轮次默认 10 轮,嵌套过深时会返回未完全还原的代码

参数的具体默认值因工具分支而异,但语义是通用的。用 2.2 版本时特别关注--max-iterations,因为控制流嵌套层数多的混淆代码,一次遍历根本处理不完,必须靠多轮迭代逼近最终结果。迭代轮次设太大会拖慢速度,设太小还原不彻底。

这些参数设计背后其实是一个适配需求:有的用户只是要把字符串看明白,有的用户需要完整还原逻辑才能继续 js 逆向实战。工具留出不同档位,避免一刀切导致简单代码被过度处理、复杂代码被漏处理。

5. AST 反混淆避坑指南:我踩过的五个典型翻车现场

5.1 字符串数组下标错乱

现象:还原后代码里的字符串全对不上号,比如变量名变成了undefined,或者解密后输出的是乱码。在 js 逆向实战里,这个问题排查起来特别熬人。

原因:混淆器把数组顺序打乱之后,解密函数接收的参数不是直接的下标,而是一个带偏移量的表达式。上一轮还原把解密函数替换掉了,却没有同步更新数组的顺序,导致所有后续的调用点全部错位。

解决:先定位数组的最终顺序再替换调用点。推荐的做法是先不等差地跑一次动态求值,把整个数组的真实值全部解出来,重建索引表,然后再把调用点替换成真实值。这样做的好处在复杂混淆里很明显——不用去猜偏移量了。

5.2 还原后的代码立刻报错:作用域绑定丢失

现象:还原插件跑完,代码看起来正常,一执行就报xxx is not defined。这种问题在 js 补环境调试场景里更为致命,因为你根本不知道哪里开始崩的。

原因:变量名混淆后,两个不同作用域里的同名变量可能被混淆器合并成了同一个_0x标识符。还原的时候只做了文本替换,没有尊重作用域边界,把不该替换的引用也换了。

解决:遍历时不要用path.node.name直接做字符串匹配,要用path.scope.getBinding(name)确认变量在当前作用域内的绑定关系。每个替换动作前都检查binding是否存在,拿不到绑定信息的节点一律跳过。

5.3 Switch 分支的排列顺序并不等于执行顺序

现象:控制流折叠之后,代码逻辑是乱的。明明 case 2 在 case 3 前面,执行顺序却是先跑 case 3。

原因:混淆器在生成 switch 时已经把 case 顺序打乱,case 的数字大小跟执行顺序没有关系,真实的执行顺序是由_0x1的赋值流动决定的。很多人的还原脚本只按照 case 值排序,从这里开始就跑偏了。

解决:绝对不能按 case 字面量排序,必须模拟状态机的跳转。从初始值开始,逐步跟踪_0x1的赋值变化,按跳转路径重建顺序。如果遇到环型跳转结构,用 visited 集合防止死循环。

5.4 死代码清除误杀了活代码

现象:开启死代码清除后,运行结果和原脚本不一致,有些变量直接没了。

原因:误判变量“未被引用”多半是因为忽略了大函数体内的字符串引用,比如eval("try{console.log(a)}catch(e){}")内部接了变量 a,AST 层面检测不到这次使用。这是工具静态分析的天生短板。

解决:死代码清除前先做一层保守保护,把动态调用的上下文标记为“不用于判定”。遇到写法极端的动态 eval 代码,建议直接跳过清死代码阶段,靠人工审读,别让工具把唯一可读的代码也删了。

5.5 嵌套控制流折叠不彻底

现象:跑完还原脚本后,代码里还有少量while(true) + switch结构存在,而且手动改起来特别麻烦。

原因:一次性遍历只折叠了一层控制流。内层分发器在外层分发器的分支里,第一次遍历时外层还没展开,内层根本不被识别为需要处理的节点。很多人的脚本只跑一遍遍历,自然结果不彻底。

解决:用循环处理机制,每次遍历完检查是否有可折叠的分发器残留,有的话继续跑。设置最大迭代次数避免无解死循环,2.2 版本里的--max-iterations就是干这个的。

6. 还原质量验证:让工具证明自己没错

还原工具输出代码后,你必须做验证,否则不确定改坏没有。最稳妥的验证方式不是看代码结构,而是让还原前后的脚本做动态对比。

对于纯函数类脚本,可以直接用 Node.js 执行对比。先加载原始混淆代码,记录输出;再加载还原代码,记录输出;最后比对两份输出。如果结果一致,验证通过。

const vm = require("vm"); const fs = require("fs"); const originalCode = fs.readFileSync("obfuscated.js", "utf-8"); const restoredCode = fs.readFileSync("restored.js", "utf-8"); let originalOutput = []; let restoredOutput = []; const sandboxOrig = { console: { log: (...args) => originalOutput.push(args) } }; vm.createContext(sandboxOrig); vm.runInContext(originalCode, sandboxOrig); const sandboxRestored = { console: { log: (...args) => restoredOutput.push(args) } }; vm.createContext(sandboxRestored); vm.runInContext(restoredCode, sandboxRestored); console.log("结果一致:", JSON.stringify(originalOutput) === JSON.stringify(restoredOutput));

这段脚本把两段代码分别放进独立的沙箱里执行,捕获所有console.log输出并对比。逻辑说明:vm.createContext创建隔离环境,避免还原代码污染测试变量。对浏览器场景,用 Puppeteer 打开一个无头页面,分别执行前后版本代码,对比页面 DOM 变化,是更贴近真实环境的验证方式。

验证通过只代表行为一致,不代表代码结构完全等价。进阶验证手段还有覆盖率和调用路径对比:在还原前后代码里注入探针函数,记录所有函数调用链和参数,做全量比对。如果调用链一致,即使文本结构差异很大,也可以认为是等价还原。

我自己的习惯是:每次改完还原插件,先拿一个未被混淆的测试代码跑一遍,确认还原输出几乎等于原版;再拿三个不同混淆器的真实样本做全量验证。这套习惯救过我很多次。希望这篇笔记能帮你在 js 逆向实战里少走几步弯路,把精力留在更有价值的逻辑分析上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询