使用 AST 技术还原混淆代码

在上一节中,我们介绍了 AST 相关的基本知识和基础的操作方法,本节中我们就来实际应用这些方法来还原 JavaScript 混淆后的代码,即一些反混淆的实现。

由于 JavaScript 混淆方式多种多样,这里就介绍一些常见的反混淆方案,如表达式还原、字符串还原、无用代码剔除、反控制流平坦化等。

表达式还原

有时候,我们会看到有一些混淆的 JavaScript 代码其实就是把简单的东西复杂化,比如说一个布尔常量 true,被写成 !![];一个数字,被转化为 parseInt 加一些字符串的拼接。通过这些方式,一些简单又直观的表达式就被复杂化了。

看下面的这几个例子,代码如下:

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

对于这种情况,有没有还原的方法呢?当然有,借助于 AST,我们可以轻松实现。

首先,在 = 的右侧,其实都是一些表达式的类型,比如说 "abc" == "bcd" 就是一个 BinaryExpression,它代表的是一个布尔类型的结果。

怎么处理呢?我们将上述代码保存为 code1.js,根据上一节学习到的知识,可以编写如下还原代码:

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

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

traverse(ast, {
    "UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression": (
        path
    ) => {
        const { confident, value } = path.evaluate();
        if (value == Infinity || value == -Infinity) return;
        confident && path.replacewith(types.valueToNode(value));
    },
});

const { code: output } = generate(ast);
console.log(output);

这里我们使用 traverse 方法对 AST 对象进行遍历,使用 "UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression" 作为对象的键名,分别用于处理一元表达式、布尔表达式、条件表达式、调用表达式。如果 AST 对应的 path 对象符合这几种表达式,就会执行我们定义的回调方法。在回调方法里面,我们调用了 pathevaluate 方法,该方法会对 path 对象进行执行,计算所得到的结果。其内部实现会返回一个 confidentvalue 字段表示置信度,如果认定结果是可信的,那么 confident 就是 true,我们可以调用 pathreplaceWith 方法把执行的结果 value 进行替换,否则不替换。

运行结果如下:

const a = true;
const b = false;
const c = 10;
const d = parseInt("50");

可以看到,原本看起来不怎么直观的代码现在被还原得非常直观了。

所以,利用这个原理,我们可以实现对一些表达式的还原和计算,提高整个代码的可读性。

字符串还原

在 11.1 节中,我们了解到,JavaScript 被混淆后,有些字符串会被转化为 Unicode 或者 UTF-8 编码的数据,比如说这样的例子:

const strings = ["\x68\x6S\x6c\x6C\x6f\x77\x6f\x72\x6C\x64"];

其实这原本就是一个简单的字符串,被转换成 UTF-8 编码之后,其可读性大大降低了。如果这样的字符串被隐藏在 JavaScript 代码里面,我们想通过搜索字符串的方式寻找关键突破口,就搜不到了。

对于这种字符串,我们能用 AST 还原吗?当然可以。

我们先在 https://astexplorer.net/ 里面把这行代码粘贴进去,结果如图 11-83 所示。

图11-83 粘贴代码后的效果

可以看到,两个字符串都被识别成 StringLiteral 类型,它们都有一个 extra 属性。extra 属性里面有个 raw 属性和 rawValue 属性,二者是不一样的,rawValue 的真实值已经被分析出来了。

因此,我们只需要将 StringLiteral 中 extra 属性的 raw 值替换为 rawValue 的值即可,实现如下:

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

