Skip to content
On this page

了解AST让逆向变得更简单

什么是 AST

AST(Abstract Syntax Tree),中文叫做“抽象语法树”,简称“语法树”,是源代码的抽象语法结构的树状表示形式,其中树上的每一个节点都表示源代码中的一种结构。语法树并不是某一种编程语言所特有的,JavaScript、Java、Python、Golang 等几乎所有的编程语言都有语法树,只是对应的编译器不同。

小时候我们得到一个玩具,总喜欢把玩具拆解成一个一个的小零件,然后按照我们自己的想法,把零件重新组装起来,这样一个新玩具就诞生了。而 JavaScript 就像一台精妙运作的机器,通过 AST 解析,我们就可以像童年时拆解玩具一样,深度了解 JavaScript 这台机器的各个零部件,然后重新按照我们自己的意愿来组装。

AST 其实我们已经很早就接触到了,比如我们使用的 IDE 语法高亮、代码检查、代码格式化、代码自动补全、代码错误提示、代码压缩等等都是用 AST 来实现的,还有为了 ES5 和 ES6 的语法向后兼容,都是需要用到 AST 来进行转换的,

AST 会有在线解析网站:https://astexplorer.net,顶部可以选择 语言编译器是否开启转化等,如下图所示,区域 ① 是源代码、区域 ② 是对应的 AST 语法树结构、区域 ③ 是编写的转换代码、区域 ④ 是转换后生成的新代码。图中演示的是 Unicode字符经过操作之后转换成正常字符

AST 语法树没有固定的格式,选择不同的语言、不同的编译器、得到的结果也是各不相同的,在 JavaScript 中,编译器有 Acorn、Espree、Esprima、Recast、Uglify-Js 等等,使用最多的是 Babel,后续的学习也是以 Babel 为主。

AST 在编译中的位置

在编译原理中,编译器转换代码通常要经过三个步骤:1.词法分析(Lexical Analysis)2.语法分析(Syntax Analysis)3.代码生成(Code Generation),下图生动了展示这一过程。

词法分析(Lexical Analysis)

词法分析阶段是编译过程的第一个阶段,这个阶段的任务就是从左到右一个字符一个字符地读入源程序,然后根据构词规则识别单词,生成 token 符号流,比如 isPanda('🐼'),会被拆分为 isPanda'🐼'四部分,每部分都有不同的含义,可以将词法分析过程想象为不同类型标记的列表或数组。

语法分析(Syntax Analysis)

语法分析是编译过程中一个逻辑阶段,语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,比如“程序”,“语句”,“表达式”等,前面的例子中,isPanda('🐼') 就会被分析为一条表达式语句 ExpressionStatementisPanda() 就会被分析称一个函数表达式 CallExpression'熊猫'就会被分析称一个变量 Literal等,众多语法之间的依赖、嵌套关系,就构成了一个树状结构,即 AST 语法树。

代码生成(Code Generation)

代码生成是最后一步,将 AST 语法树转换为可执行代码,在转换之前,我们可以直接操作语法树,进行增删改查等操作,例如,我们可以确定变量的声明位置、更改变量的值、删除某些节点等,我们将语句 isPanda('熊猫') 修改为一个布尔类型的 Literaltrue,语法树就有如下变化:

Babel 简介

Babel 是一个 JavaScript 编译器,也可以说是一个解析库,Babel 中文网:https://babeljs.cn,Babel 英文官网:https://babeljs.io,Babel 内置了很多分析 JavaScript 代码的方法,我们可以利用 Babel 将 JavaScript 代码转换成 AST 语法树,然后通过增删改查等操作这种,再转换成 JavaScript 代码。

Babel 包含的各种功能包、API、各方法可选参数等都非常多,本文不一一列举,在实际使用过程中,应当多查询官方文档,或者参考文末给出的一些学习资料。Babel 的安装和其他 Npm 包一样,需要哪个安装哪个即可,比如 npm install @babel/core @babel/parser @babel/traverse @babel/generator

