AST基本知识以及简单的解JS混淆代码示例

时间:2023-04-05 14:13:58

介绍

AST是abstract syntax tree的缩写,就是抽象语法树。AST是源代码的抽象语法结构的树形表示,树上的每一个节点都表示源代码中的一种结构,这种数据结构其实可以类比为一个大的JSON对象。

一段代码在执行前会经过三个步骤

  1. 词法分析:分解代码为一段段的词法单元 例如:const name = "huangyijia" 这段代码将被拆解为四个部分 const, name,“=”和“huangyijia”,每一部分都有一定的含义
  2. 语法分析:编译器会尝试将一个个词法单元进行语法分析,将其转化为能代表程序语法结构的数据结构。代码多了这一个个词法就会有嵌套,依赖等关系。因此表示语法结构的数据结构就构成了一个树状的结构,也就成了语法树,即AST。
  3. 指令生成:最后将AST转化为实际真正可执行的指令并执行即可

重要概念(必须清楚)

Path是描述父子节点之间的链接,Path 除了下面这些属性外,还包含 添加、更新、移动、删除 等方法

Node对象 是 Path对象中的一个属性。path.node 能取出当前遍历到的 节点各种具体信息,不能使用 Path 对象的各种方法

实例引入

首先进入学习AST一个必不可少的网站:AST explorer

AST基本知识以及简单的解JS混淆代码示例

如上图所示这一行代码被解析成了一个语法树。

认识一下语法树节点类型

AST基本知识以及简单的解JS混淆代码示例

安装@babel/parse

这里使用到的一个包是@babel/parse,它是目前最流行的JavaScript解析包。如果没有安装的话可以使用cmd:

npm install -g @babel/node

@babel/node进行安装。当然首先得已经安装了node.js。安装方法可以参考:Scrape Center 中的node.js ,或者直接进入:Node.js 的安装 | 静觅 (cuiqingcai.com)

接下来初始化一个node.js项目,新建一个文件夹 learn-ast 然后在文件夹中cmd 输入:

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

运行完毕后,在当前目录下创建一个.babelrc文件,内容如下:

{
  "presets":[
     "babel/preset-env"
  ]
}

这样就完成了初始化操作。

使用@babel/parse

对于parse方法来说,输入的是一段jJavaScript代码,输出的是AST

测试;在codes/路径下新建一个名为code1.js的JavaScript文件内容为:

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

然后我们使用parse方法将其转换为一个AST

新建一个JavaScript文件名为basic1.js,内容为:

import {parse} from "@babel/parser";
import fs from "fs";
const code = fs.readFileSync("codes/code1.js","utf-8");
let ast = parse(code);
console.log(ast)

在learn-ast文件夹中使用cmd运行命令:

babel-node basic1.js

得到如下结果:

Node {
  type: 'File',
  start: 0,
  end: 116,
  loc: SourceLocation {
    start: Position { line: 1, column: 0, index: 0 },
    end: Position { line: 6, column: 28, index: 116 },
    filename: undefined,
    identifierName: undefined
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 116,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    sourceType: 'script',
    interpreter: null,
    body: [ [Node], [Node], [Node], [Node] ],
    directives: []
  },
  comments: []
}

可以看到body中的node没有显示出来我们可以增加一行代码用来显示body:

console.log(ast.program.body)

我们就可以得到body的内部结构:

body: [
  Node {
    type: 'VariableDeclaration',
    start: 0,
    end: 12,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    declarations: [ [Node] ],
    kind: 'const'
  },
  Node {
    type: 'VariableDeclaration',
    start: 14,
    end: 35,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    declarations: [ [Node] ],
    kind: 'let'
  },
  Node {
    type: 'ForStatement',
    start: 37,
    end: 86,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    init: Node {
      type: 'VariableDeclaration',
      start: 42,
      end: 51,
      loc: [SourceLocation],
      declarations: [Array],
      kind: 'let'
    },
    test: Node {
      type: 'BinaryExpression',
      start: 52,
      end: 55,
      loc: [SourceLocation],
      left: [Node],
      operator: '<',
      right: [Node]
    },
    update: Node {
      type: 'UpdateExpression',
      start: 56,
      end: 59,
      loc: [SourceLocation],
      operator: '++',
      prefix: false,
      argument: [Node]
    },
    body: Node {
      type: 'BlockStatement',
      start: 60,
      end: 86,
      loc: [SourceLocation],
      body: [Array],
      directives: []
    }
  },
  Node {
    type: 'ExpressionStatement',
    start: 88,
    end: 116,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    expression: Node {
      type: 'CallExpression',
      start: 88,
      end: 116,
      loc: [SourceLocation],
      callee: [Node],
      arguments: [Array]
    }
  }
]

