吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 2972|回复: 41
收起左侧

[Web逆向] 某红书-4.3.2-绕过ob直面JSVMP-mns0301-详细分析

  [复制链接]
LiXieZengHui 发表于 2026-3-21 23:41
本帖最后由 LiXieZengHui 于 2026-3-28 09:06 编辑

某红书-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 中,我们看不到任何函数结构,只能观察到:

  1. opcode 的变化
  2. PC的跳转
  3. 栈顶指针的移动
  4. 栈顶元素的修改
    某次执行了乘法运算,某次执行了加法运算
    这意味着我们无法静态分析,只能通过动态插桩 + 日志的方式,记录运行时的行为,再从日志中反推出原始函数的功能。
    所以,选择运算符处进行插桩,是为了捕获每一次算术/逻辑运算的操作数和结果,还原计算过程。
    又因为
    有一部分底层操作仍然依赖原生 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码

image.png
image 1.png

arr_16 = func(arr_24)

又是拼接而成的

[0-7]

image 2.png

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)]

image 3.png

arr_24→arr_16

定位计算位置

我们先定位到运算位置

image 4.png

点击函数,发现带我们来到了一个VM中

image 5.png

那么想必转换就是在这里完成的

这一看就是一个JSVMP,而且还是带有ob混淆的

那么接下来肯定逃不过插桩,但是我们现在所处的位置是VM

根本无法修改和打点

显然我们接下来需要找到这个源代码所在位置

确定代码来源

我们先取消代码格式化,看看它的本来面目

image 6.png

第一行非常有辨识度,我们直接copy然后全局搜索

image 7.png

发现结果是来自于一个网络请求

嗯哼,那么我们直接替换就行(没想到google居然把Mock的功能做进了Chrome中)

日志插桩(不依靠AST解混淆)

在Mock之前我们还需要把日志点插入到代码中

但是这里存在ob混淆。。。我们又没有自动化解混淆脚本

虽然之前写的那个改一改能用,但是额,总归还是觉得心累(其实是懒2333)

我们想一想能不能在不依靠AST的情况下进行插桩呢?

思考。。。

小红书的ob混淆很有特征,基本上赋值就是stack[stack_top_pointer] = stack[stack_top_pointer] operator value

那么理论上我们可以通过全局搜索进行打点

能够做到以上前提的是我们能够识别出stack[stack_top_pointer]

我们先观察整个代码

image 8.png

那么我们如何识别栈呢???这个其实特别好办,因为在进入VMP之前,需要先初始化栈,我们我们只要在VMP开始的位置进行寻找即可

image_9.jpg

image 10.png

OK,那么我们现在还欠缺什么?

运算相关的日志我们已经可以打了,但是函数调用,也就是apply,这个我们还没有找到点位。。。

咋办呢?这里我们稍微借用一下AST的知识

我们知道ob混淆会将所有的字符串放进一个大数组中,然后在初始化的时候有一个自执行的函数负责旋转大数组,等到要调用了就通过解密函数去大数组里调用。

根据这个思路,我们可以先动态还原出apply的真实索引,然后看谁调用了这个索引,这样就能顺藤摸瓜找到apply的位置了。

理论可行,开始实战

image 11.png

嗯哼,apply的真实index对应50,也就是0x32

我们在混淆后的代码中搜索,搜索到了7个结果,不多,我们可以一个一个看过去

但是更聪明的做法是,根据ob混淆的尿性,肯定不可能是大数组[0x32]这种形式,大概率是一个解密函数[0x32],而且这个解密函数一般是在当前代码块中var _0x??????? = 全局解密函数,然后在调用的时候形式为_0x???????[0x32],我们特别关注一下这种就行。

我们当前代码全局解密函数为_0x1769

image 12.png

image 13.png

OK,那么当opcode == 1f的时候,就是apply调用的位置

接下来按照惯例打上日志即可

。。。

OK,日志添加完成,我们将JS压缩为一行然后替换响应体

嗯,替换完了,但是运行了之后没效果。。。

发现存在另一个js文件

image 14.png

神奇的是代码一样,那么我们同步替换ds.js即可

替换完成刷新之后,我们可以看到我们要的日志来了

image 15.png

分析日志

在最开始先初始化了4个值

image 16.png

每次从arr_24中取4个元素

image 17.png

将这4个元素视为4个字节,按照小段序拼接为4个字节,对应一个数字,也就是uint32

value = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)

我们看第一个拼装,最后的结果是[11][233][84][156]

这其实是一个小段序拼装

在还原过程中建议像我这样输出,如果之后的运算用到了之前的值,可以很方便的使用

image 18.jpg

之后的分析过程就是依葫芦画瓢,不在此赘述,嫌麻烦直接给AI,AI会说是一个类似chacha20的算法

经过一系列莫名其妙的运算之后(其实感觉是有规律的,我菜,看不出来)

我们又发现了结构性运算

image 19.png

这里面必然有什么奥妙

