AST反混淆之路——babel基本知识及常用转换操作

本文章是学习AST反混淆的笔记,包括AST介绍、babel介绍(重点!!!)、以及部分AST反混淆实验代码

参照Babel插件开发助手(官方):https://blog.csdn.net/weixin_33826609/article/details/93164633#toc-visitors

介绍

AST

AST,抽象语法树(abstract syntax code,AST)是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,这所以说是抽象的,是因为抽象语法树并不会表示出真实语法出现的每一个细节,比如说,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现。抽象语法树并不依赖于源语言的语法,也就是说语法分析阶段所采用的上下文无文文法,因为在写文法时,经常会对文法进行等价的转换(消除左递归,回溯,二义性等),这样会给文法分析引入一些多余的成分,对后续阶段造成不利影响,甚至会使合个阶段变得混乱。因些,很多编译器经常要独立地构造语法分析树,为前端,后端建立一个清晰的接口。

Babel

Babel 是一个通用的多功能的 JavaScript 编译器。此外它还拥有众多模块可用于不同形式的静态分析。

静态分析是在不需要执行代码的前提下对代码进行分析的处理过程 (执行代码的同时进行代码分析即是动态分析)。 静态分析的目的是多种多样的, 它可用于语法检查,编译,代码高亮,代码转换,优化,压缩等等场景。

Babel 是 JavaScript 编译器,更确切地说是源码到源码的编译器,通常也叫做“转换编译器(transpiler)”。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。

关于AST和Babel的一些详情(比如Node节点的定义、各种节点的特殊属性等),请参照:https://github.com/yacan8/blog/blob/master/posts/JavaScript%E6%8A%BD%E8%B1%A1%E8%AF%AD%E6%B3%95%E6%A0%91AST.md。感谢大佬!

基本知识

Babel的处理步骤

Babel 的三个主要处理步骤分别是: 解析(parse)转换(transform)生成(generate)

解析

步骤接收代码并输出 AST。 这个步骤分为两个阶段:**词法分析(Lexical Analysis) **和 语法分析(Syntactic Analysis)。.

词法分析阶段把字符串形式的代码转换为 令牌(tokens) 流。.

你可以把令牌看作是一个扁平的语法片段数组

语法分析阶段会把一个令牌流转换成 AST 的形式。 这个阶段会使用令牌中的信息把它们转换成一个 AST 的表述结构,这样更易于后续的操作。

转换

步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。 

在转换步骤中,需要进行递归的树形遍历(深度遍历)。

生成

代码生成步骤把最终(经过一系列转换之后)的 AST 转换成字符串形式的代码,同时还会创建源码映射(source maps)。.代码生成其实很简单:深度优先遍历整个 AST,然后构建可以表示转换后代码的字符串。

PS:

可以通过:https://astexplorer.net/网站将待转换的js代码转换为AST抽象语法树。

Visitors 访问者

当我们谈及“进入”一个节点,实际上是说我们在访问它们, 之所以使用这样的术语是因为有一个访问者模式(visitor)的概念。

访问者是一个用于 AST 遍历的跨语言的模式。 简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。 比如:


const Visitor = {
  Identifier() {
    console.log("Called!");
  }
};
// Identifier() { ... } 是 Identifier: { enter() { ... } } 的简写形式。.
// 也可以先创建一个访问者对象,并在稍后给它添加方法。
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}

以上为一个简单的访问者,用于遍历时,每遇到一个Identifier的时候都会调用Identifier()方法。

此种方法默认为在进入节点时进行操作,也可以在退出节点时进行操作:

const Visitor = {
  Identifier: {
    enter() {
      console.log("Entered!");
    },
    exit() {
      console.log("Exited!");
    }
  }
};

如果对不同节点有同样的操作,可以用‘|’将方法名分隔开:

const visitor = {
    "Idenfifier |MemberExpression"(path){}
}

