AST常用于Js逆向中的解混淆,但这块知识较多,所以我将其分为上下两篇进行讲解,本文(也就是上篇)主要介绍AST技术以及常用模块的功能和代码演示,下篇则主要讲解AST技术在js反混淆中的利用

一、AST技术简介

首先,我们来了解什么是AST。全称叫作Abstract Syntax Tree,中文翻译过来就是抽象语法树

简单来说AST就是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码的一种结构,这种数据结构其实可以类比成一个大的JSON对象。

一段代码在执行编译之前,一般要经过以下三个步骤

  • 词法分析:一段代码首先会被分解成段段有意义的词法单元,比如const name=”qc”这段代码,它可被折分成四部分:

    • const

    • name

    • =

    • qc

每个部分都具备一定的含义。

  • 语法分析:接着编译器会尝试对一个个法单元进行语法分析,将其转换为能代表程序语法结构的数据结构。比如

    • const就被分析为VariableDeclaration类型,代表变量声明的具体定义;

    • name就被分析为ldentifier类型,代表一个标识符

    • qc就被分析为Literal类型,代表文本内容;

  • 指令生成:最后将AST转为可执行指令并执行

这里推荐一个在线AST解析的网站,可以将Js代码解析为语法树,也就是AST形式,方便师傅们更好的理解什么是AST

https://astexplorer.net/

图片

我们随便输入两句Js代码

const name = "qc";
const age = 16;

可以看到两句Js代码被解析为body里的两段变量说明,即**(Variableclaration**)

图片

每段变量说明中都包含变量声明的具体定义(const),起始位置(startend),以及具体的说明(declarations

图片

在具体的变量定义中就可以看到参数的标识符(Identifier)和字面量(Literal),以及他们的类型,起始位置等

图片

看懂了上面的内容,你应该就能明白为什么说AST就是源代码的抽象语法结构的树状表示

AST的常见节点类型如下:

Literal:简单理解就是字面量,比如3、"abc"、null这些都是基本的字面量。在代码中又细分为数字字面量,字符字面量等;

Declarations:声明,通常声明方法或者变量。

Expressions:表达式,通常有两个作用:一个是放在赋值语句的右边进行赋值,另外还可以作为方法的参数。

Statemonts:语句。

Identifier:标识符,指代变量名,比如上述例子中的name就是ldentifier。

Classes:类,代表一个类的定义。

Functions:方法声明。

Modules:模块,可以理解为一个Node.js模块。

Program:程序,整个代码可以称为Program。

//除此之外还有很多的类型,这里就不过多阐述了

AST技术一般在前端开发中使用非常多,但我们也可以使用AST对Js代码进行转换和改写,包括还原混淆后的Js代码,例如控制流平坦化,对象键名替换等常见的混淆手段都可以通过AST技术进行还原

接下来,我们尝试使用Babel(目前最流行的Js语法编译器)来实现AST的解析与修改

Babel的使用需要Node.js环境以及Babel命令行工具,这里默认你应该有了Node.js环境,就带着你们安装一下Babel工具

//安装化babel命令行工具npm 

install -g @babel/node

接下来再初始化一个Node.js项目ast-learn用于本次AST演示

// 初始化node.js项目

npm install -D @babel/core @babel/cli @babel/preset-env

如下

图片

(上面那两条警告并不是报错,只是提示有新版本而已,不用管)

初始化之后目录如下

图片

接下来需要在当前目录创建一个.babelsrc文件,内容如下

图片

到这里我们的环境准备就完成了,接下来就是AST几个常见模块的使用了

二、babel-parser

@babel/parser是Babel中的Javascript解析器,也是一个Node.js包,就是用于将Js代码转换为AST

parser包提供了一个重要的方法,就是parse和parseExpression方法,前者支持解析一段)avaScript代码,后者则是尝试解析单个JavaScript表达式并考虑了性能问题。一般来说,我们直接使用parse方法就够了。

对于parse方法来说,输入和输出如下。

  • 输入:一段Js代码

  • 输出:该段Js代码对应的抽象语法树,也就是AST

例如现在有一段简单的Js代码如下,我们将其保存为code1.js文件

const a = 3;
let string = "Hello";
for (let i =0; i < a; i++){
  string = "world";
}
console.log("string",stirng)

我们想用parser包将其解析为AST的形式,具体的代码如下

import { parse } from "@babel/parser";
import fs from "fs";
//根据自己的实际路径来
const code = fs.readFileSync("../code_demo/code1.js","utf-8");
let ast = parse(code);
console.log(ast);

很简单吧,其实就是使用了parser包提供的parse()方法,就可以将js源代码转换为AST抽象语法树

在命令行中运行该Js文件,命令如下