我们我们猜测,可能是取了4个Uint32,然后把每个Uint32拆分成4个Uint8,这样就能够生成16位Uint8的数组了

我们验证一下

第一组取的是1210961166

转换成16进制为0x482DCD0E

按照两个字节长度进行拆分,[48] [2D] [CD] [0E]

转换成十进制为[72] [45] [205] [14]

嗯哼,如果是小端序存储的话,我们还要进行反序,最后还原一组为[14, 205, 45, 72]

嗯哼,针不戳,一模一样

image 20.png

OK,继续分析,接下来是

接下来的步骤就相当的简单了

image 21.png

我们可以知道103是根据随机数计算而来的

image 22.png

final

arr_20 = arr_4 + arr_16

arr_124

image 23.png

arr_124 = arr_108 + arr_16

arr_108

image 24.png

arr_108 = arr_97 + arr_11

arr_97

image 25.png

arr_97 = arr_44 + arr_53

arr_44

这里的arr_44是由多个长度或4或8的数组拼接而成

[0-3]

固定魔术头[121, 104, 96, 41]

[4-7]

image 26.png

[8-15]

image 27.png

[15-23]

image 28.png

有一点要注意,这里的时间戳比上面的要小。。。

[24-27]

image 29.png

不清晰来自哪里。。。

[28-31]

image 30.png

[32-35]

image 31.png

image 32.png

[35-43]

image 33.png

image 34.png

嗯,我们搜索了半天,发现了这个神似MD5的字符串的来源,是外界传入的参数

image 35.png

至于是不是MD5,我们一会儿研究

这个的计算逻辑是先把MD5字符串对半劈开,取前二分之一,一个8字节,每字节作为一个单位,然后将一个字节看作是一个完整的16进制数,再将这个数与之前计算使用的RAND&0xFF相异

arr_53/arr_a1

image 36.png

最后在开头拼接上长度

image 37.png

arr_11

image 38.png

image 39.png

arr_16

image 40.png

arr_16 = arr_4 + arr_12

arr_4

image 41.png

image 42.jpg

arr_10

image 43.png

之后的10个值也是固定运算得到的

arr_2

image 44.png

同为固定运算

OK,那么到此为止我们arr_144已经全部分析完成了,接下来我们关注一下接下来的内容

魔改RC4

image 45.png

既然长度没有发生改变,但是内容却发生了变化

排除掉简单的顺序变化,自然想到的就是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时,会出现这个

所以我们有选择的开闭断点

image 46.png

进来之后我们发现这特么的又是个ob+jsvmp

要是时间充裕的话自然可以先还原再插桩

但是我们不妨先跟着看看,毕竟我们现在只是在追踪RC4

并不是要还原所有的逻辑

这个地方已经相当接近算法位置了,应该跟一下能看到一些眉目

嗯哼,跟踪了一会儿,果然还是要被绕晕了

难道真的又要来一遍ob解混淆???补药啊,我没有自动化脚本啊呜呜呜呜

我们再仔细观察,我们刚才是在func(arr_144)的日志处发现了这个函数,

现在在这里被断住了,但不一定就是func(arr_144)啊,可能是这个虚拟机里的其他函数调用啊

毕竟根据我们刚才跟栈的时候,这里是vmp初始化的地方,我们再看一下日志

image 47.png

此时并没有在func(arr_144),说明我们还没有到位置,让我们一边关注日志信息,一边追踪

image 48.png

放开了几次,直到出现如上图

我们发现arr_144已经生成了,而外层虚拟机已经取到了_1619d69735e1d480a72d7e01c4a40b7f

感觉差不多就是这个函数了,我们此时应该开始跟踪了

一直跟一直跟,发现了

image 49.png

这个while循环似曾相识啊,哦,和外层一样,也是在初始化虚拟机

那么这个虚拟机初始化完成后,会是什么呢

我们继续跟踪

image 50.png

没跟几步,这个执行顺序已经被解密出来了

image 51.png

完成初始化之后就发现了一个全新的函数

image 52.png

丢给AI一询问,嗯哼,有点意思,AI认为是RC4

看来我们找到了加密函数了嘻嘻

为什么AI认为这个就是一个RC4呢?

因为有一个256的sboxc

然后每轮都会更新状态,每次又会向一个数组内push进当前轮的运算结果的一部分

我们可以打个日志观察一下

image 53.png

image 54.png

image 55.png

我们拿着日志让AI分析一下,然后生成代码即可

image 56.png

完美,这一部分验收通过~

base64魔改

image 57.png

我们可以看到在经过RC4转换之后变成了一个字符串

众所周知,当流式加密结束后输出的也是字节流而非字符串

那么一般来说,我们将字节→字符到时候,都会用到什么?

答对了,base64

那么大厂一般会怎样?

答对了,魔改编码表

我们再来定位一下,发现被跳转到了一个新的js文件中

image 58.png

啧啧啧,又是ob混淆+JSVMP

我们来跟一下栈

看看会发生什么

image 59.png

