什么是 AST
抽象语法树(Abstract Syntax Tree),简称 AST,初识 AST 是在一门网页逆向的课程,该课程讲述了 js 代码中混淆与还原的对抗,而所使用的技术便是 AST,通过 AST 能很轻松的将 js 源代码混淆成难以辨别的代码。同样的,也可以通过 AST 将其混淆的代码 还原成执行逻辑相对正常的代码。
例如下面的代码(目的是当天时间格式化)
Date.prototype.format = function (formatStr) {
var str = formatStr
var Week = ['日', '一', '二', '三', '四', '五', '六']
str = str.replace(/yyyy|YYYY/, this.getFullYear())
str = str.replace(/MM/, (this.getMonth() + 1).toString().padStart(2, '0'))
str = str.replace(/dd|DD/, this.getDate().toString().padStart(2, '0'))
return str
}
console.log(new Date().format('yyyy-MM-dd'))
通过 AST 混淆的结果为
const OOOOOO = [
'eXl5eS1NTS1kZA==',
'RGF0ZQ==',
'cHJvdG90eXBl',
'Zm9ybWF0',
'5pel',
'5LiA',
'5LqM',
'5LiJ',
'5Zub',
'5LqU',
'5YWt',
'cmVwbGFjZQ==',
'Z2V0RnVsbFllYXI=',
'Z2V0TW9udGg=',
'dG9TdHJpbmc=',
'cGFkU3RhcnQ=',
'MA==',
'Z2V0RGF0ZQ==',
'bG9n',
]
;(function (OOOOOO, OOOOO0) {
var OOOOOo = function (OOOOO0) {
while (--OOOOO0) {
OOOOOO.push(OOOOOO.shift())
}
}
OOOOOo(++OOOOO0)
})(OOOOOO, 115918 ^ 115930)
window[atob(OOOOOO[694578 ^ 694578])][atob(OOOOOO[873625 ^ 873624])][atob(OOOOOO[219685 ^ 219687])] = function (OOOOO0) {
function OOOO00(OOOOOO, OOOOO0) {
return OOOOOO + OOOOO0
}
var OOOOOo = OOOOO0
var OOOO0O = [
atob(OOOOOO[945965 ^ 945966]),
atob(OOOOOO[298561 ^ 298565]),
atob(OOOOOO[535455 ^ 535450]),
atob(OOOOOO[193006 ^ 193000]),
atob(OOOOOO[577975 ^ 577968]),
atob(OOOOOO[428905 ^ 428897]),
atob(OOOOOO[629582 ^ 629575]),
]
OOOOOo = OOOOOo[atob(OOOOOO[607437 ^ 607431])](/yyyy|YYYY/, this[atob(OOOOOO[799010 ^ 799017])]())
OOOOOo = OOOOOo[atob(OOOOOO[518363 ^ 518353])](
/MM/,
OOOO00(this[atob(OOOOOO[862531 ^ 862543])](), 671347 ^ 671346)
[atob(OOOOOO[822457 ^ 822452])]()
[atob(OOOOOO[974597 ^ 974603])](741860 ^ 741862, atob(OOOOOO[544174 ^ 544161])),
)
OOOOOo = OOOOOo[atob(OOOOOO[406915 ^ 406921])](
/dd|DD/,
this[atob(OOOOOO[596004 ^ 596020])]()
[atob(OOOOOO[705321 ^ 705316])]()
[atob(OOOOOO[419232 ^ 419246])](318456 ^ 318458, atob(OOOOOO[662337 ^ 662350])),
)
return OOOOOo
}
console[atob(OOOOOO[490983 ^ 490998])](new window[atob(OOOOOO[116866 ^ 116866])]()[atob(OOOOOO[386287 ^ 386285])](atob(OOOOOO[530189 ^ 530207])))
将上述代码复制到浏览器控制台内执行,将会输出当天的年月日。
AST 有什么用
除了上述的混淆代码,很多文本编辑器中也会使用到,例如:
- 编辑器的错误提示、代码格式化、代码高亮、代码自动补全;
elint
、pretiier
对代码错误或风格的检查;webpack
通过babel
转译javascript
语法;
不过本篇并非介绍 AST 的基本概念,看本篇你只需要知道如何通过 babel 编译器生成 AST 并完成上述的混淆操作即可。
有必要学 AST 吗
如果作为 JS 开发者并且想要深入了解 V8 编译,那么 AST 基本是必修课之一,像 Vue,React 主流的前端框架都使用到 AST 对代码进行编译,在 ast 学习中定能让你对 JS 语法有一个更深入的了解。
AST 误区
AST 本质上是静态分析,静态分析是在不需要执行代码的前提下对代码进行分析的处理过程,与动态分析不同,静态分析的目的是多种多样的, 它可用于语法检查,编译,代码高亮,代码转换,优化,压缩等等场景。即便你的程序也许在运行时报错,但都不会影响 AST 解析(除非语法错误),在 js 逆向中,通过静态分析还原出相对容易看的出的代码有对于代码分析,而对于一些需要知道某一变量执行 后的结果静态分析是做不到的。
环境安装
首先需要 Node 环境,这就不介绍了,其次工具 Babel 编译器可通过 npm 安装
npm i @babel/core -S-D
安装代码提示
npm i @types/node @types/babel__traverse @types/babel__generator -D
新建 js 文件,导入相关模块(也可使用 ES module 导入),大致代码如下
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const t = require('@babel/types')
const generator = require('@babel/generator').default
let jscode = fs.readFileSync(__dirname + "/demo.js", {
encoding: "utf-8"
})
// 解析为AST
let ast = parser.parse(jscode)
// 转化特征代码
traverse(ast, {
...
})
// 生成转化后的代码
let code = generator(ast).code
babel 的编译过程主要有三个阶段
- 解析(Parse): 将输入字符流解析为 AST 抽象语法树
- 转化(Transform): 对抽象语法树进一步转化
- 生成(Generate): 根据转化后的语法树生成目标代码
AST 的 API
在进行编译前,首先需要了解 Babel 的一些相关 API,这边所选择的是 babel/parser 库作为解析,还有一个在线 ast 解析网站AST explorer 能帮助我们有效的了解 AST 中的树结构。
同时 Babel 手册(中文版) babel-handbook强烈建议反复阅读,官方的例子远比我所描述来的详细。
例子
这边就举一个非常简单的例子,混淆变量名(或说标识符混淆)感受一下。引用网站代 码例子
/**
* Paste or drop some JavaScript here and explore
* the syntax tree created by chosen parser.
* You can use all the cool new features from ES6
* and even more. Enjoy!
*/
let tips = [
"Click on any AST node with a '+' to expand it",
'Hovering over a node highlights the \
corresponding location in the source code',
'Shift click on an AST node to expand the whole subtree',
]
function printTips() {
tips.forEach((tip, i) => console.log(`Tip ${i}:` + tip))
}
比如说,我要将这个 tips 标识符更改为_0xabcdef
,那么肯定是需要找到这个要 tips,在 Babel 中要找到这个则可以通过遍历特部位(如函数表达式,变量声明等等)。
鼠标点击这个 tips 查看 tips 变量在树节点中的节点。
这边可以看到有两个蓝色标记的节点,分别是VariableDeclaration
和VariabelDeclarator
,翻译过来便是变量声明与变量说明符,很显然整个let tips = [ ]
是VariableDeclaration
,而tips
则是VariabelDeclarator
。
所以要将tips
更改为_0xabcdef
就需要遍历VariabelDeclarator
并判断属性name
是否为tips
,大致代码如下。(后文代码将会省略模块引入、js 代码读取、解析与生成的代码)
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const t = require('@babel/types')
const generator = require('@babel/generator').default
let jscode = fs.readFileSync(__dirname + '/demo.js', { encoding: 'utf-8' })
let ast = parser.parse(jscode)
traverse(ast, {
VariableDeclarator(path) {
let name = path.node.id.name
if (name === 'tips') {
let binding = path.scope.getOwnBinding(name)
binding.scope.rename(name, '_0xabcdef')
}
},
})
let code = generator(ast).code
生成的代码如下,成功的将tips
更改为_0xabcdef
,并且是tips
的所有作用域(printTips 函数下)都成功替换了。
/**
* Paste or drop some JavaScript here and explore
* the syntax tree created by chosen parser.
* You can use all the cool new features from ES6
* and even more. Enjoy!
*/
let _0xabcdef = ["Click on any AST node with a '+' to expand it", "Hovering over a node highlights the \
corresponding location in the source code", "Shift click on an AST node to
expand the whole subtree"];
function printTips() {
_0xabcdef.forEach((tip, i) => console.log(`Tip ${i}:` + tip));
}
简单描述下上述代码的过程
1、遍历所有VariableDeclarator
节点,也就是tips
变量说明符(标识符)
2、获取当前遍历到的标识符的 name,也就是path.node.id.name
,在树节点是对应的也是id.name
3、判断 name 是否等于 tips,是的话,通过path.scope.getOwnBinding(name)
,获取当前标识符(tips)的作用域,scope 的意思就是作用域,如果只是赋值操作的话如path.node.id.name = '_0xabcdef'
,那只修改的let tips =
的 tips, 而后面的对 tips 进行forEach
操作的 tips 并不会更改,所以这里才需要使用binding
来获取 tips 的作用域,并调用提供好的rename
方法来进行更改。
4、调用binding.scope.rename(name, '_0xabcdef')
,将旧名字 name(tips)更改为_0xabcdef,就此整个遍历就结束,此时的 ast 已经发生了变化,所以只需要根据遍历过的 ast 生成代码便可得到修改后的代码。
如果在仔细观察的话,其实Identifier
(标识符)也是蓝色表示的,说明Identifier
也同样可以遍历,甚至比上面的效果更好(后续替换所有的标识符也是遍历这个)
traverse(ast, {
Identifier(path) {
let name = path.node.name
console.log(name)
if (name === 'tips') {
let binding = path.scope.getOwnBinding(name)
binding.scope.rename(name, '_0xabcdef')
}
},
})
并尝试输出所有的标识符,输出的 name 结果为
tips
printTips
_0xabcdef
forEach
tip
i
console
log
i
tip
这个例子也许有点啰嗦,但我认为是有必要的,同时想说的是某种混淆(还原)的实现往往可以有好几种方法遍历,会懂得融会贯通,AST 混淆与还原才能精通。
parser 与 generator
前者用于将 js 代码解析成 AST,后者则是将 AST 转为 js 代码,两者的具体参数可通过 babel 手册查看,这就不做过多介绍了。
babel-handbook #babel-generator
traverse 与 visitor
整个 ast 混淆还原最关键的操作就是遍历,而 visitor 则是根据特定标识(函数声明,变量订阅)来进行遍历各个节点,而非无意义的全部遍历。
traverse 一共有两个参数,第一个就是 ast,第二个是 visitor,而 visitor 本质是一个对象如下(分别有 JavaScript 和 TypeScript 版本,区别就是在于这样定义的 visitor 是否有代码提示)
- JS
- TS
const visitor = {
FunctionDeclaration(path) {
console.log(path.node.id.name) // 输出函数名
},
}
let visitor: Visitor = {
FunctionDeclaration(path) {
console.log(path.node.id.name) // 输出函数名
},
}
一 般来说,都是直接写到写到 traverse 内。个人推荐这种写法,因为能有 js 的代码提示,如果是 TypeScript 效果也一样。
traverse(ast, {
FunctionDeclaration(path) {
console.log(path.node.id.name) // 输出函数名
},
})
如果我想遍历函数声明与二项式表达式的话,还可以这么写
traverse(ast, {
'FunctionDeclaration|BinaryExpression'(path) {
let node = path.node
if (t.isFunctionDeclaration(node)) {
console.log(node.id.name) // 输出函数名 printTips
} else if (t.isBinaryExpression(node)) {
console.log(node.operator) // 输出操作符 +
}
},
})
不过要遍历不同类型的代码,那么对应的 node 属性肯定大不相同,其中这里使用了 t(也就是@babel/types
库)来进行判断 node 节点是否为该属性,来进行不同的操作,后文会提到 types。
上述操作将会输出 printTips
与 +
因为 printTips 函数中代码有 Tip ${i}: + tip
,这就是一个二项式表达式。
此外 visitor 中的属性中,还对应两个生命周期函数 enter(进入节点)和 exit(退出节点),可以在这两个周期内进行不同的处理操作,演示代码如下。
traverse(ast, {
FunctionDeclaration: {
enter(path) {
console.log('进入函数声明')
},
exit(path) {
console.log('退出函数声明')
},
},
})
其中 enter 与 exit 还可以是一个数组(当然基本没怎么会用到),比如
traverse(ast, {
FunctionDeclaration: {
enter: [
(path) => {
console.log('1')
},
(path) => {
console.log('2')
},
],
},
})
path 对象下还有一种方法,针对当前 path 进行遍历 path.traverse
,比如下面代码中,我遍历到了 printTips,我想输出函数内的箭头函数中的参数,那么就可以使用这种遍历。
function printTips() {
tips.forEach((tip, i) => console.log(`Tip ${i}:` + tip))
}
此时的 path.traverse 的第一个参数便不是 ast 对象了,而是一个 visitor 对象
traverse(ast, {
FunctionDeclaration(path) {
path.traverse({
ArrowFunctionExpression(path) {
console.log(path.node.params)
},
})
},
})
输出的结果如下
[
Node {
type: 'Identifier',
start: 40,
end: 43,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: 'tip'
},
name: 'tip'
},
Node {
type: 'Identifier',
start: 45,
end: 46,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: 'i'
},
name: 'i'
}
]
types
该库主要的作用是判断节点类型与生成新的节点。判断节点类型上面已经演示过了,比如判断 node 节点是否是为标识符t.isIdentifier(path.node)
,等同于path.node.type === "Identifier"
判断节点类型是很重要的一个环节,有时候混淆需要针对很多节点进行操作,但并不是每个节点都有相同的属性,判断节点才不会导致获取到的节点属性出错,甚至可以写下面的代码(将输出所有函数声明与箭头函数的参数)。
traverse(ast, {
enter(path) {
t.isFunctionDeclaration(path.node) && console.log(path.node.params)
t.isArrowFunctionExpression(path.node) && console.log(path.node.params)
}
})
types 的主要用途还是构造节点,或者说写一个 Builders(构建器),例如我要生成 let a = 100
这样的变量声明原始代码,通过 types 能轻松帮我们生成。
不过先别急着敲代码,把let a = 100
代码进行 ast 解析,看看每个代码的节点对应的 type 都是什么,这样才有助于生成该代码。
body 内的第一个节点便是我们整条的代码,输入t.variableDeclaration()
,鼠标悬停在 variableDeclaration 上,或者按 Ctrl 跳转只.d.ts 类型声明文件 查看该方法所需几个参数
declare function variableDeclaration(kind: 'var' | 'let' | 'const', declarations: Array<VariableDeclarator>): VariableDeclaration
可以看到第一个参数就是关键字,而第二个则一个数组,其中节点为VariableDeclarator
,关于variableDeclaration
与 VariableDeclarator
在前面已经提及过一次了,就不在赘述了。由于我们这里只是声明一个变量 a,所有数组成员只给一个便可,如果要生成 b,c 这些变量,就传入对应的VariableDeclarator
即可
这时候在查看下 VariableDeclarator 方法参数
declare function variableDeclarator(id: LVal, init?: Expression | null): VariableDeclarator
第一个参数 id 很显然就是标识符了,不过这里的 id 不能简简单单传入一个字符串 a,而需要通过t.identifier('a')
生成该节点,在上图中 id 就是对应Identifier
节点。然后就是第二个参数了,一个表达式,其中这个Expression
是 ts 中的联合类型(Union Types),可以看到有很多表达式
declare type Expression =
| ArrayExpression
| AssignmentExpression
| BinaryExpression
| CallExpression
| ConditionalExpression
| FunctionExpression
| Identifier
| StringLiteral
| NumericLiteral
| NullLiteral
| BooleanLiteral
| RegExpLiteral
| LogicalExpression
| MemberExpression
| NewExpression
| ObjectExpression
| SequenceExpression
| ParenthesizedExpression
| ThisExpression
| UnaryExpression
| UpdateExpression
| ArrowFunctionExpression
| ClassExpression
| MetaProperty
| Super
| TaggedTemplateExpression
| TemplateLiteral
| YieldExpression
| AwaitExpression
| Import
| BigIntLiteral
| OptionalMemberExpression
| OptionalCallExpression
| TypeCastExpression
| JSXElement
| JSXFragment
| BindExpression
| DoExpression
| RecordExpression
| TupleExpression
| DecimalLiteral
| ModuleExpression
| TopicReference
| PipelineTopicExpression
| PipelineBareFunction
| PipelinePrimaryTopicReference
| TSAsExpression
| TSTypeAssertion
| TSNonNullExpression
其中我们所要赋值的数值 100,对应的节点类型NumericLiteral
也在其中。在查看 numericLiteral 中的参数,就只给一个数值,那么便传入 100。
declare function numericLiteral(value: number): NumericLiteral;
最后整个代码如下,将 t.variableDeclaration 结果赋值为一个变量var_a
,这里的 var_a 便是一个 ast 对象,通过 generator(var_a).code 就可以获取到该 ast 的代码,也就是 let a = 100;
,默认还会帮你添加分号
let var_a = t.variableDeclaration('let', [t.variableDeclarator(t.identifier('a'), t.numericLiteral(100))])
let code = generator(var_a).code
// let a = 100;