本文仅供学习交流,因使用本文内容而产生的任何风险及后果,作者不承担任何责任
目录
一、前言-叠甲
- 这篇blog的目的是学习&分享一下ast解类ob混淆的思路和流程,并不是以拿到最终算法为目的。也可以对混淆样本做预处理之后用线上ob混淆网站进行解混淆。
- 算法比较简单,甚至不用解混淆就可以得到,或者ai梭哈,但是这些不是这篇blog的目的。看到这里“ai逆天逆地哥”可以移步了。
- ai的洪流下,一些基本的手艺还是要有的,不然,ai梭哈完全就是一个黑盒。
- 我刚开始是看的这篇大佬的文章https://www.52pojie.cn/thread-2062929-1-1.html,可能太简单了,过程并不是特别详细,我会一步一步的相对比较详细的还原混淆。
- 本文章适合有一定ast语法基础的同学,其实掌握一定的ast语法基础之后,解混淆类似这种难度一般的混淆,边琢磨边写就可以解决问题,本文章主要分享一下在解混淆“路上”可能和大佬们的不同想法。
- 国内网站不提供完整的算法和还原脚本,只做思路的分析和学习。
二、简单分析混淆样本
目标网站
aHR0cHM6Ly9odW5hbi56Y3lnb3YuY24vbHViYW4vYW5ub3VuY2VtZW50L2xpc3Q=
触发cookie生成
进入网站之后,v2-cookie已经生成了,触发翻页的请求中,已经携带了该cookie,可见图2-1。

图2-1查看开发者工具中的目标cookie
拿到v2-cookie生成的混淆脚本
清空缓存中的cookie,重新抓包,第一个请求包返回的混淆js计算生成的v2-cookie且发送请求并未携带目标cookie,见图2-2,图2-3;第二个请求包就携带了生成的v2-cookie发送了正常的请求见图2-4。

图2-2 目标请求包的响应混淆js代码

图2-3 发送目标请求包并未生成目标cookie

图2-4 生产目标cookie后发送请求并携带
把混淆代码拿到本地分析都有哪些混淆类型
这是我解完混淆之后处理的大致类型,就是ob混淆一些常见的类型,见图2-5,特殊情况的话,解密函数不是一些常见的大数组+数组位移+加密函数,但是这个也差不多。
也可以做了前期的初步处理之后用一些线上的ob混淆解混淆网站去解,这里就自行分析,写ast脚本去解混淆。

图 2-5 处理的混淆类型
三、分析混淆
常量折叠和进制&unicode还原
首先处理常量折叠,把混淆代码放进本地编辑器,映入眼帘的就是各种进制以及对应的常量计算,见图3-1,并打开astexplorer针对这部分进行分析,查看语法树结构发现主要为一元和二元表达式,见图3-2。

图 3-1 混淆样本