特别的,还可以使用别名(https://github.com/babel/babel/tree/master/packages/babel-types/src/definitions)去定义:

//Function is an alias for FunctionDeclaration, FunctionExpression, 
//ArrowFunctionExpression, ObjectMethod and ClassMethod.

const Visitor = {
  Function(path) {}
}

Paths 路径

AST 通常会有许多节点,那么节点直接如何相互关联呢? 我们可以使用一个可操作和访问的巨大可变对象表示节点之间的关联关系,或者也可以用Paths(路径)来简化这件事情。.

Path 是表示两个节点之间连接的对象。

在某种意义上,路径是一个节点在树中的位置以及关于该节点各种信息的响应式 Reactive 表示。 当你调用一个修改树的方法后,路径信息也会被更新。 Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。

Paths in Visitors(存在于访问者中的路径)

当你有一个 Identifier() 成员方法的访问者时,你实际上是在访问路径而非节点。 通过这种方式,你操作的就是节点的响应式表示(即路径)而非节点本身。

// 对表达式:a+b+c
const visitor = {
    Identifier(path){
        console.log("now: " + path.node.name);
    }
}

// 使用path.traverse(visitor)进行转换
path.traverse(visitor);

//以下是输出结果:
now: a
now: b
now: c

State(状态)

状态是抽象语法树AST转换的敌人,状态管理会不断牵扯你的精力,而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的。

const parser = require("@babel/parser");  // 解析,js转AST
const traverse = require("@babel/traverse").default;  // 转换
const t = require("@babel/types");
const generator = require("@babel/generator").default;  // 生成

const fs = require('fs');  // 文件读写

target_js = "function square(n) {  return n * n;}"


// 尝试将该代码中的n都变为x
const visitor = {
    FunctionDeclaration(path){
        const param = path.node.params[0]
        if (param.name === 'n'){
            paramName = param.name;
            param.name = 'x'
        }
    },

    Identifier(path){
        if (path.node.name === paramName){
            path.node.name = "x"
        }
    }
}

let ast = parser.parse(target_js);

traverse(ast, visitor);

let {code} = generator(ast);

// 输出:
function square(x) {
  return x * x;
}


以上代码可以做到将n变为x,但如果js代码变为:

function square(n){
    return n * n;
}

function add(n, m){
    return n + m
}

输出结果就变成了:

function square(x) {
  return x * x;
}

function add(x, m) {
  return x + m;
}

但我们本意只想变换square方法中的n。于是可以用递归的方式:

const parser = require("@babel/parser");  // 解析,js转AST
const traverse = require("@babel/traverse").default;  // 转换
const t = require("@babel/types");
const generator = require("@babel/generator").default;  // 生成

const fs = require('fs');  // 文件读写


var target_js = "function square(n){\n" +
    "    return n * n;\n" +
    "}\n" +
    "\n" +
    "function add(n, m){\n" +
    "    return n + m\n" +
    "}"


// 尝试将该代码中的n都变为x
const updateParamName = {
    "Identifier"(path){
        if (path.node.name === this.paramName){
            path.node.name = "x"
        }
    }
}


const visitor = {
    "FunctionDeclaration"(path){
        if (path.node.params.length > 1){
            return;
        }
        const param = path.node.params[0]
        if (param.name === 'n'){
            paramName = param.name;
            param.name = 'x'
            path.traverse(updateParamName, { paramName });
        }
    }
}

let ast = parser.parse(target_js);

traverse(ast, visitor);

let {code} = generator(ast);

console.log(code)


//输出结果
function square(x) {
  return x * x;
}

function add(n, m) {
  return n + m;
}

此处为特殊例子,为了演示如何从访问者中消除全局状态。

Scopes 作用域

JavaScript支持词法作用域,在树状嵌套结构中代码块创建出新的作用域。

在JavaScript中,每创建一个引用,不管是通过变量(variable)、函数(function)、类型(class)、参数(params)、模块导入(import)还是标签(label)等,都属于当前作用域。

更深的内部作用域代码可以使用外层作用域中的引用。

内层作用域也可以创建和外层作用域同名的引用。

当写转换时,必须小心作用域,必须要确保在改变代码的各个部分时不会破坏已经存在的代码。

在添加一个新的引用时需要确保新增加的引用名字和已有的所有引用不冲突,或者仅仅想找出使用一个变量的所有引用,只想在给定的作用域中找出这些饮用。

作用域可以表示为:

{
    path: path,
    block: path.node,
    parentBlock: path.parent,
    parent: parentScope,
    bindings: [...]
}

创建一个新的作用域,需要给出它的路径和父作用域,之后在遍历过程中它会在该作用域内手机所有的引用("绑定")。
一旦引用收集完毕,就可以在作用域上使用各种方法。

Bindings 绑定

所有引用属于特定的作用域,引用和作用域的这种关系被称为:绑定(binding)

单个绑定可以表示为:

Text for Translation
{
    identifier: node,
    scope: scope,
    path: path,
    kind: 'var',

    referenced: true,
    references: 3,
    referencePaths: [path, path, path],

    constant: false,
    constantViolations: [path]
}

有以上信息就可以查找一个绑定的所有引用,并且知道这是什么类型的绑定(参数,定义等),查找所属的作用域,或者拷贝标识符,甚至知道是不是常量,如果不是,那么哪里修改了它。

在很多情况下,知道一个绑定是否是常量非常有用,最有用的一种情形就是代码压缩。

API

Babel实际上是一组模块的集合。接下来是一些主要的模块,会解释他们是做什么的,以及如何使用。

babylon

Babylon是Babel的解析器。最初是从Acorn项目fork出来的。Acorn非常快,易于使用,并且针对非标准特性(以及未来的标准特性)设计了一个基于插件的架构。

babel-traverse

Babel Traverse模块维护了整棵树的状态,并且负责替换、移除和添加节点。

babel-types

Babel Types模块是一个用于AST节点的Lodash式工具库(JavaScript函数工具库,提供了基于函数式编程风格的众多工具函数),包含了构造、验证以及变化AST节点的方法。该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用。

Definitions 定义

Babel Types模块拥有每一个单一类型节点的定义,包括节点包含那些属性,什么是合法值,如何构建节点、遍历节点,以及节点的别名等信息。

单一节点类型的定义形式如下:

defineType("BinaryExpression", {
    builder: ["operator", "left", "right"],
    fields: {
        operator: {
            validate: assertValueType("string")
        },
        left:{
            validate: assertNodeType("Expression")
        },
        right:{
            validate: assertNodeType("Expression")
        }
    },
    visitor: ["left", "right"],
    aliases: ["Binary", "Expression"]
});

Builders 构建器

上边的定义中有builder字段,这个字段的出现是因为每个节点类型都有构造器方法builder,使用方法:

type.binaryExpression("*", type.identifier("a"), type.identifiier("b"));

可以创建的AST:

{
    type: "BinaryExpression",
    operator: "*",
    left: {
        type: "Identifier",
        name: "a"
    },
    right: {
        type: "Identifier",
        name: "b"
    }
}

转为js代码后:

a * b

构造器还会验证自身创建的节点,并在错误使用的情况下抛出描述性错误。于是有了验证器。

Validators 验证器

BinaryExpression的定义还包含了节点的字段fields信息,以及如何验证这些字段。

fields: {
  operator: {
    validate: assertValueType("string")
  },
  left: {
    validate: assertNodeType("Expression")
  },
  right: {
    validate: assertNodeType("Expression")
  }

}

可以创建两种验证方法。第一种是isX。

type.isBinaryExpression(maybeBinaryExpressionNode)

这个测试用来确保节点是一个二进制表达式,另外你也可以传入第二个参数来确保节点包含特定的属性和属性值。

type.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });

