[逆向知识] AST抽象语法树:混淆与反混淆的逻辑互换(二)
博客配套代码发布于github:半自动化cookie更新(欢迎顺手Star一下⭐)
相关逆向知识:
[逆向知识] AST抽象语法树:混淆与反混淆的逻辑互换(一)-CSDN博客
相关爬虫专栏:JS逆向爬虫实战 爬虫知识点合集 爬虫实战案例 逆向知识点合集
前言:
上篇文章我们详细对AST做了理解与剖析,但主要还是停留在纸面上。这期我们会真正深入到AST内部,通过真正的代码拆解转换来感受它的使用方式。
一、理解AST调试网站
AST explorer我们首先要用到这个网页:AST explorer
这是个非常重要的网站,在解析混淆代码时我们常会用到它。先把这个网页结构大致浏览:
上层: 一般保持该默认设置即可
AST Explorer | 工具名称,即「抽象语法树浏览器」。 |
Snippet | 当前编辑区内容被标记为「代码片段」,你可以在这里输入任意 JS 代码。 |
JavaScript | 当前解析的语言是 JavaScript。 |
@babel | 右侧下拉框选中的解析器是 Babel(默认用 @babel/parser 来生成 AST)。 |
default | 表示没有加载额外插件或自定义配置,用的是默认解析规则。 |
右侧:
Tree 是当前语法树结构,会表现出当前代码的各种参数。其中最重要的就是body,我们之后几乎所有ast操作都是针对body的。
Tree的JSON形式则无法调试而且也不太直观,我们一般不看它。
大致理解了网页构造后,我们再来实际感受下:
我们先在左侧写一个var a = 10; 并试着选中10,看右边:很明显,选中的部分会被标黄,那我们就知道如果想对这个10做操作,选中这个NumericLiteral即可。同理,var/a/=也一样。
二、AST环境与基础代码
首先先配置相关环境:
安装命令:
npm i @babel/core --save-dev //Babel 编译器本身,提供了 babel 的编译 API;npm i @babel/types //判断节点类型,构建新的AST节点等
npm i @babel/parser //将Javascript代码解析成AST语法树
npm i @babel/traverse //遍历,修改AST语法树的各个节点
npm i @babel/generator //将AST还原成Javascript代码
配置完成后,我们创建三个js文件,分别是main.js,encode.js,decode.js,其中main.js放主要ast代码,encode.js放混淆的代码,decode.js则作为将要被反混淆放入的代码,
main.js如下:
//main.js// fs模块 用于操作文件的读写
const fs = require("fs");
// @babel/parser 用于将JavaScript代码转换为ast树
const parser = require("@babel/parser");
// @babel/traverse 用于遍历各个节点的函数
const traverse = require("@babel/traverse").default;
// @babel/types 节点的类型判断及构造等操作
const types = require("@babel/types");
// @babel/generator 将处理完毕的AST转换成JavaScript源代码
const generator = require("@babel/generator").default;// 混淆的js代码文件
const encode_file = "./encode.js"
// 反混淆的js代码文件
const decode_file = "./decode.js"// 读取混淆的js文件
let jsCode = fs.readFileSync(encode_file, {encoding: "utf-8"});
// 将javascript代码转换为ast树
let ast = parser.parse(jsCode)// todo 编写ast插件
const visitor = {}// 调用插件,处理混淆的代码
traverse(ast,visitor)// 将处理后的ast转换为js代码(反混淆后的代码)
let {code} = generator(ast);
// 保存代码
fs.writeFile('decode.js', code, (err)=>{});
其中划出我们的最重点:
const visitor = {} 这里面的{},作为ast插件就是主要需要填写的地方。调用这个插件才会对那些混淆代码造成影响,变成另一种代码形式。
另外,
- traverse 用于遍历和转换抽象语法树(AST)的工具,转换语法树需要配置visitor使用
- visitor 是一个对象,里面可以定义一些方法,用来过滤节点
如上,环境与基础代码搭建后,我们接下来进行实战演示。
三、AST反混淆的属性
1. 写插件
在encode.js里写如下代码:
console.log('曼波!');
var a = 10;
再去AST exploerer也把这个复制下去,选中console.log('曼波!');看右边的标黄:
很明显,对象是ExpressionStatement(var a =10;对应的是下面的variable),所以我们对这个做操作即可。
进main.js写ast插件:
const visitor = {ExpressionStatement(path){console.log("ast反混淆ing......")}
}
运行程序:没有问题。
同理,后面再遇到任何想要变化的代码,按如上操作即可。于vistior={}中写 插件(path){ 操作 } 的格式即可。其中path代表当前正在遍历的节点路径,借助这个path我们就能访问和操作相关节点的属性与关系。
2. path常用属性
const visitor = {ExpressionStatement(path){console.log('当前节点对象:',path.node);console.log('节点对象类型:',path.type);console.log('节点源码:',path.toString());}
}
如图,path.node就是当前节点对象的语法树,path.type为类型,path.toString()为当前源码。
3.enter与exit
const visitor = {ExpressionStatement:{enter(path,state){console.log('开始曼波!')},exit(){console.log('结束曼波!')}}
}
顾名思义,enter与exit会分别在进入与离开当前节点对象时运行,可以在此处编写出入时想要进行的操作。
4. 多个函数处理一个节点
写个enter,并让它接受一个函数数组即可。
//encode.js
var a = 10;
function func(){console.log('曼波!');
}
function f1(){console.log('f1曼波!')
}
function f2(){console.log('f2曼波!')
}
const visitor = {"FunctionDeclaration":{enter:[f1,f2]}
}
5. 一个函数处理多个节点
const visitor = {"ExpressionStatement|VariableDeclaration":{enter(path,state){console.log('开始曼波')},exit(){console.log('结束曼波')}}
}
6. 将所有函数的首个参数改为o
//encode.js
var a = 10;
function func1(param1,param2){console.log('function1!');
}
function func2(a1,a2){console.log('function2!');
}
//ast插件B,用于修改函数参数名
const updateParamNameVisitor = {//Identifier表示被遍历节点的标识符(函数参数)Identifier(path){if(path.node.name === this.paramName){console.log(path.node) //当前节点就是函数参数path.node.name = "o"}}
}
//ast插件,假设命名为插件A
const visitor = {//指定需要遍历的节点类型为函数FunctionDeclaration(path){//获取被遍历函数的第一个参数const paramName = path.node.params[0].name;//调用traverse对函数节点向下遍历,修改函数的第一个参数//此处path就特指了被遍历的函数节点的路径(调用插件B-》updateParamNameVisitor)path.traverse(updateParamNameVisitor,{paramName:paramName})}
}
// 调用插件A,处理js代码
traverse(ast,visitor)
在decode.js中可见参数已改为o
7. 判断节点类型(将某个标识符统一改为另一个)
//encode.jsvar a = 10;
function a(a,num2){return a + num2;
};
console.log(a());
const visitor = {enter(path){//在js代码中定位到所有标识符为a(变量名为a、函数名为a等)的节点,将其名字改为bif(types.isIdentifier(path.node,{"name":"a"})){path.node.name = "b";}}
}
// 调用插件,处理js代码
traverse(ast,visitor)
// decode.js
var b = 10;
function b(b, num2) {return b + num2;
}
;
console.log(b());
8. 替换节点属性值
var a = 10 ; 的情况下:
// todo 编写ast插件
const visitor = {VariableDeclarator(path){//修改为数字类型path.node.init = types.numericLiteral(123123)}
}
traverse(ast,visitor)
9. 替换节点
节点替换节点 (replaceWith):
// 原: var a = 1; var b = 1;// todo 编写ast插件
const visitor = {NumericLiteral(path){//修改为字符串类型 path.replaceWith(types.valueToNode("123321"))}
}
/*
* 替换后的代码:var a = "123321";var b = "123321";
* */
traverse(ast,visitor)
字符串源码替换节点 (replaceWithSourceString)
// todo 编写ast插件
const visitor = {NumericLiteral(path){path.replaceWithSourceString(`function add(a,b){return a + b}`)}
}
traverse(ast,visitor)
/*
* 替换结果:
var a = function add(a, b) {return a + b;
};
* */
10. 删除节点
const visitor = {VariableDeclarator(path){path.remove();}
}
四、AST反混淆实战
1. 实战1:
var b = 1 + 2;
var c = "coo" + "kie";
var a = 1+1,b = 2+2;
var c = 3;
var d = "1" + 1;
var e = 1 + '2';
将其变成如下形式: 思路--表达式节点遍历,之后提取表达式元素,计算后替换
var b = 3;
var c = "cookie";
var a = 2,b = 4;
var c = 3;
var d = "11";
var e = "12";
代码实现:(其中为了便于理解拆分成了很多种可能形式,但其处理方式实际上就是
value = left.value + right.value这一种即可)
const visitor = {//遍历表达式节点BinaryExpression(path){// 取出表达式的各个元素:1 + 2var {left, operator, right} = path.node// 数字相加处理if (types.isNumericLiteral(left) && types.isNumericLiteral(right) && operator == "+") {value = left.value + right.value// 将原来的节点当中的原来的值进行替换path.replaceWith(types.valueToNode(value))}//字符串相加if (types.isStringLiteral(left) && types.isStringLiteral(right) && operator == "+") {value = left.value + right.value// 将原来的节点当中的原来的值进行替换path.replaceWith(types.valueToNode(value))}if (types.isStringLiteral(left) && types.isNumericLiteral(right) && operator== "+" || types.isNumericLiteral(left) && types.isStringLiteral(right)) {value = left.value + right.value// 将原来的节点当中的原来的值进行替换path.replaceWith(types.valueToNode(value))}}
}
2. 实战2:
// 处理前
var arr = '3,4,0,5,1,2'['split'](',')// 处理后
var arr = ["3", "4", "0", "5", "1", "2"]
目标:将上方代码变为下方形式
代码实现:
const visitor = {//遍历函数调用节点(split函数)CallExpression(path) {//获取函数调用节点的调用者callee和函数参数argumentslet {callee, arguments} = path.node// 通过打印节点的树结构决定访问哪些属性//console.log(callee.object.value,arguments[0].value)let data = callee.object.value //获取split函数调用者let func = callee.property.value //获取函数名let arg = arguments[0].value //获取split函数参数var res = data[func](arg) //调用函数获取返回值//用于替换当前节点path.replaceWith(types.valueToNode(res))}
}
实战3:
//处理前:
var a = 0x25,b = 0b10001001,c = 0o123456,
d = "\x68\x65\x6c\x6c\x6f\x2c\x41\x53\x54",
e = "\u0068\u0065\u006c\u006c\u006f\u002c\u0041\u0053\u0054";//处理后:
var a = 37,b = 137,c = 42798,d = "hello,AST",e = "hello,AST"
目标:上变下
思路:进AST explorer网站,将上面代码放进去并选中右边的进制,看圈黄范围:其中extra的raw是涵盖进制数据的,所以我们可以直接为其设置undefined,这样可以只采用最下方的value,以达到简化目的。
代码实现:
const visitor = {NumericLiteral({node}) {//如果节点存在extra属性且raw是以0o、0b或者0x开头的//i表示不区分大小写匹配,意味着在匹配时忽略字符的大小写差异。if (node.extra && /^0[obx]/i.test(node.extra.raw)) {//移除了数字字面量节点的编码类型信息。node.extra = undefined;}},StringLiteral({node}) {//如果节点存在extra属性且raw是以\u或者\x//g 表示全局匹配,意味着在整个字符串中查找所有匹配项,而不仅仅是找到第一个匹配就停止。if (node.extra && /\\[ux]/gi.test(node.extra.raw)) {//移除了数字字面量节点的编码类型信息。node.extra = undefined;}},
}
五、小结
对于AST混淆的代码,哪怕有个几万行也不用怕。先观察它有多少种混淆,再对这些混淆逐一写上专门的处理规则即可。因为混淆方法不特定,所以我们没有办法写一个通用的混淆框架解,只能分析代码并写上专门的处理规则。