JavaScript 代码混淆与反混淆:利用 AST 变形提升代码安全性

各位同仁,各位技术爱好者,大家好!

今天,我们齐聚一堂,共同探讨一个在现代Web开发中日益重要的议题:JavaScript代码混淆与反混淆,以及如何利用抽象语法树(AST)变形来提升代码安全性。在当今这个开源与协作盛行的时代,JavaScript代码的透明性为创新提供了沃土,但同时,也为知识产权保护、防止篡改和逆向工程带来了严峻挑战。代码混淆正是应对这些挑战的有效策略之一。

本讲座将深入剖析AST在代码混淆和反混淆中的核心作用。我们将从AST的基础概念入手,逐步展示如何通过对AST的精巧操作来实现各种混淆技术,进而探讨如何识别并逆转这些混淆,以恢复代码的可读性。我们将通过丰富的代码示例,从理论到实践,全面揭示这一领域的技术细节。

1. 抽象语法树(AST):代码的内在骨架

在深入探讨代码混淆与反混淆之前,我们必须首先理解其核心工具——抽象语法树(Abstract Syntax Tree,简称AST)。AST是源代码的抽象语法结构的树状表示,它以一种独立于具体编程语言文本语法的方式来表达程序。简单来说,AST就是我们代码的骨架,它去除了所有不必要的细节(如空格、注释、分号在特定情况下的可选性),只保留了代码的结构和语义信息。

1.1 AST的生成与解析

将源代码转换为AST的过程称为解析(Parsing)。JavaScript社区拥有众多强大的解析器,其中最常用且功能丰富的包括:

  • Acorn: 一个小巧、快速的JavaScript解析器,专注于生成符合ESTree规范的AST。
  • Esprima: 另一个广泛使用的JavaScript解析器,同样生成ESTree兼容的AST。
  • Babel Parser (@babel/parser): Babel项目的一部分,能够解析最新的JavaScript语法(包括提案阶段的语法),并生成Babel特有的AST格式(与ESTree兼容但有扩展)。

无论使用哪种解析器,其核心思想都是将源代码字符串转换为一个层次化的数据结构。

让我们看一个简单的JavaScript代码片段及其对应的AST表示(为简洁起见,这里展示的是概念性的、简化后的AST结构):

原始代码:

function add(a, b) {
  return a + b;
}
const result = add(1, 2);

概念性AST结构:

{
  "type": "Program",
  "body": [
    {
      "type": "FunctionDeclaration",
      "id": {
        "type": "Identifier",
        "name": "add"
      },
      "params": [
        { "type": "Identifier", "name": "a" },
        { "type": "Identifier", "name": "b" }
      ],
      "body": {
        "type": "BlockStatement",
        "body": [
          {
            "type": "ReturnStatement",
            "argument": {
              "type": "BinaryExpression",
              "operator": "+",
              "left": { "type": "Identifier", "name": "a" },
              "right": { "type": "Identifier", "name": "b" }
            }
          }
        ]
      }
    },
    {
      "type": "VariableDeclaration",
      "kind": "const",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "result"
          },
          "init": {
            "type": "CallExpression",
            "callee": {
              "type": "Identifier",
              "name": "add"
            },
            "arguments": [
              { "type": "Literal", "value": 1, "raw": "1" },
              { "type": "Literal", "value": 2, "raw": "2" }
            ]
          }
        }
      ]
    }
  ],
  "sourceType": "script"
}

从上面的结构可以看出,每个节点都包含一个 type 属性,表示该节点的类型(如 Program, FunctionDeclaration, Identifier, Literal 等)。不同类型的节点还会有其特有的属性,例如 FunctionDeclarationid (函数名)、params (参数列表)、body (函数体),而 BinaryExpressionoperator (操作符)、left (左操作数)、right (右操作数)。

1.2 为什么是AST而不是正则表达式?

在不了解AST的情况下,很多人可能会尝试使用正则表达式来修改或分析代码。然而,正则表达式在处理复杂的、嵌套的、上下文敏感的编程语言结构时,会迅速变得极其脆弱和难以维护。考虑以下几点:

  • 语法歧义性:JavaScript语法非常灵活,例如,一个 . 可能表示对象成员访问,也可能是一个浮点数的一部分。
  • 嵌套结构:函数、块、表达式可以任意嵌套,正则表达式很难正确匹配深层嵌套的结构。
  • 上下文敏感性:变量 a 在一个作用域中可能是一个局部变量,在另一个作用域中则可能是全局变量,正则表达式无法理解作用域的概念。

