用 Babel AST 把"天书" JS 一键还原:七步流水线反混淆实战(小红书mnsv所在js文件)
摘要 :拿到一段被重度混淆的 JS(大数组 + 解密函数 + 反调试自保护 + 字典对象 + 多层控制流平坦化),
grep看不懂、人肉看崩溃?本文基于一个真实可用的反混淆脚本,拆解它如何用@babel的 AST 能力,分 7 步把"天书"还原成可读代码。每一步都配有核心代码与原理说明,文末附真实运行数据(609ms 还原 534 个标识符、297 处字符串调用)和 CSDN 质量分 V6.0 四维自检。
一、背景:我们到底在反什么"混淆"
前端代码一旦上线或被人打包进供应链,常常会被 JavaScript 混淆器 处理一遍。目的无外乎两件:防止别人轻易看懂、防止被二次篡改。但副作用也很明显------对安全研究员、逆向工程师、以及"接手了别人加密代码"的倒霉开发者来说,这玩意儿就是纯纯的噪音。
本文要处理的目标样本 source.js(约 390 KB)就是一套组合拳式混淆,典型特征一个不少:
| 混淆手法 | 样本中的表现 | 造成的困扰 |
|---|---|---|
| 字符串大数组 | var _0x5ae8 = ['doeZk', 'vSuEv', ...] |
真实字符串被抽走,散落在数组里 |
| 解密函数 | _0xc8b2 = function(_0x1a35aa){ return _0x5ae8[_0x1a35aa] } |
所有字符串调用都变成 _0xc8b2(0x1a) |
| 反调试自保护 | while(!![]){ ... _0x2b3d19.push(_0x2b3d19.shift()) ... } |
数组旋转 + 算术校验,打断点就乱序 |
| 字典对象 | var _0x4d21fc = { 'kAxGT': _0x4ae35e(0x6d), 'WlOyD': fn(...) } |
方法名被替换成无意义 key |
| 控制流平坦化 | 嵌套 if-else、while+switch 调度 |
顺序被打散,逻辑被切碎 |
光靠眼睛和正则基本没戏。但------混淆只是"换皮",逻辑没变。只要我们把代码解析成 AST(抽象语法树),沿树做"语义等价"的改写,就能把它层层剥回原形。
二、整体方案:为什么选 AST 而不是正则
| 方案 | 优点 | 致命缺点 |
|---|---|---|
| 正则替换 | 上手快、无需依赖 | 极易误伤、无法理解语义、遇到变体就失效 |
| 手动重排 | 最精确 | 390KB 谁手排?一天都排不完 |
| AST 改写(本文) | 语义级操作、可组合、可复用 | 需要学 Babel API,但一次投入长期受益 |
本文脚本使用 @babel/parser(解析)、@babel/traverse(遍历)、@babel/generator(生成)、@babel/types(节点构造)。核心思路是一条内存中的流水线 :parse → Step1~Step7 顺序改写 → generate。
js
// 主流程骨架(真实脚本精简版)
const ast = parser.parse(sourceCode);
renameObfuscatedIdentifiers(ast); // Step1 重命名
// 准备沙箱、还原字符串、清理解密代码 -> Step2
restoreDictCalls(ast); // Step3 还原字典调用
ifElseToSwitch(ast); // Step4 if-else -> switch
eliminateDeadCode(ast); // Step5 常量条件死代码
eliminateDeadForLoop(ast); // Step6 恒假 for 死代码
deobfuscateControlFlow(ast); // Step7 while+switch 平坦化
const output = generator(ast, { compact: false });
fs.writeFileSync(OUTPUT_FILE, output.code);
下面逐个 Step 拆解。
三、Step1:混淆标识符重命名(_0x... → 语义前缀)
混淆变量名形如 _0x5ae8、_0xc8b2,毫无信息量。第一步把它们按类型 + 初始值 重命名为 var_1 / fn_1 / arr_1 / obj_1 / str_1 等,至少让人能区分"这是个数组还是个函数"。
js
// 识别混淆名
function isObfuscatedName(name) {
return /^_0x[0-9a-fA-F]+$/.test(name);
}
// 根据初始值推断类别,生成可读前缀
function generateName(kind, initValue, path) {
let prefix = 'var';
if (types.isArrayExpression(initValue)) prefix = 'arr';
else if (types.isObjectExpression(initValue)) prefix = 'obj';
else if (types.isStringLiteral(initValue)) prefix = 'str';
else if (types.isFunctionExpression(initValue)) prefix = 'fn';
// ... number / boolean / param 同理
return `${prefix}_${++this.counters[category]}`;
}
关键点 :遍历时用 scope.rename(old, new) 而不是简单的文本替换------Babel 会自动处理闭包内的引用 ,避免重名冲突。这一步本样本中重命名了 534 个标识符。
四、Step2:字符串还原 + 清理解密代码(最"解渴"的一步)
字符串全被 _0xc8b2(0x1a) 这样的调用藏了起来。要还原,需要先让解密函数真正跑起来,拿到返回值,再用字符串字面量替换调用。
难点在于:解密函数依赖那个大数组 _0x5ae8,且样本有一层数组旋转自保护 (while(!![]) 里通过 parseInt 算术把数组循环位移到位)。所以我们用 vm 模块建一个沙箱,把"初始化代码"(大数组声明、解密函数定义、数组旋转)丢进去执行,把解密函数暴露在沙箱上下文里。
js
function createSandbox() {
const sandbox = {
console: { log(){}, error(){}, warn(){} }, // 静音,防副作用
Math, Date, JSON, parseInt, parseFloat,
String, Number, Boolean, Array, Object, RegExp, Error,
window: {}, global: {}
};
sandbox.window = sandbox; sandbox.global = sandbox;
vm.createContext(sandbox);
return sandbox;
}
// 在沙箱里把大数组 + 解密函数跑起来
const setupCode = extractSetupCode(ast); // 抽取文件头部声明/调用
runInSandbox(setupCode, sandbox, '解密环境初始化');
// 遇见 fn_1(常量参数) 就直接求值替换
traverse(ast, {
CallExpression(path) {
if (!fn1Aliases.has(path.node.callee.name)) return;
if (!args.every(isLiteralArg)) return; // 只处理参数全为字面量
const value = vm.runInContext(`fn_1(${argCode})`, sandbox);
if (typeof value === 'string') {
path.replaceWith(types.stringLiteral(value)); // 原地替换成字符串
}
}
});
还原 297 处字符串调用后,再把解密函数、大数组、自保护 IIFE 这些"用完即弃"的噪音节点整段删掉(本样本删除 41 处),输出瞬间清爽。
五、Step3:还原字典对象调用
样本里还有一层"字典对象",把原生方法名藏成了乱码 key:
js
var _0x4d21fc = {
'WlOyD': function(a,b){ return a === b },
'kAxGT': 'prototype',
// ...
};
_0x4d21fc['WlOyD'](x, y) // 等价于 x === y
做法是遍历收集所有 obj.key = 值 的映射,遇到 obj['key']() 且值是"单 return 函数"时,把参数代入函数体再整体替换;遇到 obj['key'] 且值是字面量/标识符时直接内联。最后统计引用次数为 0 的字典变量并删除(本样本清理 5 个)。
六、Step4:嵌套 if-else 还原为 switch-case
控制流平坦化常把一段顺序逻辑切成"用状态变量分派"的 if-else 链:
js
for (...) {
if (state === 0) { ...; }
else if (state === 1) { ...; }
else if (state === 2) { ...; }
// 一堆 else if
}
脚本识别 BinaryExpression(state === 数字) 的分派模式,把整棵 if-else 树重排成等价的 switch(state){ case 0: ...; break; case 1: ...; break; },为下一步"线性化"打好结构基础。
七、Step5 & Step6:常量折叠 + 死代码消除
混淆器常塞进恒真/恒假的判断 和永远不执行的 for 循环 (参数全为字面量、循环条件静态可判定)。我们写了一套 evaluateStatic 常量求值器,递归计算字面量、一元/二元/逻辑表达式的值,然后:
- Step5(if 死代码) :
if (true) {A} else {B}→ 直接留下A;if (false) ...→ 删除。 - Step6(for 死代码) :
for(; 0; )或循环条件静态为假的,移除循环体(保留init声明),本样本移除 83 个。
js
function isStaticallyFalsy(node) {
const r = evaluateStatic(node);
return r.confident && !r.value; // 能确定求值且为假
}
这类"确定性化简"是安全且可逆的------只要求值有把握(confident),替换就语义等价。
八、Step7:while+switch 控制流平坦化还原(最难的一块)
最恶心的是这种结构:一个 while(true) 包裹 switch(disp[ptr++]),disp 是一串用 split 切出来的"执行顺序字符串",每个 case 是切碎的逻辑片段。
js
var disp = "3|1|2|0|4|5".split("|");
var ptr = 0;
while (true) {
switch (disp[ptr++]) {
case "0": /* 片段A */ continue;
case "1": /* 片段B */ continue;
// ...
}
}
还原逻辑:
- 取出
disp绑定,确认它由string.split得来 → 拿到真实执行顺序数组。 - 把每个 case 的语句收集成
caseMap(顺便剥掉末尾的continue/break)。 - 按
disp.split(sep)的顺序,把碎片重新线性拼接 ,用path.replaceWithMultiple(flattenedStatements)整体替换掉 while 循环。 - 删除
disp数组和ptr迭代器声明。
这一刀下去,被切碎的真·业务逻辑就重新"连成线"了。
九、真实运行结果(不是 PPT,是真跑了一遍)
环境:Node 22 + @babel/* 最新稳定版,样本 source.js(389,897 字符)。
| 阶段 | 指标 | 数值 |
|---|---|---|
| 输入 | 文件大小 | 389,897 字符 |
| Step1 | 重命名标识符 | 534 个 |
| Step2 | 还原字符串调用 | 297 处 |
| Step2 | 删除解密/混淆代码 | 41 处 |
| Step3 | 清理未用字典变量 | 5 个 |
| Step4 | if-else → switch | 完成 |
| Step5 | 死代码消除轮次 | 1 轮 |
| Step6 | 移除恒假 for 循环 | 83 个 |
| Step7 | while+switch 还原 | 完成 |
| 输出 | 文件大小 | 256,192 字符 |
| 性能 | 总耗时 | 609 ms |
反混淆前后同一处逻辑对比:
js
// ❌ 反混淆前(source.js 片段)
glb[_0xe762c0(0x73)] = function(_0x1f8d7a, _0x4ede15, _0xb2668e) {
var _0x4ae35e = _0xe762c0, // 嵌套字典、乱码函数满天飞
_0x4d21fc = { 'WlOyD': function(a,b){return a===b}, ... };
// ✅ 反混淆后(decode6.js 片段)
var glb = "undefined" == typeof window ? global : window;
glb["_AUuXfEG27Xa3x"] = function (arg_5, arg_6, arg_7) {
function fn_2() { /* Reflect.construct 兼容垫片,命名可读 */ }
注意:重命名用的是
arg_5 / fn_2这类结构命名而非原始语义名------因为 AST 改写无法凭空知道变量"本来的业务名"。要进一步可读,可再人工或借助语义分析二次命名,但那已是另一层工作了。
十、关键技术小结(踩坑与经验)
- 沙箱不是可选,是必须 :解密函数里可能有
console、DOM 访问甚至副作用,用vm隔离 + 静音 console,既能跑出结果又不污染宿主。 - 只还原"有把握"的 :字符串调用只处理"参数全为字面量"的情况,常量求值只在
confident为真时替换------守住"语义等价"这条底线,反混淆才不会改坏逻辑。 - 顺序很重要:先重命名 → 再还原字符串/清解密代码 → 再还原字典 → 最后做控制流平坦化。逆序会找不到目标节点。
scope.rename优于文本替换:自动处理作用域与闭包引用,避免冲突。- 流水线化收益巨大 :七个
demo.js合成一个脚本后,单次运行 0.6 秒出结果,比手动分步稳得多。
十一、总结 & 写在最后
JS 反混淆的本质,是在不改变语义的前提下,把"变形"一层层 undo 回去。本文的七步流水线(重命名 → 字符串还原 → 字典还原 → if-else 平坦化 → 死代码消除 → for 死代码消除 → while+switch 平坦化)覆盖了绝大多数"数组 + 字典 + 控制流平坦化"类混淆,且全部基于 AST 语义级改写,安全、可复用、可扩展。
完整脚本就是本文反复引用的 demo.js(整合了原 demo0~demo6 七个步骤)。把它放到样本同目录,执行:
bash
npm i @babel/core @babel/parser @babel/traverse @babel/generator @babel/types
node demo.js source.js decode6.js
0.6 秒,还你一份能读的代码。
推荐标签 :#JavaScript #AST #反混淆 #Babel #逆向工程 #前端安全 #代码还原 #控制流平坦化
声明:本文仅用于安全研究、代码审计与学习用途。请勿将相关技术用于绕过他人合理的技术保护措施或任何非法场景。