AST 技术学习
什么是AST
AST:Abstract Syntax Tree(抽象语法树),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。语法树不是某一种编程语言独有的,JavaScript、Python、Java、Golang 等几乎所有编程语言都有语法树。
AST 的用途很广,IDE 的语法高亮、代码检查、格式化、压缩、转译等,都需要先将代码转化成 AST 再进行后续的操作,ES5 和 ES6 语法差异,为了向后兼容,在实际应用中需要进行语法的转换,也会用到 AST。AST 并不是为了逆向而生,但做逆向学会了 AST,在解混淆时可以如鱼得水。
AST 有一个在线解析网站:AST explorer ,顶部可以选择语言、编译器、是否开启转化等,如下图所示。图中原来的 Unicode 字符经过操作之后就变成了正常字符。
语法树没有单一的格式,选择不同的语言、不同的编译器,得到的结果也是不一样的,在 JavaScript 中,编译器有 Acorn、Espree、Esprima、Recast、Uglify-JS 等,使用最多的是 Babel,后续的学习也是以 Babel 为例。
AST 在编译中的位置
在编译原理中,编译器转换代码通常要经过三个步骤:词法分析(Lexical Analysis)、语法分析(Syntax Analysis)、代码生成(Code Generation)
简单举例
一句简单的js
var a=1;
代码对应的AST语法树结构:
AST的结构类型有很多,列表如下:
知道了这些可以加深对JS的深层理解,还可以对 JavaScript 代码进行混淆以及还原
Babel
Babel 是一个 JavaScript 编译器,也可以说是一个解析库
Babel 中文网:Babel 中文网 · Babel - 下一代 JavaScript 语法的编译器
Babel 英文官网:Babel · The compiler for next generation JavaScript
Babel 内置了很多分析 JavaScript 代码的方法,我们可以利用 Babel 将 JavaScript 代码转换成 AST 语法树,然后增删改查等操作之后,再转换成 JavaScript 代码。
Babel 包含的各种功能包、API、各方法可选参数等,都非常多,本文不一一列举,在实际使用过程中,应当多查询官方文档,或者参考文末给出的一些学习资料。Babel 的安装和其他 Node 包一样,需要哪个安装哪个即可。
在做逆向解混淆中,主要用到了 Babel 的以下几个功能包:
@babel/core
:Babel 编译器本身,提供了 babel 的编译 API;@babel/parser
:将 JavaScript 代码解析成 AST 语法树;@babel/traverse
:遍历、修改 AST 语法树的各个节点;@babel/generator
:将 AST 还原成 JavaScript 代码;@babel/types
:判断、验证节点的类型、构建新 AST 节点等。
@babel/core
Babel 编译器本身,被拆分成了三个模块:@babel/parser
、@babel/traverse
、@babel/generator
以下方法的导入效果都是一样的:
const parse = require("@babel/parser").parse;
const parse = require("@babel/core").parse;
const traverse = require("@babel/traverse").default
const traverse = require("@babel/core").traverse
@babel/parser
@babel/parser
可以将 JavaScript 代码解析成 AST 语法树,其中主要提供了两个方法:
parser.parse(code, [{options}])
:解析一段 JavaScript 代码;
parser.parseExpression(code, [{options}])
:考虑到了性能问题,解析单个 JavaScript 表达式。
可选的options
demo:
const parser = require("@babel/parser");
const code = "const number = 12;";
const ast = parser.parse(code, {sourceType: "module"})
console.log(ast)
{sourceType: "module"}
演示了如何添加可选参数,输出的就是 AST 语法树
@babel/generator
@babel/generator
可以将 AST 还原成 JavaScript 代码,提供了一个 generate
方法:generate(ast, [{options}], code)
。
可选参数 options
:
举个例子原代码是 const a = 1;
,把 a
变量修改为 b
,值 1
修改为 2
,然后将 AST 还原生成新的 JS 代码:
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const code = "const a = 1;";
const ast = parser.parse(code, {sourceType: "module"})
ast.program.body[0].declarations[0].id.name = "b"
ast.program.body[0].declarations[0].init.value = 2
const result = generate(ast, {minified: true})
console.log(result.code)
最终输出的是 const b=2;
,变量名和值都成功更改了,由于加了压缩处理,等号左右两边的空格也没了。
代码里 {minified: true}
演示了如何添加可选参数,这里表示压缩输出代码,generate
得到的 result
得到的是一个对象,其中的 code
属性才是最终的 JS 代码。
代码里 ast.program.body[0].declarations[0].id.name
是 a 在 AST 中的位置,ast.program.body[0].declarations[0].init.value
是 1 在 AST 中的位置,如下图所示:
@babel/traverse
当代码多了,不可能挨个定位并修改,对于相同类型的节点,可以直接遍历所有节点来进行修改,这里就用到了 @babel/traverse
,它通常和 visitor
一起使用,visitor
是一个对象,这个名字是可以随意取的,visitor
里可以定义一些方法来过滤节点,用一个例子来演示:
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const code = `
const a = 1;
const b = 2;
const c = "hello";
const d = 3;
const e = "4";
`
const ast = parser.parse(code)
const visitor = {
NumericLiteral(path){
path.node.value = (path.node.value + 100) * 2
},
StringLiteral(path){
path.node.value = "JavaScript!"
}
}
traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
这里的代码定义了 abcde 五个变量,其值有数字也有字符串,在 AST 中可以看到对应的类型为 NumericLiteral
和 StringLiteral
:
声明了一个 visitor
对象,然后定义对应类型的处理方法,traverse
接收两个参数,第一个是 AST 对象,第二个是 visitor
,当 traverse
遍历所有节点,遇到节点类型为 NumericLiteral
和 StringLiteral
时,就会调用 visitor
中对应的处理方法,visitor
中的方法会接收一个当前节点的 path
对象,该对象的类型是 NodePath
,该对象有非常多的属性,以下介绍几种最常用的:
@babel/types
@babel/types
主要用于构建新的 AST 节点,前面的示例代码为 const a = 1;
,如果想要增加内容,比如变成 const a = 1; const b = a * 5 + 1;
,就可以通过 @babel/types
来实现。
首先观察一下 AST 语法树,原语句只有一个 VariableDeclaration
节点,现在增加了一个:
那么我们的思路就是在遍历节点时,遍历到 VariableDeclaration
节点,就在其后面增加一个 VariableDeclaration
节点,生成 VariableDeclaration
节点,可以使用 types.variableDeclaration()
方法,在 types 中各种方法名称和我们在 AST 中看到的是一样的,只不过首字母是小写的,所以我们不需要知道所有方法的情况下,也能大致推断其方法名
常见混淆还原
字符串还原
引用大佬的这个例子:
console['\u006c\u006f\u0067']('\u0048\u0065\u006c\u006c\u006f\u0020\u0077\u006f\u0072\u006c\u0064\u0021')
看AST 结构
发现 Unicode 编码对应的是 raw
,而 rawValue
和 value
都是正常的,所以我们可以将 raw
替换成 rawValue
或 value
即可,
还需要注意引号的问题,本来是 console["log"]
,还原后变成了 console[log]
,自然会报错的
除了替换值以外,直接删除 extra 节点,或者删除 raw 值也是可以的,所以以下几种写法都可以还原代码:
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const code = `console['\u006c\u006f\u0067']('\u0048\u0065\u006c\u006c\u006f\u0020\u0077\u006f\u0072\u006c\u0064\u0021')`
const ast = parser.parse(code)
const visitor = {
StringLiteral(path) {
// 以下方法均可
// path.node.extra.raw = path.node.rawValue
// path.node.extra.raw = '"' + path.node.value + '"'
// delete path.node.extra
delete path.node.extra.raw
}
}
traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
还原结果:
表达式还原
参考JSFuck 混淆的还原
其中有介绍 ![]
可表示 false,!![]
或者 !+[]
可表示 true,在一些混淆代码中,经常有这些操作,把简单的表达式复杂化,往往需要执行一下语句,才能得到真正的结果,示例代码如下:
const a = !![]+!![]+!![];
const b = Math.floor(12.34 * 2.12)
const c = 10 >> 3 << 1
const d = String(21.3 + 14 * 1.32)
const e = parseInt("1.893" + "45.9088")
const f = parseFloat("23.2334" + "21.89112")
const g = 20 < 18 ? '未成年' : '成年'
想要执行语句,我们需要了解 path.evaluate()
方法,该方法会对 path 对象进行执行操作,自动计算出结果,返回一个对象,其中的 confident
属性表示置信度,value
表示计算结果,使用 types.valueToNode()
方法创建节点,使用 path.replaceInline()
方法将节点替换成计算结果生成的新节点,替换方法有以下几种:
replaceWith
:用一个节点替换另一个节点;replaceWithMultiple
:用多个节点替换另一个节点;replaceWithSourceString
:将传入的源码字符串解析成对应 Node 后再替换,性能较差,不建议使用;replaceInline
:用一个或多个节点替换另一个节点,相当于同时有了前两个函数的功能。
对应的 AST 处理代码如下:
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const types = require("@babel/types")
const code = `
const a = !![]+!![]+!![];
const b = Math.floor(12.34 * 2.12)
const c = 10 >> 3 << 1
const d = String(21.3 + 14 * 1.32)
const e = parseInt("1.893" + "45.9088")
const f = parseFloat("23.2334" + "21.89112")
const g = 20 < 18 ? '未成年' : '成年'
`
const ast = parser.parse(code)
const visitor = {
"BinaryExpression|CallExpression|ConditionalExpression"(path) {
const {confident, value} = path.evaluate()
if (confident){
path.replaceInline(types.valueToNode(value))
}
}
}
traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
最终结果:
删除未使用变量
有时候代码里会有一些并没有使用到的多余变量,删除这些多余变量有助于更加高效的分析代码,示例代码如下:
const a = 1;
const b = a * 2;
const c = 2;
const d = b + 1;
const e = 3;
console.log(d)
删除多余变量,首先要了解 NodePath
中的 scope
,scope
的作用主要是查找标识符的作用域、获取并修改标识符的所有引用等,删除未使用变量主要用到了 scope.getBinding()
方法,传入的值是当前节点能够引用到的标识符名称,返回的关键属性有以下几个:
所以我们可以通过 constantViolations
、referenced
、references
、referencePaths
多个参数来判断变量是否可以被删除,AST 处理代码如下:
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const code = `
const a = 1;
const b = a * 2;
const c = 2;
const d = b + 1;
const e = 3;
console.log(d)
`
const ast = parser.parse(code)
const visitor = {
VariableDeclarator(path){
const binding = path.scope.getBinding(path.node.id.name);
// 如标识符被修改过,则不能进行删除动作。
if (!binding || binding.constantViolations.length > 0) {
return;
}
// 未被引用
if (!binding.referenced) {
path.remove();
}
// 被引用次数为0
// if (binding.references === 0) {
// path.remove();
// }
// 长度为0,变量没有被引用过
// if (binding.referencePaths.length === 0) {
// path.remove();
// }
}
}
traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
处理后的代码(未使用的 b、c、e 变量已被删除):
删除冗余逻辑代码
有时候为了增加逆向难度,会有很多嵌套的 if-else 语句,大量判断为假的冗余逻辑代码,同样可以利用 AST 将其删除掉,只留下判断为真的,示例代码如下:
const example = function () {
let a;
if (false) {
a = 1;
} else {
if (1) {
a = 2;
}
else {
a = 3;
}
}
return a;
};
观察 AST,判断条件对应的是 test
节点,if 对应的是 consequent
节点,else 对应的是 alternate
节点,如下图所示:
AST 处理思路以及代码:
- 筛选出
BooleanLiteral
和NumericLiteral
节点,取其对应的值,即path.node.test.value
;- 判断
value
值为真,则将节点替换成consequent
节点下的内容,即path.node.consequent.body
;- 判断
value
值为假,则替换成alternate
节点下的内容,即path.node.alternate.body
;- 有的 if 语句可能没有写 else,也就没有
alternate
,所以这种情况下判断value
值为假,则直接移除该节点,即path.remove()
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const types = require('@babel/types');
const code = `
const example = function () {
let a;
if (false) {
a = 1;
} else {
if (1) {
a = 2;
}
else {
a = 3;
}
}
return a;
};
`
const ast = parser.parse(code)
const visitor = {
enter(path) {
if (types.isBooleanLiteral(path.node.test) || types.isNumericLiteral(path.node.test)) {
if (path.node.test.value) {
path.replaceInline(path.node.consequent.body);
} else {
if (path.node.alternate) {
path.replaceInline(path.node.alternate.body);
} else {
path.remove()
}
}
}
}
}
traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
处理结果:
switch-case 反控制流平坦化
控制流平坦化是混淆当中最常见的,通过 if-else
或者 while-switch-case
语句分解步骤,示例代码:
const _0x34e16a = '3,4,0,5,1,2'['split'](',');
let _0x2eff02 = 0x0;
while (!![]) {
switch (_0x34e16a[_0x2eff02++]) {
case'0':
let _0x38cb15 = _0x4588f1 + _0x470e97;
continue;
case'1':
let _0x1e0e5e = _0x37b9f3[_0x50cee0(0x2e0, 0x2e8, 0x2e1, 0x2e4)];
continue;
case'2':
let _0x35d732 = [_0x388d4b(-0x134, -0x134, -0x139, -0x138)](_0x38cb15 >> _0x4588f1);
continue;
case'3':
let _0x4588f1 = 0x1;
continue;
case'4':
let _0x470e97 = 0x2;
continue;
case'5':
let _0x37b9f3 = 0x5 || _0x38cb15;
continue;
}
break;
}
AST 还原思路:
- 获取控制流原始数组,将
'3,4,0,5,1,2'['split'](',')
之类的语句转化成['3','4','0','5','1','2']
之类的数组,得到该数组之后,也可以选择把 split 语句对应的节点删除掉,因为最终代码里这条语句就没用了;- 遍历第一步得到的控制流数组,依次取出每个值所对应的 case 节点;
- 定义一个数组,储存每个 case 节点
consequent
数组里面的内容,并删除continue
语句对应的节点;- 遍历完成后,将第三步的数组替换掉整个 while 节点,也就是
WhileStatement
。
不同思路,写法多样,对于如何获取控制流数组,可以有以下思路:
- 获取到
While
语句节点,然后使用path.getAllPrevSiblings()
方法获取其前面的所有兄弟节点,遍历每个兄弟节点,找到与switch()
里面数组的变量名相同的节点,然后再取节点的值进行后续处理;- 直接取
switch()
里面数组的变量名,然后使用scope.getBinding()
方法获取到它绑定的节点,然后再取这个节点的值进行后续处理。
所以 AST 处理代码就有两种写法,方法一:(code.js 即为前面的示例代码,为了方便操作,这里使用 fs 从文件中读取代码)
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const types = require("@babel/types")
const fs = require("fs");
const code = fs.readFileSync("1.js", {encoding: "utf-8"});
const ast = parser.parse(code)
const visitor = {
WhileStatement(path) {
// switch 节点
let switchNode = path.node.body.body[0];
// switch 语句内的控制流数组名,本例中是 _0x34e16a
let arrayName = switchNode.discriminant.object.name;
// 获得所有 while 前面的兄弟节点,本例中获取到的是声明两个变量的节点,即 const _0x34e16a 和 let _0x2eff02
let prevSiblings = path.getAllPrevSiblings();
// 定义缓存控制流数组
let array = []
// forEach 方法遍历所有节点
prevSiblings.forEach(pervNode => {
let {id, init} = pervNode.node.declarations[0];
// 如果节点 id.name 与 switch 语句内的控制流数组名相同
if (arrayName === id.name) {
// 获取节点整个表达式的参数、分割方法、分隔符
let object = init.callee.object.value;
let property = init.callee.property.value;
let argument = init.arguments[0].value;
// 模拟执行 '3,4,0,5,1,2'['split'](',') 语句
array = object[property](argument)
// 也可以直接取参数进行分割,方法不通用,比如分隔符换成 | 就不行了
// array = init.callee.object.value.split(',');
}
// 前面的兄弟节点就可以删除了
pervNode.remove();
});
// 储存正确顺序的控制流语句
let replace = [];
// 遍历控制流数组,按正确顺序取 case 内容
array.forEach(index => {
let consequent = switchNode.cases[index].consequent;
// 如果最后一个节点是 continue 语句,则删除 ContinueStatement 节点
if (types.isContinueStatement(consequent[consequent.length - 1])) {
consequent.pop();
}
// concat 方法拼接多个数组,即正确顺序的 case 内容
replace = replace.concat(consequent);
}
);
// 替换整个 while 节点,两种方法都可以
path.replaceWithMultiple(replace);
// path.replaceInline(replace);
}
}
traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
方法二:
const parser = require("@babel/parser");
const generate = require("@babel/generator").default
const traverse = require("@babel/traverse").default
const types = require("@babel/types")
const fs = require("fs");
const code = fs.readFileSync("code.js", {encoding: "utf-8"});
const ast = parser.parse(code)
const visitor = {
WhileStatement(path) {
// switch 节点
let switchNode = path.node.body.body[0];
// switch 语句内的控制流数组名,本例中是 _0x34e16a
let arrayName = switchNode.discriminant.object.name;
// 获取控制流数组绑定的节点
let bindingArray = path.scope.getBinding(arrayName);
// 获取节点整个表达式的参数、分割方法、分隔符
let init = bindingArray.path.node.init;
let object = init.callee.object.value;
let property = init.callee.property.value;
let argument = init.arguments[0].value;
// 模拟执行 '3,4,0,5,1,2'['split'](',') 语句
let array = object[property](argument)
// 也可以直接取参数进行分割,方法不通用,比如分隔符换成 | 就不行了
// let array = init.callee.object.value.split(',');
// switch 语句内的控制流自增变量名,本例中是 _0x2eff02
let autoIncrementName = switchNode.discriminant.property.argument.name;
// 获取控制流自增变量名绑定的节点
let bindingAutoIncrement = path.scope.getBinding(autoIncrementName);
// 可选择的操作:删除控制流数组绑定的节点、自增变量名绑定的节点
bindingArray.path.remove();
bindingAutoIncrement.path.remove();
// 储存正确顺序的控制流语句
let replace = [];
// 遍历控制流数组,按正确顺序取 case 内容
array.forEach(index => {
let consequent = switchNode.cases[index].consequent;
// 如果最后一个节点是 continue 语句,则删除 ContinueStatement 节点
if (types.isContinueStatement(consequent[consequent.length - 1])) {
consequent.pop();
}
// concat 方法拼接多个数组,即正确顺序的 case 内容
replace = replace.concat(consequent);
}
);
// 替换整个 while 节点,两种方法都可以
path.replaceWithMultiple(replace);
// path.replaceInline(replace);
}
}
traverse(ast, visitor)
const result = generate(ast)
console.log(result.code)
以上代码运行后,原来的 switch-case
控制流就被还原了,变成了按顺序一行一行的代码,更加简洁明了:
参考文章:
AST - 并没有想象中那么神秘 - 『脱壳破解区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn
通过ast初步还原某js - 『脱壳破解区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn
逆向进阶,利用 AST 技术还原 JavaScript 混淆代码_ast逆向-CSDN博客
const - JavaScript | MDN (mozilla.org)