图 3-2 查看常量混淆样本的语法树
还原思路:遍历BinaryExpression UnaryExpression,使用evaluate得到二元/一元计算之后的值,若confident为真(得到一个确定的结果),对当前的path做节点的替换。
注意:遍历的时候需要判断逗号表达式,若存在逗号表达式,则可能会伤害语义。
const { confident, value } = path.evaluate();
常量折叠的插件,不做完整展示了,差一个逗号表达式的检测函数,自行用ai补上。
/*
* var a = 1 + 2; // => var a = 3;
* var b = "hello" + " world"; // => var b = "hello world";
* var c = 8 > 7; // => var c = true;
* var d = 5 << 1; // => var d = 10;
* var e = !![]; // => var e = true;
* var f = !0; // => var f = true;
* var g = ~1; // => var g = -2;
* var h = +[]; // => var h = 0;
* var i = String.fromCharCode(66); // => var i = "B"; // 这个不做还原 CallExpression
* var j = "AST is Good!".length; // => var j = 12; // 这个不做还原 MemberExpression
* var k = Math.tan(30); // => var k = -6.405331196646276; // 这个不做还原 CallExpression
*
* 以下情况会跳过:
*
* var m = 1 / 0; // 结果为 Infinity,非有限数字,不替换。
* var n = -123; // 一元负号被当前过滤条件跳过。
* var o = void 0; // void 被当前过滤条件跳过。
* var p = (doSomething(), 1 + 2); // 子树含 SequenceExpression,不处理。
* var q = unknownValue + 1; // evaluate 的 confident 为 false,不处理。
* var r = unknownFunction(); // 无法静态确定调用结果,不处理。
*
* path.evaluate() 只有在 confident 为 true 时才使用 value;如果结果是函数或非有限数字也不会替换。
*/
const constantFold = {
// 注释这两种表达式的目的是 不对 String.fromCharCode(66); "AST is Good!".length; Math.tan(30); 这种进行还原
// "BinaryExpression|UnaryExpression|MemberExpression|CallExpression"(path) {
"BinaryExpression|UnaryExpression"(path) {
// 当前表达式中是否含有逗号表达式 防止逗号表达式引起删除的副作用而改变语义
// 比如 var result = ( sendRequest(), 1 + 2 ) * 4;
// 如果不检测逗号表达式 会直接还原为 var result = 12; 使用这个插件前可以先用逗号表达式处理一下 使还原的能更加彻底
if (containsSequenceExpression(path)) {
return;
}
// 跳过一元表达式的负号 -123,-OX10,-(1+2) ---> -123,-OX10,-(3)
if (path.isUnaryExpression({ operator: "-" }) ||
path.isUnaryExpression({ operator: "void" })) {
return;
}
const { confident, value } = path.evaluate();
// 跳过无法确定的结果 和 计算值是一个函数的值 confiden===true 表示是一个确定的结果 ,但是逗号表达式中 var result = ( sendRequest(), 1 + 2 ) * 4; 这样 它同样会识别为true 所以在上述过滤中把逗号表达式过滤了
if (!confident || typeof value == "function")
return;
// 如果结果是数字,进入下一步isFinite的判断,跳过非有限数字 比如 1 / 0 就不会被替换
if (typeof value == 'number' && (!Number.isFinite(value))) {
return;
}
console.log('常量折叠---',path.toString(),"--->",value);
path.replaceWith(types.valueToNode(value));
},
}
然后进制和unicode进行还原,图3-3为大致的进制还原的情况。

图3-3 进制和unicode常见的混淆情况
还原思路:遍历NumericLiteral和StringLiteral ,识别哪些是需要还原的进制,可以用正则进行匹配,“/^0[obx]/i”用来匹配进制,“/\[ux]/gi”用来匹配Unicode,匹配到对应需要还原的对象之后,直接取path.node.extra.rawValue的值,然后做节点替换。

图 3-4 执行(遍历)还原插件
常量折叠和进制/unicode还原其实可以固定在ast编写模版中了,很多混淆刚开始都可以预处理一下这两种混淆。
花指令还原-第一步
什么是“花指令”
花指令是将常量和对应的操作封装为对象属性,从而掩盖真实语义的一种混淆手段。其典型结构如图 3-5 所示,混淆器构造一个属性对象,键为无意义标识符,值为数字、字符串或函数,原代码中的直接引用被替换为对象.属性的间接访问形式(如 vk.z、vk.E、vk['test'])。

图3-5 花指令对象属性示例
花指令通常存在嵌套。如图 3-6 所示,对象 vk 的属性不仅出现在表达式中,还会作为另一对象属性值的一部分出现,例如 'ijDxc': L(vk.z) + 's'。通常这种嵌套结构的花指令,我惯用的还原方法是循环调用几次还原插件,从而实现彻底还原对应混淆结构。