嗯哼,基本上可以确定就是这个位置

我们开始跟一下栈

嗯哼,没戏,跳转了几下就开始头晕了。。。

我们还是老老实实日志插桩吧

因为我们现在高度怀疑这个base64只是更换了编码表,根据以往的经验,我们只需要对apply位置进行插桩即可。

image 60.png

转换成16进制就是87,我们查找一下对87的应用

image 61.png

很好,就5项,我们分析一下

image 62.png

大概率就是这里,我们直接插桩试一下

PS:非要验证一下也没问题

image 63.png

嗯哼,ez,就是这儿了

依照之前的方法,我们找到apply的调用位置,然后替换代码,刷新页面,观察一下输出日志

image 64.png

嗯哼,就这么水灵灵的出来了~~~

只能说这玩意真就是越练越熟悉,之前弄抖音的时候插桩插的半死不活,再接着是分析小红书外层的JSVMP,只能步履蹒跚的学习AST解ob混淆(这个现在还不熟练),到现在随手找到apply位置,简单插桩,扫一眼日志,轻轻松松(狗头.jpg)

进入函数观察一下,发现这个base64的编码表挺抽象的,一半是硬编码。。。剩下一半看我们的传入

image 65.png

image 66.png

校验通过,至此我们完成了整个JSVMP的逆向,剩下的就是函数参数的来源

免费评分

参与人数 13吾爱币 +12 热心值 +10 收起 理由
Kcode + 1 我很赞同!
junjia215 + 1 + 1 用心讨论,共获提升!
bnm11 + 1 + 1 我很赞同!
fengbolee + 1 + 1 我很赞同!
GSSby + 1 + 1 非常详细,谢谢@Thanks!
allspark + 1 + 1 用心讨论,共获提升!
soyiC + 1 + 1 用心讨论,共获提升!
aihetianshui + 1 + 1 nb
Fourseasons + 1 厉害了大佬
Yao2903 + 1 + 1 狠人
lulanlan + 1 我很赞同!
wwb66668 + 1 + 1 谢谢@Thanks!
Bizhi-1024 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| LiXieZengHui 发表于 2026-3-23 13:59
Bizhi-1024 发表于 2026-3-23 11:47
可以看一下你的插桩是如何插的嘛?(如何判断插桩位置)

先说插桩位置
最简单vmp=>apply,然后依靠经验(连蒙带猜)
中级vmp=>apply+运算符,然后分析日志(查看结构性运算)
难度再增加,apply+运算符+全局栈,然后仔细分析日志+玄学

PS:我自问自答一下
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 中,我们看不到任何函数结构,只能观察到:
1. opcode 的变化
2. PC的跳转
3. 栈顶指针的移动
4. 栈顶元素的修改
某次执行了乘法运算,某次执行了加法运算
这意味着我们无法静态分析,只能通过动态插桩 + 日志的方式,记录运行时的行为,再从日志中反推出原始函数的功能。
所以,选择运算符处进行插桩,是为了捕获每一次算术/逻辑运算的操作数和结果,还原计算过程。
又因为
有一部分底层操作仍然依赖原生 JS 函数(如 Math.random、Array.prototype.push 等)。
这些原生函数的调用会通过 apply 发起,因此在 apply 处插桩可以捕获所有对外部原生函数的调用,补全日志中缺失的关键信息。

再多一句嘴,对于较复杂的VMP,不要一次性插全部的桩或者说一次性全点位输出日志,
先搞清楚大致流程
再分段分块插桩会好很多
同时巧用条件断点,对一段流程多次调试生成多份日志,对照分析;能够在分析过程中给自己增加信心

点评

建议把这部分也放到正文中  详情 回复 发表于 2026-3-26 19:46
Bizhi-1024 发表于 2026-3-23 11:47
可以看一下你的插桩是如何插的嘛?(如何判断插桩位置)
wapj3076 发表于 2026-3-23 12:27
 楼主| LiXieZengHui 发表于 2026-3-23 14:02
感谢所有在互联网上无私分享知识的前辈们,正是因为你们的慷慨与开放,才让后来者的我得以站在巨人的肩膀上继续前行。
crownT 发表于 2026-3-23 22:27
膜拜大佬
xixicoco 发表于 2026-3-24 00:43
大佬是真有耐心,我是看一会儿这debug就眼花了
 楼主| LiXieZengHui 发表于 2026-3-24 08:27
xixicoco 发表于 2026-3-24 00:43
大佬是真有耐心,我是看一会儿这debug就眼花了

一杯茶,一包瓜子,一个混淆看一天哈哈哈哈
jefflookmeair 发表于 2026-3-24 10:23
好长的帖,老实说,太复杂了看不懂
 楼主| LiXieZengHui 发表于 2026-3-24 10:28
jefflookmeair 发表于 2026-3-24 10:23
好长的帖,老实说,太复杂了看不懂

慢慢来,不着急,jsvmp确实需要一些前置知识
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-5-21 02:05

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表