还有一些断言式版本,会抛出异常而非true或false。

type.assertBinaryExpressiion(maybeBinaryExpressionNode);
type.assertBiinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
// Error: Expected type "BinaryExpression" with option { "operator": "*" }

Converters 变换器

babel-generator

是Babel的代码生成器,读取AST并将其转换为代码和源码映射。

const code = '......'

const ast = babylon.parse(code);
generate(ast, {}, code);
// 结果
// {
//   code: "...",
//   map: "..."
// }

// 也可以传递选项
generate(ast, {
    retainLines: false,
    compact: "auto",
    concise: false,
    quotes: "double",
}, code);

babel-template

是另一个虽然小但非常有用的模块,能使你编写字符串形式切带有占位符的代码来代替手动编码,尤其生成大规模AST时。在计算机科学中,这种能力被称为准引用(quasiquotes)。

import template from "babel-template";
import generate from "babel-generator";
import * as type from "babel-types";

const buildRequire = template(' var IMPORT_NAME = require(SOURCE); ');

const ast = buildRequire({
    IMPORT_NAME: type.identifier("myModule"),
    SOURCE: type.stringLiteral("my-module")
};

console.log(generate(ast).code);

// 结果:
var myModule = require("my-module");

编写第一个Babel插件

// 源代码:
target_js = 'foo === bar'

// AST:
//{
//  type: "BinaryExpression",
//  operator: "===",
//  left: {
//    type: "Identifier",
//    name: "foo"
//  },
//  right: {
//    type: "Identifier",
//    name: "bar"
//  }
//}

// 目标是将 === 运算符左右变量名替换
export default function({ types: t}) {
    return {
        visitor: {
            BinaryExpression(path){
                if (path.node.operator !== "==="){
                    return;
                }
                path.node.left = t.identifier("sanshi");
                path.node.rigth = t.identifier("stone");
            }
        }
    }
}


// 运行结果:
sanshi === stone

转换操作

访问

获取子节点的Path

为了得到一个AST节点的属性值,我们一般先访问到该节点,然后利用path.node.property方法即可。

// BinaryExpression AST node 的属性: 'left', 'right', 'operator'
BinaryExpression(path){
    path.node.left;
    path.node.right;
    path.node.operator;
}

访问该属性内部的path,使用path对象的get方法,传递该属性的字符串形式作为参数:

BinaryExpression(path){
    path.get('left');
}

Program(path){
    path.get('body.0');
}

检查节点的类型

如果想检查节点的类型,最好的方式是:

BinaryExpression(path){
    if (t.isIdentifier(path.node.left)){
        // ...
    }
}

或者可以对节点的属性做浅层检查:

BinaryExpression(path){
    if (t.isIdentifier(path.node.left, { name: "n" })) {
        // ...
    }
}

功能上等价于:

BinaryExpression(path){
    if (path.node.left != null && 
        path.node.left.type === "Identiifiier" &&
        path.node.left.name === "n"
    ){
        // ...
    }
}

检查路径(Path)类型

BinaryExpression(path) {
    if (path.get('left').isIdentifier({ name: "n" })){
        // ...
    }
}


// 等价于

BinaryExpression(path) {
    if (t.isIdentifier(path.node.left, { name: "n" })) {
        // ...
    }
}

检查标识符(Identifier)是否被引用

Identifier(path) {
    if (path.isReferencedIdentifier()) {
        // ...
    }
}


// 或者

Identifier(path) {
    if (t.isReferenced(path.node, path.parent)) {
        // ...
    }
}

找到特定的父路径

有时需要从一个路径向上遍历语法树,直到满足相应的条件。

// 对于每一个父路径调用callback并将其NodePath当作参数,当callback返回真值时,则将其NodePath返回。
path.findParent((path) => path.isObjcetExpression());

// 如果也需要遍历当前节点:
path.find((path) => path.isObjectExpression());

// 查找最接近的父函数或程序:
path.getFunctionParent();

// 向上遍历语法树,知道找到在列表中的父节点路径
path.getStatementParent();

获取同级路径

如果一个路径是在一个Function/Program中的列表里面,他就有同级节点。

  • 使用path.inList来判断路径是否有同级节点
  • 使用path.getSibling(index)来获取同级路径
  • 使用path.key获取路径所在容器的索引
  • 使用path.container获取路径的容器(包含所有同级节点的数组)
  • 使用path.listKey获取容器的key
target_js = "var a = 1; // pathA, path.key = 0 var b = 2; // pathB, path.key = 1 var c = 3; // pathC, path.key = 2"

export default function({ types: t }) {
    return {
        visitor: {
            VariableDeclaration(path) {
                // if the current path is pathA
                path.inList // true
                path.listKey // "body"
                paht.key // 0
                path.getSibling(0) // pathA
                path.getSibling(path.key + 1) //pathB
                path.container // [pathA, pathB, pathC]
            }
        }
    };
}

停止遍历

如果插件需要在某种情况下不运行,最简单的做法是尽早返回:

BinaryExpression(path){
    if (path.node.operator !== '**') return;
}

如果在顶级路径中进行子遍历,则可以使用2个提供的API方法:

path.skip() 会跳过当前路径之后的子节点遍历;path.stop() 完全停止遍历。

处理

替换一个节点

BinaryExpression(path) {
    path.replaceWith(
        t.binaryExpression("**", path.node.left, t.numberLiteral(2))
        // 将当前BinaryExpression替换为:binaryExpression节点,该节点值为"**", left为path.node.left, right为t.numberLiteral(2),即2。
    );
}

用多个节点替换单个节点

ReturnStatement(path) {
    path.replaceWithMultiple([
        t.expressionStatement(t.stringLiteral("Is this the real life?")),
        t.expressionStatement(t.stringLiteral("Is this just fantasy?")),
        t.expressionStatement(t.stringLiteral("(Enjoy singing the rest of the song in you head)")),
    ]);
}

PS: 注意,当多个节点替换一个表达式时,他们必须是声明。因为Babel在更换节点时广泛使用启发式算法,这意味着您可以做一些疯狂的转换,否则将会非常冗长。

用字符串源码替换节点

FunctionDeclaration(path){
    path.replaceWithSourceString(function add(a, b) {
        return a + b;
    });
}

// 结果:
- function square(n) {
-     return n * n
+ function add(a, b) {
+ return a + b

PS:不建议使用这个API,除非正在处理动态的源码字符串,否则在访问者外部解析代码更有效率

插入兄弟节点

FunctionDeclaration(path) {
    path.insertBefore(t.expressionStatement(t.stringLiteral("Because I'm easy come, easy go.")));
    path.insertAfter(t.expressionStatement(t.stringLiteral("A little high, little low.")));
}


// 结果
+     "Because I'm easy come, easy go.";
    function square(n) {
        return n * n;
    }
+     "A little high, little low.";

PS:这里同样应该使用声明或者一个声明数组。这个使用了在用多个节点替换一个节点中提到的相同的启发式算法。

插入到容器(container)中

如果要在AST节点属性中插入一个类似body那样的数组,其方法与insertBefore/insertAfter类似,但必须指定listKey。

ClassMethod(path) {
    path.get('body').unshiftContainer('body', t.expressionStatement(t.stringLiteral('before')));
    path.get('body').pushContainer('body', t.expressionStatement(t.stringLiteral('after')));
}


// 结果
class A{
+     "before"
    var a = 'middle';
+     "after"
}

删除一个节点

FunctionDeclaration(path) {
    path.remove();
}



//结果
- function square(n) {
-     return n * n;
- }

替换父节点

只需要用parentPath: path.parentPath.replaceWith即可:

BinaryExpression(path) {
    path.parentPath.replaceWith(
        t.expressionStatement(t.stringLiteral("Anyway the wind blows, doesn't really matter to me, to me."))
    );
}



// 结果
function square(n) {
-     return n * n
+     "Anyway the wind blows, doesn't really matter to me, to me.";
}

删除父节点

BinaryExpression(path) {
    path.parentPath.remove();
}


// 结果
function square(n) {
-     return n * n
}

 

基于Scope(作用域)的操作。

检查本地变量是否被绑定

FunctionDeclaration(path) {
    if (path.scope.hasBinding("n")) {
        // ...
    }
}


// 以上操作将遍历范围树,并检查特定的绑定


FunctionDeclaration(path) {
    if (path.scope.hasOwnBinding("n")) {
        // ...
    }
}

// 这步是检查一个作用域是否有自己的绑定。

创建一个UID

生成一个标识符,不会与任何本地定义的变量冲突:

FunctionDeclaration(path){
    path.scope.generateUidIdentifier("uid");
    // Node { type: "Identifier", name: "_uid" }
    path.scpoe.generateUidIdentifier("uid");
    // Node { type: "Identifier", name: "_uid2" }
}

提升变量声明至父级作用域

FunctionDeclaration(path) {
    const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
    path.remove();
    path.scope.parent.push({ id, init: path.node });
}


// 结果
- function square(n) {
+ var _square = function square(n) {
    return n * n
- }
+ }

重命名绑定及其引用

FunctionDeclaration(path) {
    path.scope.rename("n", "x");
}


// 结果
- function square(n) {
-     return n * n;
+ function square(x) {
+     return x * x;
}


// 或者将绑定重命名为生成的唯一标识符
FunctionDeclaration(path) {
    path.scope.rename("n");
}


// 结果
- function square(n) {
-     return n * n;
+ function square(_n) {
+     return _n * _n;
}

 

插件选项

如果想自定义Babel插件的行为,可以指定的插件特定选项:

{
    plugins: [
        ["my-plugin", {
            "option1": true,
            "option2": false
        }]
    ]
}

这些选项会通过state对象传递给插件访问者:

export default function({ types: t}) {
    return {
        visitor: {
            FunctionDeclaration(path, state){
                console.log(state.opts);
                //输出:
                // { option1: true, option2: false}
            }
        }
    }
}

这些选项特定于插件,不能访问其他插件中的选项

插件的准备和收尾工作

插件可以具有在插件之前或之后运行的函数。可以用于设置或清理/分析:

export default function({ types: t }) {
    return {
        pre(state) {
            this.cache = new Map();
        },
        visitor: {
            StringLiteral(path) {
                this.cache.set(path.node.value, 1);
            }
        },
        post(state) {
            console.log(this.cache);
        }
    }
}

在插件中启用其他语法

插件可以启用babylon plugins,以便用户不需要安装/启用他们。这可以防止解析错误,而不会继承语法插件。

export default function({ types: t }) {
    return {
        inherits: require("babel-plugin-syntax-jsx")
    };
}

 

你可能感兴趣的:(学习过程,javascript)