图3-6 花指令的调用及嵌套展示示例
花指令预处理-成员变量合并
混淆器为了切断对象声明与属性之间的联系,常将花指令对象的属性拆分到多条成员赋值语句中(如 obj.key = value),而非集中写在对象字面量内。由于还原插件依赖对 ObjectExpression 属性的遍历来建立"键 → 值"映射,字面量之外的属性无法被纳入映射,会导致部分引用还原失败。
由于这个混淆样例/样本中并未涉及到成员变量合并,我只举一个简单的例子。
// 混淆形态:花指令对象的属性被拆散——一部分在声明时给出,一部分通过成员赋值追加
var flowerObject = {
first: 'gfy'
};
flowerObject['second'] = 'love'; // 字面量之外的属性追加
flowerObject.third = 'you';
// 使用处:和正常花指令一样,通过 对象.键 间接引用
console.log(flowerObject.first + ' ' + flowerObject.second + ' ' + flowerObject.third);
// 输出:gfy love you
// 但是在做花指令还原处理的时候,遍历还原插件的时候,对于一些键是要从对象
// 中取对应的值的,second third 这些键虽然可以用,但是在语法树中无法统一取值
// 需要先把这些成员合并到统一的一个对象中
// 合并后:所有属性回到对象字面量中,还原插件可以一次性处理
var flowerObject = {
first: 'gfy',
second: 'love',
third: 'you'
};
// 方便后续还原花指令
还原思路
我的还原思路和一些大佬的还原思路有所不同,我对于花指令字符串和花指令函数一般分为两步进行还原,第一步为把所有字符串、数字、函数在被引用处做节点替换,此时针对花指令字符串/数字的还原已经完成,第二步为针对函数的处理,把对应的花指令函数替换之后的函数值计算出来,拿花指令的函数值对引用处的节点做替换。
这个小结主要完成花指令还原的第一步;
①识别花指令对象这个结构,如图3-7,traverse遍历VariableDeclarator结构,同时判断id节点为Identifier以及init节点为ObjectExpression。针对这个混淆样本已经可以把对应的花指令对象结构过滤出来了,不再做其他冗余的收紧/过滤条件比如判断init.properties的长度等等。
if(!(types.isIdentifier(id.node) && types.isObjectExpression(init.node))){
return
}

图 3-7 花指令对象的抽象语法树
②做一个映射存各个对象内的键值对,方便后续在遍历这些对象引用的时候取键对应的值,并做节点替换。
function get_map(init_path){
const k_v_map = new Map()
for(let i of init_path.get('properties')){
const k = i.get('key').node.name || i.get('key').node.value;
const v = i.get('value').node;
k_v_map.set(k,types.cloneNode(v))
}
return k_v_map
}
③遍历花指令对象的引用位置
// 获取花指令对象的变量名
const name = id.node.name;
// 拿到这个变量的绑定
const binding = path.scope.getBinding(name);
// 上一步中的得到花指令对象的键值映射
const map_ = get_map(init);
// ⭐️ 这里binding.referencePaths 这个属性为这个对象的所有引用的path,遍历所有引用位置
for(let i of binding.referencePaths){
// 此处的i一般为变量的标识符 处理替换一般拿到的是Identifier,做处理一般拿到标识符的父节点成员表达式的path
const parentPath = i.parentPath;
// 接下来的代码稍后处理
// ...
// ...
}
④ 做花指令的值和引用部分的节点替换,比如
const gfy = {
'love': 'you';
}
const lover = gfy.love ;
// 替换为
const lover = 'you' ;
// 函数部分的替换与当前替换类似,不再做举例。
完整插件
const restore_flower_string = {
VariableDeclarator(path){
const id = path.get('id');
const init = path.get('init');
if(!(types.isIdentifier(id.node) && types.isObjectExpression(init.node))){
return
}
// console.log(path.toString())
const name = id.node.name;
const binding = path.scope.getBinding(name);
if(!binding){
return
}
// 在这里的时候直接把obj做一个映射
const map_ = get_map(init);
for(let i of binding.referencePaths){
const parentPath = i.parentPath;
if(!types.isMemberExpression(parentPath.node)){
continue
}
// 如果是赋值表达式则跳过
if(parentPath.parentPath.isAssignmentExpression() && parentPath.key === "left"){
continue
}
const property = parentPath.get('property')
const k = property.node.name || property.node.value;
if(!k){
console.log('花指令字符串还原中 无',parentPath.toString(), '---', k)
continue
}
const replace_node = map_.get(k)
if(!replace_node){
console.log('花指令字符串还原中 原字符串属性无对应的对象',parentPath.toString(), '---', k)
continue
}
// 因为replace_node这里已经是节点对象了 所以不能用path.tostring()进行输出了 可以用 generator(replace_node).code 输出节点对象的源码
console.log('花指令字符串还原',parentPath.toString(),'---', generator(replace_node).code)
// 每次替换时 重新克隆一个新的节点 这个非常非常非常重要 不然存在多次调用的时候 用的都是同一个节点对象 进行修改的时候会对其进行污染导致语义还原错误
parentPath.replaceWith(types.cloneNode(replace_node,true));
}
}
}
字符串解密及其标识符别名还原
简单举例子介绍这些是怎样的混淆
在典型的ob混淆中,字符串加密通常有三件套构成解密函数对字符串进行解密。三件套分别为大数组+数组位移+加密函数。其中 console.log(decryptString(0) + ' ' + decryptString(1) + ' ' + decryptString(2)); 即可视为字符串加密之后的混淆代码。
// ① 大数组:代码中的字符串被全部抽离,集中存到一个数组里
var stringArray = ['love', 'you', 'gfy'];
// ② 数组移位:自执行函数把数组"轮转",打乱下标与字符串的对应关系
(function (arr, n) {
while (n--) {
arr.push(arr.shift());
}
})(stringArray, 2);
// 移位后数组实际为 ['gfy', 'love', 'you'],静态阅读无法直接对应
// ③ 解密函数:所有字符串引用统一改为"函数 + 下标"的间接形式
function decryptString(index) {
return stringArray[index];
}
// 原始代码:console.log('gfy' + ' ' + 'love' + ' ' + 'you')
// 混淆代码
console.log(decryptString(0) + ' ' + decryptString(1) + ' ' + decryptString(2));
// 输出:gfy love you
标识符别名+字符串加密,就是不再用decrypt做直接的字符串解密,而是用其他变量嵌套的命名,比如下边demo中,aliasOne、aliasTwo、aliasThree均为解密函数,都可以对字符串进行解密,为了更方便的用统一一个加密函数的标识符,需要对标识符别名进行还原。
// 字符串加密:字符串统一抽离到数组中,使用时经解密函数取回
// (真实样本中数组里存的是密文、函数内是解密算法;demo 为突出结构,直接用明文演示)
var encryptedArray = ['gfy', 'love', 'you'];
function decrypt(index) {
return encryptedArray[index];
}
// 标识符别名:解密函数被多次转授,层层改名掩盖真实身份
var aliasOne = decrypt;
var aliasTwo = aliasOne;
var aliasThree = aliasTwo;
// 内置对象同样被换名
var myConsole = console;
// 原始代码:console.log('gfy' + ' ' + 'love' + ' ' + 'you')
myConsole.log(aliasThree(0) + ' ' + aliasThree(1) + ' ' + aliasThree(2));
// 输出:gfy love you
在当前混淆样本中字符串加密以及标识符别名的痕迹
发现D1(588),D1(596)...等等符合字符串加密的格式,且D1 = L,如图3-8,向上溯源L,L = d,且 L(510)也有加密,如图3-9,继续向上溯源,找到d函数即为所需的解密函数,如图3-10。