在做 JavaScript 代码解混淆的过程中,主要用到了 Babel 的以下几个核心包,本文也仅介绍以下几个功能包:

  1. @babel/core:Babel 编译器本身,提供了 Babel 的编译 API;
  2. @babel/parser:将 JavaScript 代码解析为 AST 语法树;
  3. @babel/traverse:遍历、修改 AST 语法树的各个节点;
  4. @babel/generator:将 AST 语法树 还原成 JavaScript 代码;
  5. @babel/types:判断、验证节点的类型、构建新 AST 节点等;

@babel/core

Babel 编译器本身,被拆分为三个模块:@babel/parser@babel/traverse@babel/generator,比如以下方法的导入效果都是一样的:

javascript
const parse = require("@babel/parser").parse;  
const parse = require("@babel/core").parse;  
  
const traverse = require("@babel/traverse").default;  
const traverse = require("@babel/core").traverse;  

@babel/parser

@babel/parser 可以将 JavaScript 代码解析成 AST 语法树,其中主要提供了两个方法:

  • parser.parse(code, [{options}]):解析一段 JavaScript 代码;
  • parser.parseExpression(code, [{options}]:考虑到了性能问题,解析单个 JavaScript 表达式;

部分可选参数 options

参数描述
allowImportExportEverywhere默认 importexport 声明语句只能出现在程序的最顶层,设置为 true 则在任何地方都可以声明
allowReturnOutsideFunction默认如果在顶层中使用 return 语句会引起错误,设置为 true 就不会报错
sourceType默认为 script,当代码中含有 importexport 等关键字时会报错,需要指定为 module
errorRecovery默认如果 babel 发现一些不正常的代码就会抛出错误,设置为 true 则会在保存解析错误的同时继续解析代码,错误的记录将保存在最终生成 AST 的 errors 属性中,当然如果遇到严重的错误,依然会终止解析

举个例子看的比较清楚:

js
const parser = require("@babel/parser");  
  
const code = "const a = 1;";  
const ast = parser.parse(code, {sourceType: "module"});  
console.log(ast);  

{sourceType: "module"} 演示了如何添加可选参数,输出的就是 AST 语法树,这和在线网站 https://astexplorer.net/ 解析出来的语法树是一样的:

@babel/generator

@babel/generator 可以将 AST 还原成 JavaScript 代码,提供了一个 generate 方法:generate(ast, [{options}], code)

部分可选参数 options

参数描述
auxiliaryCommentBefore在输出文件内容的头部添加注释块文字
auxiliaryCommentAfter在输出文件内容末尾添加注释块文字
comments输出内容是否包含注视
compact输出内容是否不添加空格,避免格式化
concise输出内容是否减少空格使其更紧凑一些
minified是否压缩输出代码 |
retainLines尝试在输出代码中使用源代码中相同的行号
jsescOption{"minimal": true} 代码中 Unicode 转中文

接着前面的例子,源代码是 const a = 1;,现在我们把 a 变量修改为 b,值 1 修改为 2,然后将 AST 还原新生成的 JavaScript 代码输出:

js
const parser = require("@babel/parser");  
const generate = require("@babel/generator").default;  
  
const code = "const a = 1;";  
const ast = parser.parse(code, {sourceType: "module"});  
ast.program.body[0].declarations[0].id.name = "b";  
ast.program.body[0].declarations[0].init.value = 2;  
const result = generate(ast, {minified: true});  
  
consloe.log(result.code);  

最终输出的代码是 const b=2;,变量名和值都成功修改了,由于添加了压缩处理的可选项,等号左右两边的空格也没了。

代码里 {minified: true} 演示了如何添加可选参数,这里表示压缩输出代码, generate 得到的 result 得到的是一个对象,其中的 code 属性才是最中的 JavaScript 代码。

代码里 ast.program.body[0].declarations[0].id.namea 在 AST 结构中的位置,ast.program.body[0].declarations[0].init.value1 在 AST 结构中的位置,如下图所示:

@babel/traverse

在一般实践过程中,代码肯定是很多行的, 我们不可能像前面那样直接定位到地方并且修改,对于相同类型的节点,我们直接可以通过遍历所有节点来进行修改,这里就用到了 @babel/traverse,它通常和 visitor 一起使用,visitor 是一个对象,这个名字是可以随意取的,visitor 这里可以定义一些方法来过滤节点,这里还是用一个例子来演示:

js
const parser = require("@babel/parser");  
const generate = require("@babel/generator").default;  
const traverse = require("@babel/traverse").default;  
  
const code = `  
const a = 1500;  
const b = 60;  
const c = "hi";  
const d = 787;  
const e = "1244"  
`  
const ast = parser.parse(code);  
  
const visitor = {  
NumericLiteral(path) {  
path.node.value = (path.node.value + 100) * 2  
},  
StringLiteral(path) {  
path.node.value = "I Love JavaScript!"  
}  
}  
  
traverse(ast, visitor);  
const result = generate(ast);  
console.log(result.code);  

这里的原始代码定义了 abcde 五个变量,其值有数字也有字符串,我们在 AST 中可以看到对应的类型为 NumericLiteralStringLiteral

然后我们声明了一个 visitor 对象,然后定义对应类型的处理方法,traverse 接收两个参数,第一个是 AST 对象,第二个是 visitor,当 traverse 遍历所有节点,遇到节点类型为 NumericLiteralStringLiteral 时,就会调用 visitor 中对应的处理方法, visitor 中的方法会接收一个当前节点的 path 对象,该对象的类型是 NodePath,该对象有非常多的属性,以下介绍几种最常用的:

属性描述
toString()当前路径的源码
node当前路径的节点
parent当前路径的父级节点
parentPath当前路径的父级路径
type当前路径的类型

path 对象除了很多属性外,还有很多方法,比如 替换节点删除节点插入节点寻找父级节点获取同级节点添加注释判断节点类型等,可在需要时查询相关文档或查看源码,后续介绍 @babel/types 部分将会举部分例子来演示,以后的实战文章也会有相关实例,篇幅有限本文不在细说。

因此在上面的代码中,path.node.value 就拿到了变量的值,然后我们就可以进一步对其修改了,以上代码运行后,所有的数字都会在原有的值上加上 100 后再乘以 2,所有字符串都会被替换成 I Love JavaScript!,结果如下:

js
const a = 3200;  
const b = '320';  
const c = "I Love JavaScript!";  
const d = 1774;  
const e = "I Love JavaScript!"  

如果多个类型的节点处理方式都一样的话,那么还可以使用 | 来将所有节点连接成字符串,将同一个方法应用到所有节点:

js
const visitor = {  
NumericLiteral(path) {  
path.node.value = (path.node.value + 100) * 2  
},  
StringLiteral(path) {  
path.node.value = "I Love JavaScript!"  
}  
}  
  
/** 等同于下面代码 */  
const visitor = {  
"NumericLiteral|StringLiteral"(path) {  
path.node.value = "I Love JavaScript!"  
}  
}  

visitor 对象有多种写法,以下几种写法效果都是一样的:

js
const visitor = {  
NumericLiteral(path) {  
path.node.value = (path.node.value + 100) * 2  
},  
StringLiteral(path) {  
path.node.value = "I Love JavaScript!"  
}  
}  
js
const visitor = {  
NumericLiteral: function(path) {  
path.node.value = (path.node.value + 100) * 2  
},  
StringLiteral: function(path) {  
path.node.value = "I Love JavaScript!"  
}  
}  
js
const visitor = {  
NumericLiteral: {  
enter(path) {  
path.node.value = (path.node.value + 100) * 2  
}  
},  
StringLiteral: {  
enter(path) {  
path.node.value = "I Love JavaScript!"  
}  
}  
}  
js
const visitor = {  
enter(path) {  
if (path.node.type === "NumericLiteral") {  
path.node.value = (path.node.value + 100) * 2  
}  
if (path.node.type === "StringLiteral") {  
path.node.value = "I Love JavaScript!"  
}  
}  
}  

以上几种写法中有用到了 enter 方法,在节点遍历的过程中,进入节点(enter)与退出(exit)节点都会访问一次节点,traverse 默认在进入节点时进行节点处理,如果要在退出节点时处理,那么在 visitor 中就必须声明 exit 方法。

@babel/types

@babel/types 主要用于构建新的 AST 节点,前面的示例代码为 const a = 1;,如果想要增加内容,比如编程 const a = 1; const b = a * 5 + 1;,就可以通过 @babel/types 来实现。

首先观察一下 AST 语法树,原语句只有一个 VariableDeclaration 节点,现在增加了一个:

那么思路就出来了,遍历所有节点,当遍历到 VariableDeclaration 节点,就在其后面增加一个 VariableDeclaration 节点,生成 VariableDeclaration 节点,可以使用 types.variableDeclaration() 方法,在 types 中各种方法名称和我们在 AST 中看到的是一样的,只不过首字母是小写的,所以我们不需要知道所有方法的情况下,也能大致推断其方法名,只知道这个方法还不行,还得知道传入的参数是什么,可以查文档,不过这里推荐直接看源码,非常清晰明了。

js
function variableDeclaration(kind: "var" | "let" | "const", declarations: Array<BabelNodeVariableDeclarator>)  

可以看到需要 kinddeclarations 两个参数,其中 declarationsVariableDeclarator 类型的节点组成的数组列表,所以我们可以先写出以下 visitor 部分的代码,其中 path.insertAfter() 是在该节点之后插入新节点的意思:

js
const visitor = {  
VariableDeclaration(path) {  
let declaration = types.variableDeclaration("const", [declarator]);  
path.insertAfter(declaration);  
}  
}  

接下来我们还需要进一步定义 declarator,也就是 VariableDeclarator 类型的节点,查询其源码如下:

js
function variableDeclarator(id: BabelNodeLVal, init?: BabelNodeExpression)  

观察 AST 结构如下,id 为 Identifier 对象,init 为 BinaryExpression 对象,如下图所示:

先来处理 id,可以使用 types.identifier() 方法来生成,其源码为 function identifier(name: string),name 在这里就是 b 了,此时 visitor 代码就可以这么写:

js
const visitor = {  
VariableDeclaration(path) {  
let declarator = types.variableDeclarator(types.identifier("b"), init);  
let declaration = types.variableDeclaration("const", [declarator]);  
path.insertAfter(declaration);  
}  
}  

然后在来看下 init 该如何定义,首先仍是看 AST 结构:

init 为 BinaryExpression 对象,left 左边是 BinaryExpression 对象,right 右边是 NumericLiteral 对象,可以用 types.binaryExpression() 方法来生成 init,其源码如下:

js
function binaryExpression(  
operator: "+" | "-" | "/" | "%" | "*" | "**" | "&" | "|" | ">>" | ">>>" | "<<" | "^" | "==" | "===" | "!=" | "!==" | "in" | "instanceof" | ">" | "<" | ">=" | "<=",  
left: BabelNodeExpression | BabelNodePrivateName,  
right: BabelNodeExpression  
)  

此时 visitor 代码就可以这么写:

js
const visitor = {  
VariableDeclaration(path) {  
let init = types.binaryExpression("+", left, right);  
let declarator = types.variableDeclarator(types.identifier("b"), init);  
let declaration = types.variableDeclaration("const", [declarator]);  
path.insertAfter(declaration);  
}  
}  

紧接着继续构造 left 和 right 这两个节点,方法和前面一样,观察 AST 结构,查询对应方法传入的参数,直到把所有的节点都构造完毕,最终的 visitor 代码应该是这样:

js
const visitor = {  
VariableDeclaration(path) {  
let left = types.binaryExpress("*", types.identifier("a"), types.numericLiteral(5));  
let right = types.numericLiteral(1);  
let init = types.binaryExpression("+", left, right);  
let declarator = types.variableDeclarator(types.identifier("b"), init);  
let declaration = types.variableDeclaration("const", [declarator]);  
path.insertAfter(declaration);  
}  
}  

注意:在 path.insertAfter() 这句代码后面加了一句 path.stop(),表示插入完成后立即停止遍历当前节点和后续节点,添加的新节点也是 VariableDeclaration,如果不加停止语句的话,就会陷入无限循环。

插入新节点后,再转换成 JavaScript 代码,就可以看到多了一行新代码,如下图所示:

参考资料

END

国内关于 Babel 的资料很少,但是只要我们多观察分析源码,接着对照 AST 在线解析网站的结构,充分的耐心总会有所收获,以上介绍的只是 AST 和 Babel 的基本操作,我们实际工作中遇到的一些混淆的代码还得视情况而定。

Released under the MIT License.