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

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

AST技术封面

AST解混淆示例


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

AST(Abstract Syntax Tree),即抽象语法树,是源代码语法结构的树状表现形式——树上的每个节点,都对应代码里的标识符、变量声明、函数调用、if判断等基础语法单元。

可以把 JS 混淆代码想象成被打乱组装的乐高积木:通过 AST 解析,我们能精准定位每一块积木的类型和位置;通过修改 AST,我们可以按逻辑顺序重新拼好它

AST 的应用场景

它并非逆向专属,日常开发的很多工具都在默默用它:

  • IDE 辅助:语法高亮、代码补全、自动格式化
  • 代码转译:Babel 将 ES6+ 语法降级为 ES5
  • 代码压缩:Uglify、Terser 等工具删除冗余、缩短变量名
  • 逆向解混淆:还原被打乱/加密的 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 是目前最主流的 JS 编译器,内置了完善的 AST 操作 API,完全能满足入门级逆向需求。

安装依赖

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

核心功能包

包名作用
@babel/parser将代码字符串解析为 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:构建新节点的工具

需要新增代码时必须用它,方法名是节点类型的「首字母小写」。

偷懒技巧

不确定方法的参数时,直接在支持 TypeScript 的 IDE 里按住 Ctrl + 点击 方法名看类型定义,比翻官方文档快 N 倍!


四、实战演练:5个常见混淆的还原方案

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

混淆代码常把普通字符串转成编码,比如 console['\u006c\u006f\u0067']

核心观察

在 AST 中,编码后的字符串虽然 extra.raw 是乱码,但 value 已经是 Node.js 自动转义后的正常字符

还原代码

const visitor = {
  StringLiteral(path) {
    // 删除 raw,让 generator 用 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) {
      // 把计算后的 value 转为 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);
    // 未定义、或被修改过、或被引用过 → 保留
    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 分支
      path.replaceInline(path.node.consequent.body);
    } else if (path.node.alternate) {
      // 条件为假且有 else → 替换为 else 分支
      path.replaceInline(path.node.alternate.body);
    } else {
      // 否则删除整个 if
      path.remove();
    }
  }
};

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

控制流平坦化是最常用的混淆手段之一,通过 while-switch-case + 数组打乱代码顺序。

核心思路

  1. 找到控制流数组(比如 '3,4,0,5,1,2'['split'](',')
  2. 按数组顺序依次取出对应 case 的内容
  3. 删除 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%的入门级混淆都难不倒你!