某红书-4.3.2-JSVMP-mns0301详细分析
接口关键字:dXNlcl9wb3N0ZWQ=
碎碎念:
由于这是笔者的第二次完整分析(为什么是第二次呢,这就说来话长了),所以简单的内容不再赘述。本文的重点在于不使用AST对ob混淆进行还原,直面ob混淆下的JSVMP,通过日志插桩的方式,分析出mns0301的生成逻辑。
不出意外,应该还会有一篇文章讲一下通过AST还原最外层JS文件的ob混淆,时间待定,希望能在下周提交2333。不过其实逆向完了发现解混淆确实不是必须的,只要明白了原理,混不混淆意义不大,更多的是一种心理上的压力。
文章中所有内容仅供学习交流使用,不用于其他任何目的,抓包内容、敏感网址、数据接口均已做脱敏处理。严正声明禁止用于商业和非法用途,否则由此产生的一切后果与作者本人无关。
下面的文章是“形”,这里讲一下VMP的内核⬇️
先说插桩位置
最简单vmp=>apply,然后依靠经验(连蒙带猜)
中级vmp=>apply+运算符,然后分析日志(查看结构性运算)
难度再增加,apply+运算符+全局栈,然后仔细分析日志+玄学
Q&A
Q:为什么要对VMP进行插桩?为什么对apply和运算符进行插桩?
A:VMP的目的在于代码混淆 + 虚拟化
当原始代码为
function encrypt(a, b) {
return a b + 1;
}
VMP会将其变为
h[r[++p]] = h[r[++p]].apply(h[r[++p]], l);
此时原始的调用关系已经完全不可见了。
这个函数调用现在变成了字节码中的一段字节,
而对于函数中的运算=>a b + 1
在运行过程中根据 opcode 跳转到对应的运算分支(乘法分支、加法分支)逐条执行。
因此,在常规代码中我们可以直接阅读函数体来理解其功能;
而在 VMP 中,我们看不到任何函数结构,只能观察到:
- opcode 的变化
- PC的跳转
- 栈顶指针的移动
- 栈顶元素的修改
某次执行了乘法运算,某次执行了加法运算
这意味着我们无法静态分析,只能通过动态插桩 + 日志的方式,记录运行时的行为,再从日志中反推出原始函数的功能。
所以,选择运算符处进行插桩,是为了捕获每一次算术/逻辑运算的操作数和结果,还原计算过程。
又因为
有一部分底层操作仍然依赖原生 JS 函数(如 Math.random、Array.prototype.push 等)。
这些原生函数的调用会通过 apply 发起,因此在 apply 处插桩可以捕获所有对外部原生函数的调用,补全日志中缺失的关键信息。
再多一句嘴,对于较复杂的VMP,不要一次性插全部的桩或者说一次性全点位输出日志,
先搞清楚大致流程
再分段分块插桩会好很多
同时巧用条件断点,对一段流程多次调试生成多份日志,对照分析;
Q:我有好几份日志,但是VMP中拿时间戳或者rand进行初始化,我每次获得的日志不一样啊!?
A:通过console注入,hook rand和Date.now()或者具体使用到的函数。如果多次取用时间戳,like ts_1\ts_2\ts_3这种的
在进入关键分许区域断住,然后此时开始hook,如此可以清晰的看到哪个点位调用了ts_i,ts_i+1
在分析过程中要给自己增加信心!直面困难,见招拆招~
arr_144生成方式分析
arr_20
arr_4
在window中取得_dsn对应的值,并将其转换为ASCII码
arr_16 = func(arr_24)
又是拼接而成的
[0-7]
uint32_to_LE_list(TIMESTAMP **&** 0xFFFFFFFF)
uint32_to_LE_list(math**.**floor(TIMESTAMP **/** 0x100000000))
[8-23]
[int(md5_str[i **:** i **+** 2]**,** 16) **for** i **in** range(0**,** len(md5_str) **-** 1, 2)]
arr_24→arr_16
定位计算位置
我们先定位到运算位置
点击函数,发现带我们来到了一个VM中
那么想必转换就是在这里完成的
这一看就是一个JSVMP,而且还是带有ob混淆的
那么接下来肯定逃不过插桩,但是我们现在所处的位置是VM
根本无法修改和打点
显然我们接下来需要找到这个源代码所在位置
确定代码来源
我们先取消代码格式化,看看它的本来面目
第一行非常有辨识度,我们直接copy然后全局搜索
发现结果是来自于一个网络请求
嗯哼,那么我们直接替换就行(没想到google居然把Mock的功能做进了Chrome中)
日志插桩(不依靠AST解混淆)
在Mock之前我们还需要把日志点插入到代码中
但是这里存在ob混淆。。。我们又没有自动化解混淆脚本
虽然之前写的那个改一改能用,但是额,总归还是觉得心累(其实是懒2333)
我们想一想能不能在不依靠AST的情况下进行插桩呢?
思考。。。
小红书的ob混淆很有特征,基本上赋值就是stack[stack_top_pointer] = stack[stack_top_pointer] operator value
那么理论上我们可以通过全局搜索进行打点
能够做到以上前提的是我们能够识别出stack[stack_top_pointer]
我们先观察整个代码
那么我们如何识别栈呢???这个其实特别好办,因为在进入VMP之前,需要先初始化栈,我们我们只要在VMP开始的位置进行寻找即可
OK,那么我们现在还欠缺什么?
运算相关的日志我们已经可以打了,但是函数调用,也就是apply,这个我们还没有找到点位。。。
咋办呢?这里我们稍微借用一下AST的知识
我们知道ob混淆会将所有的字符串放进一个大数组中,然后在初始化的时候有一个自执行的函数负责旋转大数组,等到要调用了就通过解密函数去大数组里调用。
根据这个思路,我们可以先动态还原出apply的真实索引,然后看谁调用了这个索引,这样就能顺藤摸瓜找到apply的位置了。
理论可行,开始实战
嗯哼,apply的真实index对应50,也就是0x32
我们在混淆后的代码中搜索,搜索到了7个结果,不多,我们可以一个一个看过去
但是更聪明的做法是,根据ob混淆的尿性,肯定不可能是大数组[0x32]这种形式,大概率是一个解密函数[0x32],而且这个解密函数一般是在当前代码块中var _0x??????? = 全局解密函数,然后在调用的时候形式为_0x???????[0x32],我们特别关注一下这种就行。
我们当前代码全局解密函数为_0x1769
OK,那么当opcode == 1f的时候,就是apply调用的位置
接下来按照惯例打上日志即可
。。。
OK,日志添加完成,我们将JS压缩为一行然后替换响应体
嗯,替换完了,但是运行了之后没效果。。。
发现存在另一个js文件
神奇的是代码一样,那么我们同步替换ds.js即可
替换完成刷新之后,我们可以看到我们要的日志来了
分析日志
在最开始先初始化了4个值
每次从arr_24中取4个元素
将这4个元素视为4个字节,按照小段序拼接为4个字节,对应一个数字,也就是uint32
value = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
我们看第一个拼装,最后的结果是[11][233][84][156]
这其实是一个小段序拼装
在还原过程中建议像我这样输出,如果之后的运算用到了之前的值,可以很方便的使用
之后的分析过程就是依葫芦画瓢,不在此赘述,嫌麻烦直接给AI,AI会说是一个类似chacha20的算法
经过一系列莫名其妙的运算之后(其实感觉是有规律的,我菜,看不出来)
我们又发现了结构性运算
这里面必然有什么奥妙
我们我们猜测,可能是取了4个Uint32,然后把每个Uint32拆分成4个Uint8,这样就能够生成16位Uint8的数组了
我们验证一下
第一组取的是1210961166
转换成16进制为0x482DCD0E
按照两个字节长度进行拆分,[48] [2D] [CD] [0E]
转换成十进制为[72] [45] [205] [14]
嗯哼,如果是小端序存储的话,我们还要进行反序,最后还原一组为[14, 205, 45, 72]
嗯哼,针不戳,一模一样
OK,继续分析,接下来是
接下来的步骤就相当的简单了
我们可以知道103是根据随机数计算而来的
final
arr_20 = arr_4 + arr_16
arr_124
arr_124 = arr_108 + arr_16
arr_108
arr_108 = arr_97 + arr_11
arr_97
arr_97 = arr_44 + arr_53
arr_44
这里的arr_44是由多个长度或4或8的数组拼接而成
[0-3]
固定魔术头[121, 104, 96, 41]
[4-7]
[8-15]
[15-23]
有一点要注意,这里的时间戳比上面的要小。。。
[24-27]
不清晰来自哪里。。。
[28-31]
[32-35]
[35-43]
嗯,我们搜索了半天,发现了这个神似MD5的字符串的来源,是外界传入的参数
至于是不是MD5,我们一会儿研究
这个的计算逻辑是先把MD5字符串对半劈开,取前二分之一,一个8字节,每字节作为一个单位,然后将一个字节看作是一个完整的16进制数,再将这个数与之前计算使用的RAND&0xFF相异
arr_53/arr_a1
最后在开头拼接上长度
arr_11
arr_16
arr_16 = arr_4 + arr_12
arr_4
arr_10
之后的10个值也是固定运算得到的
arr_2
同为固定运算
OK,那么到此为止我们arr_144已经全部分析完成了,接下来我们关注一下接下来的内容
魔改RC4
既然长度没有发生改变,但是内容却发生了变化
排除掉简单的顺序变化,自然想到的就是RC4
但是经过测试,发现这并不是一个标准的RC4
var _0x57c9e7 = function _0x30ce91() {
var _0x9eca1a = _0x5edc27
, _0x50f290 = arguments;
return _0x30ce91[_0x9eca1a(0x53)] > 0x0 || _0x30ce91['ΙII']++,
_0x31ad27(_0x30754b, _0x30ce91[_0x9eca1a(0x7b)], _0x30ce91[_0x9eca1a(0x5b)], _0x50f290, _0x30ce91[_0x4d21fc[_0x9eca1a(0x42)]], this, null, 0x0);
};
我们跳转到_0x30ce91进行查看整个函数组成
开始是一个赋值语句,紧接着就是return
return中是逗号运算,最后是一个函数调用,这才是我们需要关注的
我们在这个位置打上断点
根据已有经验,当生成位mns0301、mns0101时,会出现这个
所以我们有选择的开闭断点
进来之后我们发现这特么的又是个ob+jsvmp
要是时间充裕的话自然可以先还原再插桩
但是我们不妨先跟着看看,毕竟我们现在只是在追踪RC4
并不是要还原所有的逻辑
这个地方已经相当接近算法位置了,应该跟一下能看到一些眉目
嗯哼,跟踪了一会儿,果然还是要被绕晕了
难道真的又要来一遍ob解混淆???补药啊,我没有自动化脚本啊呜呜呜呜
我们再仔细观察,我们刚才是在func(arr_144)的日志处发现了这个函数,
现在在这里被断住了,但不一定就是func(arr_144)啊,可能是这个虚拟机里的其他函数调用啊
毕竟根据我们刚才跟栈的时候,这里是vmp初始化的地方,我们再看一下日志
此时并没有在func(arr_144),说明我们还没有到位置,让我们一边关注日志信息,一边追踪
放开了几次,直到出现如上图
我们发现arr_144已经生成了,而外层虚拟机已经取到了_1619d69735e1d480a72d7e01c4a40b7f
感觉差不多就是这个函数了,我们此时应该开始跟踪了
一直跟一直跟,发现了
这个while循环似曾相识啊,哦,和外层一样,也是在初始化虚拟机
那么这个虚拟机初始化完成后,会是什么呢
我们继续跟踪
没跟几步,这个执行顺序已经被解密出来了
完成初始化之后就发现了一个全新的函数
丢给AI一询问,嗯哼,有点意思,AI认为是RC4
看来我们找到了加密函数了嘻嘻
为什么AI认为这个就是一个RC4呢?
因为有一个256的sbox ⇒c
然后每轮都会更新状态,每次又会向一个数组内push进当前轮的运算结果的一部分
我们可以打个日志观察一下
我们拿着日志让AI分析一下,然后生成代码即可
完美,这一部分验收通过~
base64魔改
我们可以看到在经过RC4转换之后变成了一个字符串
众所周知,当流式加密结束后输出的也是字节流而非字符串
那么一般来说,我们将字节→字符到时候,都会用到什么?
答对了,base64
那么大厂一般会怎样?
答对了,魔改编码表
我们再来定位一下,发现被跳转到了一个新的js文件中
啧啧啧,又是ob混淆+JSVMP
我们来跟一下栈
看看会发生什么
嗯哼,基本上可以确定就是这个位置
我们开始跟一下栈
嗯哼,没戏,跳转了几下就开始头晕了。。。
我们还是老老实实日志插桩吧
因为我们现在高度怀疑这个base64只是更换了编码表,根据以往的经验,我们只需要对apply位置进行插桩即可。
转换成16进制就是87,我们查找一下对87的应用
很好,就5项,我们分析一下
大概率就是这里,我们直接插桩试一下
PS:非要验证一下也没问题
嗯哼,ez,就是这儿了
依照之前的方法,我们找到apply的调用位置,然后替换代码,刷新页面,观察一下输出日志
嗯哼,就这么水灵灵的出来了~~~
只能说这玩意真就是越练越熟悉,之前弄抖音的时候插桩插的半死不活,再接着是分析小红书外层的JSVMP,只能步履蹒跚的学习AST解ob混淆(这个现在还不熟练),到现在随手找到apply位置,简单插桩,扫一眼日志,轻轻松松(狗头.jpg)
进入函数观察一下,发现这个base64的编码表挺抽象的,一半是硬编码。。。剩下一半看我们的传入
校验通过,至此我们完成了整个JSVMP的逆向,剩下的就是函数参数的来源