AST 简述

AST 是源代码的抽象语法结构的树状表示。利用它可以还原混淆后的js代码。
@babel/parser 是js语法编译器 Babel 的 nodejs 包,内置很多分析 js 的方法,可以实现js到AST的转换。
JS 转为 AST:https://astexplorer.net/

准备工作:

需安装nodejs环境以及babel,babel 安装:
npm install @babel/node @babel/core @babel/cli @babel/preset-env
新建目录 AST,其下新建文件 .babelrc,内容如下:

{
  "presets": [
    "@babel/preset-env"
  ]
}

这样就完成了初始化。

节点类型

打开 https://astexplorer.net/
Parser Settings选择@babel/parser, 然后在左侧任意输入一段js,右侧会展示对应的AST,其是由一层层的数据结构嵌套构成,每一个含有type属性的内容都可以视为该类型的一个节点,常见的节点类型如下:

Literal 字面量,简单的文字表示,如3,abc,null,true 等。它进一步分为 RegExpLiteral、NullLiteral、StringLiteral、BooleanLiteral、NumericLiteral、BigIntLiteral 等类型;
Declarations 声明,如 FunctionDeclaration、VariableDeclaration 分别表示声明一个方法和变量;
Expressions 表达式,它本身会返回一个计算结果,通常有两个作用,一个是放在赋值语句的右边赋值,另一个是作为方法的参数,如 LogicalExpression、ConditionalExpression、ArrayExpression 分别表示逻辑运算表达式、三元运算表达式、数组表达式;此外,还有一些特殊的表达式,如YieldExpression、AwaitExpression、ThisExpression;
Statements 语句,如 IfStatements、SwitchStatements、BreakStatement 等控制语句,和一些特殊语句 DebuggerStatement、BlockStatements等;
Identifier 标识符,指代一些变量的名称,如 name
Classes 类,代表一个类的定义,包括 Class、ClassBody、ClassMethod、ClassProperty等
Functions 方法声明,一般代表 FunctionDeclaration、FunctionExpression 等
Modules 模块,可以理解为一个 nodejs 模块,包括 ModuleDeclaration、ModuleSpecifier 等
Program 程序,整个代码可以成为 Program

@babel/parser 的使用

它是 Babel 的js解释器,也是一个nodejs包,提供一些重要的方法,parse 解析js代码,parseExpression 尝试解析单个js表达式并考虑性能。一般使用parse就足够了。
parse 输入:一段js代码;输出:该js代码对应的抽象语法树AST
js代码包含多种类型的表达,归类如下:
https://github.com/babel/babel/blob/master/packages/babel-parser/ast/spec.md

接下来,使用一下parse,首先在目录AST下新建一个文件夹code,新建文件 code1.js ,内容如下:

const a = 3;
let string = 'hello';
for (let i = 0; i < a; i++) {
    string += 'world';
}
console.log('string', string)

简单写了一段js代码,然后同目录下新建文件 basic1.js,内容如下:

import { parse } from "@babel/parser";
import fs from 'fs';

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);
console.log(ast)

使用parse将代码转为ast抽象语法树,命令行输入 babel-node basic1.js 运行,输出如下:

Node {
  type: 'File',
  start: 0,
  end: 128,
  loc: SourceLocation {
    start: Position { line: 1, column: 0, index: 0 },
    end: Position { line: 8, column: 0, index: 128 },
    filename: undefined,
    identifierName: undefined
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 128,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    sourceType: 'script',
    interpreter: null,
    body: [ [Node], [Node], [Node], [Node] ],
    directives: []
  },
  comments: []
}

可以看到,整个AST的根节点就是一个Node,type是File,代表其是一个 File 类型的节点;其下有很多属性 start、end 等等,其中的 progarm 也是一个 Node,type为Program,代表其是一个程序。同样,program 也包含一些属性,其中 body 是比较重要的属性,这里是一个列表类型,其中每个元素也都是一个Node,只不过输出结果没有详细展示了。
可以通过 console.log(ast.program.body) 详细打印Node的内容。
js转为ast后,如何转换回来呢,可以使用generate方法。

