咳咳,各位观众老爷晚上好!今天咱们不聊风花雪月,来点硬核的,聊聊怎么扒掉 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
(&&, ||) 嵌套。 这通常是控制流被混淆了。 - 大量的
StringLiteral
和NumericLiteral
,但是值是经过编码或加密的。 这通常是字符串和数字常量被混淆了。 - 使用
eval
或Function
构造函数动态执行代码。 这通常是代码自修改或动态生成代码。 - 使用 Proxy 对象进行拦截和修改操作。 这种情况相对少见,但也会增加分析难度。
通过分析节点类型和属性,我们可以找到这些混淆的“罪魁祸首”。
第三部分:节点替换:釜底抽薪,还原代码
找到混淆的地方后,就要进行节点替换了。节点替换就是用新的节点替换 AST 中的旧节点,从而达到反混淆的目的。
咱们还是用 @babel/traverse
来进行节点替换。在 traverse
函数中,可以定义各种 visitor
函数,用来处理不同类型的节点。
举个例子,假设我们想把所有的 Identifier
节点的 name
属性改成 "hello":
traverse(ast, {
Identifier(path) {
path.node.name = 'hello';
},
});
这段代码会遍历 AST,找到所有的 Identifier
节点,然后把它们的 name
属性改成 "hello"。
反混淆的常见策略:
接下来,咱们来聊聊几种常见的反混淆策略,并给出相应的代码示例。
-
还原变量名和函数名:
- 问题: 混淆器通常会把变量名和函数名改成无意义的字符串,降低代码的可读性。
- 解决: 可以通过分析代码的上下文,或者使用 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
非常重要,它可以确保所有引用该变量的地方都被更新。
-
简化控制流:
- 问题: 混淆器通常会使用复杂的
ConditionalExpression
和LogicalExpression
嵌套,来混淆代码的控制流。 - 解决: 可以通过计算表达式的值,然后用更简单的代码替换它。
-
示例: 假设我们有这样一个复杂的条件表达式:
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.confident
为true
,表示计算结果是可靠的,我们可以用计算结果替换原来的表达式。
- 问题: 混淆器通常会使用复杂的
-
还原字符串和数字常量:
- 问题: 混淆器通常会对字符串和数字常量进行编码或加密,增加代码的分析难度。
- 解决: 可以找到编码或加密的算法,然后用解码或解密后的值替换它。
-
示例: 假设我们有一个简单的字符串编码:
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'); } } }, });
这个例子比较简单,实际情况可能会更复杂,需要根据具体的编码算法进行解码。
-
处理
eval
和Function
构造函数:- 问题: 混淆器可能会使用
eval
或Function
构造函数动态执行代码,增加代码的分析难度。 - 解决: 尽量避免执行动态代码。如果必须执行,可以先分析动态代码的逻辑,然后用等价的静态代码替换它。
-
示例: 假设我们有这样一个使用
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
调用。
- 问题: 混淆器可能会使用
-
删除 debugger 语句和无用代码:
- 问题: 混淆器可能会插入
debugger
语句来干扰调试,或者插入一些永远不会执行到的无用代码来增加代码的复杂度。 - 解决: 遍历AST,找到这些节点,然后删除它们。
- 示例:
traverse(ast, { DebuggerStatement(path) { path.remove(); console.log('Removed debugger statement'); }, });
- 问题: 混淆器可能会插入
第四部分:实战演练:一个简单的反混淆工具
光说不练假把式。咱们来写一个简单的反混淆工具,把上面讲的知识应用起来。
这个工具的功能很简单:
- 还原变量名和函数名 (把
_0xabc
替换成myVariable
) - 简化控制流 (计算
ConditionalExpression
和LogicalExpression
的值) - 删除
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!');
使用方法:
- 把混淆后的代码保存到
obfuscated.js
文件中。 - 运行这个脚本。
- 反混淆后的代码会保存到
deobfuscated.js
文件中。
第五部分:更高级的反混淆技巧
上面的例子只是一个简单的演示。实际情况中,混淆代码可能会更复杂,需要使用更高级的反混淆技巧。
- 控制流平坦化 (Control Flow Flattening): 混淆器会把代码的控制流打乱,变成一个巨大的
switch
语句。要反混淆这种代码,需要分析switch
语句的逻辑,然后还原代码的控制流。 - Dead Code Injection: 混淆器会插入一些永远不会执行到的代码,增加代码的复杂度。要反混淆这种代码,需要找到这些无用代码,然后删除它们。
- Opaque Predicates: 混淆器会使用一些永远为真或永远为假的表达式,来混淆代码的控制流。要反混淆这种代码,需要分析这些表达式的值,然后用
true
或false
替换它们。 - WebAssembly 混淆: 一些高级的混淆器会把 JavaScript 代码编译成 WebAssembly,然后再进行混淆。要反混淆这种代码,需要先反编译 WebAssembly 代码,然后再进行分析。
这些高级技巧需要更深入的理解代码混淆和反混淆的原理,以及更强大的工具和技术。
第六部分:总结与展望
今天咱们聊了 AST 遍历和节点替换,以及如何利用它们进行自动化反混淆。虽然反混淆是一个复杂的工程,但只要掌握了正确的方法和工具,就能有效地还原代码,保护自己的知识产权。
表格总结:
技术/工具 | 作用 | 优点 | 缺点 |
---|---|---|---|
AST | 代码的树形结构表示 | 易于分析和修改代码结构,避免语法错误 | 学习曲线陡峭,需要理解各种节点类型 |
@babel/parser |
将代码解析成 AST | 快速、准确 | 需要配置,支持不同的语法特性 |
@babel/traverse |
遍历和修改 AST | 灵活、强大,可以自定义各种 visitor 函数 |
需要理解 AST 的结构,容易出错 |
@babel/generator |
将 AST 生成代码 | 可以格式化代码,方便阅读 | 生成的代码可能与原始代码略有不同 |
Source Map | 源代码和混淆后代码的映射 | 还原变量名和函数名 | 需要混淆器生成 Source Map,可能会泄露代码信息 |
反编译工具 | 将 WebAssembly 代码转换成可读代码 | 可以分析 WebAssembly 代码的逻辑 | 反编译后的代码可能难以理解,需要专业的知识 |
未来的展望:
随着代码混淆技术的不断发展,反混淆的难度也会越来越大。未来的反混淆工具需要更加智能化、自动化,能够自动识别和处理各种混淆技术。同时,也需要更加强大的调试工具,能够帮助开发者快速定位和解决问题。
好了,今天的讲座就到这里。希望大家有所收获,也希望大家在反混淆的道路上越走越远!下次再见!