反爬虫AST混淆JavaScript与还原——3、Babel的APl

1,134 阅读13分钟

Babel的编译步骤

Babel是一个强大的JS编译器,确切地说是源码到源码的编译器,通常也叫作转换编译器(transpiler)。为Babel提供一些JS代码,Babel更改这些代码,然后返回新生成的代码。为Babel提供的代码会比较方便地转化为AST抽象语法树,之后只需要专注AST抽象语法树的转化生成。

Babel的编译过程主要有以下三个阶段。

  1. 解析(Parse):将输入字符流解析为AST抽象语法树。
  2. 转化(Transform):对抽象语法树进一步转化。
  3. 生成(Generate.:根据转化后的语法树生成目标代码。

Babel的解析

Babel的解析实际上包含了两个内容:词法分析和语法分析。经过这一步后,可以直接得到输入源代码的AST抽象语法树,极大地方便了AST程序的开发。

Babel的转化

转化步骤接收AT并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。后续的混淆JS代码以及对混淆过的JS代码进行还原都在此处,这是本书的重点。之所以将字符流转化为抽象语法树,原因是树状结构更加容易进行原子操作,可以对任意的节点进行精细化处理。在抽象语法树中,代码间的关系被抽象为节点间的关系,而实现相同功能的节点之间的表示也是相同的。利用这一点,可以在语法树层面对输入的代码进行增、删、改、查,而不必关心具体的代码书写。制定几条规则,Babel就可以对抽象语法树进行遍历,完成整个代码的批量操作。

Babel的生成

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

这一步,就可以得到从AST抽象语法树层面修改过的代码。

代码的基本结构

let obj = {
  name: 'javaScriptAST',
  add: function (a, b) {
    return a + b + 1000
  },
  mul: function (a, b) {
    return a * b + 1000
  }
}

将上面原始代码保存到demo.js文件中,注意,需要保存为utf-8编码的格式。另外新建一个文件,用来解析demo.js,AST解析转换代码的基本结构如下所示:

const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const t = require('@babel/types')
const generator = require('@babel/generator').default
const jscode = fs.readFileSync('./demo.js', {
  encoding: 'utf-8'
})

let ast = parser.parse(jscode) // 这里会将代码解析为一个AST树
let code = generator(ast).code // 在这里对AST进行一系列的操作

fs.writeFileSync('./demoNew.js', code, err => {}) // 输出转换AST后的数据

// let astJson = JSON.stringify(ast)
// fs.writeFileSync('./ast.json', astJson, err => {}) // 输出AST树数据

@babel/parser 用来将JS代码转换成AST,

@babel/traverse 用来遍历AST中的节点,

@babel/types 用来判断节点类型、生成新的节点等,

@babel/generator用来把AST转换成JS代码,

可以看出,AST处理JS文件的基本步骤为:首先读取JS文件,解析成AST,再对节点进行一系列的增、删、改、查操作,接着生成JS代码,最后保存到新文件中。

parser与generator

这两个组件的作用是相反的。parser组件用来将JS代码转换成AST,generator用来将AST转换成 JS代码。

使用 let ast=parser.parse(jscode) 即可完成JS代码转换到AST的过程。这时输出AST,就会跟网页中解析出一样的结构。输出前先使用JSON.stringify把对象转为json数据,另外,parser的parse方法是有第2个参数的。

let ast = parser.parse(jscode, {
  sourceType: 'module'
})

sourceType默认为script。当解析的JS代码中,含有'import'、'export''等关键字时,需要指定sourceType为module,不然会有如下报错:

SyntaxError:'import'and 'export'may appear only with 'sourceType:"module"'(1:0)

使用let code = generator(ast).code;可以把AST转换为JS代码。generator返回的是一个对象,其中的code属性才是需要的代码。同时,generator 的第二个参数接收一个对象,可以设置一些选项来影响输出的结果。完整的选项介绍可在Babel官方文档babeljs.io/docs/en/bab…

let code = generator(ast, {
    retainLines: false, 
    comments: false, 
    compact: true
}).code

console.log(code)

retainLines表示是否使用与源代码相同的行号,默认为false,输出的是格式化后的代码。comments表示是否保留注释,默认为true。compact表示是否压缩代码,与其相同作用的选项还有 minified 和 concise,只不过压缩的程度不一样,minified 压缩得最多,concise压缩得最少。多个选项之间可以配合使用。

traverse与visitor

traverse组件用来遍历AST,简单说就是把AST上的各个节点都运行一遍,但单纯把节点都运行一遍是没有意义的,所以traverse需要配合visitor使用。

visitor是一个对象,它可以定义一些方法,用来过滤节点。接下来用一个实际案来解traverse和visitor的效果,代码如下;

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

let visitor = {};
visitor.FunctionExpression = function(path){
	console.log("javaScriptAST");
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

// 输出 javaScriptAST

首先声明对象,对象的名字可随意定义,再给对象增加一个名为FunctionExpression的方法,它的名字是需要遍历的节点类型,需要注意大小写。traverse会遍历所有的节点,当节点类型为FunctionExpression时,调用visitor中相应的方法。如果想要处理其他节点类型,例如Identifier,可以在visitor中继续定义方法,以Identifier命名即可。visitor中的方法接收一个参数,traverse在遍历时,会把当前节点的Path对象传给它,传过来的是Path对象而非节点(Node)。最后把visitor作为第二个参数传到traverse里,传给traverse的第一个参数是整个AST。这段代码的意思是,从头开始遍历AST中的所有节点,过滤出FunctionExpression节点,执行相应的方法。在原始代码中,有两个FunctionExpression节点,因此,会输出两次 javaScriptAST。

定义visitor的方式有以下三种,最常用的是visitor2这种形式。

const visitor1 = {
    FunctionExpression: function (path) {
        console.log("javaScriptAST");
    }
};

const visitor2 = {
    FunctionExpression (path) {
        console.log("javaScriptAST");
    }
};

const visitor3 = {
    FunctionExpression: {
        enter(path) {
            console.log("javaScriptAST");
        }
    }
};

在visitor3中,存在一个enter。在遍历节点的过程中,有两次机会来访问一个节点,即进入节点时(enter)与退出节点时(exit)。以原始代码中的add函数为例,节点的遍历过程可描述如下:

进入FunctionExpression
    进入Identifier(params[0])走到尽头
    退出Identifier(params[0])
    进入Identifier(params[1])走到尽头
    退出Identifier(params[1])
    进入BlockStatement(body)
        进入ReturnStatement(body)
            进入BinaryExpression(argument)
                进入BinaryExpression(left)
                    进入Identifier(left)走到尽头
                    退出Identifier(left)
                    进人Identifier(right)走到尽头
                    退出Identifier(right)
            		退出BinaryExpression(left)
                进入NumericLiteral(right)走到尽头
                退出NumericLiteral(right)
            退出BinaryExpression(argument)
        退出ReturnStatement(body)
    退出BlockStatement(body)
退出FunctionExpression

正确选择节点处理时机,有助于提高代码效率。可以看出traverse是一个深度优先的遍历过程。因此,如果存在父子节点,那么enter的处理时机是先处理父节点,再处理子节点。而exit的处理时机是先处理子节点,再处理父节点。traverse默认是在enterI时处理,

如果要在exit时处理,必须在visitor中写明。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor3 = {
    FunctionExpression: {
        enter(path) {
            console.log("javaScriptAST enter");
        },
        exit(path) {
            console.log("javaScriptAST exit");
        }
    }
};

traverse(ast, visitor3);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

还可以把方法名用 "|" 连接成FunctionExpression | BinaryExpression 形式的字符串,把同一个函数应用到多个节点,例如:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
	"FunctionExpression|BinaryExpression"(path){
		console.log("javaScriptAST");
	}
};

traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

也可以把多个函数应用于同一个节点。原先是把一个函数赋值给enter或者exit,现在改为函数的数组,会按顺序依次执行。示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function func1(path){
	console.log('func1');
}
function func2(path){
	console.log('func2');
}
const visitor = {
	FunctionExpression: {
		enter: [func1, func2]
	}
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

traverse并非必须从头遍历,它可在任意节点向下遍历。例如,想要把代码中所有函数的第一个参数改为x,代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const updateParamNameVisitor = {
    Identifier(path) {
        if (path.node.name === this.paramName) {
            path.node.name = "x";
        }
    }
};
const visitor = {
    FunctionExpression(path) {
        const paramName = path.node.params[0].name;
        path.traverse(updateParamNameVisitor, {
            paramName
        });
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

处理后的输出结果

这段代码先用traverse根据visitor去遍历所有节点。当得到 FunctionExpression 节点时,用 path.traverse 根据 updateParamNameVisitor 去遍历当前节点下的所有子节点,然后修改与函数第一个参数相同的标识符。在使用path.traverse时,还可以额外传入一个对象,在对应的visitor中用this去引用它。其中 path.node 才是当前节点,所以path.node.params[0].name可以取出函数的第一个参数名。

types组件

该组件主要用来判断节点类型、生成新的节点等。判断节点类型的方法很简单,例如,t.isldentifier(path.node),它等同于 path.node.type==="Identifier"。还可以在判断类型的同时附加条件,示例如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
	enter(path) {
		if (
			path.node.type === "Identifier" &&
			path.node.name === "n"
		){
			path.node.name = "x";
		}
	}
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

上述代码用来把标识符改为x,这是官方手册中的案例,但在实际修改中还需要考虑标识符的作用域。在这个案例中,visitor没有做任何过滤,遍历到任何一个节点都调用enter函数,所以要判断类型为Identifier且name的值为n,才修改为x。这个案例可以等同地写为:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
	enter(path) {
		if (
			t.isIdentifier(path.node, {name: 'n'})
		){
			path.node.name = "x";
		}
	}
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

如果要判断其他类型,只需要更改i后面的类型。这些方法还可以归纳为:当节点不符合要求,会抛出异常而不是返回true或false:

t.assertBinaryExpression(maybeBinaryExpressionNode);
t.assertBinaryExpression(maybeBinaryExpressionNode,{operator:"*"});

可以看出,types组件中用于判断节点类型的函数是可以自己实现的,且过程也较为容易。因此,types组件最主要的功能是可以方便地生成新的节点。接下来尝试用types组件来生成原始代码。注意,Babel中的API有很多,不可能全部记住API的用法,一定要学会查看代码提示。

在原始代码中,最开始是一个变量声明语句,类型为VariableDeclaration。因此,可以用

t.VariableDeclaration去生成它。在vscode中输入"t.variabledeclaration",然后将鼠标指针悬停在VariableDeclaration上,就会出现代码提示。也可以按Crl键,同时单击VariableDeclaration,跳转到一个以ts为后缀的文件中有如下一段代码:

这段代码最后一个冒号后表示这个函数的返回值类型。括号里面的冒号前,是VariableDeclaration节点的属性。括号里面的冒号后,表示该参数允许传的类型。Array表示这个参数是一个数组。因此,变量声明语句的生成代码可以写为:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

let loaclAst = t.valueToNode([1, "2", false, null, undefined, /\w\s/g, {x: '1000', y: 2000}]);
let code = generator(loaclAst).code;
console.log(code);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

要生成上述代码中的varDec,需要先生成一个VariableDeclarator节点,表示变量声明的具体的值,在ts文件中的定义如下:

export function variableDeclarator(id:LVal, init?:Expression | null):VariableDeclarator;

VariableDeclarator是该函数的返回值,id和init是VariableDeclarator节点的属性。init后面的问号,代表该参数可省略。根据8.l.1节的分析,这里的id是Identifier类型。生成Identifier的方法很简单,在ts文件中的定义如下:

export function identifier(name:any) : Identifier;

在原始代码中对obj进行了初始化。所以这里的init是需要传值的。那么,生成varDec的代码可以写为:

let varDec = t.variableDeclarator(t.identifier('obj'), objExpr);

接着要生成objExpr。这里需要一个ObjectExpression,在ts文件中的定义如下:

export function objectExpression(properties:Array <ObjectMethod | ObjectProperty | SpreadElement>) : ObjectExpression;

对象的属性可以有多个,所以需要数组。因此,生成objExpr的代码可以写为:

let objExpr = t.objectExpression([objProp1,objProp2,objProp3]);

在上述代码中,objPropl、objProp2和objProp3都没有进行赋值。这里,还有一个新类型ObjectProperty,在ts文件中的定义如下:

export function objectProperty(key:any,value:Expression | PatternLike,computed?:boolean, shorthand?:any, decorators?:Array<Decorator> | null) : ObjectProperty;

key的值在原始代码中为name,它是一个Identifier。后面三个参数都是可选的,这里都选择不传入。其中,节点属性 computed 将在后续内容中介绍。value表示对象属性的具体的值。在原始代码中,第1个属性的值是一个字符串字面量,用 StringLiteral 表示。第2个和第3个属性的值都为函数表达式,用FunctionExpression表示。StringLiterala 在 ts 文件中的定义如下:

export function stringLiteral(value:string) : StringLiteral;

因此,生成obj三个属性的代码为:

let objProp1 = t.objectProperty(t.identifier('name'), t.stringLiteral('javaScriptAST'));
let objProp2 = t.objectProperty(t.identifier('add'), funcExpr2);
let objProp3 = t.objectProperty(t.identifier('mul'), funcExpr3);

上述代码中,funcExpra2 和 funcExpr3 还没有进行赋值。接着介绍 FunctionExpression 在ts文件中的定义,id表示函数名,params 表示参数列表,body用 BlockStatement 包裹所有语句,其余参数可选,代码如下:

export function functionExpression (id:Identifier | null | undefined, params:Array<Identifier | Pattern | RestElement | TSParameterProperty>, body: BlockStatement, generator?: boolean, async?: boolean): FunctionExpression;
export function blockStatement (body:Array<Statement>, directives?: Array<Directive>):BlockStatement;

在原始代码中都是由匿名函数直接赋值给obj的属性。因此,这里id为null,params列表用t.identifier生成,BlockStatement节点用 t.blockStatement 生成,代码如下:

let a = t.identifier('a')
let b = t.identifier('b')
let bloSta2 = t.blockStatement([retSta2])
let bloSta3 = t.blockStatement([retSta3])
let funcExpr2 = t.functionExpression(null, [a, b], bloSta2)
let funcExpr3 = t.functionExpression(null, [a, b], bloSta3)

上述代码中,retSta2和retSta3还需另外生成。特别说明的是,如果要生成一个空函数,即函数体为空,则 blockStatement 的参数给空数组,而不是null。原始代码中,两个函数内都含有返回语句、二项式和数值字面量。在AST中可以分别使用ReturnStatement、BinaryExpression 和 NumericLiteral 来表示。它们在ts文件中的定义为:

export function returnStatement(argument?:Expression | null):ReturnStatement;

export function binaryExpression(operator:"+"|"-"|"/"|"%"|"*"|"**"|"&"|"|"|">>"|">>>"|"<<"|"^"|"=="|"==="|"!="|"!=="|"in"|"instanceof"|">"|"<"|">="|"<=",left:Expression,right:Expression):BinaryExpression;

export function numericLiteral(value:number):NumericLiteral;

接下来,把代码中剩余的部分生成完毕:

let a = t.identifier('a')
let b = t.identifier('b')
let binExpr2 = t.binaryExpression('+', a, b)
let binExpr3 = t.binaryExpression('×', a, b)
let retSta2 = t.returnStatement(t.binaryExpression('+', binExpr2, t.numericLiteral(1000)))
let retSta3 = t.returnStatement(t.binaryExpression('+', binExpr3, t.numericLiteral(1000)))
let bloSta2 = t.blockStatement([retSta2])
let bloSta3 = t.blockStatement([retSta3])

完整的代码,以及执行之后的结果如下所示:

let a = t.identifier('a')
let b = t.identifier('b')
let binExpr2 = t.binaryExpression('+', a, b)
let binExpr3 = t.binaryExpression('*', a, b)
let retSta2 = t.returnStatement(t.binaryExpression('+', binExpr2, t.numericLiteral(1000)))
let retSta3 = t.returnStatement(t.binaryExpression('+', binExpr3, t.numericLiteral(1000)))
let bloSta2 = t.blockStatement([retSta2])
let bloSta3 = t.blockStatement([retSta3])
let funcExpr2 = t.functionExpression(null, [a, b], bloSta2)
let funcExpr3 = t.functionExpression(null, [a, b], bloSta3)
let objProp1 = t.objectProperty(t.identifier('name'), t.stringLiteral('javaScriptAST'))
let objProp2 = t.objectProperty(t.identifier('add'), funcExpr2)
let objProp3 = t.objectProperty(t.identifier('mul'), funcExpr3)
let objExpr = t.objectExpression([objProp1, objProp2, objProp3])
let varDec = t.variableDeclarator(t.identifier('obj'), objExpr)
let loaclAst = t.variableDeclaration('let', [varDec])
let code = generator(loaclAst).code
console.log(code)

/* 输出结果
let obj = {
    name: "javaScriptAST",
    add: function (a, b) {
        return a + b + 1000;
    },
    mul: function (a, b) {
        return a * b + 1000;
    }
}
*/

在J代码处理转换过程中,生成的新节点一般会添加或替换到已有的节点中。

上述案例中,用到了 StringLiteral 和 NumericLiteral,同时在 Babel 中还定义了一些其他的字面量。

export function nullLiteral(): NullLiteral;
export function booleanLiteral(value: boolean): BooleanLiteral;
export function regExpLiteral(pattern: string, flags?: any): RegExpLiteral;

因此,不同的字面量需要调用不同的方法生成。当生成比较多的字面量时,难度会不断上升。其实在Babel中还提供了valueToNode,如下所示:

export function valueToNode(value: undefined): Identifier
export function valueToNode(value: boolean): BooleanLiteral
export function valueToNode(value: null): NullLiteral
export function valueToNode(value: string): StringLiteral
export function valueToNode(value: number): NumericLiteral | BinaryExpression | UnaryExpression
export function valueToNode(value: RegExp): RegExpLiteral
export function valueToNode(value: ReadonlyArray<undefined | boolean | null | string | number | RegExp | object>): ArrayExpression
export function valueToNode(value: object): ObjectExpression
export function valueToNode(value: undefined | boolean | null | string | number | RegExp | object): Expression

由此可以看出,valueToNode可以很方便地生成各种类型。除了原始类型undefined、null、string、number和boolean,还可以是对象类型RegExp、ReadonlyArray和object,示例代码如下:

let loaclAst = t.valueToNode([1, "2", false, null, undefined, /w\s/g, { x: '1000', y: 2000 }]);
let code = generator(loaclAst).code;
console.log(code);

结合ts文件中的定义和AST解析后的json数据,可以迅速掌握这些API的使用方法。

前面的介绍中提到了Babel解析后的AST,其实它是一段json数据。因此,也可以按照AST的结构来构造一段json数据,以此生成想要的代码,但比使用types组件麻烦。

let obj = {}
obj.type = 'BinaryExpression';
obj.left = { type: 'NumericLiteral', value: 1000 };
obj.operator = '/'
obj.right = { type: 'NumericLiteral', value: 2000 };
let code = generator(obj).code;
console.log(code);

//输出 1000 / 2000

本章节就先到这吧,因为后面剩下的Path对象实在太多东西了