AST通过明确的节点类型和父子关系,完美地解决了这些问题。它提供了一个结构化的、语义化的视图,使得我们可以精确地定位和修改代码的任何部分,而无需担心意外地匹配到不相关的代码。

常用AST节点类型速览表:

AST节点类型 描述 示例代码片段
Program 整个程序的根节点。 整个文件
FunctionDeclaration 函数声明。 function foo() {}
VariableDeclaration 变量声明(var, let, const)。 const x = 1;
VariableDeclarator 变量声明中的单个变量及其初始化值。 x = 1
Identifier 标识符,如变量名、函数名。 x, foo
Literal 字面量,如字符串、数字、布尔值。 "hello", 123, true
ExpressionStatement 表达式语句,即一个表达式作为语句。 console.log(x);
CallExpression 函数调用。 foo(1, 2)
MemberExpression 成员访问,如对象属性访问。 obj.prop
BinaryExpression 二元表达式,如加减乘除、比较。 a + b, x === y
IfStatement if 语句。 if (x) { ... }
BlockStatement 块语句,通常由 {} 括起来。 { let x = 1; }
ReturnStatement return 语句。 return x;

2. 利用AST变形进行代码混淆

代码混淆的目的是在不改变程序外部行为的前提下,使其内部结构和逻辑变得难以理解、分析和逆向工程。AST变形是实现这一目标的核心手段。通过对AST节点进行增删改查,我们可以创造出各种复杂的、令人困惑的代码结构。

2.1 混淆的通用流程

  1. 解析 (Parse): 将源代码解析成AST。
  2. 遍历与转换 (Traverse & Transform): 深度优先或广度优先遍历AST,识别目标节点,并对其进行修改、替换、插入或删除。这一步是混淆的核心。
  3. 生成 (Generate): 将修改后的AST重新生成为混淆后的源代码字符串。

我们将使用Babel工具链来演示AST的解析、遍历和生成,因为它提供了非常友好的API和对最新JS语法的支持。

  • @babel/parser: 解析器。
  • @babel/traverse: 遍历器,用于访问和修改AST节点。
  • @babel/generator: 生成器,将AST转换回代码。
  • @babel/types (或 t): 辅助函数,用于创建和检查AST节点。
// 示例:引入Babel相关模块
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types'); // 用于创建新的AST节点

2.2 常见的AST混淆技术

2.2.1 标识符重命名 (Identifier Renaming)

这是最基础也是最有效的混淆技术之一。它将有意义的变量名、函数名、参数名替换为短小、无意义的字符串(如 a, b, _0x123abc 等)。这极大地增加了代码的可读性障碍。

混淆目标: 所有的 Identifier 节点。

AST变形策略:

  1. 遍历AST,找到所有的 Identifier 节点。
  2. 为每个 Identifier 生成一个新的、唯一的、无意义的名称。
  3. 关键在于:当一个标识符被重命名时,所有引用它的地方也必须同步更新。Babel的 scope 机制可以很好地处理这一点。

代码示例:基础标识符重命名

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

const code = `
function calculateSum(num1, num2) {
  const intermediateResult = num1 + num2;
  return intermediateResult * 2;
}
const finalAnswer = calculateSum(10, 20);
console.log(finalAnswer);
`;

const ast = parser.parse(code, {
  sourceType: 'script'
});

let uid = 0;
const renameMap = new Map(); // 存储原始名称到混淆名称的映射

traverse(ast, {
  // 访问所有Identifier节点
  Identifier(path) {
    const { node, scope } = path;

    // 排除特定保留字或全局变量,例如 'console', 'log'
    if (['console', 'log', 'global', 'window', 'document'].includes(node.name)) {
      return;
    }

    // 确保只重命名当前作用域内的局部变量/函数/参数
    // 或者全局作用域中未被声明但被引用的标识符
    const binding = scope.getBinding(node.name);

    if (binding && binding.path.node === node) { // 如果是声明点,则生成新名称
      let newName = renameMap.get(node.name);
      if (!newName) {
        newName = '_' + (uid++).toString(36); // 生成类似 _0, _1, _a 的名称
        renameMap.set(node.name, newName);
      }
      // 重命名绑定,Babel会自动更新所有引用
      scope.rename(node.name, newName);
    } else if (binding) { // 如果是引用点,则使用已有的混淆名称
      const originalName = binding.identifier.name;
      const newName = renameMap.get(originalName);
      if (newName) {
        node.name = newName;
      }
    } else { // 可能是未声明的全局变量,也重命名
        let newName = renameMap.get(node.name);
        if (!newName) {
            newName = '_' + (uid++).toString(36);
            renameMap.set(node.name, newName);
        }
        node.name = newName;
    }
  }
});

