Babel API

Nothing someone says before the word “but” really counts.

0x01 Babel Intro

上一节使用Python实现了一个简易的解析器,了解了源代码到AST的转换过程。但若从头开始实现一个编译器是十分复杂的。

Babel 是一个工具链,主要用于在当前和旧的浏览器或环境中,将 ECMAScript 2015+ 代码转换为 JavaScript 向后兼容版本的代码。它能做下面的事情:

  • 转换语法

  • 源代码转换

  • polyfill目标环境缺少的功能

Babel编译过程主要有以下三个阶段

  • 解析(Parse):将输入字符流解析为AST抽象语法树

  • 转化(Transform):对抽象语法树进一步转化

    接收AST并对其进行遍历,在此过程中对节点进行添加、更新、移除等操作,后续的混淆JS代码以及混淆过的JS代码进行还原都在此处。

  • 生成(Generate):根据转化后的语法树生成目标代码

之所以把源码转化为抽象语法树,是因为树状结构更加容易进行原子操作,可以对任意的节点进行精细化处理。抽象语法树中,代码间的关系被抽象为节点间的关系,实现相同功能的节点之间的表示也相同。因此我们不需要关心具体的代码,只要指定几条规则就行

源代码:

let obj = {
    name: 'test',
    add: function(a, b) {
        return a + b + 10;
    },
    mul: function(a, b) {
        return a + b * 2;
    }
}

使用Babel生成AST并进行转换

Babel有很多组件和API

  • @babel/parser: 将JS代码转换为AST

  • @babel/travers: 遍历AST节点

  • @babel/types: 判断节点类型、生成新节点

  • @babel/generator: 把AST转化为JS代码

Babel处理JS文件的流程

  • 读取JS文件

  • 解析成AST

  • 对AST节点进行增删改查

  • 生成新的JS代码并保存

0x02 parser|generator

parser: JS转AST

generator: AST转JS

  • parser.parse返回一个对象,可以使用JSON.stringify将其转化为JSON字符串

  • 第二个参数是一个对象,sourceType默认为script,当解析的JS代码中含有importexport等关键字时,需要指定sourceTypemodule

  • generator返回的一个对象,其code属性才是需要的代码

  • retainLines: 是否使用与源代码相同的行号,默认false,输出的是格式化后的代码

  • comments: 是否保留注释,默认true

  • compact: 是否压缩代码

0x03 traverse|visitor

traverse用于遍历节点,配合visitor进行节点过滤

打印了两次visit FunctionExpression

visitor中方法的参数path对应Path对象而非节点对象Node

visitor的定义一般有如下三种方式:

在遍历节点的过程中,有两次机会访问一个节点,分别是进入节点(enter)和退出节点(exit)

可以把同一个函数应用到多个节点,|连接方法名

也可以把多个函数应用于同一个节点

当遍历到某个节点时,我们可以定义新的visitor对其下的子树进行特殊的处理,如下面代码的功能为替换所有函数的第一个参数为x

先根据visitortraverse所有节点,当遍历到FunctionExpression节点时,再用updateParamNameVisitor去遍历当前节点下的所有子节点。使用path.traverse时还可以额外传入一个对象,在对应的visitor中可以用this去引用。

0x04 types

用于判断节点类型、生成新的节点

下面两种写法等价,用于把所有变量名x改成n

下面用types来生成我们最早的代码

由上一节的AST在线解析的分析,首先要生成一个variableDeclarationkind参数是声明类型,declarations是一个VariableDeclarator数组

idIdentifier, init后面跟着?表示可选参数,init表示是否在变量声明时初始化变量,这里需要一个ObjectExpression

对象可以有多个属性,因此properties是个数组,我们需要三个ObjectProperty

第一个属性值是StringLiteral

第二、三个属性值是FunctionExpression

  • id: 函数名 Identifier

  • params: 参数数组

  • body: 函数体 BlockStatement

bodyStatement数组,我们的函数体内只有返回语句

返回语句由二元表达式构成

完整代码:

生成的代码:

上面用到了StringLiteralNumericLiteral, Babel 中还定义了一些其他的字面量

当生成比较多的字面量时,会比较麻烦,所以Babel还提供了valueToNod

0x05 Path

Node对象包含在Path对象中,下面会介绍Path对象的属性及方法

image-20230707163023924

Path Methods

  • 获取Node/Path

为获取节点的属性值,一般先访问到该节点,再直接点访问(.)属性

image-20230707163944598

但这种方法获取的是Node或具体属性值,无法使用Path类的方法

image-20230707164355092

path.get支持多级访问,如path.get('left.type')

得到的是封装的NodePath对象,就可以使用Path相关方法

  • 判断Path类型

上面的types组件可以用来判断Node的类型

Path也提供了判断自身类型的方法

  • 节点转代码

很多时间需要在执行过程中把部分节点转为代码,而非在最后才把整个AST转成代码,generator组件可以做到

