简介: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.2(20230203)这个标题,说的就是拿抽象语法树(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把字符串变成 AST,traverse深度优先遍历每个节点,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 能自动处理静态字符串数组和线性平坦化,但遇到加密数组、条件平坦化、反调试就歇菜。我的习惯是先用工具跑一遍,看哪一步输出不对,再针对那一步手写插件。还原的本质是理解混淆器的套路,工具只是把重复劳动自动化。每次拿到新样本,先花十分钟看它的混淆特征,比直接上工具快得多。
希望帮到你。
本文还有配套的精品资源,点击获取