const code = fs.readFileSync("code2.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];

这样我们就成功实现了混淆字符串的还原。

如果我们把这个脚本应用于混杂了混淆字符串的 JavaScript 文件,那么其中的混淆字符串就可以被还原出来。

无用代码剔除

在 11.1 节中,我们还了解过其他的混方式,比如说为了使代码的可读性降低,混淆工具会给原来的代码注入一些无用的代码,这些代码本身其实无法被执行。

这里还是拿 11.1 节的样例来介绍,代码如下:

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();

这里首先声明了两个方法,最后分别调用,而且两个方法内部都有一些 if else 语句。比如,第一个 if 语句的判定条件是 !![[]],乍看起来并不能直观地看出它的真实值到底是多少,其实这里有一个双重否定,后面紧跟一个二维数组 [[]]。由于 [[]] 本身就是一个非空对象,加上双重否定之后结果就是 true。第二个 if 语句的判定条件则是一个字符串的判断,前者 "xmv2nOdfy2N".charAt(4) 其实就是字符 nString.fromCharCode(110) 就是把 110 这个 ASCII 码转换为字符,结果也是 n,而判定符又是 !==,所以整个表达式的结果就是 false

所以说,第一个方法其实执行的是 if 对应的区块,else 对应的区块是不会被执行的。第二个方法其实执行的是 else 对应的区块,if 对应的区块是不会被执行的。不会被执行到的代码其实是余的,起到一些干扰作用,加大我们分析代码的难度。

对于这种情况,我们也可以使用 AST 来把一些僵尸代码去除。

首先,我们把上述代码贴到 https://astexplorer.net/ 分析一下。选中第一个方法里面的 if 语句,如图 11-84 所示,可以看到它对应的就是一个 IfStatement 节点,它有 typestartendloctestconsequentalternate 这几个属性,其中 test 就是指 if 判定语句,就是 !![[]]consequent 就是 if 对应的代码区块,alternate 就是 else 对应的代码区块。

图 11-84 选中第一个方法里面的 if 语句

所以,这里我们可以实现如下还原代码:

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

const code = fs.readFileSync("code3.js", "utf-8");
let ast = parse(code);

traverse(ast, {
    IfStatement(path) {
        let { consequent, alternate } = path.node;
        let testPath = path.get("test");
        const evaluateTest = testPath.evaluateTruthy();
        if (evaluateTest == true) {
            if (types.isBlockStatement(consequent)) {
                consequent = consequent.body;
            }
            path.replaceWithMultiple(consequent);
        } else if (evaluateTest === false) {
            if(alternate != null) {
                if (types.isBlockStatement(alternate)) {
                    alternate = alternate.body;
                }
                path.replaceWithMultiple(alternate);
            } else {
                path.remove();
            }
        }
    }
});

const { code: output } = generate(ast);
console.log(output);

这里我们定义了一个 IfStatement 的处理方法:首先获取到 path 对应节点的 consequentalternate 属性,然后拿到 test 属性对应的 path,赋值为 testPath,接着调用 testPathevaluateTruthy 方法,evaluateTruthy 方法可以返回对应 path 的真值。比如说,对于第一个 if 判定语句 !![[]],它的值是 true,那么 evaluateTruthy 方法返回的结果就是 true

如果是 true 的话,应该怎么办呢?很简单,直接将整个 path 替换成 consequent 对应的节点就好了。也就是说,对于第一个方法,原本是:

if (!![[]]) {
    console.log("hello world");
} else {
    console.log("this");
    console.log("is");
    console.log("dead");
    console.log("code");
}

直接替换成:

console.log("hello world");

所以,原本不被执行到的代码就被完全删除了,同时 ifelse 语句也被删除了,最后只剩下可以被执行到的代码。

最后的运行结果如下:

const _Ox16c18d = function() {
    console.log("hello world");
};

const _0x1f7292 = function() {
    console.log("nice to meet you");
}

_0x16c18d();
_0x1f7292();

可以看到,无用代码被剔除了,代码变得非常精简,可读性大大增强。

反控制流平坦化

另外,在 11.1 节中,我们还看到一种混淆方式,叫作控制流平坦化,其实就是把原本正常执行的逻辑顺序进行了混淆,通过一些 if else 或者 switch 语句进行拆分,这导致我们不能很直观地看到各个代码区块执行的顺序。

还是拿之前的样例,代码如下:

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;
}

可以看到,这里首先定义了一个 s 变量,其中使用 split 方法对字符串进行分割,结果其实就是 ["3","1","2"],然后配合使用 whileswitch 语句,这里判定 s[x++] 变量,每执行一次循环,它的结果就会变一次,三次循环分别就是 3、1、2,然后每次循环都匹配对应的 case 语句并执行不同的语句。