const output = generate(ast, {}, code);
console.log("--- 标识符重命名混淆 ---");
console.log("原始代码:n", code);
console.log("混淆后代码:n", output.code);
/*
// 可能的混淆后代码输出:
function _0(_1, _2) {
  const _3 = _1 + _2;
  return _3 * 2;
}
const _4 = _0(10, 20);
console.log(_4);
*/

注意: 上面的重命名逻辑是简化的,实际的混淆器需要更复杂的逻辑来处理作用域、全局变量、属性名等情况,以避免破坏代码功能。例如,对象属性名如果不是计算属性,通常不应该被重命名,因为它们可能是外部API的一部分。

2.2.2 字符串字面量混淆 (String Literal Obfuscation)

将代码中的字符串字面量(如 "hello", "error message")替换为某种编码形式,并在运行时通过一个解码函数恢复。这使得静态分析字符串变得困难。

混淆目标: StringLiteral 节点。

AST变形策略:

  1. 收集所有 StringLiteral 节点的值。
  2. 将这些字符串存储在一个数组中(通常是全局数组),并打乱顺序。
  3. 创建一个或多个用于从数组中获取字符串的辅助函数(例如,一个函数接受索引,另一个函数接受一个加密的索引,然后进行解密并查找)。
  4. 将原始的 StringLiteral 节点替换为对辅助函数的 CallExpression 节点。

代码示例:字符串字面量混淆

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const code = `
function greet(name) {
  const message = "Hello, " + name + "!";
  console.log(message);
  return "Welcome";
}
greet("World");
`;

const ast = parser.parse(code, { sourceType: 'script' });

const stringLiterals = [];
const stringMap = new Map(); // 原始字符串 -> 混淆后的索引

traverse(ast, {
  StringLiteral(path) {
    const { node } = path;
    if (!stringMap.has(node.value)) {
      stringLiterals.push(node.value);
      stringMap.set(node.value, stringLiterals.length - 1);
    }
    // 将字符串字面量替换为数组访问
    path.replaceWith(
      t.memberExpression(
        t.identifier('__str_pool'), // 假设有一个全局字符串池
        t.numericLiteral(stringMap.get(node.value)),
        true // computed: true 表示通过索引访问
      )
    );
  }
});

// 创建字符串池数组和辅助函数
const obfuscatedStrings = shuffleArray(stringLiterals); // 实际混淆器会打乱顺序
const stringPoolIdentifier = t.identifier('__str_pool');
const stringPoolDeclaration = t.variableDeclaration('const', [
  t.variableDeclarator(
    stringPoolIdentifier,
    t.arrayExpression(obfuscatedStrings.map(str => t.stringLiteral(str)))
  )
]);

// 将字符串池声明插入到AST的顶部
ast.program.body.unshift(stringPoolDeclaration);

const output = generate(ast, {}, code);
console.log("n--- 字符串字面量混淆 ---");
console.log("原始代码:n", code);
console.log("混淆后代码:n", output.code);

function shuffleArray(array) {
  // 简单的Fisher-Yates洗牌算法
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}
/*
// 可能的混淆后代码输出:
const __str_pool = ["Hello, ", "!", "Welcome", "World"]; // 顺序可能被打乱

function greet(name) {
  const message = __str_pool[0] + name + __str_pool[1]; // 索引根据实际打乱后的顺序
  console.log(message);
  return __str_pool[2];
}
greet(__str_pool[3]); // 索引根据实际打乱后的顺序
*/

实际混淆器在替换时会更加复杂,例如:

  • 不是直接用索引,而是用加密后的索引。
  • 数组中的字符串会经过进一步的编码(Base64, Hex等)。
  • 会有一个专门的解码函数来处理这些编码和索引。
2.2.3 控制流平坦化 (Control Flow Flattening)

这是更高级的混淆技术,旨在破坏代码的自然执行流程,使其难以通过静态分析追踪。常见的做法是将 if/else, for, while 等控制结构转换为一个巨大的 switch 语句,通过一个“状态机”变量来控制程序的下一步执行。

混淆目标: IfStatement, ForStatement, WhileStatement 等控制流节点。

