介绍
AST是abstract syntax tree的缩写,就是抽象语法树。AST是源代码的抽象语法结构的树形表示,树上的每一个节点都表示源代码中的一种结构,这种数据结构其实可以类比为一个大的JSON对象。
一段代码在执行前会经过三个步骤
- 词法分析:分解代码为一段段的词法单元 例如:const name = "huangyijia" 这段代码将被拆解为四个部分 const, name,“=”和“huangyijia”,每一部分都有一定的含义
- 语法分析:编译器会尝试将一个个词法单元进行语法分析,将其转化为能代表程序语法结构的数据结构。代码多了这一个个词法就会有嵌套,依赖等关系。因此表示语法结构的数据结构就构成了一个树状的结构,也就成了语法树,即AST。
- 指令生成:最后将AST转化为实际真正可执行的指令并执行即可
重要概念(必须清楚)
Path是描述父子节点之间的链接,Path 除了下面这些属性外,还包含 添加、更新、移动、删除 等方法
Node对象 是 Path对象中的一个属性。path.node 能取出当前遍历到的 节点各种具体信息,不能使用 Path 对象的各种方法
实例引入
首先进入学习AST一个必不可少的网站:AST explorer
如上图所示这一行代码被解析成了一个语法树。
认识一下语法树节点类型
安装@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代码了:
另外,generate还可以在第二个参数中接收一些配置选项,第三个参数可以接收源代码最为输出的参考:
const output = generate(ast,{/*options*/,code});
options部分配置
使用@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输出出来。
运行结果如下:
控制台输出了很多内容,每次都输出一个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)
运行结果如下:
另外除了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)
运行结果如下:
使用"@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.通过网站判断要插入的代码类型:
我们可以很容易可以看出 要插入的这一行代码是VariableDeclarationl类型的,要生成VariableDeclaration类型的节点,就要使用types的variableDeclaration方法了,二者的差别仅仅是在开头字母大小写。
怎么使用API呢
这是variableDeclaration的官方文档:
可以看到第一个必要参数是"var"|"let"|"const"|"using"其中之一。第二个必要参数是VariableDeclarator类型的列表
那么要构造declarations,就要使用到types的variableDeclarator方法了
这是variableDeclarator的文档;
可以看到要想构造VariableDeclarator类型的declarations需要一个必要参数其中id是Identitifier对象 init默认为空此时我们是需要设置一下的,可以看到init是Expression对象查看一下AST类型:
可以看到init是BinaryExpression类型的,从此处也可以看到init不为null,我们可以借助binaryExpression来进行构造。文档如下:
可以看到有三个参数是必须的,第一个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)
执行结果如下:
分析代码
这里我们使用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中看一下结构:
我们可以看到值已经还原出来了
代码实现还原
//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)
代码分析
前面的步骤就不说了,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中看一下:
这里我们选中一个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)
执行结果:
代码分析
复杂一点的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++]):
可以看到比较重要的两个属性,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。
就是下图中的关键节点: 由于我们关注的是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)
执行
声明
以上内容参考崔庆才老师编写的《python3网络爬虫开发实践》一书,内容仅供参考,如有侵权请联系:1522287808@qq.com