针对 Babel 或 TypeScript 编译后的 AST 混淆,如何利用 AST 遍历和节点替换进行自动化反混淆?

咳咳,各位观众老爷晚上好!今天咱们不聊风花雪月,来点硬核的,聊聊怎么扒掉 Babel 或 TypeScript 编译后 AST 混淆的“马甲”,让代码裸奔!

今天的主题是:AST 遍历与节点替换:自动化反混淆的屠龙之术

说起混淆,那真是前端攻城狮的噩梦。本来就头发稀疏,再来个混淆,简直是雪上加霜。但别怕,咱们今天就来学学怎么用 AST (Abstract Syntax Tree,抽象语法树) 这把锋利的宝剑,斩妖除魔,让混淆代码现出原形。

第一部分:AST 是个啥?为啥要用它?

首先,得搞清楚 AST 是个什么玩意儿。简单来说,AST 就是代码的一种树形结构表示。你可以把它想象成一棵语法树,每个节点代表代码中的一个语法结构,比如变量声明、函数调用、表达式等等。

举个例子,这段简单的 JavaScript 代码:

const x = 1 + 2;
console.log(x);

用 AST 表示出来,大概是这个样子(简化版):

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "x"
          },
          "init": {
            "type": "BinaryExpression",
            "operator": "+",
            "left": {
              "type": "NumericLiteral",
              "value": 1
            },
            "right": {
              "type": "NumericLiteral",
              "value": 2
            }
          }
        }
      ],
      "kind": "const"
    },
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "CallExpression",
        "callee": {
          "type": "MemberExpression",
          "object": {
            "type": "Identifier",
            "name": "console"
          },
          "property": {
            "type": "Identifier",
            "name": "log"
          },
          "computed": false
        },
        "arguments": [
          {
            "type": "Identifier",
            "name": "x"
          }
        ]
      }
    }
  ],
  "sourceType": "module"
}

看起来有点吓人,但别慌。关键是理解每个节点代表的含义。比如,VariableDeclaration 表示变量声明,BinaryExpression 表示二元表达式(加减乘除等等)。

为啥要用 AST 呢?

因为直接操作字符串代码太容易出错了,而且很难理解代码的结构。AST 就像代码的骨架,我们可以通过操作 AST 来修改、分析代码,而不用担心语法错误。

第二部分:AST 遍历:找到混淆的“罪魁祸首”

要反混淆,首先得找到混淆的地方。这就需要 AST 遍历了。

AST 遍历就是按照一定的顺序,访问 AST 中的每个节点。常用的遍历方法有两种:

  • 深度优先遍历 (Depth-First Traversal): 先访问一个节点的子节点,再访问兄弟节点。
  • 广度优先遍历 (Breadth-First Traversal): 先访问一个节点的所有兄弟节点,再访问子节点。

一般来说,深度优先遍历更常用,因为它更符合代码的执行顺序。

咱们用 @babel/traverse 这个库来实现 AST 遍历。先安装:

npm install @babel/traverse @babel/parser @babel/generator --save-dev

然后,写一段简单的代码,遍历 AST 并打印节点类型:

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

const code = fs.readFileSync('obfuscated.js', 'utf-8'); // 读取混淆后的代码
const ast = parser.parse(code, {
  sourceType: 'module', // 或者 'script',取决于你的代码
});

traverse(ast, {
  enter(path) {
    console.log('Node type:', path.node.type);
  },
});

// console.log(generator(ast).code); // 可选:生成代码并打印,用于调试

这段代码会读取 obfuscated.js 文件中的代码,解析成 AST,然后遍历 AST,打印每个节点的类型。

重点来了:如何识别混淆代码?

