JavaScript AST (抽象语法树):如何利用 esprima, estraverse, escodegen 等库进行 AST 的遍历、修改和代码生成,实现自动化代码转换和反混淆?

大家好,我是代码界的Tony老师,今天给大家安排一个“发型”改造——JavaScript AST 玩转指南!

各位靓仔靓女们,平时写代码是不是感觉像在流水线上拧螺丝?有没有想过,代码也能像头发一样,想剪成啥样就剪成啥样?今天咱们就聊聊 JavaScript AST(Abstract Syntax Tree,抽象语法树),让你拥有“代码发型师”的超能力!

什么是 AST?为什么要用它?

想象一下,你写了一段 JavaScript 代码,电脑是怎么“看懂”的呢?它可不是像我们一样一字一句地阅读,而是先把它分解成一个树状结构,这个树就是 AST。

AST 将代码的语法结构用一种树形的数据结构表示出来。树的每个节点代表代码中的一个语法单元,例如变量声明、函数定义、运算符、表达式等等。

为什么要用 AST?因为它能让我们:

  • 理解代码结构: 就像解剖人体一样,AST 可以让我们深入了解代码的内部结构,知道每个部分的作用和关系。
  • 修改代码行为: 通过修改 AST,我们可以改变代码的逻辑,实现代码转换、优化、反混淆等功能。
  • 自动化代码处理: 我们可以编写程序来自动分析和修改 AST,从而实现自动化代码处理。

举个例子:

有一段简单的代码:const a = 1 + 2;

它的 AST 大概长这样(简化版):

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a"
          },
          "init": {
            "type": "BinaryExpression",
            "operator": "+",
            "left": {
              "type": "Literal",
              "value": 1
            },
            "right": {
              "type": "Literal",
              "value": 2
            }
          }
        }
      ],
      "kind": "const"
    }
  ]
}

是不是有点眼花缭乱?别怕,我们慢慢来。可以看到,这段代码被分解成了 Program(程序)、VariableDeclaration(变量声明)、VariableDeclarator(变量声明符)、Identifier(标识符)、BinaryExpression(二元表达式)和 Literal(字面量)等节点。

AST 工具箱:esprima, estraverse, escodegen

有了 AST,还得有工具才能操作它。这里给大家介绍三个好帮手:

  • esprima: 负责把 JavaScript 代码解析成 AST。就像一把手术刀,把代码“解剖”成 AST。
  • estraverse: 负责遍历 AST。就像一个导游,带着我们游览 AST 这棵大树。
  • escodegen: 负责把 AST 转换回 JavaScript 代码。就像一个魔术师,把 AST 变回我们熟悉的 JavaScript 代码。

这三个工具就像一个流水线,把 JavaScript 代码变成 AST,然后我们可以修改 AST,最后再把修改后的 AST 变回 JavaScript 代码。

安装它们:

npm install esprima estraverse escodegen

实战演练:代码转换小技巧

光说不练假把式,咱们来几个实战例子,看看怎么用这些工具来改造代码。

1. 给所有变量加 console.log

有时候我们想调试代码,需要在每个变量旁边加 console.log。手动加太麻烦了,可以用 AST 来自动加。

const esprima = require('esprima');
const estraverse = require('estraverse');
const escodegen = require('escodegen');

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

// 1. 解析代码成 AST
const ast = esprima.parseScript(code);

// 2. 遍历 AST,找到所有变量声明
estraverse.traverse(ast, {
  enter: function(node) {
    if (node.type === 'VariableDeclarator') {
      // 创建一个 console.log 的 AST 节点
      const consoleLogNode = {
        "type": "ExpressionStatement",
        "expression": {
          "type": "CallExpression",
          "callee": {
            "type": "MemberExpression",
            "computed": false,
            "object": {
              "type": "Identifier",
              "name": "console"
            },
            "property": {
              "type": "Identifier",
              "name": "log"
            }
          },
          "arguments": [
            {
              "type": "Identifier",
              "name": node.id.name // 变量名
            }
          ]
        }
      };

      // 把 console.log 节点插入到变量声明的后面
      node.parent.body.splice(node.parent.body.indexOf(node.parent) + 1, 0, consoleLogNode);
    }
  }
});

// 3. 把 AST 转换回代码
const transformedCode = escodegen.generate(ast);

console.log(transformedCode);

这段代码会把原始代码变成:

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

是不是很方便?以后再也不用手动加 console.log 了!