babel-node parser_demo1.js

运行结果如下

图片

可以看到已经成功把code1.js的内容解析为AST了,这个AST的内容上面有提到过了,这里就不多赘述了,感兴趣的师傅们可以自己再看看

三、babel-generate

上面的parser包是将Js代码转换为AST,而generate则是可以将AST转换为Javascript代码

这里我们尝试将上面代码中parser解析的AST对象重新转换为Js代码

实现代码如下

import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";

const code = fs.readFileSync("../code_demo/code1.js", "utf-8");
let ast = parse(code);
// console.log(ast);
const { code: output } = generate(ast);
console.log(output);

运行之后结果如下

图片

可以看到我们通过generate函数处理Js代码解析过后得到的AST对象后,重新通过这个AST对象得到了对应的Js源代码

四、babel-traverse

前面我们了解了AST的解析,输入任意一段avaScript代码,我们便可以分析出其AST,也可以通过AST还原出对应的Js代码,但是我们还并不能实现JavaScript代码的反混淆。下面我们还需要进一步了解另一个强大的功能,就是AST的遍历和修改

遍历我们使用的@babel/traverse,它可以接收一个AST利用tracerse方法就可以遍历其中的所有节点。在遍历时,我们便可以对每一个节点进行对应的操作了。

下面我们来看这样一段代码

import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import fs from "fs";

const code = fs.readFileSync("../code_demo/code1.js", "utf-8");
let ast = parse(code);
traverse(ast, {
  enter(path) {
    console.log(path);
  },
});

使用parse将源码解析为AST对象后,使用了traverse函数来遍历整个AST语法树,每次遍历时打印当前遍历到的内容

这里的enter代表“进入”这个事件,即每次遍历到一个path,都会有一个“进入”的行为,简单理解也就是每次遍历都会触发一个“enter”,

运行结果如下

图片

可以看到内容比较复杂。首先我们可以看到它的类型是NodePath,拥有parentcontainernodescopetype等多个属性。比如node属性是一个Node类型的对象,他代表当前正在遍历的节点。

所以我们可以利用path.node拿到当前对应的Node对象,也可以利用path.parent拿到当前Node对象的父节点。

我们可以对代码作出如下修改

-

-

console.log(path);
//将打印nodepath改为打印node
console.log(path.node)

这样运行之后就只会输出AST中遍历到的每个node对象,如下

图片

可以看到打印出了许多个node节点

上面我们也讲了,利用traverse函数对AST对象进行遍历时,我们也可以在这个过程中进行操作,简单来说就是当遍历到符合我们条件的node时,对当前的node进行一定的操作,实现起来也很简单,跟师傅们以前写过的代码中的循环类似

这里我们还是用这个Js源代码来演示

const a = 3;
let string = "Hello";
for (let i = 0; i < a; i++) {
  string = "world";
}
console.log("string", string);

例如我们想要通过修改AST的方式将上述代码的a变量的值和string变量的值修改为我们指定的值,也就是变成如下内容

const a = 5;
let string = "Hello";
for (let i = 0; i < a; i++) {
  string = "qc";
}
console.log("string", string);

具体的实现代码如下

import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";

const code = fs.readFileSync("../code_demo/code1.js", "utf-8");
// js代码解析为ast
let ast = parse(code);
traverse(ast, {
  //enter方法在遍历到每个节点时都会被调用,path就是被遍历到的当前节点的相关信息
  //这样就相当于遍历AST,作出对应操作
  enter(path) {
    // 遍历AST时修改指定位置的节点内容
    let node = path.node;
    if (node.type === "NumericLiteral" && node.value === 3) {
      node.value = 5;
    }
    if (node.type === "StringLiteral" && node.value === "world") {
      node.value = "qc";
    }
  },
});

// ast还原为js代码
const { code: output } = generate(ast);
console.log(output);

这段代码在每次遍历时,提取了对应的node对象并进行判断,例如第一个判断,就是进行了如下判断

  • 判断当前node的类型是否是数字字面量

  • 当前node的值是否等于3

如果这两个条件都满足,就说明遍历到了a的值所在的node,也就是我们想要操作的node,也就是下图这个node

图片

此时代码再将node的value值赋值为5,这样就实现了一次AST对象的修改,第二个判断也是同理,这里就不过多阐述了

最后再将修改过后的AST对象转换为Js源代码,这样就实现了通过AST技术对Js代码的修改操作

运行结果如下

图片

可以看到a和string的值,都已经被成功修改了,这样我们就实现了一次修改AST节点内容来对Js代码进行修改的操作

(注意,这里不要理解为AST修改的是源文件,源文件是没有任何变化的,因为Js源代码解析为AST,我们修改AST,再转换为Js源代码并输入,整个过程中源代码只是用来解析为第一个AST对象的,我们并没有将修改后的Js代码赋值到源文件,也就是源文件是不变的)