@babel/generate 的使用

它也是一个nodejs包,提供了 generate 方法将 AST 还原为 js 代码

import { parse } from "@babel/parser";
import generate from "@babel/generator"
import fs from 'fs';

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);
const { code: output} = generate(ast)
console.log(output)

同样命令行键入 babel-node basic1.js 运行,输出如下:

const a = 3;
let string = 'hello';
for (let i = 0; i < a; i++) {
  string += 'world';
}
console.log('string', string);

generate 方法还可以传入第二个参数,接收一些配置选项,第三个参数接收原代码作为输出的参考,用法:

const output = generate(ast, { /* options */ }, code);

options 可选部分配置:

auxiliaryCommentBefore string类型,在输出文件开头添加注释可选字符串;
auxiliaryCommentAfter string类型,在输出文件末尾添加注释可选字符串;
retainLines boolean类型,默认false,尝试在输出代码中使用与源代码相同的行号;
retainFunctionParens boolean类型,默认false,保留表达式周围的括号;
comments boolean类型,默认true,输出中是否应包含注释;
compact boolean或auto类型,默认opts.minfied,设置为true以避免添加空格进行格式化;
minified boolean类型,默认false,是否压缩后输出;

@babel/traverse 的使用

知道了如何在 js 和 AST 间转换,还是不能实现 js 代码的反混淆,还需要了解另一个强大的功能,AST的遍历和修改。
遍历的使用的是 @babel/traverse,它接收一个 AST,利用 traverse 方法就可以遍历其中的所有节点。在遍历方法中,就可以对所有节点操作了。
先感受下遍历的基本实现,新建 basic2.js:

import { parse } from "@babel/parser";
import fs from 'fs';
import { traverse } from "@babel/core";

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);
traverse(ast, {
    enter(path) {
        console.log(path)
    },
})

命令行输入 babel-node basic2.js 运行,结果很长,会输出每一个path对象,取其中一部分如下:

NodePath {
    contexts: [ [TraversalContext] ],
    state: undefined,
    opts: { enter: [Array], _exploded: true, _verified: true },
    _traverseFlags: 0,
    skipKeys: null,
    parentPath: NodePath {
      contexts: [Array],
      state: undefined,
      opts: [Object],
      _traverseFlags: 0,
      skipKeys: null,
      parentPath: [NodePath],
      container: [Array],
      listKey: 'body',
      key: 3,
      node: [Node],
      type: 'ExpressionStatement',
      parent: [Node],
      hub: undefined,
      data: null,
      context: [TraversalContext],
      scope: [Scope]
    },
    container: Node {
      type: 'ExpressionStatement',
      start: 95,
      end: 124,
      loc: [SourceLocation],
      expression: [Node]
    },
    listKey: undefined,
    key: 'expression',
    node: Node {
      type: 'CallExpression',
      start: 95,
      end: 124,
      loc: [SourceLocation],
      callee: [Node],
      arguments: [Array]
    },
    type: 'CallExpression',
    parent: Node {
      type: 'ExpressionStatement',
      start: 95,
      end: 124,
      loc: [SourceLocation],
      expression: [Node]
    },
    hub: undefined,
    data: null,
    context: TraversalContext {
      queue: [Array],
      priorityQueue: [],
      parentPath: [NodePath],
      scope: [Scope],
      state: undefined,
      opts: [Object]
    },
    scope: Scope {
      uid: 0,
      path: [NodePath],
      block: [Node],
      labels: Map(0) {},
      inited: true,
      bindings: [Object: null prototype],
      references: [Object: null prototype],
      globals: [Object: null prototype],
      uids: [Object: null prototype] {},
      data: [Object: null prototype] {},
      crawling: false
    }
  }

可以看到,这是一个 NodePath 类型的节点,里面还有 node、parent 等多个属性,我们可以利用 path.node 拿到当前对应的Node对象,也可以利用 path.parent 拿到当前Node对象的父节点。
这样,就可以使用它来对Node进行一些处理,如把最初的代码修改为 a=5, string = "hi",可以这样:

import { parse } from "@babel/parser";
import generate from "@babel/generator"
import fs from 'fs';
import traverse from "@babel/traverse";

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);
traverse(ast, {
    enter(path) {
        let node = path.node;
        if (node.type === "NumericLiteral" && node.value === 3) {
            node.value = 5;
        }
        if (node.type === "StringLiteral" && node.value === "hello") {
            node.value = "hi";
        }
    },
});

const { code: output } = generate(ast, {
    retainLines: true,
});
console.log(output)
// 输出如下
const a = 5;
let string = "hi";
for (let i = 0; i < a; i++) {
  string += 'world';
}
console.log('string', string);

除了 enter 外,还可以直接定义对应类型的解析方法,这样遇到此类型的节点就会被自动调用:

traverse(ast, {
    NumericLiteral(path) {
        if (path.node.value === 3) {
            path.node.value = 5;
        }
    },
    StringLiteral(path) {
        if (path.node.value === 'hello') {
            path.node.value = 'hi';
        }
    }
})

traverse部分改成如上内容,输出是一样的。

还可以通过 remove 方法删除某个节点,如:

traverse(ast, {
    CallExpression(path) {
        let node = path.node;
        if (node.callee.object.name === 'console' && node.callee.property.name === 'log') {
            path.remove();
        }
    },
});

这样就删除了所有console.log语句。
如果想插入节点,就要用到 types 了。

@babel/types 的使用

使用它可以方便的声明新的节点,比如 const a = 1; 如果想增加一行 const b = a + 1; 可以这么写:

import { parse } from "@babel/parser";
import generate from "@babel/generator";
import * as types from "@babel/types";
import traverse from "@babel/traverse";

const code = 'const a = 1;';
let ast = parse(code);

traverse(ast, {
    VariableDeclaration(path) {
        let init = types.binaryExpression(
            '+',
            types.identifier('a'),
            types.numericLiteral(1)
        );
        let declarator = types.variableDeclarator(types.identifier('b'), init);
        let declaration = types.variableDeclaration("const", [declarator]);
        path.insertAfter(declaration);
        path.stop();
    },
})

const { code: output } = generate(ast, {
    retainLines: true,
});
console.log(output)
# 输出为 const a = 1;const b = a + 1;

至于为什么这么写,可以结合js转为 AST 的内容,配合官方文档 (https://astexplorer.net/ 右上角点击 Parser: @babel/parser-*.**.*),来构造节点。
本例中首先把 const b = a + 1; 转为 AST 查看对应内容:

可以看到,这个语句转为 AST 后是一个 type 为 VariableDeclaration 的节点,查看官方文档对应内如如下,

想构造一个 type 为 VariableDeclaration 的节点,需使用 types 的 variableDeclaration 方法,传入的第一个参数为声明的关键词,第二个为 Array<VariableDeclarator> 类型的节点,对比 AST 可以看到

第一个传入的是const,第二个传入的是一个 type 为 VariableDeclarator 的节点构成的列表,当然本例中这个列表只有一个元素。
所以,构造一个 type 为 VariableDeclaration 的节点,代码大致是这样的:let declaration = types.variableDeclaration("const", [VariableDeclarator]);
VariableDeclarator 类型的节点又该如何构造呢?继续查询官方文档:

可以看到,需要传入一个id和一个init,对比 AST 可以知道需要传入的具体内容,id 是一个Identifier类型的节点,其name是 b,init 是一个BinaryExpression类型的节点,这两个节点如何构造,继续查阅官方文档,这里不再赘述,最终实现的结果就如上方代码。

了解了这些知识,来看几个简单的反混淆 js 的例子。
1. 表达式还原
原始代码:

const a = !![];
const b = "abc" === "bcd"
const c = (1 << 3) | 2
const d = parseInt('5' + '0')

还原代码:

import { parse } from "@babel/parser";
import generate from "@babel/generator"
import fs from 'fs';
import traverse from "@babel/traverse";
import * as types from "@babel/types";

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);