混淆代码通常具有以下特征:

  • 大量的 Identifier 节点,但是 name 属性是无意义的字符串 (a, b, c, _0x123 等等)。 这通常是变量名和函数名被混淆了。
  • 复杂的 ConditionalExpression (三元运算符) 和 LogicalExpression (&&, ||) 嵌套。 这通常是控制流被混淆了。
  • 大量的 StringLiteralNumericLiteral,但是值是经过编码或加密的。 这通常是字符串和数字常量被混淆了。
  • 使用 evalFunction 构造函数动态执行代码。 这通常是代码自修改或动态生成代码。
  • 使用 Proxy 对象进行拦截和修改操作。 这种情况相对少见,但也会增加分析难度。

通过分析节点类型和属性,我们可以找到这些混淆的“罪魁祸首”。

第三部分:节点替换:釜底抽薪,还原代码

找到混淆的地方后,就要进行节点替换了。节点替换就是用新的节点替换 AST 中的旧节点,从而达到反混淆的目的。

咱们还是用 @babel/traverse 来进行节点替换。在 traverse 函数中,可以定义各种 visitor 函数,用来处理不同类型的节点。

举个例子,假设我们想把所有的 Identifier 节点的 name 属性改成 "hello":

traverse(ast, {
  Identifier(path) {
    path.node.name = 'hello';
  },
});

这段代码会遍历 AST,找到所有的 Identifier 节点,然后把它们的 name 属性改成 "hello"。

反混淆的常见策略:

接下来,咱们来聊聊几种常见的反混淆策略,并给出相应的代码示例。

  1. 还原变量名和函数名:

    • 问题: 混淆器通常会把变量名和函数名改成无意义的字符串,降低代码的可读性。
    • 解决: 可以通过分析代码的上下文,或者使用 Source Map,来还原变量名和函数名。
    • 示例: 假设我们有一个简单的变量名混淆:

      const _0xabc = 10;
      console.log(_0xabc);

      我们可以通过分析 VariableDeclarator 节点的 id 属性,找到变量名,然后用更有意义的名字替换它。

      traverse(ast, {
        VariableDeclarator(path) {
          if (path.node.id.type === 'Identifier' && path.node.id.name.startsWith('_0x')) {
            const originalName = path.node.id.name;
            const newName = 'myVariable'; // 替换成更有意义的名字
            path.scope.rename(originalName, newName); // 使用 scope.rename 来更新所有引用
            console.log(`Renamed ${originalName} to ${newName}`);
          }
        },
      });

      path.scope.rename 非常重要,它可以确保所有引用该变量的地方都被更新。

  2. 简化控制流:

    • 问题: 混淆器通常会使用复杂的 ConditionalExpressionLogicalExpression 嵌套,来混淆代码的控制流。
    • 解决: 可以通过计算表达式的值,然后用更简单的代码替换它。
    • 示例: 假设我们有这样一个复杂的条件表达式:

      const result = (true && (false || 1 > 0)) ? 'yes' : 'no';
      console.log(result);

      我们可以直接计算出表达式的值,然后用常量替换它。

      const { evaluate } = require('@babel/traverse');
      
      traverse(ast, {
        ConditionalExpression(path) {
          const result = path.evaluate();
          if (result.confident) {
            path.replaceWith(
              {
                type: 'StringLiteral',
                value: result.value,
              }
            );
            console.log(`Replaced ConditionalExpression with ${result.value}`);
          }
        },
        LogicalExpression(path) {
          const result = path.evaluate();
          if (result.confident) {
            path.replaceWith(
              {
                type: 'BooleanLiteral',
                value: result.value,
              }
            );
            console.log(`Replaced LogicalExpression with ${result.value}`);
          }
        }
      });

      path.evaluate() 函数可以计算表达式的值。如果 result.confidenttrue,表示计算结果是可靠的,我们可以用计算结果替换原来的表达式。

  3. 还原字符串和数字常量:

    • 问题: 混淆器通常会对字符串和数字常量进行编码或加密,增加代码的分析难度。
    • 解决: 可以找到编码或加密的算法,然后用解码或解密后的值替换它。
    • 示例: 假设我们有一个简单的字符串编码:

      const encodedString = '0x680x650x6c0x6c0x6f';
      const decodedString = String.fromCharCode(parseInt(encodedString.substring(2, 4), 16), parseInt(encodedString.substring(6, 8), 16), parseInt(encodedString.substring(10, 12), 16), parseInt(encodedString.substring(14, 16), 16), parseInt(encodedString.substring(18, 20), 16));
      console.log(decodedString);

      我们可以找到解码算法,然后用解码后的字符串替换它。

      traverse(ast, {
        VariableDeclarator(path) {
          if (path.node.id.type === 'Identifier' && path.node.id.name === 'decodedString') {
            if (path.node.init.type === 'CallExpression' && path.node.init.callee.type === 'MemberExpression' && path.node.init.callee.object.type === 'Identifier' && path.node.init.callee.object.name === 'String' && path.node.init.callee.property.type === 'Identifier' && path.node.init.callee.property.name === 'fromCharCode') {
              // 这里可以找到解码算法,然后用解码后的字符串替换它
              // 为了简化示例,我们假设已经知道了解码后的字符串是 "hello"
              path.replaceWith(
                {
                  type: 'VariableDeclarator',
                  id: {
                    type: 'Identifier',
                    name: 'decodedString',
                  },
                  init: {
                    type: 'StringLiteral',
                    value: 'hello',
                  },
                }
              );
              console.log('Decoded string');
            }
          }
        },
      });

      这个例子比较简单,实际情况可能会更复杂,需要根据具体的编码算法进行解码。

  4. 处理 evalFunction 构造函数:

    • 问题: 混淆器可能会使用 evalFunction 构造函数动态执行代码,增加代码的分析难度。
    • 解决: 尽量避免执行动态代码。如果必须执行,可以先分析动态代码的逻辑,然后用等价的静态代码替换它。
    • 示例: 假设我们有这样一个使用 eval 的代码:

      const code = 'console.log("hello");';
      eval(code);

      我们可以分析 code 变量的值,然后用等价的静态代码替换它。

      traverse(ast, {
        CallExpression(path) {
          if (path.node.callee.type === 'Identifier' && path.node.callee.name === 'eval' && path.node.arguments.length === 1 && path.node.arguments[0].type === 'StringLiteral') {
            const code = path.node.arguments[0].value;
            // 解析字符串代码为AST
            const evalAst = parser.parse(code);
            // 替换eval调用
            path.replaceWithMultiple(evalAst.program.body);
            console.log('Replaced eval call');
          }
        },
      });

      这段代码将eval调用的字符串内容解析为AST,然后将解析后的AST节点替换掉原来的eval调用。

  5. 删除 debugger 语句和无用代码:

    • 问题: 混淆器可能会插入debugger语句来干扰调试,或者插入一些永远不会执行到的无用代码来增加代码的复杂度。
    • 解决: 遍历AST,找到这些节点,然后删除它们。
    • 示例:
      traverse(ast, {
        DebuggerStatement(path) {
          path.remove();
          console.log('Removed debugger statement');
        },
      });

第四部分:实战演练:一个简单的反混淆工具

光说不练假把式。咱们来写一个简单的反混淆工具,把上面讲的知识应用起来。

这个工具的功能很简单:

  • 还原变量名和函数名 (把 _0xabc 替换成 myVariable)
  • 简化控制流 (计算 ConditionalExpressionLogicalExpression 的值)
  • 删除 debugger 语句
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
const fs = require('fs');

