知道创宇安全研究团队 ChiChou:2015.7.28
最近在研究一些 XSS 蠕虫的时候遇到了类似如下代码混淆:
观察其代码风格,发现这个混淆器做了这几件事:
经过我的搜索,这样的代码很有可能是通过 javascriptobfuscator.com 的免费版生成的。其中免费版可以使用的三个选项(Encode Strings / Strings / Replace Names)也印证了前面观察到的现象。
这些变换中,变量名混淆是不可逆的。如果程序能智能到自动给变量命名,不仅我们的 F5 工具会更好用,也能给有命名恐惧症的程序员节省不少时间呢。说回来变量名替换可以通过人工的方式,使用 IDE(如 WebStorm)的代码重构功能,结合代码行为分析进行手工重命名还原。
而字符串的还原是否可以使用脚本进行自动化呢?答案是肯定的。 要对一段代码进行静态分析或者更进一步执行,我们需要一个 parser 来获得代码的抽象语法树(Abstract Syntax Tree,AST),也就是源代码的抽象语法结构的树状表现形式。通过 AST 我们可以对代码进行分析或者修改(重构),比单纯的正则匹配更准确且更通用。 在这里我使用了 esprima 作为词法解析工具。其接口很简单,使用一个静态方法即可:
var ast = esprima.parse('var a = /hello\s+world/;');
关于 esprima 返回的语法树的具体格式,可以参考其文档。另外 Esprima 提供了一个在线工具,可以把任意(合法的)Javascript 代码解析成为 AST 并输出: http://esprima.org/demo/parse.html 要实现具体的行为分析和代码替换,还得对语法树进行遍历。
可以直接手写树的遍历(非递归、递归方式均可),不过使用与 esprima 同门的 estraverse 将更为简单。Estraverse 的接口给我的感觉有点像 PULL 方式解析 XML。Estraverse 提供两个静态方法,estraverse.traverse 和 estraverse.replace。前者单纯遍历 AST 的节点,通过返回值控制是否继续遍历到叶子节点;而replace方法则可以在遍历的过程中直接修改 AST,实现代码重构功能。
我们回到之前的代码混淆上。其中的字符串将会被提取到一个全局的数组,在语法树中我们可以观察到这样的特征: 在全局作用域下,出现一个 VariableDeclarator,其init 属性为ArrayExpression,而且所有元素都是 Literal。这说明这个数组所有元素都是常量。我们简单地将其求值,与变量名(标识符)关联起来。
接下来进入第二个 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]);
为了不误伤全局变量声明,我在替换字符串之后,并没有移除那个由混淆器额外添加上去的全局字符串表。
以下样本是随手从 YOU MIGHT NOT NEED JQUERY 上摘录下来的:
我们来尝试还原。用 node.js 执行:node deobfuscator.js obfuscated.js>deobfuscated.js
可以看到除了变量名还是一如既往的恶心外,代码已经可以阅读了。