使用@babel/generate还原AST为JavaScript

在learn-ast中新建一个文件为basic2.js,内容为:

import {parse} from "@babel/parser";
import generate from "@babel/generator"
import fs from "fs";
const code = fs.readFileSync("codes/code1.js","utf-8");
let ast = parse(code);
const {code:output} = generate(ast);
console.log(output)

可以看到就从ast还原为JavaScript代码了:

AST基本知识以及简单的解JS混淆代码示例

另外,generate还可以在第二个参数中接收一些配置选项,第三个参数可以接收源代码最为输出的参考:

const output = generate(ast,{/*options*/,code});

options部分配置

AST基本知识以及简单的解JS混淆代码示例

使用@babel/traverse对ast进行遍历和修改

新建JavaScript文件为basic3.js 内容为:

import {parse} from "@babel/parser";
import traverse from "@babel/traverse";
import fs from "fs";
const code = fs.readFileSync("codes/code1.js","utf-8");
let ast = parse(code);
traverse(ast,{
    enter(path){
      console.log(path)  
    },
});

这里我们调用了traverse方法,给第一个参数传入ast对象,给第二个参数定义了相关的处理逻辑,这里声明了一个enter方法,它接收path参数。这个enter方法在每个节点被遍历到时都会被调用,其中path里面就包含了当前被遍历到的节点相关信息。这里把path输出出来。

运行结果如下:

AST基本知识以及简单的解JS混淆代码示例

控制台输出了很多内容,每次都输出一个path对象。首先我们可以看到他的类型是NodePath,它拥有parent,container,node,scope,type等多种属性。我们可以使用path.node拿到当前对应的Node对象,使用parent.node拿到当前node对象的父节点。

如此我们便可以使用它对node进行一些处理。比如修改a的值。将a = 3;改为a = 5:

//basic4.js


import {parse} from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator"
import fs from "fs";
const code = fs.readFileSync("codes/code1.js","utf-8");
let ast = parse(code);
// 修改a = 5    string = "hi"
traverse(ast,{
    enter(path){
        //让node为当前节点的node
      let node =path.node;
       if(node.type==="NumericLiteral" && node.value === 3){
           node.value = 5
       }
       if(node.type==="StringLiteral" && node.value === "hello"){
           node.value = "hi"
       }
    },
});
//将修改后的ast 转化为JavaScript代码
const {code:output} = generate(ast,{retainLines:true,})
console.log(output)

运行结果如下:

AST基本知识以及简单的解JS混淆代码示例

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

//basic5.js


import {parse} from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator"
import fs from "fs";
const code = fs.readFileSync("codes/code1.js","utf-8");
let ast = parse(code);
// 修改a = 5    string = "hi"
traverse(ast,{
    NumericLiteral(path){
        if(path.node.value===3){
            path.node.value = 5
        } 
    },
    StringLiteral(path){
        if(path.node.value==="hello"){
            path.node.value = "hi"
        } 
   },
});
//将修改后的ast 转化为JavaScript代码
const {code:output} = generate(ast,{retainLines:true,})
console.log(output)

运行结果如下:

AST基本知识以及简单的解JS混淆代码示例

使用"@babel/types"插入新节点

比如:

实例

//basic6.js


//我们有这样一行代码
const a = 5;
//想要变为
const a = 5;
const b = a + 1


//需要以下代码

import {parse} from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator"
import * as types from "@babel/types";

const code = "const a = 5;";
let ast = parse(code);
traverse(ast,{
    //遇到变量声明的时候调用
    VariableDeclaration(path){
        //这是右边的内容 声明一个二进制表达式
        let init = types.binaryExpression(
        //运算符
        "+",
        //第一个元素 一个标识符
        types.identifier("a"),
        //第二个元素 一个数字
        types.numericLiteral(1)
        );
        //这是左边的内容
        let declarator = types.variableDeclarator(types.identifier("b"),init);
        let declaration = types.variableDeclaration("const",[declarator]);
        //将构造的节点插入到当前path之后
        path.insertAfter(declaration);
        //跳出循环
        path.stop();
    },
});

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

分析实例