所以说,代码真正的执行顺序其实是:

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

而经过控制流平坦化之后,代码原本的执行顺序就被混淆了,我们一眼不能看出真正的执行顺序。

要进行代码的还原,我们就需要做如下处理。

  • 首先找到 Switch 语句相关节点,拿到对应的节点对象,比如各个 case 语句对应的代码区块。

  • 分析 switch 语句判定条件 s 变量的对应的列表结果,比如将 "3|1|2".split("|") 转化为 ["3", "1", "2"]

  • 遍历 s 变量对应的列表,将其和各个 case 语句进行匹配,顺序得到对应的代码区块并保存。

  • 用上一步得到的代码替换原来的代码即可。

上述思路虽然看起来是专门为当前示例代码设计的还原方案,但其实其对应的逻辑就是混淆工具 obfuscator 的常用套路,都是先用一个类似 bac 这样的字符串,然后调用 split 方法得到一个列表,再使用 Switch 语句来匹配列表的每一个元素并执行对应的代码。所以,上述解决方案其实也可以算作较为通用的解决方案。

接下来,我们分析一下。首先,还是把上述代码粘贴到 https://astexplorer.net/ 分析一下,while 语句就不再赞述了,它就是一个无限循环。我们看看 switch 语句的结构,如图 11-85 所示。

图 11-85 switch 语句的结构

可以看到。它是一个 SwitchStatement 节点,带有 discriminantcases 两个属性:前者就是判定条件,对应的就是 s[x++];后者就是三个 case 语句,对应的是三个 SwitchCase 节点。

所以我们先尝试把可能用到的节点获取到,比如 discriminantcasesdiscriminantobjectproperty,相关代码如下:

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;
    },
});

图 11-86 展开 object

先拿到这个节点的 name 属性,添加如下代码:

let arrName = object.name;

这其实是一个数组,那么它原始的定义在哪里呢?其实在上面的声明语句里,就是 consts = "3|1|2".split("|");。那么我们知道了 s,怎么拿到其原始定义呢?我们可以使用 scope 对象的 getBinding 方法获取到它绑定的节点,添加如下代码:

let binding = scope.getBinding(arrName);

其实这个 binding 就对应 "3|1|2".split("|"); 这段代码。

我们再选中这段代码,可以看到它是一个 CallExpression 节点,如图 11-87 所示。

图 11-87 CallExpression 节点

这里我们怎么获取它的真实值呢?其实就是使用 "3|1|2" 调用 split 方法即可。我们可以分别逐层拿到对应的值,然后进行动态调用,添加如下的代码:

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);

上面这几行代码其实就等同于调用了 "3|1|2".Split("|"),只不过这里面的值是我们从节点里面动态获取的。所以,这里 arrayFlow 的值就是 ["3","1","2"] 了。

后面怎么处理呢?我们只需要遍历这个列表,找出对应的 case 语句对应的代码即可。由于遍历的执行是有顺序的,所以最终拿到的每个 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);
});

这里我们声明了一个 resultBody 变量用于保存匹配到的 case 对应的代码,同时还把 continue 语句移除了。

最后,resultBody 里面就对应了三块代码:

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

这样原本的代码顺序就被我们还原出来了。

最后,我们只需要把最外层 path 对象的代码替换成 resultBody 对应的代码即可,添加如下代码:

path.replaceWithMultiple(resultBody);

最终整理一下,完整代码如下:

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

const code = fs.readFileSync("code4.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;

可以看到,原本控制流平坦化的代码就被还原得清晰又简洁,而且代码的执行顺序也一目了然,这样我们就实现了反控制流平坦化。

总结

在本节中,我们通过四个案例讲解了利用 AST 还原混淆代码的过程。案例虽然基础,但是其中的思路值得深人研究。有了 AST 的加持,很多混淆代码都有机会被还原得更加简洁、易读,从而能大大降低我们逆向代码的难度。