代码解释:

  • esprima.parseScript(code): 将JavaScript代码字符串 code 解析成一个抽象语法树 (AST)。
  • estraverse.traverse(ast, { … }): 遍历抽象语法树 astenter 函数会在遍历到每个节点时被调用。
  • node.type === ‘VariableDeclarator’: 检查当前节点是否为变量声明符。例如,const sum = a + b 中的 sum 就是一个变量声明符。
  • consoleLogNode: 创建一个表示 console.log(variableName) 的 AST 节点。
    • type: "ExpressionStatement": 表示一个表达式语句。
    • expression: 表达式的具体内容。
    • CallExpression: 表示一个函数调用。
    • callee: 被调用的函数。这里是 console.log
      • MemberExpression: 表示一个成员表达式,例如 console.log
      • Identifier: 表示一个标识符,例如 consolelog
    • arguments: 函数调用的参数。这里是变量名 node.id.name
  • node.parent.body.splice(…):consoleLogNode 插入到变量声明语句之后。
    • node.parent: 当前节点的父节点。
    • node.parent.body: 父节点的 body 属性,通常是一个语句数组。
    • node.parent.body.indexOf(node.parent): 找到当前节点在其父节点的 body 数组中的索引。
    • splice(index + 1, 0, consoleLogNode): 在找到的索引之后插入 consoleLogNodesplice 方法用于在数组中添加或删除元素。
  • escodegen.generate(ast): 将修改后的抽象语法树 ast 转换回 JavaScript 代码字符串。

2. 把 var 变成 let

var 有一些问题,现在都推荐用 letconst。我们可以用 AST 来自动把 var 变成 let

const esprima = require('esprima');
const estraverse = require('estraverse');
const escodegen = require('escodegen');

const code = `
  function foo() {
    var x = 1;
    var y = 2;
    if (true) {
      var z = 3;
    }
    console.log(x + y + z);
  }
`;

// 1. 解析代码成 AST
const ast = esprima.parseScript(code);

// 2. 遍历 AST,找到所有 var 声明
estraverse.traverse(ast, {
  enter: function(node) {
    if (node.type === 'VariableDeclaration' && node.kind === 'var') {
      // 把 kind 改成 let
      node.kind = 'let';
    }
  }
});

// 3. 把 AST 转换回代码
const transformedCode = escodegen.generate(ast);

console.log(transformedCode);

这段代码会把原始代码变成:

function foo() {
    let x = 1;
    let y = 2;
    if (true) {
        let z = 3;
    }
    console.log(x + y + z);
}

是不是很方便?以后再也不用手动改 var 了!

3. 移除所有 console.log

有时候我们想发布代码,需要把所有的 console.log 移除掉。可以用 AST 来自动移除。

const esprima = require('esprima');
const estraverse = requireverse');
const escodegen = require('escodegen');

const code = `
  console.log('hello');
  function bar() {
    console.log('world');
  }
`;

// 1. 解析代码成 AST
const ast = esprima.parseScript(code);

// 2. 遍历 AST,找到所有 console.log 调用
estraverse.traverse(ast, {
  enter: function(node, parent) {
    if (node.type === 'ExpressionStatement' &&
        node.expression.type === 'CallExpression' &&
        node.expression.callee.type === 'MemberExpression' &&
        node.expression.callee.object.type === 'Identifier' &&
        node.expression.callee.object.name === 'console' &&
        node.expression.callee.property.type === 'Identifier' &&
        node.expression.callee.property.name === 'log') {
      // 移除 console.log 节点
      parent.node.body.splice(parent.key, 1);
    }
  }
});

// 3. 把 AST 转换回代码
const transformedCode = escodegen.generate(ast);

console.log(transformedCode);

这段代码会把原始代码变成:

function bar() {}

是不是很方便?以后再也不用手动删 console.log 了!

进阶:代码反混淆

代码混淆是一种常见的保护代码的方式,它可以让代码难以阅读和理解。但是,我们可以用 AST 来反混淆代码,让代码恢复可读性。

举个例子:

有一段混淆过的代码:

var _0x4a9a = ["log", "hellox20world"];
(function(_0x2b2c0f, _0x4a9a80) {
    var _0x5c19e2 = function(_0x5871c2) {
        while (--_0x5871c2) {
            _0x2b2c0f["push"](_0x2b2c0f["shift"]());
        }
    };
    _0x5c19e2(++_0x4a9a80);
}(_0x4a9a, 0x12d));
var _0x5c19 = function(_0x2b2c0f, _0x4a9a80) {
    _0x2b2c0f = _0x2b2c0f - 0x0;
    var _0x5c19e2 = _0x4a9a[_0x2b2c0f];
    return _0x5c19e2;
};
console[_0x5c19("0x0")](_0x5c19("0x1"));

这段代码看起来很乱,但是我们可以用 AST 来把它还原成:

console.log("hello world");

反混淆步骤:

  1. 找到字符串数组: 找到 _0x4a9a 这样的字符串数组,它通常是混淆代码用来存储字符串的。
  2. 找到解密函数: 找到 _0x5c19 这样的解密函数,它通常是用来从字符串数组中取字符串的。
  3. 替换解密函数调用: 遍历 AST,找到所有 _0x5c19("0x0") 这样的解密函数调用,把它替换成对应的字符串。
  4. 清理无用代码: 移除字符串数组和解密函数。