想要插入节点呢就要首先构造节点,构造节点需要由内而外的进行构造。

1.通过网站判断要插入的代码类型:

AST基本知识以及简单的解JS混淆代码示例

我们可以很容易可以看出 要插入的这一行代码是VariableDeclarationl类型的,要生成VariableDeclaration类型的节点,就要使用types的variableDeclaration方法了,二者的差别仅仅是在开头字母大小写。

怎么使用API呢

这是variableDeclaration的官方文档:

AST基本知识以及简单的解JS混淆代码示例

可以看到第一个必要参数是"var"|"let"|"const"|"using"其中之一。第二个必要参数是VariableDeclarator类型的列表

那么要构造declarations,就要使用到types的variableDeclarator方法了

这是variableDeclarator的文档;

AST基本知识以及简单的解JS混淆代码示例

可以看到要想构造VariableDeclarator类型的declarations需要一个必要参数其中id是Identitifier对象 init默认为空此时我们是需要设置一下的,可以看到init是Expression对象查看一下AST类型:

AST基本知识以及简单的解JS混淆代码示例

可以看到init是BinaryExpression类型的,从此处也可以看到init不为null,我们可以借助binaryExpression来进行构造。文档如下:

AST基本知识以及简单的解JS混淆代码示例

AST基本知识以及简单的解JS混淆代码示例

可以看到有三个参数是必须的,第一个operater必须是图中的类型,left是Identifier对象类型的可以使用types的identifier方法实现,

而right可以使用types的numericLiteral 方法即可。

由此构造VariableDeclaration类型的节点所需要的参数就全部凑齐了。使用一个变量接受这个Node即可然后使用path的insertAfter方法插入到当前path的后面即可。不要忘记跳出循环哦。

path.stop()

应用:使用AST技术还原混淆代码

表达式还原

案例

来看下面的例子:

//code2.js


const a = !![];
const b = "abc" == "bcd";
const c = (1<<3) | 2;
const d = parentInt("5"+"0");

新建文档huanyuan.js:

//huanyuan1.js

import {parse} from "@babel/parser";
import generate from "@babel/generator";
import traverse from "@babel/traverse";
import * as types from "@babel/types"
import fs from "fs";
const code = fs.readFileSync("codes/code2.js","utf-8");
let ast = parse(code);
traverse(ast,{
    "UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression":(
    path
    )=>{
        const { confident,value } = path.evaluate();
        if(value==Infinity || value == -Infinity) return;
        confident && path.replaceWith(types.valueToNode(value));
    },
});
const {code:output} = generate(ast);
console.log(output)

执行结果如下:

AST基本知识以及简单的解JS混淆代码示例

分析代码

这里我们使用traverse方法遍历了ast,使用"UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression"作为对象键名,分别用于处理一元表达式、布尔表达式、条件表达式、调用表达式。如果ast对应的path符合这几种表达式,就会执行我们定义的回调方法。在回调方法中,我们调用了path的evaluate()方法,该方法会对path对象执行,计算所得到的结果。器内部实现会返回一个confident和value字段表示置信度,如果认定结果是可信的,那么confident就是true,我们调用path的replaceWith方法,把执行的结果进行替换,否则不替换。

利用这个原理,可以实现对一些表达式的还原和计算。


字符串还原

我们知道JavaScript代码被混淆后,有些字符串会被转化为Unicode或者UTF-8编码

例如如下代码:

//code3.js
const  strings = ["\x68\x65\x6c\x6c\x6f","\x77\x6f\x72\x6c\x64"];

我们可以先将这串代码方法在AST Explorer中看一下结构:

AST基本知识以及简单的解JS混淆代码示例

我们可以看到值已经还原出来了

代码实现还原

//huanyuan2.js


import {parse} from "@babel/parser";
import generate from "@babel/generator";
import traverse from "@babel/traverse";
import * as types from "@babel/types"
import fs from "fs";
const code = fs.readFileSync("codes/code3.js","utf-8");
let ast = parse(code);
traverse(ast,{
   StringLiteral({node}){
       if(node.extra && /\\[ux]/gi.test(node.extra.raw)){
           node.extra.raw = node.extra.value;  //这里使用node.extra.rawValue 会没有双引号
       }
   },
});
const {code:output} = generate(ast);
console.log(output)

AST基本知识以及简单的解JS混淆代码示例

AST基本知识以及简单的解JS混淆代码示例

代码分析