AST变形策略:

  1. 识别函数或代码块中的所有基本块(Basic Block),即没有分支的连续语句序列。
  2. 将每个基本块封装成一个 case 分支。
  3. 引入一个状态变量,该变量的值决定 switch 语句下一个执行哪个 case
  4. 将原始的控制流语句替换为一个无限 while 循环,循环体内包含一个 switch 语句。

代码示例:控制流平坦化(概念性实现)

此处的实现会非常复杂,需要进行CFG(Control Flow Graph)分析。我们只展示其混淆后的形式和大致的思路。

原始代码:

function process(flag) {
  let result = 0;
  if (flag) {
    result = 10;
  } else {
    result = 20;
  }
  console.log(result);
}

混淆后代码(概念性):

function process(flag) {
  let result = 0;
  let state = 0; // 初始状态

  while (true) {
    switch (state) {
      case 0: // 模拟原始代码的入口点
        if (flag) {
          state = 1; // 跳转到if分支
        } else {
          state = 2; // 跳转到else分支
        }
        break;
      case 1: // 模拟if分支
        result = 10;
        state = 3; // 跳转到后续代码
        break;
      case 2: // 模拟else分支
        result = 20;
        state = 3; // 跳转到后续代码
        break;
      case 3: // 模拟后续代码
        console.log(result);
        return; // 退出循环
      default:
        return; // 异常情况
    }
  }
}

在AST层面,这意味着将 IfStatement 节点转换为 BlockStatement 内部的 SwitchStatementWhileStatement 节点。这需要创建新的 VariableDeclaration (for state), WhileStatement, SwitchStatement, SwitchCase, BreakStatement, ReturnStatement 等节点。

2.2.4 表达式混淆 (Expression Obfuscation)

通过改变表达式的结构,使其变得冗长或难以直接推断其值。

混淆目标: Literal (数字、布尔值), BinaryExpression 等。

AST变形策略:

  • 数字字面量转换10 可以变成 (5 + 5),或者 parseInt("0xa"),甚至 eval("10")
  • 布尔字面量转换true 可以变成 !0false 可以变成 !1
  • 数学表达式重排a + b + c 可以变成 (a + c) + b(a - (-b)) + c

代码示例:简单数字字面量混淆

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const code = `
const x = 123;
const y = x * 2 + 5;
function getValue() {
  return 100;
}
`;

const ast = parser.parse(code, { sourceType: 'script' });

traverse(ast, {
  NumericLiteral(path) {
    const { node } = path;
    const originalValue = node.value;

    // 随机选择一种混淆方式
    const strategy = Math.floor(Math.random() * 3);

    let newNode;
    switch (strategy) {
      case 0: // 拆分为加法
        const part1 = Math.floor(originalValue / 2);
        const part2 = originalValue - part1;
        newNode = t.binaryExpression('+', t.numericLiteral(part1), t.numericLiteral(part2));
        break;
      case 1: // 转换为十六进制字符串并用parseInt
        newNode = t.callExpression(
          t.identifier('parseInt'),
          [t.stringLiteral('0x' + originalValue.toString(16))]
        );
        break;
      case 2: // 简单的算术变形,如乘除
        const factor = Math.floor(Math.random() * 5) + 2; // 随机因子
        newNode = t.binaryExpression(
          '/',
          t.numericLiteral(originalValue * factor),
          t.numericLiteral(factor)
        );
        break;
      default:
        return; // 不进行混淆
    }
    path.replaceWith(newNode);
  },
  BooleanLiteral(path) {
    const { node } = path;
    path.replaceWith(t.unaryExpression('!', t.numericLiteral(node.value ? 0 : 1))); // true -> !0, false -> !1
  }
});

const output = generate(ast, {}, code);
console.log("n--- 表达式混淆 ---");
console.log("原始代码:n", code);
console.log("混淆后代码:n", output.code);
/*
// 可能的混淆后代码输出:
const x = parseInt("0x7b"); // 123 -> 0x7b
const y = x * (1 + 1) + (2 + 3); // 2 -> (1+1), 5 -> (2+3)
function getValue() {
  return (50 + 50); // 100 -> (50+50)
}
*/
2.2.5 死代码注入 (Dead Code Injection)

插入永远不会被执行的代码块,以增加代码量和分析的复杂性。这些代码块通常包含混淆的逻辑,使得逆向工程师在分析时浪费时间。

混淆目标: 任何 BlockStatementFunctionDeclaration