traverse(ast, {
    // 键名分别对应于处理 一元表达式、布尔表达式、条件表达式、调用表达式
    "UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression"(path) {
        // evaluate 方法会对path对象进行计算(执行),得到可信度和结果,confident = true
        let { confident, value } = path.evaluate();
        // 如果是标识符或NaN,就跳过
        if (value === Infinity || value === -Infinity || isNaN(value)) return;
        // 如果可信,即 confident 为 true,就替换 evaluate(计算)得到的值
        confident && path.replaceWith(types.valueToNode(value));
    },
});

const { code: output } = generate(ast);
console.log(output)
// 输出如下
const a = true;
const b = false;
const c = 10;
const d = 50;

2. 字符串还原
有一些字符会被混淆为 Unicode 或 UTF-8 编码,如

const strings = ["\x68\x65\x6c\x6c\x6f", "\x77\x6f\x72\x6c\x64"]

把此行代码放入 AST Explore 查看对应的 AST,部分如下:

可以看到,extra下显示了编码字符及对应的原始值,只需要把 raw 的内容修改为 rawValue 的内容即可,代码如下:

import { parse } from "@babel/parser";
import generate from "@babel/generator"
import fs from 'fs';
import traverse from "@babel/traverse";
import * as types from "@babel/types";

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);

traverse(ast, {
    StringLiteral({ node }) {
        if (node.extra && /\\[ux]/gi.test(node.extra.raw)) {
            node.extra.raw = node.extra.rawValue;
        }
    }
})

const { code: output } = generate(ast);
console.log(output)
// 输出如下:
const strings = [hello, world];

3. 无用代码剔除

const _0x16c18d = function () {
    if (!![[]]) {
        console.log('hello world');
    } else {
        console.log('this');
        console.log('is');
        console.log('dead');
        console.log('code');
    }
};
const _0x1f7292 = function () {
    if ("xmv2nOdfy2N".charAt(4) !== String.fromCharCode(110)) {
        console.log('this');
        console.log('is');
        console.log('dead');
        console.log('code');
    } else {
        console.log('nice to meet you')
    }
};
_0x16c18d();
_0x1f7292();

这段代码只是打印了两行内容,多了很多无效代码,将其转为 AST,部分如下:

这是第一个if语句转换为的 AST,test 为 if 语句的判断条件,consequent 是 if 语句块内的代码;alternate 是 else 语句块内的代码;
据此,删除无用代码的代码如下:

import { parse } from "@babel/parser";
import generate from "@babel/generator"
import fs from 'fs';
import traverse from "@babel/traverse";
import * as types from "@babel/types";

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);

traverse(ast, {
    IfStatement(path) {
        let { consequent, alternate } = path.node;
        let testPath = path.get('test');
        // evaluateTruthy 方法返回path对应的真值,比如第一个if条件是 !![[]],它为 true,该方法就返回true
        const evaluateTest = testPath.evaluateTruthy();
        if (evaluateTest === true) {
            // 如果if的判断条件是true,就把 if 语句块的内容节点替换原本的IfStatement节点
            if (types.isBlockStatement(consequent)) {
                consequent = consequent.body;
            }
            path.replaceWithMultiple(consequent);
        } else if (evaluateTest === false) {
            // 如果if的判断条件是false,就把 else 语句块的内容节点替换原本的IfStatement节点
            if (evaluateTest != null) {
                if (types.isBlockStatement(alternate)) {
                alternate = alternate.body;
            }
            path.replaceWithMultiple(alternate);
            } else {
                path.remove();
            }
        }
    }
})

const { code: output } = generate(ast);
console.log(output)
// 输出如下
const _0x16c18d = function () {
  console.log('hello world');
};
const _0x1f7292 = function () {
  console.log('nice to meet you');
};
_0x16c18d();
_0x1f7292();