Path对象重写了ObjecttoString,其中调用了generator组件把节点转为代码,因此可以直接path.toString()来把节点转为代码,或者path + ''隐式转换

  • 替换节点属性

获取节点属性直接赋值即可

  • 替换整个节点

replaceWith:一换一

replaceWithMultiple:多换一

replaceInline:接收一个参数,若该参数不为数组,等价于replaceWith;若该参数为数组,等价于replaceWithMultiple

replaceWithSourceString:用字符串源码替换节点

  • 删除节点

EmptyStatement为空语句,就是多余的分号

  • 插入节点

Parent Path

Path对象有两个属性

  • parentPathNodePath 父级Path

  • parentNode 父节点

  1. path.findParent

一些情况下需要从一个路径向上遍历语法树,直到满足相应的条件

image-20230711142231248
  1. path.find

使用方法和上面的path.findParent一致,不过find方法查找的范围包含当前节点,而findParent不包含

  1. path.getFunctionParent

向上查找与当前节点最近的函数父节点

  1. path.getStatementParent

向上查找与当前节点最近的语句父节点,如声明语句、return语句、if语句、switch语句、while语句

查找包含当前节点,因此在这个例子中需要从parentPath中去调用

Parallel Path

先了解一下container

image-20230711150518316
  • listKey:容器名

  • key:当前节点在容器中的位置

  • container:存储同级节点的数组

(有时候当前节点没有同级节点,container可能为单个Node,而非数组)

  1. path.inList:判断是否有同级节点(注意,当container为数组但只有一个成员时,会返回true)

  2. path.getSibling(index):获取同级Path,index为容器数组中的索引,index可以通过对path.key进行加减操作来定位到不同

  3. unshiftContainer、pushContainer

    unshiftContainer往容器最前面加入节点

    pushContainer往容器最后面加入节点

0x05 scope

scope可以方便地查找标识符的作用域,获取并修改标识符的所有引用,以及判断标识符是否为参数或常量

以下面的代码为例

  • 获取标识符作用域

scope.block属性来获取标识符作用域,返回Node对象

上述代码中变量e是定义在add函数内部的,作用域范围为整个add

若遍历的是一个函数,需要去获取父级作用域

  • 标识符绑定

    scope.getBinding('a')

image-20230711210945219
  • constant:判断当前变量是否被更改

  • identifier:标识符的Node对象

  • kind:表明a是add的参数

  • referenced:标识符是否被引用

  • references:被引用的次数

scope.getOwnBinding用于获取当前节点自己的绑定,即不包含父级作用域中定义的标识符的绑定,但该函数会得到子函数中定义的标识符的绑定

可以看到demo中定义的d变量也通过getOwingBinding获取到了。若只想获取当前节点下定义的标识符,而不涉及子函数的话,还需要进一步判断,可以通过判断标识符作用域是否与当前函数的一致来确定

通过binding.scope.block获取标识符作用域,转为代码后再与当前节点的代码比较,可以确定是否为当前函数中定义的标识符

  • referencePaths

若标识符被引用,referencePaths中会存放所有引用该标识符节点的Path对象(NodePath对象)

image-20230712082301706

add方法return处引用了两次a,其父节点为BinaryExpression,两个NodePathkey分别为leftright

  • constantViolations

若标识符被修改,constantViolations中会存放所有修改了该标识符节点的Path对象(NodePath数组)

image-20230712081844791

add方法的第一行修改了a标识符a = 400

  • scope.traverse

用于遍历作用域中的节点

上述代码将FunctionExpression下的a标识符作用域中对a的赋值语句改成赋值为666

  • scope.rename

标识符重命名,这个方法会同时修改所有引用该标识符的地方

如下将add函数中的b变量重命名为x

但我们指定的'x'可能会与现有标识符冲突,这时可以使用scope.generateUidIdentifier来生成一个标识符,这样就可以实现一个简单的标识符混淆

让我们多定义一些函数,并让其局部变量重名

可以发现不同函数的局部变量混淆过后的变量名不同,实际上可以让混淆之后的变量名重复,甚至让函数中的局部变量跟当前函数中没有引用到的全局变量重名(如原始代码中的全局变量a和add中的a参数),以此做到更复杂的混淆。

  • scope的其他方法

    • scope.hasBinding('a')查询是否有标识符a的绑定

      scope.getBinding('a')返回undefined等价于scope.hashBinding('a')返回false

    • scope.hasOwnBinding('a')查询当前节点是否有自己的绑定

      上面的demo函数中,OwnBinding只有一个d,函数名demo属于父级作用域

    • scope.getAllBindings

      获取当前节点的所有绑定,返回一个对象,以标识符名为属性名,对于的Binding为属性值

    • scope.hasReference('a')

      查询当前节点是否有a标识符的引用

    • scope.getBindingIdentifier('a')

      获取当前节点中绑定的a标识符,返回Identifier的Node对象

Last updated