代码实现:

const esprima = require('esprima');
const estraverse = require('estraverse');
const escodegen = require('escodegen');

const code = `
var _0x4a9a = ["log", "hellox20world"];
(function(_0x2b2c0f, _0x4a9a80) {
    var _0x5c19e2 = function(_0x5871c2) {
        while (--_0x5871c2) {
            _0x2b2c0f["push"](_0x2b2c0f["shift"]());
        }
    };
    _0x5c19e2(++_0x4a9a80);
}(_0x4a9a, 0x12d));
var _0x5c19 = function(_0x2b2c0f, _0x4a9a80) {
    _0x2b2c0f = _0x2b2c0f - 0x0;
    var _0x5c19e2 = _0x4a9a[_0x2b2c0f];
    return _0x5c19e2;
};
console[_0x5c19("0x0")](_0x5c19("0x1"));
`;

// 1. 解析代码成 AST
const ast = esprima.parseScript(code);

let stringArrayName = null;
let decryptFunctionName = null;
let stringArray = null;

// 2. 找到字符串数组和解密函数
estraverse.traverse(ast, {
  enter: function(node) {
    if (node.type === 'VariableDeclaration' && node.declarations[0].init && node.declarations[0].init.type === 'ArrayExpression') {
      // 找到字符串数组
      stringArrayName = node.declarations[0].id.name;
      stringArray = node.declarations[0].init.elements.map(element => element.value);
    } else if (node.type === 'VariableDeclaration' && node.declarations[0].init && node.declarations[0].init.type === 'FunctionExpression' && node.declarations[0].init.params.length === 2) {
      // 找到解密函数
      decryptFunctionName = node.declarations[0].id.name;
    }
  }
});

// 3. 替换解密函数调用
estraverse.traverse(ast, {
  enter: function(node, parent) {
    if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === decryptFunctionName) {
      // 替换解密函数调用
      const index = parseInt(node.arguments[0].value);
      const stringValue = stringArray[index];
      parent.node[parent.key] = {
        type: 'Literal',
        value: stringValue
      };
    }
  }
});

// 4. 清理无用代码 (这里简化处理,直接移除变量声明)
estraverse.traverse(ast, {
    enter: function(node, parent) {
        if (node.type === 'VariableDeclaration' && (node.declarations[0].id.name === stringArrayName || node.declarations[0].id.name === decryptFunctionName)) {
            parent.node.body.splice(parent.key, 1);
        }
    }
});

// 5. 把 AST 转换回代码
const transformedCode = escodegen.generate(ast);

console.log(transformedCode);

这段代码会把混淆过的代码变成:

console["log"]("hello world");

虽然还有 console["log"],但是已经比原来的代码可读性高多了。 还可以继续处理,把console["log"] 转为 console.log,这里就不再赘述了。

代码解释:

  • 找到字符串数组和解密函数: 通过遍历AST,找到符合特定模式的节点,提取字符串数组的名称 (stringArrayName)、解密函数的名称 (decryptFunctionName) 和字符串数组的内容 (stringArray)。
  • 替换解密函数调用: 再次遍历AST,找到所有对解密函数的调用。通过解析调用参数(索引值),从 stringArray 中获取对应的字符串值,并将函数调用替换为字符串字面量。
  • 清理无用代码: 移除字符串数组和解密函数的定义,使代码更加简洁。
  • 后续处理: 可以继续优化生成的代码,例如将 console["log"] 转换为 console.log,删除无用的括号等。

AST 的应用场景

AST 的应用场景非常广泛,除了上面提到的代码转换和反混淆,还可以用于:

  • 代码静态分析: 检查代码中的潜在问题,例如未使用的变量、重复的代码等。
  • 代码优化: 优化代码的性能,例如消除无用的代码、合并重复的表达式等。
  • 代码生成: 根据 AST 生成不同语言的代码,例如把 JavaScript 代码转换成 TypeScript 代码。
  • 代码编辑器: 实现代码自动补全、代码高亮、代码重构等功能。
  • 代码测试: 自动生成测试用例,提高测试覆盖率。

总结

AST 是一个强大的工具,它可以让我们深入了解和修改代码。掌握 AST 可以让你成为一个真正的“代码发型师”,随心所欲地改造代码,让代码焕发新的活力!

一些建议:

  • 多练习: 熟能生巧,多写代码,多用 AST 来解决实际问题。
  • 阅读源码: 阅读 esprima, estraverse, escodegen 的源码,了解它们的内部实现。
  • 参考资料: 查阅 AST 相关的资料,例如 ECMAScript 规范、AST Explorer 等。

希望今天的分享对大家有所帮助!下次再见!

发表回复

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