4. 反控制流平坦化
一个简单的代码如下:

const c = 0;
const a = 1;
const b = 3;

经过简单的控制流平坦化后代码如下:

const s = '3|1|2'.split('|');
let x = 0;
while (true) {
    switch (s[x++]) {
        case '1':
            const a = 1;
            continue;
        case '2':
            const b = 3;
            continue;
        case '3':
            const c = 0;
            continue;
    }
    break;
}

还原思路:
首先找到switch语句相关节点,拿到对应的节点对象,如各个case语句对应的代码区块;
分析 switch 语句的判定条件 s 变量对应的列表结果,比如将 "3|1|2".split("|") 转化为 ["3", "1", "2"];
遍历 s 变量对应的列表,将其和各个 case 匹配,顺序得到对应的结果并保存;
用上一步得到的代码替换原来的代码。

还是把上面代码转为 AST 查看,switch 部分如下:

可以看到,它是一个 SwitchStatement 节点,discriminant 就是判断条件,这个例子中对应 s[x++],cases 就是case语句的集合,对应多个 SwitchCase 节点。
可以先把可能用到的节点取到,如 discriminant、cases、discriminant的 object 和 property

traverse(ast, {
    WhileStatement(path) {
        const { node, scope } = path;
        const { test, body } = node;
        let switchNode = body.body[0];
        let { discriminant, cases } = switchNode;
        let { object, property } = discriminant
    }
})

接下来追踪下判定条件 s[x++] ,展开 object,可以看到其 name 是 s,可以通过 scope 的 getBinding 方法获取到绑定它的节点;绑定的就是 "3|1|2".split("|") ,查看绑定的代码,可以看到是一个 CallExpression 节点,根据AST逐层拿到对应的值,然后动态调用:

let arrName = object.name;
let binding = scope.getBinding(arrName);
let { init } = binding.path.node;
object = init.callee.object;
property = init.callee.property;
let argument = init.arguments[0].value;
let arrayFlow = object.value[property.name](argument);

拿到 arrayFlow (["3", "1", "2"])后遍历它,找到对应的 case 语句对应的代码即可

let resultBody = [];
arrayFlow.forEach((index) => {
   let switchCase = cases.filter((c) => c.test.value === index)[0];
   let caseBody = switchCase.consequent;
   if (types.isContinueStatement(caseBody[caseBody.length - 1])) {
        caseBody.pop();
   }
   resultBody = resultBody.concat(caseBody);
});

最后替换即可:path.replaceWithMultiple(resultBody)

完整代码(全部代码均对照AST实现):

import { parse } from "@babel/parser";
import generate from "@babel/generator"
import fs from 'fs';
import traverse from "@babel/traverse";
import * as types from "@babel/types";

const code = fs.readFileSync('./code1.js', 'utf-8');
let ast = parse(code);

traverse(ast, {
    WhileStatement(path) {
        const { node, scope } = path;
        const { test, body } = node;
        let switchNode = body.body[0];
        let { discriminant, cases } = switchNode;
        let { object, property } = discriminant;
        let arrName = object.name;
        let binding = scope.getBinding(arrName);
        let { init } = binding.path.node;
        object = init.callee.object;
        property = init.callee.property;
        let argument = init.arguments[0].value;
        let arrayFlow = object.value[property.name](argument);
        let resultBody = [];
        arrayFlow.forEach((index) => {
           let switchCase = cases.filter((c) => c.test.value === index)[0];
           let caseBody = switchCase.consequent;
           if (types.isContinueStatement(caseBody[caseBody.length - 1])) {
                caseBody.pop();
           }
           resultBody = resultBody.concat(caseBody);
        });
        path.replaceWithMultiple(resultBody)
    }
})

const { code: output } = generate(ast);
console.log(output)
// 输出如下:
const s = '3|1|2'.split('|');
let x = 0;
const c = 0;
const a = 1;
const b = 3;
posted @ 2024-05-15 18:05  脱下长日的假面  阅读(33)  评论(0编辑  收藏  举报