最近在研究一些 XSS 蠕虫的时候遇到了类似如下代码混淆:
观察其代码风格,发现这个混淆器做了这几件事:
- 字符串字面量混淆:首先提取全部的字符串,在全局作用域创建一个字符串数组,同时转义字符增大阅读难度,然后将字符串出现的地方替换成为数组元素的引用。
- 变量名混淆:不同于压缩器的缩短命名,此处使用了下划线加数字的格式,变量之间区分度很低,相比单个字母更难以阅读。
- 成员运算符混淆:在 Javascript 中,window[‘top’] 和 window.top 是等价的。混淆器便利用这一特性,将成员访问复杂化,首先替换成字符串,然后对字符串进行混淆。
经过我的搜索,这样的代码很有可能是通过 javascriptobfuscator.com 的免费版生成的。其中免费版可以使用的三个选项(Encode Strings / Strings / Replace Names)也印证了前面观察到的现象。
这些变换中,变量名混淆是不可逆的。如果程序能智能到自动给变量命名,不仅 IDA 的 F5 工具会更好用,也能给有命名恐惧症的程序员节省不少时间呢。说回来变量名替换可以通过人工标注的方式,使用 IDE(如 WebStorm)的代码重构功能,结合代码行为分析和自己的理解进行手工重命名还原。
而字符串的还原是否可以使用脚本进行自动化呢?答案是肯定的。 要对一段代码进行静态分析或者更进一步执行,我们需要一个 parser 来获得代码的抽象语法树(Abstract Syntax Tree,AST),也就是源代码的抽象语法结构的树状表现形式。通过 AST 我们可以对代码进行分析或者修改(重构),比单纯的正则匹配更准确且和具有通用性。 在这里我使用了 esprima 作为词法解析工具。其接口很简单,调用一个静态方法即可:
var ast = esprima.parse('var a = /hello\s+world/;');
esprima 返回的语法树的具体格式可以参考其文档。另外 Esprima 提供了一个在线工具,可以把任意(合法的)Javascript 代码解析成为 AST 并输出: esprima.org/demo/parse.html
要实现具体的行为分析和代码替换,还得对语法树进行遍历。可以直接手写树的遍历(非递归、递归方式),不过使用与 esprima 同门的 estraverse 将更为简单。Estraverse 的接口给我的感觉有点像 PULL 方式解析 XML。Estraverse 提供两个静态方法,estraverse.traverse
和 estraverse.replace
。前者单纯遍历 AST 的节点,通过返回值控制是否继续遍历到叶子节点;而 replace 方法则可以在遍历的过程中直接修改 AST,实现代码重构功能。
回到之前的代码混淆上。其中的字符串将会被提取到一个全局的数组,在语法树中我们可以观察到这样的特征: 在全局作用域下,出现一个 VariableDeclarator
,其 init
属性为 ArrayExpression
,而且所有元素都是 Literal
。这说明这个数组所有元素都是常量。我简单地将其还原为字符串数组,并用 hash 表与变量名(标识符)关联起来。
接下来进入第二个 pass,也就是将数组元素的引用替换为原本的字面量(内联)。取数组成员的表达式将被解析为 MemberExpression
节点,其 property
即是下标。在这里下标直接取了数字,我们直接读出先前暂存的数组内容,替换上去即可。如果混淆器再猥琐一点,是可以无限次迭代,将数字继续展开为更复杂的表达式的(如 2 转换为 (Math.log(1024) / Math.log(2)) / (Math.pow(2, 2) + 1)
)。
说个题外话,其实作用域管理是有现成的模块(escope)。对付这个混淆器可以简单用一个计数器来处理作用域的深度,判断变量否在全局作用域声明。事实上这里简化了处理。在 Javascript 中,作用域链上存在变量名的优先级,全局上的变量名是可以被局部变量重新定义的。如果混淆器再变态一点,在不同的作用域上使用相同的变量名,对付起来就复杂了。
最后一步是将 AST 重新转回字符串的形式。同样地,你也可以手动遍历树来还原代码,但这个轮子已经有了,同属 estools 出品的 escodegen 可以轻松实现。
以下是 PoC 代码,需要使用 node.js 执行。稍作修改也可以在浏览器里跑。
/** * Author: ChiChou * * Deobfuscate code generated by free version of * JavascriptObfuscator (https://javascriptobfuscator.com/Javascript-Obfuscator.aspx) * * Usage: node deobfuscator.js file.js>output.js * */
var esprima = require('esprima');
var estraverse = require('estraverse');
var escodegen = require('escodegen');
function shouldSwitchScope(node) {
return node.type.match(/^Function(Express|Declarat)ion$/);
}
function main(fileName) {
var code = require('fs').readFileSync(fileName).toString();
var ast = esprima.parse(code);
var strings = {};
var scopeDepth = 0; // initial: global
// pass 1: extract all strings
estraverse.traverse(ast, {
enter: function(node) {
if (shouldSwitchScope(node)) {
scopeDepth++;
}
if (scopeDepth == 0 &&
node.type === esprima.Syntax.VariableDeclarator &&
node.init &&
node.init.type === esprima.Syntax.ArrayExpression &&
node.init.elements.every(function(e) {return e.type === esprima.Syntax.Literal})) {
strings[node.id.name] = node.init.elements.map(function(e) {
return e.value;
});
this.skip();
}
},
leave: function(node) {
if (shouldSwitchScope(node)) {
scopeDepth--;
}
}
});
// pass 2: restore code
ast = estraverse.replace(ast, {
enter: function(node) {},
leave: function(node) {
// restore strings
if (node.type === esprima.Syntax.MemberExpression &&
node.computed &&
strings.hasOwnProperty(node.object.name) &&
node.property.type === esprima.Syntax.Literal
) {
var val = strings[node.object.name][node.property.value];
return {
type: esprima.Syntax.Literal,
value: val,
raw: val
}
}
if (node.type === esprima.Syntax.MemberExpression &&
node.property.type === esprima.Syntax.Literal &&
typeof node.property.value === 'string'
) {
return {
type: esprima.Syntax.MemberExpression,
computed: false,
object: node.object,
property: {
type: esprima.Syntax.Identifier,
name: node.property.value
}
}
}
}
});
console.log(escodegen.generate(ast));
}
main(process.argv[2]);