如何通过静态分析 AST 手动编写一个简单的 Ja vaScript 代码关键词混淆工具

理解 AST 与混淆目标
要手动实现一个Ja vaScript代码混淆工具,起点在哪里?没错,就是抽象语法树(AST)。简单来说,AST是源代码的“骨架”或结构化蓝图,它将代码分解为一个个节点,比如Identifier(标识符)、VariableDeclaration(变量声明)等。那么,混淆的核心目标究竟是什么?其实很明确:在不改变程序任何行为的前提下,将那些可以安全改动的标识符——比如变量名、函数名、参数名——替换成毫无意义的短名称(例如_a、$0)。同时,必须牢牢守住底线:Ja vaScript的关键字(如if、return)、内置对象(如Array、JSON)以及外部引用(比如全局的console或模块导入名),这些统统不能动。
AST是Ja vaScript代码静态分析的基础,需先解析为树结构;混淆核心是安全重命名绑定标识符(如变量、函数名),保留关键字、内置对象及外部引用;须构建作用域链区分声明与引用,再用计数器生成唯一短名替换。
选择解析器并提取可混淆标识符
工欲善其事,必先利其器。在Ja vaScript生态里,Acorn和@babel/parser是两款主流的AST解析器。前者以轻量和标准兼容著称,后者则生态完善,对新语法支持更好。这里以Acorn为例,看看第一步怎么做:
- 调用
acorn.parse(code, { ecmaVersion: 2022, sourceType: 'module' }),就能得到整段代码的AST根节点。 - 接下来遍历这棵树,把所有类型为
Identifier的节点都找出来。但别急着全收,必须过滤掉以下几类“钉子户”:- 节点本身虽为
Identifier,但其name属于Ja vaScript保留字(可以用is-reserved-word这类库来辅助判断)。 - 作为对象属性访问中的属性名出现(例如
obj.prop里的prop),并且这个属性名没有在局部作用域中被声明过——这类属于“字面量属性名”,通常不能混淆(除非你打算进行更激进、风险也更高的属性名压缩,这里我们暂且不考虑)。 - 具体来说,就是出现在
MemberExpression的property位置,且computed === false(即不是obj[prop]这种动态访问),同时该属性名在作用域链里找不到声明。
- 节点本身虽为
构建作用域链与识别绑定标识符
光有AST结构还不够。一个关键问题是:如何区分一个标识符是“声明”还是“引用”?举个例子:
function foo(x) { let y = x + 1; return y; }
在这行代码里,foo、x、y是绑定标识符,也就是可以被混淆的目标。而函数体里出现的两个x和y,则是引用标识符,它们需要被追溯到各自的声明,然后进行统一替换。
立即学习“Ja va免费学习笔记(深入)”;
所以,我们需要手动实现一个简易的作用域分析。不必追求完整的ES规范,把握核心逻辑即可:
- 采用深度优先策略遍历AST,同时维护一个作用域栈(可以用数组实现)。每当进入一个新的作用域(比如遇到
FunctionDeclaration、BlockStatement配合let/const时),就往栈里推入一个新的作用域对象。 - 遇到声明类节点(如
VariableDeclarator.id、FunctionDeclaration.id、ArrowFunctionExpression.params)时,就在当前最内层的作用域对象里记录下这个标识符的名字和相关信息(例如声明种类是var还是let)。 - 遇到
Identifier节点时,就从内向外逐层查找作用域栈。如果找到了匹配的声明,就把它标记为“可混淆的引用”,并关联到对应的声明节点。 - 如果从最顶层作用域都找不到声明,那就把它视为全局引用(比如直接使用的
console),这类标识符跳过混淆。
生成混淆名并执行替换
确定了哪些能改,接下来就是怎么改。生成混淆名的策略要满足两点:一是避免命名冲突,二是最好具有确定性(相同的输入产生相同的输出,便于调试)。一个常用且可靠的方法是计数器加字符集:
- 先定义一个字符集,比如:
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_'。 - 实现一个
nextName()函数:从1开始编号,然后将数字转换为这个字符集进制下的字符串(例如1→‘a’,27→‘aa’,53→‘ba’)。 - 为每一个声明节点分配一个唯一的混淆名(首次遇到该声明时生成并缓存起来),之后所有引用这个声明的标识符,都替换成同一个名字。
- 这里有个细节需要注意:不同作用域里的同名变量(比如嵌套循环里的
i)应该被当作独立的实体,分配不同的混淆名,绝不能混淆彼此。 - 替换操作直接在AST节点的
name属性上进行(Acorn的AST是可变的)。全部修改完成后,使用escodegen或recast这类工具将AST重新生成代码字符串即可。
来看两段核心逻辑的伪代码示例:
// 声明节点处理伪代码
if (node.type === 'Identifier' && isBinding(node)) {
if (!bindingMap.has(node)) {
bindingMap.set(node, nextName());
}
}
// 引用节点处理伪代码
if (node.type === 'Identifier' && isReference(node)) {
const bindingNode = findBinding(node);
if (bindingNode && bindingMap.has(bindingNode)) {
node.name = bindingMap.get(bindingNode);
}
}
验证与边界处理
混淆完成,大功告成了?别急,最后一步验证至关重要,必须确保混淆后的代码行为与原始代码完全一致。有几个关键检查点和边界情况需要特别注意:
- 功能验证:运行混淆前后的代码,对比输出是否一致。对于简单的脚本,可以用
eval或Node.js的vm模块快速做个冒烟测试。 - 特殊标识符:
this、arguments、super这类语言内置的特殊标识符,绝对禁止混淆。 - 模块导入:在ES6模块语句
import { a as b } from './x'中,b是本地绑定,可以混淆;但a是对外导出的名称,除非你能同时控制导出端的代码,否则不能动。 - 动态访问:对于
obj[expr]这种动态属性访问,其中的expr是表达式,不受标识符混淆的影响,无需特殊处理。 - 安全区:正则表达式的字面量、模板字符串里的内容、代码注释中的文本,这些都不会被AST解析为标识符节点,因此天然不会被混淆工具触及。
这些点听起来不复杂,但却非常容易忽略,务必仔细处理。
