利用 AST 技术还原 JavaScript 混淆代码

原文作者:K哥爬虫 | 发布于 2022-04-28

AST技术封面

AST解混淆示例


一、先搞懂:AST 到底是什么?

AST(Abstract Syntax Tree,抽象语法树) 是源代码语法结构的树状表示——树上的每个节点,都对应着代码中的一个语法单元,比如变量声明、函数调用、if 条件判断等。

你可以把一段被混淆的 JavaScript 代码想象成一堆积木乱糟糟地堆在一起:通过 AST 解析,能精准定位每一块积木的类型和位置;通过修改 AST,我们可以按正确的逻辑顺序重新排列它们,最终拼回可读的代码。

AST 的广泛应用

AST 并不是逆向工程的专属玩具,你日常用到的很多开发工具,背后都在用它:

  • IDE 功能:语法高亮、智能补全、自动格式化
  • 代码转译:Babel 把 ES6+ 语法翻译成 ES5,核心就是操作 AST
  • 代码压缩:UglifyJS、Terser 等工具删除无用代码、缩短变量名,同样依赖 AST
  • 逆向解混淆:还原被打乱/加密的 JS 代码 ← 这就是本文要讲的重点

上手利器:AST 在线可视化工具

强烈推荐在 https://astexplorer.net/ 边改边看:

  1. 顶部栏:语言选 JavaScript,编译器选 @babel/parser(本文默认选择)
  2. 区域①:粘贴你想分析的混淆代码或正常代码
  3. 区域②:实时看到对应的 JSON 树形结构,点击节点可以高亮对应的源代码部分,反之亦然
  4. 区域③:编写 Babel 转换脚本(在这里对语法树增删改查)
  5. 区域④:预览转换后生成的代码

AST在线解析工具


二、基础铺垫:AST 在编译过程中的定位

可能大多数前端同学平时不会去写编译器,但理解下面这三步,是玩转逆向解混淆的基本功:

源代码 → 词法分析 → 语法分析 → AST → [我们在这里修改] → 代码生成 → 目标代码

编译过程示意图

1. 词法分析:拆成一个个「单词」

从左到右逐字符扫描代码,把它们切分成有独立意义的 Token(记号)流。比如 log('Hi') 会被拆成:

  • log → 标识符
  • ( → 左圆括号
  • 'Hi' → 字符串字面量
  • ) → 右圆括号

2. 语法分析:拼出完整的「句子」

把 Token 序列按照语法规则组合起来,建立嵌套、先后等关系,最终生成一棵 AST。例如 log('Hi') 的 AST 大致长这样:

ExpressionStatement(表达式语句)
  └── CallExpression(函数调用表达式)
      ├── Identifier(标识符):log
      └── [Arguments]
          └── StringLiteral(字符串字面量):“Hi”

3. 代码生成:把修改后的树还原成代码

在 AST 层完成修剪、替换、排序等操作后,再生成可执行的代码字符串。我们做解混淆的所有工作,都集中在这中间的 AST 层上。


三、核心武器:Babel 全家桶快速上手

Babel 是目前最主流的 JavaScript 编译器,内部提供了一套成熟易用的 AST 操作 API,完全能满足入门级逆向需求。

安装方式

npm install @babel/core @babel/parser @babel/traverse @babel/generator @babel/types

核心包速览

包名作用
@babel/parser把代码字符串解析为 AST(JSON 树结构)
@babel/traverse结合 Visitor 模式 批量遍历/修改 AST 节点
@babel/generator将修改后的 AST 还原成代码字符串
@babel/types判断节点类型、快速创建新的 AST 节点

@babel/parser:解析代码为 AST

最常用的是 parse 方法:

const parser = require("@babel/parser");

const code = "const a = 1;";
const ast = parser.parse(code, { 
  sourceType: "module" // 支持 ES Module 语法,可选
});
console.log(ast); // 输出嵌套的 JSON,直接在 astexplorer 里看更直观

@babel/traverse:批量处理节点

如果只是单点修改,可以直接用路径访问(如 ast.program.body[0]),但处理大批量节点时,必须依靠 traverse + visitor

基础示例:批量修改数字和字符串

const parser = require("@babel/parser");
const generate = require("@babel/generator").default;
const traverse = require("@babel/traverse").default;

const code = `
const a = 1500;
const b = "hi";
const c = 787;
`;
const ast = parser.parse(code);

// visitor 对象:键是节点类型,值是处理函数
const visitor = {
  NumericLiteral(path) {
    path.node.value = (path.node.value + 100) * 2;
  },
  StringLiteral(path) {
    path.node.value = "I Love JavaScript!";
  }
};

traverse(ast, visitor);
console.log(generate(ast).code);

输出结果:

const a = 3200;
const b = "I Love JavaScript!";
const c = 1774;

visitor 的几种常见写法

以下四种方式效果完全相同,可以根据习惯选用:

// 1. 简写方法(最常用)
const visitor = { NumericLiteral(path) {...} };

// 2. 统一入口 + 类型判断(适合需要统一处理的情况)
const visitor = {
  enter(path) {
    if (path.node.type === "NumericLiteral") {...}
  }
};

// 3. 多个类型共享一个处理函数
const visitor = {
  "NumericLiteral|StringLiteral"(path) {...}
};

提示enter 是进入节点时调用的钩子,exit 是离开节点时调用的钩子(一般用于后序处理)。


@babel/types:构建新节点

当需要向 AST 中新增代码时,必须使用 @babel/types,它的方法名通常是节点类型的「首字母小写」。

高效技巧