另外,除了定义enter方法外,我们还可以直接定义对应特定类型的解析方法,这样遇到此类型的节点时,该方法就会被自动调用

简单来说就是enter是每次遍历都会触发,除此之外还有许多解析方法,是遍历到特定类型的node时才会被触发并执行内部代码

例如NumericLiteralStringLiteral用法,我们尝试用这两个来重写上方修改AST对象的代码,如下

import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";

// 遍历到指定类型时修改node

const code = fs.readFileSync("../code_demo/code1.js", "utf-8");
let ast = parse(code);

traverse(ast, {
  // 遍历到数字字面量修改值
  NumericLiteral(path) {
    if (path.node.value === 3) {
      path.node.value = 5;
    }
  },
  //   遍历到字符字面量修改值
  StringLiteral(path) {
    if (path.node.value === "Hello") {
      path.node.value = "hi";
    }
  },
});

const { code: output } = generate(ast);
console.log(output);

简单来说就是遍历到node节点的type为数字字面量或者字符字面量时,就触发并执行内部代码,运行结果如下

图片

可以看到也能实现对Js源代码的修改,效果是一样的

除此之外还有很多玩法,比如删除某个node等

这里我们演示一下删除code1.js最后一行代码对应的节点,代码如下

import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";

const code = fs.readFileSync("../code_demo/code1.js", "utf-8");
let ast = parse(code);

traverse(ast, {
  CallExpression(path) {
    let node = path.node;
    if (
      node.callee.object.name === "console" &&
      node.callee.property.name == "log"
    ) {
      path.remove();
    }
  },
});

const { code: output } = generate(ast, {
  retainLines: true,
});
console.log(output);

运行结果如下

图片

可以看到最后一行的console.log操作代码已经被删除了,这个其实更简单,只需要在遍历到对应node时调用remove方法即可

上面和大家演示了简单的替换和删除,那如果想要插入一个节点,应该怎么做呢?那就需要用到我们下面讲的types了

五、babel-types

@babel/types是一个Node.js包,里面定义了各种各样的对象,我们可以方便地使用types声明一个新的节点,也就是对应的代码插入操作。

例如我们现在有这样一个代码

const a = 'qc';

我们想增加一行代码,改为

const a = 'qc';
const daylight = "nn" + a;

这时候我们就可以使用types包实现这个操作,实现代码如下


import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import * as types from "@babel/types";
const code = "const a = 'qc';";
let ast = parse(code);

traverse(ast, {
  VariableDeclaration(path) {
    // 创建一个二元表达式,参数依次操作符,左操作数和右操作数,并赋值给init
    let init = types.binaryExpression(
      "+",
      types.stringLiteral("nn"),
      types.identifier("a")
    );
    // 创建变量声明的AST节点,并定义变量名为a
    // t.variableDeclarator(id,init)
    // id:           必需 是t.identifier,即标识符
    // init:         可选 是expression对象,即表达式
    let declarator = types.variableDeclarator(
      types.identifier("daylight"),
      init
    );
    // t.variableDeclaration(kind,declarations)
    // kind:        必需 'var'|'let'|'const'
    // declarations: 必需 是array<t.variableDeclarator>,即variableDeclarator对象组成的数组
    let declaration = types.variableDeclaration("const", [declarator]);
    // 插入到path节点之后
    path.insertAfter(declaration);
    // 停止遍历
    path.stop();
  },
});
const { code: output } = generate(ast, {
  retainLines: true,
});
console.log(output);

代码简单来说就是从内到外构造节点结构,首先利用binaryExpression构造一个如下的二元表达式

"nn" + a

再用这个二元表达式创建一个变量声明,变量名为daylight,也就变成了

daylight = "nn" + a

再用variableDeclaration声明定义const,也就是形成了我们要插入的完整内容

const daylight = "nn" + a

最后再利用insertAfter将代码插入到AST指定位置,然后停止遍历,这就是上述利用AST技术在js代码中插入新代码的整个过程

最后运行,结果如下

图片

可以看到成功利用AST技术插入了新的Js代码

六、小结

本篇文章带师傅们了解了什么是AST技术,以及AST的几个简单使用示例,包括AST的解析,遍历以及利用AST技术对Js源代码的增删改操作(建议师傅们自己运行一下代码试试),让师傅们对AST技术有了一个大致的认识,也为后面利用AST对加密过后的Js源代码进行解混淆做一个铺垫

一起期待下篇的内容吧,最后感谢一下师傅们这么久以来的支持,文章有什么不对的地方欢迎指出或者私信讨论哈