使用 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 对象符合这几种表达式,就会执行我们定义的回调方法。在回调方法里面,我们调用了 path 的 evaluate 方法,该方法会对 path 对象进行执行,计算所得到的结果。其内部实现会返回一个 confident 和 value 字段表示置信度,如果认定结果是可信的,那么 confident 就是 true,我们可以调用 path 的 replaceWith 方法把执行的结果 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) 其实就是字符 n,String.fromCharCode(110) 就是把 110 这个 ASCII 码转换为字符,结果也是 n,而判定符又是 !==,所以整个表达式的结果就是 false。
所以说,第一个方法其实执行的是 if 对应的区块,else 对应的区块是不会被执行的。第二个方法其实执行的是 else 对应的区块,if 对应的区块是不会被执行的。不会被执行到的代码其实是余的,起到一些干扰作用,加大我们分析代码的难度。
对于这种情况,我们也可以使用 AST 来把一些僵尸代码去除。
首先,我们把上述代码贴到 https://astexplorer.net/ 分析一下。选中第一个方法里面的 if 语句,如图 11-84 所示,可以看到它对应的就是一个 IfStatement 节点,它有 type、start、end、loc、test、consequent、alternate 这几个属性,其中 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 对应节点的 consequent 和 alternate 属性,然后拿到 test 属性对应的 path,赋值为 testPath,接着调用 testPath 的 evaluateTruthy 方法,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");
所以,原本不被执行到的代码就被完全删除了,同时 if 和 else 语句也被删除了,最后只剩下可以被执行到的代码。
最后的运行结果如下:
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"],然后配合使用 while 和 switch 语句,这里判定 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语句进行匹配,顺序得到对应的代码区块并保存。 -
用上一步得到的代码替换原来的代码即可。
|
上述思路虽然看起来是专门为当前示例代码设计的还原方案,但其实其对应的逻辑就是混淆工具 |
接下来,我们分析一下。首先,还是把上述代码粘贴到 https://astexplorer.net/ 分析一下,while 语句就不再赞述了,它就是一个无限循环。我们看看 switch 语句的结构,如图 11-85 所示。
图 11-85 switch 语句的结构
可以看到。它是一个 SwitchStatement 节点,带有 discriminant 和 cases 两个属性:前者就是判定条件,对应的就是 s[x++];后者就是三个 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;
},
});
图 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;
可以看到,原本控制流平坦化的代码就被还原得清晰又简洁,而且代码的执行顺序也一目了然,这样我们就实现了反控制流平坦化。