function deobfuscate(code) {
  const ast = parser.parse(code, {
    sourceType: 'module', // 或者 'script',取决于你的代码
  });

  traverse(ast, {
    VariableDeclarator(path) {
      if (path.node.id.type === 'Identifier' && path.node.id.name.startsWith('_0x')) {
        const originalName = path.node.id.name;
        const newName = 'myVariable'; // 替换成更有意义的名字
        path.scope.rename(originalName, newName); // 使用 scope.rename 来更新所有引用
        console.log(`Renamed ${originalName} to ${newName}`);
      }
    },
    ConditionalExpression(path) {
      const result = path.evaluate();
      if (result.confident) {
        path.replaceWith(
          {
            type: 'StringLiteral',
            value: result.value,
          }
        );
        console.log(`Replaced ConditionalExpression with ${result.value}`);
      }
    },
    LogicalExpression(path) {
      const result = path.evaluate();
      if (result.confident) {
        path.replaceWith(
          {
            type: 'BooleanLiteral',
            value: result.value,
          }
        );
        console.log(`Replaced LogicalExpression with ${result.value}`);
      }
    },
    DebuggerStatement(path) {
      path.remove();
      console.log('Removed debugger statement');
    },
  });

  return generator(ast).code;
}

// 读取混淆后的代码
const code = fs.readFileSync('obfuscated.js', 'utf-8');

// 反混淆
const deobfuscatedCode = deobfuscate(code);

// 写入反混淆后的代码
fs.writeFileSync('deobfuscated.js', deobfuscatedCode);

console.log('Deobfuscation complete!');

使用方法:

  1. 把混淆后的代码保存到 obfuscated.js 文件中。
  2. 运行这个脚本。
  3. 反混淆后的代码会保存到 deobfuscated.js 文件中。

第五部分:更高级的反混淆技巧

上面的例子只是一个简单的演示。实际情况中,混淆代码可能会更复杂,需要使用更高级的反混淆技巧。

  • 控制流平坦化 (Control Flow Flattening): 混淆器会把代码的控制流打乱,变成一个巨大的 switch 语句。要反混淆这种代码,需要分析 switch 语句的逻辑,然后还原代码的控制流。
  • Dead Code Injection: 混淆器会插入一些永远不会执行到的代码,增加代码的复杂度。要反混淆这种代码,需要找到这些无用代码,然后删除它们。
  • Opaque Predicates: 混淆器会使用一些永远为真或永远为假的表达式,来混淆代码的控制流。要反混淆这种代码,需要分析这些表达式的值,然后用 truefalse 替换它们。
  • WebAssembly 混淆: 一些高级的混淆器会把 JavaScript 代码编译成 WebAssembly,然后再进行混淆。要反混淆这种代码,需要先反编译 WebAssembly 代码,然后再进行分析。

这些高级技巧需要更深入的理解代码混淆和反混淆的原理,以及更强大的工具和技术。

第六部分:总结与展望

今天咱们聊了 AST 遍历和节点替换,以及如何利用它们进行自动化反混淆。虽然反混淆是一个复杂的工程,但只要掌握了正确的方法和工具,就能有效地还原代码,保护自己的知识产权。

表格总结:

技术/工具 作用 优点 缺点
AST 代码的树形结构表示 易于分析和修改代码结构,避免语法错误 学习曲线陡峭,需要理解各种节点类型
@babel/parser 将代码解析成 AST 快速、准确 需要配置,支持不同的语法特性
@babel/traverse 遍历和修改 AST 灵活、强大,可以自定义各种 visitor 函数 需要理解 AST 的结构,容易出错
@babel/generator 将 AST 生成代码 可以格式化代码,方便阅读 生成的代码可能与原始代码略有不同
Source Map 源代码和混淆后代码的映射 还原变量名和函数名 需要混淆器生成 Source Map,可能会泄露代码信息
反编译工具 将 WebAssembly 代码转换成可读代码 可以分析 WebAssembly 代码的逻辑 反编译后的代码可能难以理解,需要专业的知识

未来的展望:

随着代码混淆技术的不断发展,反混淆的难度也会越来越大。未来的反混淆工具需要更加智能化、自动化,能够自动识别和处理各种混淆技术。同时,也需要更加强大的调试工具,能够帮助开发者快速定位和解决问题。

好了,今天的讲座就到这里。希望大家有所收获,也希望大家在反混淆的道路上越走越远!下次再见!

发表回复

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