当前位置: 首页 > article >正文

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 的以下几个功能包:

  1. @babel/core:Babel 编译器本身,提供了 babel 的编译 API;
  2. @babel/parser:将 JavaScript 代码解析成 AST 语法树;
  3. @babel/traverse:遍历、修改 AST 语法树的各个节点;
  4. @babel/generator:将 AST 还原成 JavaScript 代码;
  5. @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 中的 scopescope 的作用主要是查找标识符的作用域、获取并修改标识符的所有引用等,删除未使用变量主要用到了 scope.getBinding() 方法,传入的值是当前节点能够引用到的标识符名称,返回的关键属性有以下几个:

所以我们可以通过 constantViolationsreferencedreferencesreferencePaths 多个参数来判断变量是否可以被删除,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 处理思路以及代码:

  1. 筛选出 BooleanLiteral 和 NumericLiteral 节点,取其对应的值,即 path.node.test.value
  2. 判断 value 值为真,则将节点替换成 consequent 节点下的内容,即 path.node.consequent.body
  3. 判断 value 值为假,则替换成 alternate 节点下的内容,即 path.node.alternate.body
  4. 有的 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 还原思路:

  1. 获取控制流原始数组,将 '3,4,0,5,1,2'['split'](',') 之类的语句转化成 ['3','4','0','5','1','2'] 之类的数组,得到该数组之后,也可以选择把 split 语句对应的节点删除掉,因为最终代码里这条语句就没用了;
  2. 遍历第一步得到的控制流数组,依次取出每个值所对应的 case 节点;
  3. 定义一个数组,储存每个 case 节点 consequent 数组里面的内容,并删除 continue 语句对应的节点;
  4. 遍历完成后,将第三步的数组替换掉整个 while 节点,也就是 WhileStatement

不同思路,写法多样,对于如何获取控制流数组,可以有以下思路:

  1. 获取到 While 语句节点,然后使用 path.getAllPrevSiblings() 方法获取其前面的所有兄弟节点,遍历每个兄弟节点,找到与 switch() 里面数组的变量名相同的节点,然后再取节点的值进行后续处理;
  2. 直接取 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)


http://www.kler.cn/news/362973.html

相关文章:

  • uniapp使用webView打开的网页有缓存如何解决(APP,微信小程序)
  • oracle 行转列(PIVOT 多个行数据按照指定的列进行汇总) 列转行(UNPIVOT)
  • 从0到1学习node.js(path模块以及HTTP协议)
  • 安装Openeuler出现的问题
  • 【python openai function2json小工具】
  • 西安周边环境最好的楼宇(园区)
  • 【华为HCIP实战课程十五】OSPF的环路避免及虚链路,网络工程师
  • 10.19 RHCSA 作业
  • 【QT】常用控件(三)
  • (三)行为模式:11、模板模式(Template Pattern)(C++示例)
  • 【系统分析师】-论文-论需求开发
  • 双目视觉三角测量C语言代码实现不使用OpenCV
  • R语言笔记(一)
  • 基于neo4j的糖尿病知识图谱数据
  • 深入浅出 Vue3 nextTick
  • 推荐一个开源非线性视频编辑器:Kdenlive
  • 在一台不能接入互联网的服务器(cenos7)安装DOCKER
  • 基于YOLOv8深度学习的无人机视角高精度太阳能电池板检测与分析系统【python源码+Pyqt5界面+数据集+训练代码】深度学习实战、目标分割
  • 成长的代价
  • Telephony Contact
  • 信息系统的分类
  • JavaScript进阶:手写代码挑战(一)
  • chat_gpt回答:python 复制xml文件
  • AI绘画教程分享:Stable Diffusion最新使用指南
  • 大括号块作用域的起源是什么?为什么整型数值用补码保存?char类型变量是存储为int类型大小吗?枚举为什么被当做整型?编程语言标识符为什么不能以数字开头?
  • Java最全面试题->Java基础面试题->JavaWeb面试题->Maven面试题