图3-8 字符串加密痕迹1

图3-9 字符串加密痕迹2

图3-10 溯源到解密函数d
还原思路
先对标识符别名进行还原,然后针对字符串加密,一般的做法是把解密函数以及对应的辅助函数扣到单独的文件中,然后导入解密函数的文件,调用解密函数对字符串加密的每一处进行解密-节点替换,从而实现还原。
标识符别名还原
这里用了递归,为了把链式的标识符别名还原彻底,直接把通用插件做展示,不讲解,自行理解。
function solve_IdentifierName(path) {
const id = path.node.id;
const init = path.node.init;
if (!types.isIdentifier(id) || !types.isIdentifier(init)) {
return;
}
const binding = path.scope.getBinding(id.name);
if (!binding || binding.constantViolations.length !== 0) {
return;
}
// ① 先把链上更外面的别名消到当前名
for (const referencePath of [...binding.referencePaths]) {
const parentPath = referencePath.parentPath;
if (
parentPath.isVariableDeclarator() &&
referencePath.key === "init"
) {
solve_IdentifierName(parentPath);
}
}
// ② 递归回来后重新取引用,再换成原名
const bindingAfter = path.scope.getBinding(id.name);
if (!bindingAfter) {
return;
}
console.log('标识符别名还原---',id.name, "--->", init.name);
for (const referencePath of [...bindingAfter.referencePaths]) {
referencePath.replaceWith(types.cloneNode(init));
}
}
const IdentifierElseName = {
VariableDeclarator(path){
solve_IdentifierName(path)
}
}
traverse(ast, IdentifierElseName)

图3-11 标识符别名还原运行日志
拿到解密函数及辅助/配套函数
解密函数配套函数可见图3-12,注意点1导出函数前,需要向a这个栈中push一个种子175,这个种子参与后续的计算。