如果不确定方法需要哪些参数,在支持 TypeScript 的编辑器里 按住 Ctrl + 点击方法名 跳转到类型定义,比翻官方文档快得多。


四、实战修炼:五种典型混淆的还原方案

4.1 Unicode / 十六进制字符串还原

混淆工具常常把普通字符串变成编码形式,比如 console['\u006c\u006f\u0067']

关键发现

在 AST 中,这类字符串虽然 extra.raw 里是乱糟糟的转义序列,但 value 属性已经被 Node.js 自动处理成了正常的文字

还原代码

const visitor = {
  StringLiteral(path) {
    // 删除 raw 属性,生成代码时会基于 value 重新输出
    delete path.node.extra?.raw;
    delete path.node.extra?.rawValue;
  }
};

4.2 静态表达式求值还原

混淆代码会把简单的常量替换成一长串运算,例如 const a = !![]+!![]+!![](实际结果就是 3)。

核心方法

使用 Babel 内置的 path.evaluate(),它会自动判断表达式是否可以在不执行的情况下计算出结果(即静态可计算)。

还原代码

const types = require("@babel/types");

const visitor = {
  "BinaryExpression|CallExpression|ConditionalExpression"(path) {
    const { confident, value } = path.evaluate();
    if (confident) {
      // 将计算结果值转换成 AST 节点并原地替换
      path.replaceInline(types.valueToNode(value));
    }
  }
};

注意:如果表达式中引用了外部变量,confident 会返回 false,此时不要强行替换,否则会丢失逻辑。


4.3 清除未使用的变量和函数

混淆代码里经常会塞入大量无用的声明,纯属干扰分析。

核心方法

使用 path.scope.getBinding() 检查变量/函数的引用情况:

  • referenced:是否被其他代码引用
  • constant:是否为常量(考虑过是否被重新赋值)

还原代码

const visitor = {
  VariableDeclarator(path) {
    const binding = path.scope.getBinding(path.node.id.name);
    // 未找到绑定 or 被重新赋值过 or 被引用过 → 保留
    if (!binding || binding.constantViolations.length > 0 || binding.referenced) return;
    // 否则是“僵尸代码”,直接删除
    path.remove();
  }
};

4.4 删除永真/永假的 if-else 分支

混淆代码中常常出现 if (false) 或者 if (1) 这类分支,其中一些分支永远不会执行,一些则是必然执行。

还原代码

const types = require("@babel/types");

const visitor = {
  IfStatement(path) {
    const test = path.node.test;
    // 只处理条件值是布尔或数字字面量的情况(变量条件无法静态判断)
    if (!types.isBooleanLiteral(test) && !types.isNumericLiteral(test)) return;

    if (test.value) {
      // 条件为真 → 用 if 分支的语句直接替换 if 语句
      path.replaceInline(path.node.consequent.body);
    } else if (path.node.alternate) {
      // 条件为假且有 else → 用 else 分支替换
      path.replaceInline(path.node.alternate.body);
    } else {
      // 条件为假且没有 else → 删除整个 if
      path.remove();
    }
  }
};

4.5 switch-case 反控制流平坦化(入门版)

控制流平坦化是最常用的混淆手段之一,它通过 while-switch-case 结构加上一个乱序的数组,把原本顺序执行的代码切成片,再按数组顺序重新“播放”。

核心思路

  1. 定位控制顺序的数组(比如 '3,4,0,5,1,2'['split'](',')
  2. 按索引顺序依次取出对应的 case 块内容
  3. 去掉每个 case 末尾的 continue 语句
  4. 用拼接好的正常顺序代码替换整个 while 结构

还原代码(前置依赖查找版)

const visitor = {
  WhileStatement(path) {
    const switchNode = path.node.body.body[0];
    // 拿到控制流数组的变量名
    const arrayName = switchNode.discriminant.object.name;

    let controlFlowArr = [];
    // 向 while 前面的兄弟节点查找数组定义
    path.getAllPrevSiblings().forEach(prevPath => {
      const { id, init } = prevPath.node.declarations?.[0] || {};
      if (id?.name === arrayName) {
        // 简化版 split 模拟,实际中可能遇到更复杂的生成方式
        const str = init.callee.object.value;
        const separator = init.arguments[0].value;
        controlFlowArr = str.split(separator);
        // 删除已经用过的数组定义
        prevPath.remove();
      }
    });

    // 按顺序拼接 case 内容
    let replaceNodes = [];
    controlFlowArr.forEach(index => {
      const caseBody = switchNode.cases[index].consequent;
      // 如果最后一条语句是 continue,就删掉
      if (types.isContinueStatement(caseBody.at(-1))) caseBody.pop();
      replaceNodes = replaceNodes.concat(caseBody);
    });

    // 替换掉整个 while
    path.replaceWithMultiple(replaceNodes);
  }
};

五、要点速查与学习资源

核心技术点速查

操作核心方法
解析代码@babel/parserparse()
批量遍历/修改@babel/traversetraverse(ast, visitor)
生成代码@babel/generatorgenerate(ast)
创建新节点@babel/typestypes.xxx()
计算静态表达式path.evaluate()
检查变量引用path.scope.getBinding()
内联替换节点path.replaceInline()
删除节点path.remove()

推荐学习资源

资源链接
AST 在线可视化astexplorer.net
Babel 中文官网babeljs.cn
Babel Traverse 中文文档evilrecluse.top/Babel-traverse-api-doc

解混淆没有一成不变的万能公式,核心思路就是:根据目标混淆工具的具体输出,配合 AST 工具一点一点还原真实的逻辑。掌握本文的基础知识和套路之后,90% 的入门级 JavaScript 混淆都挡不住你了。