AST变形策略:

  1. 生成一个永远为 false 的“不透明谓词”(Opaque Predicate),例如 (false && true) 或者 (new Date().getTime() === 0)
  2. 在代码中插入一个 IfStatement,其 test 表达式就是这个不透明谓词。
  3. if 语句的 consequent (if 分支)包含死代码,alternate (else 分支)包含原始代码。或者,直接在原始代码中插入一个 if (false) { 死代码 } 块。

代码示例:死代码注入

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const code = `
function performTask() {
  console.log("Task started.");
  // 核心逻辑
  let result = 100;
  console.log("Task finished with result:", result);
}
performTask();
`;

const ast = parser.parse(code, { sourceType: 'script' });

traverse(ast, {
  FunctionDeclaration(path) {
    const { node } = path;
    const bodyStatements = node.body.body;

    // 创建一个不透明谓词,永远为假
    const opaquePredicate = t.binaryExpression(
      '===',
      t.numericLiteral(Math.floor(Math.random() * 1000) + 1), // 随机数1
      t.numericLiteral(Math.floor(Math.random() * 1000) + 1001) // 随机数2,确保不相等
    ); // 例如:(567 === 1234)

    // 创建死代码块
    const deadCodeBlock = t.blockStatement([
      t.expressionStatement(
        t.callExpression(
          t.memberExpression(t.identifier('console'), t.identifier('warn')),
          [t.stringLiteral("This code will never run!")]
        )
      ),
      t.variableDeclaration('var', [
        t.variableDeclarator(
          t.identifier('deadVar'),
          t.binaryExpression('+', t.numericLiteral(1), t.numericLiteral(2))
        )
      ])
    ]);

    // 将死代码注入到函数体的开头或中间
    bodyStatements.unshift(
      t.ifStatement(opaquePredicate, deadCodeBlock)
    );
  }
});

const output = generate(ast, {}, code);
console.log("n--- 死代码注入混淆 ---");
console.log("原始代码:n", code);
console.log("混淆后代码:n", output.code);
/*
// 可能的混淆后代码输出:
function performTask() {
  if (234 === 5678) { // 随机生成的不透明谓词
    console.warn("This code will never run!");
    var deadVar = 1 + 2;
  }
  console.log("Task started.");
  let result = 100;
  console.log("Task finished with result:", result);
}
performTask();
*/
2.2.6 代理函数调用 (Proxy Function Calls)

将对原始函数的直接调用替换为通过一个代理函数进行的调用。代理函数可能会增加一层间接性,或者执行一些额外的检查。

混淆目标: CallExpression 节点。

AST变形策略:

  1. 找到所有的 CallExpression
  2. 为每个函数创建一个代理函数(或一个通用的代理函数)。
  3. CallExpressioncallee 替换为代理函数的调用。

代码示例:代理函数调用混淆

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const code = `
function originalFunc(x, y) {
  return x + y;
}
const result = originalFunc(5, 10);
console.log(result);
`;

const ast = parser.parse(code, { sourceType: 'script' });

const proxyMap = new Map(); // 原始函数名 -> 代理函数名

traverse(ast, {
  // 在函数声明时创建代理
  FunctionDeclaration(path) {
    const { node } = path;
    const originalName = node.id.name;
    if (originalName === 'console' || originalName === 'log') return; // 排除内置函数

    const proxyName = '_proxy_' + originalName;
    proxyMap.set(originalName, proxyName);

    // 创建代理函数:
    // function _proxy_originalFunc(...args) { return originalFunc(...args); }
    const proxyFunctionDeclaration = t.functionDeclaration(
      t.identifier(proxyName),
      [t.restElement(t.identifier('args'))], // ...args
      t.blockStatement([
        t.returnStatement(
          t.callExpression(
            t.identifier(originalName),
            [t.spreadElement(t.identifier('args'))] // ...args
          )
        )
      ])
    );
    // 将代理函数插入到原始函数声明之前
    path.insertBefore(proxyFunctionDeclaration);
  },
  // 替换函数调用
  CallExpression(path) {
    const { node } = path;
    if (node.callee.type === 'Identifier') {
      const originalName = node.callee.name;
      const proxyName = proxyMap.get(originalName);
      if (proxyName) {
        node.callee = t.identifier(proxyName); // 将调用者替换为代理函数
      }
    }
  }
});

const output = generate(ast, {}, code);
console.log("n--- 代理函数调用混淆 ---");
console.log("原始代码:n", code);
console.log("混淆后代码:n", output.code);
/*
// 可能的混淆后代码输出:
function _proxy_originalFunc(..._args) {
  return originalFunc(..._args);
}
function originalFunc(x, y) {
  return x + y;
}
const result = _proxy_originalFunc(5, 10);
console.log(result);
*/

