Babel AST 入门
AST(Abstract Syntax Tree,抽象语法树)是将源代码解析成树形数据结构的表示方式。Babel 提供了一套完整的 AST 工具链,在 JS 逆向中可以用来还原混淆代码、提取加密参数、批量替换变量名等。 1. 工具链组成(#%E5%B7%A5%E5%85%B7%E9%93%BE%E7%BB%84%E6%88%90) 2. 安装(#%E5%AE%89%E8%A3%85) 3. 第一步:解析代码(parser)(#%E7%AC%AC%E4%B8%80%E6%AD%A5%EF%BC%9A%E8%A7%A3%E6%9E%90%E4%BB%A3%E
官方文档:https://babeljs.io/docs/babel-types
适用版本:@babel/core 7.x+(2026-05-08 核实)
AST(Abstract Syntax Tree,抽象语法树)是将源代码解析成树形数据结构的表示方式。Babel 提供了一套完整的 AST 工具链,在 JS 逆向中可以用来还原混淆代码、提取加密参数、批量替换变量名等。
目录
- 工具链组成
- 安装
- 第一步:解析代码(parser)
- 常用 AST 节点类型
- 第二步:遍历 AST(traverse)
- Path 对象的常用方法
- 第三步:构造/修改节点(types)
- 第四步:生成代码(generator)
- 完整工作流
- 实战示例
工具链组成
Babel AST 的处理分四个步骤,对应四个包:
源代码(字符串)
↓ @babel/parser(解析)
AST(树结构)
↓ @babel/traverse(遍历 + 修改)
AST(修改后)
↓ @babel/generator(生成)
目标代码(字符串)
| 包 | 作用 |
|---|---|
@babel/parser |
把 JS 字符串解析成 AST |
@babel/traverse |
遍历 AST 的每个节点,提供访问/修改接口 |
@babel/types |
创建新节点、判断节点类型的工具函数库 |
@babel/generator |
把 AST 重新生成为 JS 字符串 |
安装
npm install @babel/parser @babel/traverse @babel/types @babel/generator
第一步:解析代码(parser)
const parser = require('@babel/parser')
const code = `
function add(a, b) {
return a + b
}
const result = add(1, 2)
`
const ast = parser.parse(code, {
sourceType: 'module', // 'script' | 'module' | 'unambiguous'
plugins: ['jsx'], // 支持 JSX 语法(可选)
})
console.log(ast.type) // 'File'
console.log(ast.program) // Program 节点,包含所有顶层语句
parser.parse 选项
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
sourceType |
string | 'script' |
'module' 支持 import/export;'unambiguous' 自动判断 |
plugins |
string[] | [] |
启用语法插件,如 'jsx'、'typescript'、'decorators' |
errorRecovery |
boolean | false |
遇到语法错误时继续解析(处理混淆代码时很有用) |
strictMode |
boolean | false |
强制严格模式 |
tokens |
boolean | false |
把 token 列表附加到 AST |
ranges |
boolean | false |
每个节点附加 [start, end] 位置数组 |
常用 AST 节点类型
推荐用 AST Explorer 实时查看任意代码对应的 AST 结构。
基础节点
| 节点类型 | 对应代码 | 核心字段 |
|---|---|---|
Identifier |
foo、bar |
name: string |
NumericLiteral |
42、3.14 |
value: number |
StringLiteral |
'hello'、"world" |
value: string |
BooleanLiteral |
true、false |
value: boolean |
NullLiteral |
null |
— |
TemplateLiteral |
`hello ${name}` |
quasis、expressions |
表达式节点
| 节点类型 | 对应代码 | 核心字段 |
|---|---|---|
BinaryExpression |
a + b、x > 0 |
operator、left、right |
UnaryExpression |
!x、-n、typeof x |
operator、argument、prefix |
LogicalExpression |
a && b、x || y |
operator、left、right |
AssignmentExpression |
x = 1、a += b |
operator、left、right |
CallExpression |
fn(a, b) |
callee、arguments |
MemberExpression |
obj.key、arr[0] |
object、property、computed |
ConditionalExpression |
a ? b : c |
test、consequent、alternate |
SequenceExpression |
(a, b, c) |
expressions |
ArrowFunctionExpression |
(x) => x + 1 |
params、body、async |
FunctionExpression |
function(x) {} |
id、params、body |
语句节点
| 节点类型 | 对应代码 | 核心字段 |
|---|---|---|
VariableDeclaration |
var x = 1、const y |
kind(var/let/const)、declarations |
VariableDeclarator |
x = 1(声明中的单个变量) |
id、init |
FunctionDeclaration |
function foo() {} |
id、params、body |
ReturnStatement |
return x |
argument |
IfStatement |
if (x) {} else {} |
test、consequent、alternate |
BlockStatement |
{ ... } |
body: Statement[] |
ExpressionStatement |
foo() (作为语句) |
expression |
WhileStatement |
while (x) {} |
test、body |
ForStatement |
for (;;) {} |
init、test、update、body |
一个示例:a + b 的 AST 结构
{
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Identifier",
"name": "a"
},
"right": {
"type": "Identifier",
"name": "b"
}
}
第二步:遍历 AST(traverse)
@babel/traverse 使用访问者模式(Visitor Pattern):你提供一个对象,键是节点类型,值是访问该类型时要执行的函数。
const traverse = require('@babel/traverse').default
traverse(ast, {
// 进入节点时触发
Identifier(path) {
console.log('Identifier:', path.node.name)
},
// 也可以用 enter / exit 分开
FunctionDeclaration: {
enter(path) {
console.log('进入函数:', path.node.id.name)
},
exit(path) {
console.log('离开函数:', path.node.id.name)
},
},
})
同时访问多种节点类型
用 | 分隔多种类型:
traverse(ast, {
'StringLiteral|NumericLiteral'(path) {
console.log('字面量:', path.node.value)
},
})
Path 对象的常用方法
path 是对节点的包装,提供了节点本身(path.node)以及操作节点的方法。
读取信息
path.node // 当前 AST 节点
path.parent // 父节点(AST 节点,非 path)
path.parentPath // 父节点的 path
path.key // 当前节点在父节点中的键名(如 'body', 'left')
path.listKey // 当前节点在数组中的 key(如 'body')
path.inList // 是否在数组中
path.scope // 当前作用域
类型判断
path.isIdentifier() // 是否是 Identifier 节点
path.isIdentifier({ name: 'foo' }) // 是否是名为 foo 的 Identifier
path.isFunctionDeclaration()
path.isStringLiteral()
path.isLiteral() // 是否是任意字面量
path.isExpression() // 是否是表达式
path.isStatement() // 是否是语句
path.isReferenced() // 是否被引用(而不是定义)
修改节点
// 替换节点
path.replaceWith(newNode) // 用单个新节点替换
path.replaceWithMultiple([n1, n2]) // 用多个节点替换(仅限在 body 中)
path.replaceWithSourceString('1 + 1') // 用代码字符串替换
// 删除节点
path.remove() // 删除当前节点
// 插入节点
path.insertBefore(node) // 在当前节点前插入
path.insertAfter(node) // 在当前节点后插入
控制遍历
path.skip() // 跳过当前节点的所有子节点(不再向下遍历)
path.stop() // 停止整个遍历
作用域相关
path.scope.bindings // 当前作用域内所有绑定(变量声明)
path.scope.hasBinding('x') // 是否有变量 x
path.scope.getBinding('x') // 获取变量 x 的绑定信息
path.scope.rename('oldName', 'newName') // 重命名变量(自动处理所有引用)
第三步:构造/修改节点(types)
@babel/types(通常缩写为 t)提供每种节点的工厂函数和判断函数。
const t = require('@babel/types')
// 创建节点
t.identifier('foo') // Identifier: foo
t.numericLiteral(42) // NumericLiteral: 42
t.stringLiteral('hello') // StringLiteral: 'hello'
t.booleanLiteral(true) // BooleanLiteral: true
t.nullLiteral() // NullLiteral: null
t.binaryExpression('+', t.identifier('a'), t.identifier('b'))
// → a + b
t.callExpression(t.identifier('fn'), [t.numericLiteral(1)])
// → fn(1)
t.memberExpression(t.identifier('obj'), t.identifier('key'))
// → obj.key
t.memberExpression(t.identifier('arr'), t.numericLiteral(0), true)
// → arr[0](computed: true 表示方括号访问)
t.variableDeclaration('const', [
t.variableDeclarator(t.identifier('x'), t.numericLiteral(1))
])
// → const x = 1
t.functionDeclaration(
t.identifier('add'),
[t.identifier('a'), t.identifier('b')],
t.blockStatement([
t.returnStatement(
t.binaryExpression('+', t.identifier('a'), t.identifier('b'))
)
])
)
// → function add(a, b) { return a + b }
// 判断函数(所有节点类型都有对应的 isXxx)
t.isIdentifier(node)
t.isStringLiteral(node)
t.isFunctionDeclaration(node)
第四步:生成代码(generator)
const generate = require('@babel/generator').default
const { code } = generate(ast, {
// 选项
comments: true, // 是否保留注释,默认 true
compact: false, // 是否压缩(去掉空格换行),默认 false
minified: false, // 是否完全压缩,默认 false
concise: false, // 适度减少空格,默认 false
retainLines: false, // 尽量保持原始行号,默认 false
jsescOption: { // 字符串转义选项
minimal: true, // 只转义必须转义的字符
},
})
console.log(code)
完整工作流
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const t = require('@babel/types')
const generate = require('@babel/generator').default
const code = `var x = 1`
// 1. 解析
const ast = parser.parse(code)
// 2. 遍历 + 修改
traverse(ast, {
NumericLiteral(path) {
// 把所有数字字面量加 10
path.node.value += 10
},
})
// 3. 生成
const { code: output } = generate(ast)
console.log(output) // var x = 11
实战示例
示例一:将所有 var 替换为 const
traverse(ast, {
VariableDeclaration(path) {
if (path.node.kind === 'var') {
path.node.kind = 'const'
}
},
})
示例二:删除所有 console.log 调用
traverse(ast, {
ExpressionStatement(path) {
const { expression } = path.node
// 判断是否是 console.log(...)
if (
t.isCallExpression(expression) &&
t.isMemberExpression(expression.callee) &&
t.isIdentifier(expression.callee.object, { name: 'console' }) &&
t.isIdentifier(expression.callee.property, { name: 'log' })
) {
path.remove()
}
},
})
示例三:计算常量表达式(常见混淆还原)
混淆代码经常把 1 写成 0x1,把 'hello' 写成 '\x68\x65\x6c\x6c\x6f',或把 2 写成 1 + 1。
traverse(ast, {
BinaryExpression(path) {
const { left, right, operator } = path.node
// 只处理两边都是数字字面量的情况
if (!t.isNumericLiteral(left) || !t.isNumericLiteral(right)) return
let result
switch (operator) {
case '+': result = left.value + right.value; break
case '-': result = left.value - right.value; break
case '*': result = left.value * right.value; break
case '/': result = left.value / right.value; break
case '|': result = left.value | right.value; break
case '^': result = left.value ^ right.value; break
case '>>': result = left.value >> right.value; break
case '<<': result = left.value << right.value; break
default: return
}
path.replaceWith(t.numericLiteral(result))
},
})
// 1 + 1 → 2
// 3 * 4 → 12
示例四:还原十六进制字符串('\x68\x65\x6c\x6c\x6f' → 'hello')
traverse(ast, {
StringLiteral(path) {
// 强制用普通字符串重写节点,babel generator 会自动用可读形式输出
path.replaceWith(t.stringLiteral(path.node.value))
path.skip() // 避免死循环
},
})
示例五:重命名混淆变量名
traverse(ast, {
Identifier(path) {
// 把 _0x1234 形式的变量名改为 var_N
if (/^_0x[0-9a-f]+$/.test(path.node.name)) {
// scope.rename 会自动重命名所有引用(声明 + 使用)
const binding = path.scope.getBinding(path.node.name)
if (binding) {
path.scope.rename(path.node.name)
}
}
},
})
示例六:提取所有字符串字面量
const strings = []
traverse(ast, {
StringLiteral(path) {
strings.push({
value: path.node.value,
line: path.node.loc?.start.line,
})
},
})
console.log(strings)
注意事项
- 避免死循环:在
StringLiteral或NumericLiteral内部用replaceWith创建同类节点时,新节点会再次触发同一 visitor,需要在替换后调用path.skip()跳过。 - 节点不可复用:同一个节点对象不能同时挂载在树的两个位置,需要复制时用
t.cloneNode(node)。 - 位置信息:修改后生成的代码不保留原始行列号,source map 会失效,调试时注意。
- errorRecovery:处理混淆代码可能有非标准语法,解析时建议开启
errorRecovery: true。
最佳实践
用 AST Explorer 可视化调试 Visitor:在 astexplorer.net 选择 @babel/parser 解析代码,实时查看每个节点类型和结构,在右侧写 Visitor 代码并即时看到转换结果,比本地开发调试快 10 倍。
path.replaceWith 替换节点时用 t.xxx 构建新节点:从 @babel/types 导入构建器(const t = require('@babel/types')),用 t.identifier('newName')、t.callExpression(...) 等 API 构建节点,不要手写字符串拼接 JS 代码。
混淆代码先处理字符串解密函数:OB 混淆的第一步是还原所有字符串——遍历 CallExpression 节点,若调用目标是字符串解密函数,直接在 Visitor 中调用该函数取到明文字符串,用 t.stringLiteral(result) 替换调用节点。
path.scope.rename 批量重命名作用域内变量:不需要手动 traverse 找所有引用,Babel 作用域分析已经处理好了,path.scope.rename('_0xabc', 'userId') 一行代码重命名所有引用。
每步还原后保存中间文件:babel/generator 输出中间结果后保存文件,逐步还原而非一次完成,方便定位某一步出错的位置,也方便团队协作。
常见陷阱
陷阱:path.node 和 node 混用导致作用域分析失效
现象: 手动修改了 path.node.name,但后续 path.scope.rename 没有生效,或引用计数不正确。
原因: 直接修改 node 的属性会绕过 Babel 的作用域跟踪;应该用 path.replaceWith 或 path.scope.rename 等操作让 Babel 感知变化。
解决: 使用 Babel Path API 而非直接修改 node 属性;重命名用 path.scope.rename,替换用 path.replaceWith(newNode)。
陷阱:Visitor 的 enter / exit 时机不对导致无限递归
现象: Visitor 运行后文件大小急剧增长或进程 OOM。
原因: 在 enter 中替换节点为相同类型的新节点,Babel 又对新节点触发同一 Visitor,产生无限递归。
解决: 替换节点后调用 path.skip() 跳过新节点的后续遍历;或把处理逻辑放到 exit 钩子中(已完成子节点处理)。
陷阱:generate 输出的代码缺少原始格式信息
现象: Babel 还原后的代码是单行,调试困难;或注释丢失。
原因: @babel/generator 默认不保留原始格式;Babel 的 AST 默认不保留注释。
解决: generate(ast, { comments: true, compact: false }) 保留注释并格式化输出;或在 @babel/parser 解析时传 { attachComment: true } 保留注释。