图3-12 解密函数文件展示
字符串解密
标识符别名还原完成之后见图3-11,字符串加密处调用的函数都是解密函数d,看调用解密函数的语法树见图3-13,只需要遍历CallExpression,且只需要判断callee.node.name == 'd' 即可,就可以把所有的字符串加密过滤出来,然后调用3.5取的解密函数,将 arguments[0].node.value 解密函数的入参值传入扣出的解密函数拿到字符串的值,做节点替换即可。
const decryptString = {
CallExpression(path){
const callee = path.get('callee')
const arguments = path.get('arguments')
const callee_name = callee.node.name;
if(callee_name !== 'd'){
return
}
if(arguments.length !== 1){
return
}
const argument = arguments[0].node.value;
const v = decrypt(argument);
console.log('字符串解密---',path.toString(),'---',decrypt(argument))
path.replaceWith(types.valueToNode(v))
}
}
const decrypt = require('./decrypt')
traverse(ast, decryptString);

图3-13 加密函数调用语法树

图3-14 字符串解密运行日志
再做一次常量折叠以及花指令字符串还原
做完字符串解密之后,后续需要再做一次变量折叠,比如 var a = d(1) + d(2); ---> var a = 'g' + 'fy'; ---> var a = 'gfy'; 见图3-14。
比如J[d(211)] ---> J["Jnqvw"], 这还是一个花指令字符串的格式,对象.键,所以再进行若干次花指令字符串还原(防止有嵌套)。
traverse(ast, constantFold);
// // 花指令字符串还原
for(let i = 1; i<=2;i++){
console.log('第',i,'次花指令字符串还原')
traverse(ast, restore_flower_string);
console.log('--------------------------')
}

图3-15 常量折叠及花指令再次还原日志
花指令还原-第二步函数还原
这一步还原的目标混淆代码类型是什么
存在诸下述代码的情况,这种就是对于花指令对象E,如图3-16,原混淆代码是 M = E'dgqzn' 进行花指令第一步还原(我习惯叫做'花指令字符串还原')之后的产物,,花指令还原第二步就是针对这种函数还原的。
M = function (F, t) {
return F + t;
}(function (F, t) {
return F + t;
}(this['p'](), '-'), M["toLowerCase"]())
// 期望还原为
M = this['p']() + '-' + M["toLowerCase"]();

图3-16 花指令对象E
还原思路
可见图3-17,遍历 CallExpression 节点,注意,存在很多嵌套结构的花指令函数,故遍历exit 。过滤出目标花指令函数的过滤条件,如下示代码,然后把花指令函数的形参在函数体内引用的位置,全部用实参做替换(注意作用域为当前函数的call作用域)。替换完毕之后,把函数体内return 语句,把语句替换整个花指令函数,语句已经是实参替换完之后的了。
// 保证callee是函数表达式
if(!types.isFunctionExpression(callee.node)){
return
}
// 保证形参数量和实参数量相同
if(arguments.length !== params.length){
return
}
// 花指令函数的 return 唯一且位于body.body.0
if(!types.isReturnStatement(callee.get('body').node.body[0])){
return
}
// 自执行的入参不为0
if(path.node.arguments.length === 0){
return
}

图3-17 花指令函数嵌套结构的抽象语法树
花指令函数完整的还原插件,还原运行日志如图3-18所示。
const restore_flower_func = {
CallExpression:{
exit(path){
const callee = path.get('callee')
const arguments = path.get('arguments')
const params = callee.get('params')
// 保证callee是函数表达式
if(!types.isFunctionExpression(callee.node)){
return
}
// 保证形参数量和实参数量相同
if(arguments.length !== params.length){
return
}
// 花指令函数的 return 唯一且位于body.body.0
if(!types.isReturnStatement(callee.get('body').node.body[0])){
return
}
// 自执行的入参不为0
if(path.node.arguments.length === 0){
return
}
console.log(path.toString());
console.log('\n -------');
// 用实参数把函数体内的形参引用的位置全部做一个替换
// 形参
for(let i =0; i<params.length; i++){
const current_param = params[i];
const current_param_name = current_param.node.name;
const current_argument = arguments[i];
const current_argument_node = types.cloneNode(current_argument.node, true);
// 遍历形参的引用
// 遍历时 注意作用域 这里不应该用path 应该用callee
const binding = callee.scope.getBinding(current_param_name);
if(!binding){
continue
}
// 在形参引用的位置 用实参做替换
for(let j of binding.referencePaths){
j.replaceWith(current_argument_node, true)
}
}
// 形参实参替换完之后 取return表达式的替换整个调用表达式
// callee.get('body').get('body') 这一步取到的是body的路径列表
const return_argument = callee.get('body').get('body')[0].node.argument;
// cloneNode 参数 true 是深克隆的意思 会复制当前节点以及它下面的全部子节点。
path.replaceWith(types.cloneNode(return_argument,true))
}
}
}