3. 利用AST变形进行代码反混淆

代码反混淆是混淆的逆过程,旨在恢复混淆代码的可读性和可理解性。与混淆不同,反混淆往往无法完全恢复到原始代码,但可以大大提高代码的分析效率。反混淆通常需要识别混淆模式,然后应用对应的AST转换来简化或还原这些模式。

3.1 反混淆的通用流程

  1. 解析 (Parse): 将混淆后的源代码解析成AST。
  2. 遍历与转换 (Traverse & Transform): 深度优先或广度优先遍历AST,识别混淆模式,并对其进行简化、替换或删除。这一步是反混淆的核心。
  3. 生成 (Generate): 将修改后的AST重新生成为反混淆后的源代码字符串。

3.2 常见的AST反混淆技术

3.2.1 字符串字面量反混淆 (String Literal Deobfuscation)

这是反混淆中最常见的任务之一。如果字符串被编码并存储在一个数组中,并通过一个辅助函数访问,反混淆器可以尝试静态执行或模拟执行这个辅助函数,并将 CallExpression 替换回 StringLiteral

反混淆目标: CallExpressionMemberExpression 节点,这些节点用于从字符串池中获取字符串。

AST变形策略:

  1. 首先识别字符串池数组的声明(例如 const __str_pool = [...])和可能的解码函数。
  2. 遍历AST,找到所有对字符串池的访问(例如 __str_pool[index])。
  3. 如果索引是常量,则直接从字符串池中取出对应的值。
  4. 如果索引是通过计算得到的,尝试进行常量折叠或模拟执行计算逻辑。
  5. MemberExpressionCallExpression 节点替换为 StringLiteral 节点。

代码示例:字符串字面量反混淆

我们以上面混淆后的代码为例进行反混淆。

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const obfuscatedCode = `
const __str_pool = ["Hello, ", "!", "Welcome", "World"];

function greet(name) {
  const message = __str_pool[0] + name + __str_pool[1];
  console.log(message);
  return __str_pool[2];
}
greet(__str_pool[3]);
`;

const ast = parser.parse(obfuscatedCode, { sourceType: 'script' });

let stringPool = []; // 用于存储解析出的字符串池
let stringPoolIdentifierName = '';

// 阶段1: 提取字符串池
traverse(ast, {
  VariableDeclaration(path) {
    const { node } = path;
    if (node.kind === 'const' && node.declarations.length === 1) {
      const declarator = node.declarations[0];
      if (declarator.init && declarator.init.type === 'ArrayExpression') {
        const elements = declarator.init.elements;
        if (elements.every(e => e.type === 'StringLiteral')) {
          stringPool = elements.map(e => e.value);
          stringPoolIdentifierName = declarator.id.name;
          path.remove(); // 移除字符串池的声明
          path.stop(); // 找到后停止遍历
        }
      }
    }
  }
});

// 阶段2: 替换字符串池的引用
if (stringPoolIdentifierName && stringPool.length > 0) {
  traverse(ast, {
    MemberExpression(path) {
      const { node } = path;
      // 检查是否是对字符串池的访问,且索引是常量
      if (
        node.object.type === 'Identifier' &&
        node.object.name === stringPoolIdentifierName &&
        node.property.type === 'NumericLiteral' &&
        typeof node.property.value === 'number'
      ) {
        const index = node.property.value;
        if (index >= 0 && index < stringPool.length) {
          path.replaceWith(t.stringLiteral(stringPool[index]));
        }
      }
    }
  });
}

const output = generate(ast, {}, obfuscatedCode);
console.log("n--- 字符串字面量反混淆 ---");
console.log("混淆后代码:n", obfuscatedCode);
console.log("反混淆后代码:n", output.code);
/*
// 反混淆后代码输出:
function greet(name) {
  const message = "Hello, " + name + "!";
  console.log(message);
  return "Welcome";
}
greet("World");
*/
3.2.2 代理函数内联 (Proxy Function Inlining)

如果混淆器引入了简单的代理函数,反混淆器可以识别这些代理函数,并将其调用直接替换为对原始函数的调用(或直接内联原始函数体)。

反混淆目标: FunctionDeclaration 节点(代理函数),以及 CallExpression 节点(对代理函数的调用)。

