各位同仁,各位技术爱好者,大家好!
今天,我们齐聚一堂,共同探讨一个在现代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 等)。不同类型的节点还会有其特有的属性,例如 FunctionDeclaration 有 id (函数名)、params (参数列表)、body (函数体),而 BinaryExpression 有 operator (操作符)、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 混淆的通用流程
- 解析 (Parse): 将源代码解析成AST。
- 遍历与转换 (Traverse & Transform): 深度优先或广度优先遍历AST,识别目标节点,并对其进行修改、替换、插入或删除。这一步是混淆的核心。
- 生成 (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变形策略:
- 遍历AST,找到所有的
Identifier节点。 - 为每个
Identifier生成一个新的、唯一的、无意义的名称。 - 关键在于:当一个标识符被重命名时,所有引用它的地方也必须同步更新。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变形策略:
- 收集所有
StringLiteral节点的值。 - 将这些字符串存储在一个数组中(通常是全局数组),并打乱顺序。
- 创建一个或多个用于从数组中获取字符串的辅助函数(例如,一个函数接受索引,另一个函数接受一个加密的索引,然后进行解密并查找)。
- 将原始的
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变形策略:
- 识别函数或代码块中的所有基本块(Basic Block),即没有分支的连续语句序列。
- 将每个基本块封装成一个
case分支。 - 引入一个状态变量,该变量的值决定
switch语句下一个执行哪个case。 - 将原始的控制流语句替换为一个无限
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 内部的 SwitchStatement 和 WhileStatement 节点。这需要创建新的 VariableDeclaration (for state), WhileStatement, SwitchStatement, SwitchCase, BreakStatement, ReturnStatement 等节点。
2.2.4 表达式混淆 (Expression Obfuscation)
通过改变表达式的结构,使其变得冗长或难以直接推断其值。
混淆目标: Literal (数字、布尔值), BinaryExpression 等。
AST变形策略:
- 数字字面量转换:
10可以变成(5 + 5),或者parseInt("0xa"),甚至eval("10")。 - 布尔字面量转换:
true可以变成!0,false可以变成!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)
插入永远不会被执行的代码块,以增加代码量和分析的复杂性。这些代码块通常包含混淆的逻辑,使得逆向工程师在分析时浪费时间。
混淆目标: 任何 BlockStatement 或 FunctionDeclaration。
AST变形策略:
- 生成一个永远为
false的“不透明谓词”(Opaque Predicate),例如(false && true)或者(new Date().getTime() === 0)。 - 在代码中插入一个
IfStatement,其test表达式就是这个不透明谓词。 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变形策略:
- 找到所有的
CallExpression。 - 为每个函数创建一个代理函数(或一个通用的代理函数)。
- 将
CallExpression的callee替换为代理函数的调用。
代码示例:代理函数调用混淆
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 反混淆的通用流程
- 解析 (Parse): 将混淆后的源代码解析成AST。
- 遍历与转换 (Traverse & Transform): 深度优先或广度优先遍历AST,识别混淆模式,并对其进行简化、替换或删除。这一步是反混淆的核心。
- 生成 (Generate): 将修改后的AST重新生成为反混淆后的源代码字符串。
3.2 常见的AST反混淆技术
3.2.1 字符串字面量反混淆 (String Literal Deobfuscation)
这是反混淆中最常见的任务之一。如果字符串被编码并存储在一个数组中,并通过一个辅助函数访问,反混淆器可以尝试静态执行或模拟执行这个辅助函数,并将 CallExpression 替换回 StringLiteral。
反混淆目标: CallExpression 或 MemberExpression 节点,这些节点用于从字符串池中获取字符串。
AST变形策略:
- 首先识别字符串池数组的声明(例如
const __str_pool = [...])和可能的解码函数。 - 遍历AST,找到所有对字符串池的访问(例如
__str_pool[index])。 - 如果索引是常量,则直接从字符串池中取出对应的值。
- 如果索引是通过计算得到的,尝试进行常量折叠或模拟执行计算逻辑。
- 将
MemberExpression或CallExpression节点替换为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变形策略:
- 识别代理函数的模式:通常代理函数只包含一个
return语句,该语句调用另一个函数,并将所有参数直接转发。 - 记录代理函数与它所代理的原始函数之间的映射。
- 遍历AST,找到所有对代理函数的
CallExpression。 - 将这些
CallExpression的callee替换为原始函数。 - 移除代理函数的
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变形策略(高级):
- 识别
while (true)循环内部的switch语句,以及控制switch语句状态的状态变量。 - 对每个
case分支进行分析,确定其执行后将跳转到哪个case。 - 根据分析结果,尝试将
switch语句重构回if/else、for或while等结构。这通常涉及将case块提升到switch外部,并用条件跳转替代状态变量的赋值。 - 移除状态变量和
while循环。
这是一个非常复杂的领域,通常需要结合数据流分析、符号执行等技术。简单的AST遍历不足以完成这项任务,需要更专业的工具和算法。
3.2.4 常量折叠与传播 (Constant Folding and Propagation)
这是一种优化技术,但也是反混淆的重要手段。它在编译时计算常量表达式的值,并用其结果替换表达式,从而简化代码。
反混淆目标: BinaryExpression, UnaryExpression 等,其操作数都是常量。
AST变形策略:
- 遍历AST,找到所有
BinaryExpression或UnaryExpression节点。 - 检查这些表达式的所有操作数是否都是
Literal(常量)。 - 如果是,则在编译时执行该操作,并用一个新的
Literal节点替换整个表达式。 - 对于变量,如果一个变量被初始化为常量,并且在后续没有被重新赋值,那么所有对该变量的引用都可以被其常量值替换(常量传播)。
代码示例:常量折叠
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变形技术,不仅能帮助我们构建更安全的应用程序,也能为我们揭示代码深层次的奥秘,提升我们作为开发者的技术洞察力。