图3-18 花指令函数还原运行日志
控制流平坦化
简单介绍什么是控制流
由于这个混淆样本中的控制流是很常规的类型,故按照混淆样本的控制流类型举例,特征就是在while/for的循环中的switch中,按照状态跳转数组,不断在case块中跳转,把原有的顺序代码打乱,从而起到混淆的作用。
var first = 'gfy';
var second = 'love';
var third = 'you';
console.log(first + ' ' + second + ' ' + third);
// 输出:gfy love you
// 控制流混淆之后
// 状态数组:真正的执行顺序藏在这里
var states = '2|0|3|1'.split('|');
var i = 0;
while (true) {
switch (states[i++]) {
case '0':
var second = 'love';
continue;
case '1':
console.log(first + ' ' + second + ' ' + third);
continue;
case '2':
var first = 'gfy';
continue;
case '3':
var third = 'you';
continue;
}
break;
}
// 输出:gfy love you
在混淆样本中控制流的痕迹
如图3-19,这个混淆样本中只有两处控制流,但是由于cookie主要的生成算法就是位于这个控制流中,所以对其进行平坦化。

图3-19 混淆样本中真实的控制流痕迹
还原思路
如图3-20所示,遍历 WhileStatement ,当前混淆样本的控制流过滤条件为1. while(值),这个对应的值恒真,可用 test.evaluateTruthy() 判断 2. while下即为 SwitchStatement 。
过滤出来对应的控制流之后,接下来的还原思路是:
-
找到控制流中真正的跳转顺序(在此混淆样本中是数组)比如 "6|4|2|3|1|5|0""split"
-
将switch中,case的索引值和对应的语法代码块存为映射,比如下示代码,可存为 0-- M = this['p']() + '-' + M["to....)(M); 的映射关系,注意映射关系中的值,要用Node对象 ,比如 map_.set(value, types.cloneNode(consequent[0].node)) 。
-
创建一个数组用来存正确顺序的代码块,遍历正确的跳转顺序,从映射中拿到对应索引值的代码块,push进数组中,直至遍历完毕。
-
用整个数组(数组内应全为Node对象类型)替换整个WhileStatement , path.replaceWithMultiple(temp_) ,要用多值替换。
case '0':
M = this['p']() + '-' + M["toLowerCase"](), (void 0 !== j && (j["nothing2reload10ba"] || j["reload"]) || function () {})(M);
continue;

图3-20 控制流的抽象语法树
// 拿到case索引值和对应节点对象的映射
function get_map_case(SwitchStatementPath){
const case_list = SwitchStatementPath.get('cases');
const map_ = new Map();
for(let i of case_list){
const value = i.get('test').node.value;
const consequent = i.get('consequent');
map_.set(value, types.cloneNode(consequent[0].node))
}
return map_
}
const restore_controlFlow = {
WhileStatement(path){
const test = path.get('test');
const body_body = path.get('body').get('body')
// 这里我记得是有一个方法 能直接判断出这个里的布尔真假 一会找找看
// 判断while恒真
if(test.node.value !== true){
return
}
// 判断while下紧接着就是switch块 符合控制流结构
if(!types.isSwitchStatement(body_body[0].node)){
return
}
// 定位到switch内状态跳转变量
const switchStatement = body_body[0];
const situation_skip_name = switchStatement.get('discriminant').get('object').node.name;
const binding = path.scope.getBinding(situation_skip_name);
if(!binding){
return
}
const skip_array = eval(binding.path.toString());
// 接下来把switch块和跳转序号做一个对应关系
const map_ = get_map_case(switchStatement)
// console.log(map_)
// 遍历跳转变量
const temp_ = [];
for(let i of skip_array){
temp_.push(map_.get(i))
}
path.replaceWithMultiple(temp_)
}
}
// // 控制流还原
traverse(ast, restore_controlFlow);
另类的花指令函数
对控制流平坦化之后,发现还存在不同的花指令函数,如图3-21.

图3-21 存在不同类型的花指令函数
上边的步骤不是已经对花指令函数处理了吗?为什么还存在
观察两种类型的花指令函数,可从花指令属性对象看,如图3-22和图3-23,在ob混淆中更常见的是图3-22中的情况,函数中只有一行return ,实参做替换之后,直接对调用节点做替换,而上述处理的也是这种类型。
见图3-23,当前混淆样本中特有的花指令函数,多了try-finally结构,且多了对a的入栈出栈语法,所以上述的还原插件对于这种不同的类型不起效。

图3-22 常见的花指令函数对象属性

图3-23 存在当前样本中的花指令对象属性
还原思路
方法1,在混淆源码中把多出的结构全部删除,之前的插件同样可对这种进行处理还原。
方法2,还原思路和上述的相同,只不过重新定位return 即可。
const restore_flower_func_specially = {
CallExpression:{
// exit:内层壳先换成表达式,外层壳再替换,链式调用一轮就能消掉
exit(path){
const callee = path.get('callee')
const arguments = path.get('arguments')
if(!types.isFunctionExpression(callee.node)){
return
}
const params = callee.get('params')
if(arguments.length !== params.length){
return
}
// 这个样本的栈壳函数体只有一条 try/finally。try 里有 for 的外层函数不能消
const body = callee.get('body').get('body')
if(body.length !== 1 || !body[0].isTryStatement() || !body[0].node.finalizer){
return
}
const tryPath = body[0]
// finally 只有 a.pop()
const finallyBody = tryPath.get('finalizer').get('body')
if(finallyBody.length !== 1 || !isAPop(finallyBody[0].node)){
return
}
// return 在 try 最后一条;前面只允许 a.push(175)、d、var x = d
const tryBody = tryPath.get('block').get('body')
const returnPath = tryBody[tryBody.length - 1]
if(!returnPath || !returnPath.isReturnStatement() || !returnPath.node.argument){
return
}
for(let i = 0; i < tryBody.length - 1; i++){
if(!isSeed(tryBody[i].node)){
return
}
}
console.log(path.toString());
console.log('\n -------');
// 和 restore_flower_func 一样:形参引用换成实参。每次都 clone,避免同一个实参节点被挪走
for(let i = 0; i < params.length; i++){
const binding = callee.scope.getBinding(params[i].node.name);
if(!binding){
continue
}
for(let j of [...binding.referencePaths]){
j.replaceWith(types.cloneNode(arguments[i].node, true))
}
}
path.replaceWith(types.cloneNode(returnPath.node.argument, true))
}
}
}
死代码剔除
什么是死代码
比如以下恒真/假语句,从不会执行的语句,无引用的变量等等。
var first = 'gfy';
// ① 恒假 if:整个分支永远不会执行
if (false) {
console.log('hate');
}
// ② 恒真 if:else 分支是死代码
if (1 + 2 === 3) {
var second = 'love';
} else {
var second = 'hate';
}
// ③ 恒定三元:条件恒真,永远取左侧
var third = true ? 'you' : 'hate';
// ④ 无引用变量:声明了但从未使用(花指令还原后最常见的残留)
var unused = 'hate';
function show() {
console.log(first + ' ' + second + ' ' + third);
return;
// ⑤ 不可达语句:return 之后永远不会执行
console.log('hate');
}
show();
// 输出:gfy love you
死代码剔除之后
var first = 'gfy';
var second = 'love';
var third = 'you';
function show() {
console.log(first + ' ' + second + ' ' + third);
return;
}
show();
// 输出:gfy love you
在混淆样本中的体现
这些本是花指令的属性对象,但是经过上述的还原操作之后,这些对象已经无引用,灰色的变量标识符,即在混淆文件中找不到对应的用法。可以剔除。

如图3-24 死代码展示
还原思路
总结了通用的剔除死代码插件,会放在附件中。

如图3-24 死代码剔除插件人运行日志展示
四、算法与运行
解混淆之后的算法
解混淆之后的算法如图4-1所示,由于这个cookie直接分析算法即可,算法可用即不需要做文件替换来验证解混淆的脚本是否伤害到了语义,正常ob混淆需要做响应文件的替换来验证解混淆是否成功,主要是不要伤害到语义。

图4-1 解混淆之后的算法展示
运行
用re提取 arg1 的值作为入参,经过算法生成cookie,得到成功的响应,如图4-2所示。

图4-2 算法生成cookie运行界面