AST变形策略:

  1. 识别代理函数的模式:通常代理函数只包含一个 return 语句,该语句调用另一个函数,并将所有参数直接转发。
  2. 记录代理函数与它所代理的原始函数之间的映射。
  3. 遍历AST,找到所有对代理函数的 CallExpression
  4. 将这些 CallExpressioncallee 替换为原始函数。
  5. 移除代理函数的 FunctionDeclaration

代码示例:代理函数内联反混淆

我们以上面混淆后的代码为例进行反混淆。

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const obfuscatedCode = `
function _proxy_originalFunc(..._args) {
  return originalFunc(..._args);
}
function originalFunc(x, y) {
  return x + y;
}
const result = _proxy_originalFunc(5, 10);
console.log(result);
`;

const ast = parser.parse(obfuscatedCode, { sourceType: 'script' });

const proxyFuncMap = new Map(); // 代理函数名 -> 原始函数名

traverse(ast, {
  // 阶段1: 识别代理函数并记录映射
  FunctionDeclaration(path) {
    const { node } = path;
    if (node.id.name.startsWith('_proxy_')) { // 简单的匹配规则
      if (node.body.body.length === 1 && node.body.body[0].type === 'ReturnStatement') {
        const returnArg = node.body.body[0].argument;
        if (returnArg && returnArg.type === 'CallExpression' && returnArg.callee.type === 'Identifier') {
          // 检查参数是否是简单的转发
          if (
            node.params.length === 1 && node.params[0].type === 'RestElement' &&
            returnArg.arguments.length === 1 && returnArg.arguments[0].type === 'SpreadElement' &&
            node.params[0].argument.name === returnArg.arguments[0].argument.name
          ) {
            const proxyName = node.id.name;
            const originalName = returnArg.callee.name;
            proxyFuncMap.set(proxyName, originalName);
            path.remove(); // 移除代理函数声明
          }
        }
      }
    }
  },
  // 阶段2: 替换对代理函数的调用
  CallExpression(path) {
    const { node } = path;
    if (node.callee.type === 'Identifier') {
      const calleeName = node.callee.name;
      if (proxyFuncMap.has(calleeName)) {
        node.callee.name = proxyFuncMap.get(calleeName); // 替换为原始函数名
      }
    }
  }
});

const output = generate(ast, {}, obfuscatedCode);
console.log("n--- 代理函数内联反混淆 ---");
console.log("混淆后代码:n", obfuscatedCode);
console.log("反混淆后代码:n", output.code);
/*
// 反混淆后代码输出:
function originalFunc(x, y) {
  return x + y;
}
const result = originalFunc(5, 10);
console.log(result);
*/
3.2.3 控制流反平坦化 (Control Flow De-flattening)

这是反混淆中最具挑战性的任务之一。它需要复杂的静态分析来重建原始的控制流图。

反混淆目标: WhileStatement 内部的 SwitchStatement,以及状态变量。

AST变形策略(高级):

  1. 识别 while (true) 循环内部的 switch 语句,以及控制 switch 语句状态的状态变量。
  2. 对每个 case 分支进行分析,确定其执行后将跳转到哪个 case
  3. 根据分析结果,尝试将 switch 语句重构回 if/elseforwhile 等结构。这通常涉及将 case 块提升到 switch 外部,并用条件跳转替代状态变量的赋值。
  4. 移除状态变量和 while 循环。

这是一个非常复杂的领域,通常需要结合数据流分析、符号执行等技术。简单的AST遍历不足以完成这项任务,需要更专业的工具和算法。

3.2.4 常量折叠与传播 (Constant Folding and Propagation)

这是一种优化技术,但也是反混淆的重要手段。它在编译时计算常量表达式的值,并用其结果替换表达式,从而简化代码。

反混淆目标: BinaryExpression, UnaryExpression 等,其操作数都是常量。

AST变形策略:

  1. 遍历AST,找到所有 BinaryExpressionUnaryExpression 节点。
  2. 检查这些表达式的所有操作数是否都是 Literal(常量)。
  3. 如果是,则在编译时执行该操作,并用一个新的 Literal 节点替换整个表达式。
  4. 对于变量,如果一个变量被初始化为常量,并且在后续没有被重新赋值,那么所有对该变量的引用都可以被其常量值替换(常量传播)。

代码示例:常量折叠

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');

const obfuscatedCode = `
const x = 10 + 20;
const y = (x / 2) - 5;
const z = !(!true);
console.log(x, y, z);
`;

const ast = parser.parse(obfuscatedCode, { sourceType: 'script' });

