img

什么是 AST

AST(Abstract Syntax Tree),中文抽象语法树,简称语法树(Syntax Tree),是源代码的抽象语法结构的树状表现形式,树上的每个节点都表示源代码中的一种结构。语法树不是某一种编程语言独有的,JavaScript、Python、Java、Golang 等几乎所有编程语言都有语法树。

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

AST 的用途很广,IDE 的语法高亮、代码检查、格式化、压缩、转译等,都需要先将代码转化成 AST 再进行后续的操作,ES5 和 ES6 语法差异,为了向后兼容,在实际应用中需要进行语法的转换,也会用到 AST。AST 并不是为了逆向而生,但做逆向学会了 AST,在解混淆时可以如鱼得水。

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

语法树没有单一的格式,选择不同的语言、不同的编译器,得到的结果也是不一样的,在 JavaScript 中,编译器有 Acorn、Espree、Esprima、Recast、Uglify-JS 等,使用最多的是 Babel,后续的学习也是以 Babel 为例。

1

AST 在编译中的位置

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

2

词法分析

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

3

语法分析

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

4

代码生成

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

5

Babel 简介

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

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

在做逆向解混淆中,主要用到了 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 节点等。

6

@babel/core

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

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

默认 import 和 export 声明语句只能出现在程序的最顶层,设置为 true 则在任何地方都可以声明

allowReturnOutsideFunction

默认如果在顶层中使用 return 语句会引起错误,设置为 true 就不会报错

sourceType

默认为 script,当代码中含有 import 、export 等关键字时会报错,需要指定为 module

errorRecovery

默认如果 babel 发现一些不正常的代码就会抛出错误,设置为 true 则会在保存解析错误的同时继续解析代码,错误的记录将被保存在最终生成的 AST 的 errors 属性中,当然如果遇到严重的错误,依然会终止解析

举个例子看得比较清楚:

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/ 解析出来的语法树是一样的:

7

@babel/generator

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

部分可选参数 options:

参数

描述

auxiliaryCommentBefore

在输出文件内容的头部添加注释块文字

auxiliaryCommentAfter

在输出文件内容的末尾添加注释块文字

comments

输出内容是否包含注释

compact

输出内容是否不添加空格,避免格式化

concise

输出内容是否减少空格使其更紧凑一些

minified

是否压缩输出代码

retainLines

尝试在输出代码中使用与源代码中相同的行号

接着前面的例子,原代码是 const a = 1;,现在我们把 a 变量修改为 b,值 1 修改为 2,然后将 AST 还原生成新的 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})



console.log(result.code)

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

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

代码里 ast.program.body[0].declarations[0].id.name 是 a 在 AST 中的位置,ast.program.body[0].declarations[0].init.value 是 1 在 AST 中的位置,如下图所示:

8

@babel/traverse

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

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 中可以看到对应的类型为 NumericLiteral 和 StringLiteral:

9

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

属性

描述

toString()

当前路径的源码

node

当前路径的节点

parent

当前路径的父级节点

parentPath

当前路径的父级路径

type

当前路径的类型

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

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

const a = 3200;

const b = 320;

const c = "I Love JavaScript!";

const d = 1774;

const e = "I Love JavaScript!";

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

const visitor = {

    "NumericLiteral|StringLiteral"(path) {

        path.node.value = "I Love JavaScript!"

    }

}

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

const visitor = {

    NumericLiteral(path){

        path.node.value = (path.node.value + 100) * 2

    },

    StringLiteral(path){

        path.node.value = "I Love JavaScript!"

    }

}
const visitor = {

    NumericLiteral: function (path){

        path.node.value = (path.node.value + 100) * 2

    },

    StringLiteral: function (path){

        path.node.value = "I Love JavaScript!"

    }

}
const visitor = {

    NumericLiteral: {

        enter(path) {

            path.node.value = (path.node.value + 100) * 2

        }

    },

    StringLiteral: {

        enter(path) {

            path.node.value = "I Love JavaScript!"

        }

    }

}
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 节点,现在增加了一个:

10

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

11

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

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

const visitor = {

    VariableDeclaration(path) {

        let declaration = types.variableDeclaration("const", [declarator])

        path.insertAfter(declaration)

    }

}

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

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

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

12

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

const visitor = {

    VariableDeclaration(path) {

        let declarator = types.variableDeclarator(types.identifier("b"), init)

        let declaration = types.variableDeclaration("const", [declarator])

        path.insertAfter(declaration)

    }

}

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

13

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

function binaryExpression(

    operator: "+" | "-" | "/" | "%" | "*" | "**" | "&" | "|" | ">>" | ">>>" | "<<" | "^" | "==" | "===" | "!=" | "!==" | "in" | "instanceof" | ">" | "<" | ">=" | "<=",

    left: BabelNodeExpression | BabelNodePrivateName, 

    right: BabelNodeExpression

)

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

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 代码应该是这样的:

const visitor = {

    VariableDeclaration(path) {

        let left = types.binaryExpression("*", 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.stop()

    }

}

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

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

14

常见混淆还原

了解了 AST 和 babel 后,就可以对 JavaScript 混淆代码进行还原了,以下是部分样例,带你进一步熟悉 babel 的各种操作。