前面的步骤就不说了,traverse中定义的函数,StringLiteral({node}) 当遇到String字面量的时候将当前node对象传进去,然后怕段是否为空,并通过/\[ux]/gi.test(node.extra.raw)方法解析原混淆字符串。若均不为空则将其替换。

无用代码剔除

代码实现

//code4.js


const Ox16c18d = function() {
	if (!![
			[]
		]) {
		console.log("hello world");
	} else {
		console.log("this");
		console.log("is");
		console.log("dead");
		console.log("code");
	}
};
const Ox1f7292 = function() {
	if ("xmv2nOdfy2N".charAt(4) !== String.fromCharCode(110)) {
		console.log("this");
		console.log("is");
		console.log("dead");
		console.log("code");
	} else {
		console.log("nice to meet you");
	}
};
Ox16c18d();
Ox1f7292();

在这里我们定义了俩函数两个函数内部用很多的if else判断。如,第一个if语句的判断条件时!![[ ]],乍一看并不能直观的看出时true还是false其实这是一个双重否定,后面紧跟一个二维数组,注意这里的二维数组是一个非空对象。所以加上双重否定之后还是true。第二个if中判断条件是"xmv2nOdfy2N".charAt(4)!== String.fromCharCode(110) 前者的值是n 后者的值也是n但是,是”!==“ 所以就是false。对于这样的问题我们就可以使用AST,将这些僵尸代码去除。

我们首先在AST Explore中看一下:

AST基本知识以及简单的解JS混淆代码示例

这里我们选中一个if判断,分析其结构,

type就是该语句是一个if判断语句,

Ioc就是开始和结束的行号以及列号,

test就是if括号中的内容也就是!![[ ]]

consequent就是if为true中的语句

alternate就是else中的内容

新建一个JavaScript文档来剔除那些无用代码:

//huanyuan3.js


import {
	parse
} from "@babel/parser";
import generate from "@babel/generator";
import traverse from "@babel/traverse";
import * as types from "@babel/types"
import fs from "fs";
const code = fs.readFileSync("codes/code4.js", "utf-8");
let ast = parse(code);
traverse(ast, {
	IfStatement(path) {
		let {
			consequent,
			alternate
		} = path.node;
		let testPath = path.get("test");
		const evaluateTest = testPath.evaluateTruthy();
		if (evaluateTest == true) {
			if (types.isBlockStatement(consequent)) {
				consequent = consequent.body;
			}
			path.replaceWithMultiple(consequent);
		} else if (evaluateTest == false) {
			if (alternate != null) {
				if (types.isBlockStatement(alternate)) {
					alternate = alternate.body;
				}
				path.replaceWithMultiple(alternate);
			} else {
				path.remove()
			}
		}
	}
});
const {
	code: output
} = generate(ast);
console.log(output)


执行结果:

AST基本知识以及简单的解JS混淆代码示例

代码分析

复杂一点的JavaScript都是要结合AST Explore分析出的结构来写的。

这里是通过IfStatement(path)方法,在遇到IfStatement类型的node时,拿到判断语句test然后使用evaluateTruthy()方法判断if中的test是否为true,如果为true则只留if中的body,否则只留else中的body(将整个path替换允许执行的BlockStatement的body)。


<p align = "center" style="font-size:30px;font-weight:bold;color:red">重点:反控制流平坦化</p>

反控制流平坦化

控制流平坦化就是通过一些if else 和swith语句进行拆分代码块,使得JavaScript代码难以阅读的一种代码混淆方式。

代码实现

这里我们用到的案例是:

//code5.js

const s = "3|1|2".split("|");
let x = 0;
while (true) {
	switch (s[x++]) {
		case "1":
			const a = 1;
			continue;
		case "2":
			const b = 3;
			continue;
		case "3":
			const c = 0;
			continue;
	}
	break;
}

我们简单的分析一下代码就可以看出,通过使用switch语句将语句块的运行顺序进行打乱,这导致我们不能一目了然的看出代码的执行顺序,还可以添加更多无用的代码(不可能的选项),那么这怎么办呢?当然是AST了。

首先还是一如既往的,使用AST Explore解析一下代码结构,选中switch(s[x++]):

AST基本知识以及简单的解JS混淆代码示例

可以看到比较重要的两个属性,discriminant和cases,前者就是判定条件对应的就是s[x++]后者是三个case语句,对应的就是三个switch节点。所以我们先尝试把可能用到的节点获取到。

