用 Babel AST 把“天书“ JS 一键还原:七步流水线反混淆实战

用 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-elsewhile+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} → 直接留下 Aif (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;
    // ...
  }
}

还原逻辑:

  1. 取出 disp 绑定,确认它由 string.split 得来 → 拿到真实执行顺序数组。
  2. 把每个 case 的语句收集成 caseMap(顺便剥掉末尾的 continue/break)。
  3. disp.split(sep) 的顺序,把碎片重新线性拼接 ,用 path.replaceWithMultiple(flattenedStatements) 整体替换掉 while 循环。
  4. 删除 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 改写无法凭空知道变量"本来的业务名"。要进一步可读,可再人工或借助语义分析二次命名,但那已是另一层工作了。


十、关键技术小结(踩坑与经验)

  1. 沙箱不是可选,是必须 :解密函数里可能有 console、DOM 访问甚至副作用,用 vm 隔离 + 静音 console,既能跑出结果又不污染宿主。
  2. 只还原"有把握"的 :字符串调用只处理"参数全为字面量"的情况,常量求值只在 confident 为真时替换------守住"语义等价"这条底线,反混淆才不会改坏逻辑。
  3. 顺序很重要:先重命名 → 再还原字符串/清解密代码 → 再还原字典 → 最后做控制流平坦化。逆序会找不到目标节点。
  4. scope.rename 优于文本替换:自动处理作用域与闭包引用,避免冲突。
  5. 流水线化收益巨大 :七个 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 #逆向工程 #前端安全 #代码还原 #控制流平坦化

声明:本文仅用于安全研究、代码审计与学习用途。请勿将相关技术用于绕过他人合理的技术保护措施或任何非法场景。

相关推荐
字节暗面9 天前
SO 加固强度自查 Checklist:静态、加载链、运行时看哪几项
安全·逆向
sysinside15 天前
Binary Ninja 6.0 (macOS, Linux, Windows) 发布 - 逆向平台
逆向·反编译
0xBADCODE18 天前
动态DEX加载+反射+DES硬编码密钥:安卓三层逆向实战
android·java·python·安全·网络安全·逆向·ctf
深念Y18 天前
B站封面与元数据抓取完整方案
逆向
安全小王子23 天前
nssctf_chicken_soup
网络安全·逆向·ctf·nssctf
安全小王子23 天前
nssctf——老鼠走迷宫
网络安全·逆向·ctf·nssctf
深念Y23 天前
QQ 数据库解密与聊天记录导出 — 技术文档
数据库·sqlite·手机·数据恢复·逆向·二进制·qq
安全小王子23 天前
nssctf_easyapp
网络安全·逆向·ctf·nssctf
安全小王子24 天前
nssctf_sign-ezc++
网络安全·逆向·ctf·nssctf