traverse(ast, {
  // 处理二元表达式
  BinaryExpression(path) {
    const { node } = path;
    if (t.isLiteral(node.left) && t.isLiteral(node.right)) {
      try {
        // 使用eval来安全地计算常量表达式
        const result = eval(`${node.left.value} ${node.operator} ${node.right.value}`);
        path.replaceWith(t.valueToNode(result)); // t.valueToNode 会将JS值转换为相应的AST节点
      } catch (e) {
        // 忽略无法计算的表达式
      }
    }
  },
  // 处理一元表达式
  UnaryExpression(path) {
    const { node } = path;
    if (t.isLiteral(node.argument)) {
      try {
        // 使用eval来安全地计算常量表达式
        const result = eval(`${node.operator}${node.argument.value}`);
        path.replaceWith(t.valueToNode(result));
      } catch (e) {
        // 忽略无法计算的表达式
      }
    }
  }
});

const output = generate(ast, {}, obfuscatedCode);
console.log("n--- 常量折叠反混淆 ---");
console.log("混淆后代码:n", obfuscatedCode);
console.log("反混淆后代码:n", output.code);
/*
// 反混淆后代码输出:
const x = 30;
const y = 10;
const z = true;
console.log(x, y, z);
*/

警告: 在反混淆中使用 eval 需要极其谨慎,只应在确认表达式为纯常量计算且不包含恶意代码时使用。实际的工具会使用更安全的静态求值器。

4. 实践工具与框架

掌握了AST变形的原理后,了解一些趁手的工具能大大提高效率。

AST解析器:

  • @babel/parser: 最佳选择,支持最新JS语法,与Babel生态系统无缝集成。
  • Acorn: 快速,小巧,用于需要轻量级解析器的场景。
  • Esprima: 历史悠久,功能全面。

AST遍历与转换:

  • @babel/traverse: Babel生态的核心,功能强大,支持作用域管理。
  • estree-walker: 适用于通用ESTree结构的轻量级遍历器。

AST生成器:

  • @babel/generator: Babel生态的一部分,可配置性强。
  • escodegen: 另一个流行的AST到代码生成器。

可视化工具:

  • AST Explorer (astexplorer.net): 在线工具,允许你输入代码,查看其AST结构,并尝试不同的解析器和转换器。这是学习和调试AST变形的绝佳资源。

现有混淆器:

  • JavaScript Obfuscator: 功能丰富的在线JavaScript混淆工具。
  • Terser: 主要用于代码压缩(minification),但也提供了一些轻量级混淆功能,如变量名缩短。

反混淆工具:

  • 大多数反混淆工具都是定制化的脚本或研究项目,没有一个“万能”的通用反混淆器。
  • JSNice (已停止维护): 早期一个研究项目,尝试通过机器学习恢复变量名和类型。
  • 各种开源项目和GitHub上的自定义脚本: 针对特定混淆器或特定模式的反混淆工具。

5. 安全考量与局限性

代码混淆与反混淆是一个持续的“猫鼠游戏”。

混淆的局限性:

  • 并非加密:混淆并非加密。它只是增加了代码理解的难度,但熟练的逆向工程师总能找到突破口。
  • 性能开销:过度混淆,特别是控制流平坦化和冗余代码注入,会增加代码体积,降低运行时性能。
  • 调试困难:混淆后的代码难以调试和维护,即使是开发者自己也会感到困惑。
  • 兼容性问题:某些激进的混淆方式可能会与特定环境或JavaScript引擎不兼容,导致运行时错误。

反混淆的局限性:

  • 无法完全还原:许多混淆技术是不可逆的,或者说,完美还原到原始代码是不可能的。反混淆的目标是提高可读性,而不是恢复原貌。
  • 特定性:有效的反混淆往往需要针对特定的混淆器和混淆模式进行定制。
  • 复杂性:控制流反平坦化等高级反混淆技术涉及复杂的静态分析和图论算法,实现难度高。

结语

通过本次讲座,我们深入探讨了JavaScript代码混淆与反混淆的核心——抽象语法树。我们看到了AST如何作为代码的骨架,支撑着各种复杂变形的实现。无论是为了保护知识产权而进行代码混淆,还是为了分析和理解代码而进行反混淆,AST都是不可或缺的强大工具。

在数字时代,代码安全与透明度的权衡将持续进行。理解AST变形技术,不仅能帮助我们构建更安全的应用程序,也能为我们揭示代码深层次的奥秘,提升我们作为开发者的技术洞察力。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注