traverse(ast, {
	WhileStatement(path) {
		const {
			node,
			scope
		} = path;
		const {
			test,
			body
		} = node;
		let switchNode = body.body[0];
		let {
			discriminant,
			cases
		} = switchNode;
		let {
			object,
			property
		} = discriminant;
	}
})

在这里我们获取了path的node和scope以及node的test和body,然后还获取了body的第1个body。然后将第1个body的discriminant和cases获取到以及discriminant的object以及property。

就是下图中的关键节点: AST基本知识以及简单的解JS混淆代码示例 由于我们关注的是switch的判定条件,所以这里近一步追踪下判定条件s[x++]。我们从上图中可以看到,object就是一个Identifier节点。展开后的object其中有一个name属性就是"s",先拿到这个属性:

let arrName = object.name;

这其实就是一个数组,那么他的原始定义在哪儿呢?其实就在上上面的声明语句里,const s = "3|1|2".split("|");。那怎么拿到其原始定义呢?我们可以使用scope对象的getBinding方法获取到它绑定的节点。

let binding = scope.getBinding(arrName);

其实binding对应的就是 "3|1|2".split("|") 这段代码。我们选中这段代码,可以看到它是CallExpression类型的节点。那么怎么获取到他的真实值呢?其实就是使用 "3|1|2" 调用split方法即可。我们可以分别逐层拿到对应的值,然后进行动态调用:

let {init} = binding.path.node;  //这里的init就是CallExpression节点
object = init.callee.object;   //这里的object就是"3|1|2"这个对象
property = init.callee.property;  //这里的property就是split方法
let argument = init.arguments[0].value; //就是"|"
let arrayFlow = object.value[property.name](argument); //模拟调用过程

这个模拟调用因为值都是动态获取的,所以此时的arrayFlow的值就是["3","1","2"]了。后面我们只需要遍历列表然后找到对应case后面的代码即可。由于遍历是有顺序的所以拿到的顺序就是代码执行的顺序了。

let resultBody = [];
arrayFlow.forEach((index)=>{
    let switchCase =cases.filter((c)=>c.test.value==index)[0];
    let caseBody = SwitchCase.consequent;
    if(types.isContinueStatement(caseBody[caseBody.lenth-1])){
        caseBody.pop();
    }
    resultBody = resultBody.concat(caseBody);
});

这里我们声明了一个resultBody变量用来保存匹配到的case对应的代码,同时把continue语句移除了。最后这里的resultBody就是对应了三块代码:

const c = 0;
const a = 1;
const b = 3;

最后只需要把外层的path对象的代码替换为resultBody中的代码即可。

path.replaceMultiple(reslutBody);

最终整理一下:

//huanyuan4.js

import {
	parse
} from "@babel/parser";
import generate from "@babel/generator";
import traverse from "@babel/traverse";
import * as types from "@babel/types"
import fs from "fs";
const code = fs.readFileSync("codes/code5.js", "utf-8");
let ast = parse(code);
traverse(ast, {
	WhileStatement(path) {
		const {
			node,
			scope
		} = path;
		const {
			test,
			body
		} = node;
		let switchNode = body.body[0];
		let {
			discriminant,
			cases
		} = switchNode;
		let {
			object,
			property
		} = discriminant;
		let arrName = object.name;
		let binding = scope.getBinding(arrName);
		let {
			init
		} = binding.path.node; //这里的init就是CallExpression节点
		object = init.callee.object; //这里的object就是"3|1|2"这个对象
		property = init.callee.property; //这里的property就是split方法
		let argument = init.arguments[0].value; //就是"|"
		let arrayFlow = object.value[property.name](argument); //模拟调用过程
		let resultBody = [];
		arrayFlow.forEach((index) => {
			let switchCase = cases.filter((c) => c.test.value == index)[0];
			let caseBody = switchCase.consequent;
			if (types.isContinueStatement(caseBody[caseBody.lenth - 1])) {
				caseBody.pop(); //剔除continue语句
			}
			resultBody = resultBody.concat(caseBody);
		});
		path.replaceMultiple(resultBody);
         console.log(resultBody);
	}
});
const {
	code: output
} = generate(ast);
console.log(output)

执行 AST基本知识以及简单的解JS混淆代码示例

声明

以上内容参考崔庆才老师编写的《python3网络爬虫开发实践》一书,内容仅供参考,如有侵权请联系:1522287808@qq.com