AST还原ob混淆
碎碎念
上期说过要出一个AST还原分析,但是拖了好久,为啥呢?因为没劲,为啥会没劲呢?因为还原的意义不大,而且使用古法手工还原不仅费眼睛还慢还会错,扔给AI分析一下,马上就帮你还原好了。在这种时候坚持古法,基本上是吃力不讨好。
而且这个还原没什么复杂的思维过程(不过也可能是我菜,不知道其中的巧思;如有,还望大佬评论区指点一二),更多人的卡点是在于不会用语法。就比如看到表达式return 3+4,那肯定知道要还原成7,可是如何使用代码去还原,这个就不会了。再比如看到肯定不会执行的死代码,那肯定知道要删掉啊,可怎么用代码删除呢?不会了。我个人感觉大部分人是被语法卡住了。那么有必要学习语法吗?额,不好说,我个人认为这种直接交给AI就行了,没必要劳神苦思,我古法还原(小部分还是依靠AI)纯粹是过一下还原思路。
再者就是AST没有一个固定的标准,还原到什么程度算成功???答案是无所谓,你觉得OK就行,懂了原理压根儿不用还原,在几个关键位置自己手动算一下就行了,其他的非关键点全是干扰,看都不用看;心大的,挑几个类型还原一下,看着输入了就进入下一阶段开始分析了;胆子小的/强迫症的,不行,必须严格按照流程,所有的混淆都要还原。。。只能说仁者智者,丰俭由君~
定位ob混淆位置
因为我们现在要分析的就是s的生成逻辑,很自然在s.window.mnsv2处断住
然后单步执行进入mnsv2观察生成逻辑
嗯,看到这密密麻麻的十六进制表示和数组索引调用,我们就知道这个js被混淆了。。。
直接copy到本地,我们要开始AST还原喽
先不要急着还原,让我们来识别一下具体进行了哪些类型的混淆
字符串混淆
缘起
这个字符串数组怎么来的?为什么出现这三者意味着字符串混淆?字符串混淆为了啥?
举个例子,假设我们现在在逆向一个login接口,我们只知道接口中存在校验参数X-Sign ,我们按照一贯的套路,肯定是先使用搜索大法(搜索大法未必是最快的定位方式,这里包括之后都为演示需要),看看能不能定位到X-Sign的生成逻辑
倘若JS文件中的代码如下o
function login(username, password) {
var token = generateToken(username, password);
fetch("https://api.xxx.com/login", {
method: "POST",
headers: {
"X-Sign": token
}
});
}
token = func(xxx)
那么显然,X-Sign的值来自于token,下一步我只需要继续搜索token即可,逆向过程畅通无阻。
那么作为防护者,肯定是不希望我们这么轻松的逆向出生成逻辑,他就想🤔好嘛,你喜欢搜索是吧,好好好,那我就让你搜索不到,是的,就是这么朴实无华。
字符串混淆诞生
那么如何让搜索失效呢?不如把这个X-Sign使用base64编码的结果WC1TaWdu替换吧?嗯哼,好像可以诶,这样如果你直接搜索X-Sign啥也搜不出来,然后每次我自己执行的时候我就调用一下base64解码,我发送的时候还是正常请求。因此混淆后的代码就变成了
function login(username, password) {
var token = generateToken(username, password);
var headerName = atob("WC1TaWdu");
var requestOptions = {
method: "POST",
headers: {}
};
requestOptions.headers[headerName] = token;
fetch("https://api.xxx.com/login", requestOptions);
}
上面是初级混淆,有经验的逆向者一眼看破,不就是调了个base64嘛,轻松还原,你能调我也能调,pass~
解密函数诞生
防守者:“靠,我应该如何继续升级呢???上次被破解是因为逆向者发现我调用了base64,完成了定位,我该如何让他发现不了我调用base64呢?
诶,不如我把base64这个解密函数抽离出来吧,我也别叫他atob了,我弄个十六进制的变量名就好了。让这个变量等于atob,但是叫做abto还是会被发现啊,我直接通过方括号属性访问器在window中取值就好了诶,方括号中可以是任意字符串,这里能做文章的地方就多了去了。我可以把atob 这四个字符通过转义字符的方式转换成对应的ASCII码值。所以混淆后的代码如下
var _0x4a7f = window['\x61\x74\x6f\x62'];
function login(username, password) {
var token = generateToken(username, password);
// Step 2: Use the aliased function instead of the original 'atob'
var headerName = _0x4a7f("WC1TaWdu");
var requestOptions = {
method: "POST",
headers: {}
};
// Step 3: Assign and send
requestOptions.headers[headerName] = token;
fetch("<https://api.xxx.com/login>", requestOptions);
}
逆向者:嗯哼,有点意思,所有的类base64字符串之前都跟着一个_0x4a7f ,那么大概率这玩意儿就是解密函数,打点验证一下,嗯,没毛病,pass~
防守者:。。。靠,我该怎么办,解密函数这里已经穷尽变化了,再怎么变也变不出花来了,那么想要获得最终结果,需要输入&解密函数,那么现在只能在输入上做文章了,逆向的视角每次看到JS中的base64字符串就知道这里要调用解密函数,实在是可恶,我要把JS中所有能用字符串代替的东西都抽离出来,放到一起,这样就算你看到了类base64字符串,你也不知道哪里调用了,而我把那些抽离出来的字符串存放在一个数组中,在调用的地方通过数组索引取到对应的字符串,然后再调用解密函数,这样不就完美了嘛?简单写一下就类似于decrypt(string_array[index])
所以升级完整的代码如下
var _0x5a2e = ["WC1TaWdu", "POST", "<https://api.xxx.com/login>"];
var _0x4a7f = window['\x61\x74\x6f\x62']; // window['atob']
function login(username, password) {
var token = generateToken(username, password);
// _0x5a2e[0] is "WC1TaWdu"
var headerName = _0x4a7f(_0x5a2e[0]);
var requestOptions = {
method: _0x5a2e[1],
headers: {}
};
requestOptions.headers[headerName] = token;
fetch(_0x5a2e[2], requestOptions);
}
字符串数组&自执行函数诞生
但是到这儿也有个问题,就是逆向者只需要根据索引在数组中找到对应的字符串再调用解密函数即可,还是会被比较简单攻破,还有什么方法呢???既然薄弱点事因为索引和字符串对应,那么我们直接让索引和字符串不对应,这样逆向者根据索引取到的字符串不就是错误的吗,诶嘿,这样就能增加难度了
那么如何保证静态分析的时候索引对应错误字符串,但是执行的时候又正常呢?简单,我们可以在代码执行对开始的时候对数组进行初始化,将数组中错误的顺序还原成正确的顺序,然后在之后的计算中就能够正确运行了,概括一下就是
string_array=[]
string_array=sorted(string_array)
string=decrypt(string_array[index])
为了继续恶心逆向者,我再把索引改成十六进制,这样一来看起来就让人亚历山大,说不定逆向者扫了一眼就觉得这玩意儿难的一批,直接开摆了,所有最终的代码如下
// Step 1: The Scrambled Array (被打乱顺序的字典,故意把 URL 放在了第一位)
var _0x5a2e = ["<https://api.xxx.com/login>", "WC1TaWdu", "POST"];
// Step 2: The Self-Invoking Restorer Function (自执行还原函数,sorted 逻辑)
(function(arr, shiftCount) {
// 当 shiftCount 大于 0 时,不断循环
while (shiftCount--) {
// 把数组开头的元素拿出来,塞到数组末尾去
arr.push(arr.shift());
}
})(_0x5a2e, 0x1);
// 0x1 就是数字 1。执行一次后,第一个元素跑到最后,数组就变回了正确的 ["WC1TaWdu", "POST", "<https://api.xxx.com/login>"]
// Step 3: The aliased decrypt function
var _0x4a7f = window['\x61\x74\x6f\x62']; // window['atob']
function login(username, password) {
var token = generateToken(username, password);
// Step 4: Use hex indexes to fetch the correctly sorted string, then decrypt
// 此时的 _0x5a2e[0x0] 已经在上面被还原成了 "WC1TaWdu"
var headerName = _0x4a7f(_0x5a2e[0x0]);
var requestOptions = {
// 0x1 就是索引 1,对应 "POST"
method: _0x5a2e[0x1],
headers: {}
};
requestOptions.headers[headerName] = token;
// 0x2 就是索引 2,对应 "<https://api.xxx.com/login>"
fetch(_0x5a2e[0x2], requestOptions);
}
虽然没有引入什么复杂的魔改算法,或者说引入什么清奇的思路,但是无疑能够恶心新手或者说不懂原理的人。这才是第一步,恶心人足够了。
解混淆
嗯哼,这样以来,字符串混淆的前因后果&原理我们就已经十分清楚了,接下来解混淆不过是倒着依葫芦画瓢。所以这个字符串加密不过是拿来恶心人的,并不会显著增加逆向难度,对于了解了原理的读者来说,这个就是个纸老虎,一戳就破了
我们的解混淆思路很简单,先获取到索引正确的字符串数组,然后调用解密函数获取对应的字符串即可。
我们可以简单的输出一下索引正确的字符串数组
嗯,看来我们的思路没毛病,还看到一个有点意思的玩意儿,黄框标记的就是,我嗅到了控制流平坦化的味道哈哈哈哈哈(此处按下不表,后文会有详细分析)
然后我们观察一下如何将字符串还原回去
所以混淆后的统一格式就是_0xe762c0(0x1a)
OK,我们厘清一下思路
通过traverse 遍历代码中所有的CallExpression,如果callee.nam==’_0xe762c0’ 说明就是我们要还原的位置,然后我们获取arguments[0],再传递给解密函数,获取到结果之后使用replaceInline 替换节点。
因此有代码
const handlerDecryptFunc = {
CallExpression(path) {
if (path.node.callee.name !== decrypt.name) return;
const argNode = path.node.arguments[0];
if (!argNode) return;
if (!types.isStringLiteral(argNode) && !types.isNumericLiteral(argNode)) return;
const arg = argNode.value;
const decrypted = decrypt(arg);
const originalExpr = path.toString();
path.replaceInline(types.stringLiteral(decrypted));
console.log(`[解密] ${originalExpr} => "${decrypted}"`);
}
}
运行之后发现。。。没有任何地方被更改???什么鬼???
我们输出日志观察一下
奇怪,怎么没有任何直接调用解密函数的节点???我们再回头分析一下,发现都是间接调用
也就是说我们在进行字符串还原之前还少了一步,验明正身,代码中调用的都是化身。。。
观察可知我们需要遍历所有的VariableDeclarator节点,判断这个变量声明的初始化标识符的name是不是我们的解密函数,如果是的话我们就
假设解密函数是B,变量声明标识符是A
- 获取当前path对应的作用域scope
- 在scope中查询B的绑定
- 在B的绑定中查询所有对B引用的节点
- 对所有引用B的节点进行替换,替换为A
- 删除B路径
很多人可能会问,为啥中间还有个获取binding???直接在作用域里查询name的reference不就好了吗?为啥要多此一举?这是因为name只是一个string,而getBinding能够获取到一个实例对象,只有实例对象才有referencePaths属性,因此getBinding是必不可少的
因此有代码
const handlerSameIdentifier = {
VariableDeclarator(path) {
if (path.node.init?.name === decrypt.name) {
var huashenReferencePaths = path.scope.getBinding(path.node.id.name).referencePaths
for (const referencePath of huashenReferencePaths) {
referencePath.replaceWith(types.identifier(path.node.init.name))
}
path.remove()
}
}
}
我们运行后检查输出
成功删除化身~
然后我们再配合解密函数还原字符串混淆
可以看到输出了一大堆日志,输出文件中也看到了正确的字符串,那么初步判定还原字符串解混淆成功~
函数字典还原
接下来映入眼帘的就是一个超长的字典,键都是字符串,值的类型基本上是函数调用,间或字符串
那这个难度也不高,我们只需要找到所有调用字典的地方,然后手动完成字典查询,再根据值的类型生成对应的节点即可
函数调用还原
我们先看一下字典值的类型为函数调用的情况
因此我们有思路
- 遍历所有的CallExpression
- 如果callee.type是MemberExpression且callee.object.name是dict.name
- 获取callee.property.value
- 获取CallExpression.arguments
- 获取当前path的作用域,然后获取dict的绑定,在绑定中获取dict的所有properties
- 遍历dict的所有properties,如果key == callee.property.value,获取对应的value
- 使用value和arguments重构当前path
但是先不要着急,我们继续观察,同为函数调用,却又两种不同的形式
当arguments数量≥2时,第一个参数永远是函数名,因此我们再构造节点的时候还需要通过arguments数量来判定传入参数列表的起点。
我们再观察,虽然同为函数表达式,但是有的却是内部在进行二元运算,这两者在还原的时候肯定是不同的,因此我们再观察
可以看到函数表达式参数的类型不再是CallExpression了,而是BinaryExpression
因此针对这种分支,我们思路如下
- 获取二元运算的操作符operator
- 获取传入的参数
- 替换原始path
因此有代码
var handlerDict2Func = {
CallExpression: {
exit(path) {
// 确认 callee 是字典的成员访问,如 _0x4d21fc["GoWMj"]
if (!types.isMemberExpression(path.node.callee)) return;
const dictName = path.node.callee.object?.name;
if ("_0x4d21fc" != dictName) return;
const binding = path.scope.getBinding(dictName);
if (!binding) return;
const propertyKey = path.node.callee?.property?.value;
if (!propertyKey) return;
const properties = binding?.path?.get("init.properties");
for (const property of properties) {
if (property.node.key.value !== propertyKey) continue;
// 取 return 语句的 argument
const returnArgument = property.node.value.body.body[0]?.argument;
if (!returnArgument) continue;
const originalExpr = path.toString();
if (types.isCallExpression(returnArgument)) {
// 函数调用模式:第一个参数是被调用的函数,其余是实参
path.replaceWith(types.callExpression(path.node.arguments[0], path.node.arguments.slice(1)));
console.log(`[字典-函数调用] ${originalExpr} => ${path.toString()}`);
} else if (types.isBinaryExpression(returnArgument)) {
// 二元运算模式:还原运算符和两个操作数
const operator = returnArgument.operator;
path.replaceWith(types.binaryExpression(operator, path.node.arguments[0], path.node.arguments[1]));
console.log(`[字典-二元运算] ${originalExpr} => ${path.toString()}`);
}
break;
}
}
}
}
运行一下看看效果
但是我们观察输出的解混淆文件,发现虽然目标字典已被还原,但是还有大大小小好几个其他的字典文件,咋办呢?其实很简单,我们把所有文件归制成一个列表,然后命中就进行解混淆即可。
dictList **=** ["_0x4d21fc"**,** "_0x52517b"**,** "_0xc2212b"**,** "_0x2c7266"**,** "_0x4f7875"]
嗯哼,还原完成,非常完美
字符串还原
接下来我们需要还原的就是字典中的值为字符串类型的了
这个十分的简单,判定是否为还原点位的逻辑和上面一样,然后找到在字典properties中对应的值,直接还原即可。
有代码
// 替换字典中的字符串属性访问
// 如:_0x4d21fc["XrKsV"] → "hello"(字典中该 key 对应的 value 是字符串字面量)
var handlerDict2Str = {
MemberExpression(path) {
const dictName = path.node.object.name;
if (!dictList.includes(dictName)) return;
const binding = path.scope.getBinding(dictName);
if (!binding) return;
const propertyKey = path.node.property?.value;
if (!propertyKey) return;
const properties = binding.path.get("init.properties");
for (const property of properties) {
if (property.node.key.value !== propertyKey) continue;
// 只处理 value 是字符串字面量的属性(函数类型由 handlerDict2Func 处理)
// 注意:不加 isStringLiteral 守卫,因为节点经过前面 handler 处理后类型可能有变化
const propertyValue = property.node.value.value;
if (typeof propertyValue !== "string") continue;
const originalExpr = path.toString();
path.replaceWith(types.stringLiteral(propertyValue));
console.log(`[字典-字符串] ${originalExpr} => "${propertyValue}"`);
break;
}
}
}
运行后
看起来还是十分赏心悦目的
删除无用字典
量不大甚至可以考虑手动删除,量大了批量删除
有代码
// 删除字典变量声明(在 handlerDict2Func/handlerDict2Str 替换完所有引用后执行)
var handlerRemoveDict = {
VariableDeclarator(path) {
const dictName = path.node.id?.name;
if (!dictList.includes(dictName)) return;
path.remove();
console.log(`[删除字典] "${dictName}" 声明已移除`);
}
};
还原控制流平坦化
function _0x31ad27(_0x541033, _0x534897, _0x12161e, _0x1e583c, _0xaf8777, _0x31bb79, _0x40f090, _0x19443e) {
var _0x28aafb = "3|1|2|0|4|5".split("|"),
_0x2577c8 = 0;
while (true) {
switch (_0x28aafb[_0x2577c8++]) {
case "0":
for (_0x5f1d55["$" + _0x31ff41] = _0x5f1d55, _0x219876 = 0; _0x219876 < _0x31ff41; _0x219876++) _0x5f1d55[_0x13a1af = "$" + _0x219876] = _0xaf8777[_0x13a1af];
continue;
case "1":
null == _0x31bb79 && (_0x31bb79 = this), _0xaf8777 && !_0xaf8777.d && (_0xaf8777.d = 0, _0xaf8777.$0 = _0xaf8777, _0xaf8777[1] = {});
continue;
case "2":
var _0x5f1d55 = {},
_0x31ff41 = _0x5f1d55.d = _0xaf8777 ? _0xaf8777.d + 1 : 0;
continue;
case "3":
var _0x13a1af, _0x219876;
continue;
case "4":
for (_0x219876 = 0, _0x31ff41 = _0x5f1d55.length = _0x1e583c.length; _0x219876 < _0x31ff41; _0x219876++) _0x5f1d55[_0x219876] = _0x1e583c[_0x219876];
continue;
case "5":
return _0x19443e && _0x509192[_0x534897], _0x509192[_0x534897], _0x1233dd(_0x541033, _0x534897, _0x12161e, 0, _0x5f1d55, _0x31bb79, null)[1];
}
break;
}
}
非常典型的控制流平坦化
我们看到正确的执行顺序被封装在一个字符串"3|1|2|0|4|5"里,而这个字符串正是之前还原出来的,所以前置的还原步骤还是非常有必要的
我们通过split进行分割之后按照数字顺序进行执行
也就是说这段代码的执行顺序是3、1、2、0、4、5
那么我们的思路就是将switch中的跳转执行代码转换成顺序执行代码
-
识别到这个while块&switch块
也就是说条件必须是while块中的body[0]必须是switch块
-
获取执行顺序
我们观察能够发现
所以接下来的任务就是获取`while`节点的兄弟节点
我们可以通过`path.key`来获取`while`节点在其父节点的子节点列表中的索引
那么对应的执行顺序字符串节点的索引就是`path.key -1`
然后我们使用path.getSibling()方法获取到这个执行顺序节点的path
我们对包含执行顺序节点的path中的进行遍历
在遍历的过程中我们遍历所有的`StringLiteral`节点
如果这个节点存在`”|”`,我们就认为这个节点就是我们要找的执行顺序节点
然后我们拿去对应的value并通过stop停止冒泡
然后通过split方法获取真正的执行顺序序列
- 获取switch所有分支
- 进入cases
- foreche
- 获取key
- 获取case中的Statement/Expression
3. 构建caseMaps
- 按执行顺序取元素并添加到数组
- 使用数组替换while&switch节点
- 删除while的兄弟节点,也就是存储执行顺序的节点
- 输出日志
有代码
// 处理控制流平坦化
var handlerWhileSwitch = {
WhileStatement(path) {
// Step 1: Ensure the while loop contains a switch statement
let switchNode = path.node.body.body[0];
if (!types.isSwitchStatement(switchNode)) return;
// Step 2: Locate the password book (the string sequence)
// We look at the sibling node just before the while loop
let prevSibling = path.getSibling(path.key - 1);
let sequenceString = "";
// Extracting "3|1|2|0|4|5" from the variable declaration
prevSibling.traverse({
StringLiteral(strPath) {
if (strPath.node.value.includes("|")) {
sequenceString = strPath.node.value;
strPath.stop();
}
}
});
if (!sequenceString) return;
console.log("[控制流平坦化] 找到执行顺序 => " + sequenceString);
// Step 3: Parse the sequence into an array
let sequenceArray = sequenceString.split("|");
// Step 4: Map each case to its corresponding statements
let caseMap = {};
switchNode.cases.forEach(c => {
let key = c.test.value;
// Filter out 'continue' statements from the block
let statements = c.consequent.filter(node => !types.isContinueStatement(node));
caseMap[key] = statements;
});
// Step 5: Reconstruct the execution flow linearly
let reconstructedStatements = [];
sequenceArray.forEach(key => {
if (caseMap[key]) {
reconstructedStatements = reconstructedStatements.concat(caseMap[key]);
}
});
// Step 6: Replace the while loop with the flattened statements
path.replaceWithMultiple(reconstructedStatements);
// Step 7: Clean up the useless variables used for the switch dispatch
prevSibling.remove();
console.log("[控制流平坦化] Control flow deobfuscation completed successfully!");
}
}