moumouhu x-zse-96 加密逆向 V3.0 算法还原

You can’t just walk away from things. ——《夜行动物》

逃避解决不了问题。

PS: 本文仅供学习参考、仅供学习参考、仅供学习参考,不得用于商业用途。

请求分析

本次目标站点

aHR0cHM6Ly93d3cuemhpaHUuY29tL3NlYXJjaD90eXBlPWNvbnRlbnQmcT1weXRob24=

JS 分析

  全局搜索大法,搜索关键词 x-zse-96,只有一个文件中包含该关键词,该文件中共搜索到2条匹配结果,全部在合适的位置打上断点,最终在12685处成功断上。

  通过上图可以看出加密就是 T 方法中 signature 的值

1
signature: (0,F(r).encrypt)(f()(s))

  简化一下

1
signature: F(r).encrypt(f()(s))

  在合适的位置打上断点刷新页面,成功断上后在控制台观察

接下来让我们一步步分析

s

  回到源代码处,向上观察

1
2
3
4
5
6
i = n.zse93  // 固定值
o = n.dc0 // cookie 中 dc0 的 值
a = n.xZst81 // x-zst-81 首页可为空
c = H(e) // 请求链接
u = q(t) // ""
s = [i, c, o, W(u) && u, a].filter(Boolean).join("+"); // 拼接

  因为不止一个请求通过该加密,所以我们在 c = H(e) 打上要分析链接的条件断点,方便后续调试

  取消掉其他断点,仅保留该条件断点刷新页面,成功断上;单步执行到 s 处,然后在控制台分析

  经过多次调试得出 s = 固定值 + 请求链接 + cookie dc0 的值;如果不是首页第一次请求,或者你没有清除缓存,后面还会在拼接上 x-zst-81

f()(s)

  在条件断点成功断上后,再在 signature 处添加断点(如果需要刷新页面,需先移除 signature 处断点,清空缓存,然后再刷新页面,在 signature 处添加断点),执行到signature 处;在控台调试观察

  可以看出 f()(s) 的结果是固定,由数字字母构成的32位加密,那么我们有理由怀疑这是 md5 ,将拼接的字符串拿到 https://www.sojson.com/hash.html ,选择 md5 测试,结果与控制台一致

F(r).encrypt

  在控制台输入 F(r).encrypt,可以看到这个方法,单击输出进入该方法

  可以看到,加密入口就是这里了

  将整个js抠下来放到 VS Code 或者 WebStorm 中,找到上图处,从 D 方法外层折叠代码

  将这一块整个扣下来,然后掐头去尾并按照下图注释

至此,如果不想补环境或者搞算法,上面扣下来的代码使用 jsRPC 便够用了

算法还原

  在扣下来的源码中可以看到大量控制流,在看了一些教程后了解到这就是jsvmp。本文应对的方法是插桩,也就是打日志断点。

  最开始使用的笨方法,也就是在每个控制流都打上日志断点,后来看了些大佬文章后,做了些精简,在以下位置做了插桩

放到浏览器中

  因为代码中有环境监测,所以需要将代码放到浏览器测试执行

  打开 Chrome 浏览器,进入目标站点页面,打开控制台;点击源代码;点击代码段;点击新代码段;重命名(可选);将打完日志断点的代码粘贴到右侧

  找一个 f()(s) 生成的 md5 进行后续测试。在测试的时候发现同一个 md5 生成的加密结果是不一样的,这说明要么加入了时间戳要么是随机数。经过测试是加入了随机数,为了方便调试那就把随机数的生成 hook 掉

1
2
3
Math.random = function () {
return 0.12342184792608113
}

加密规律

  通过日志可以看出加密结果是一个个拼接起来的,有一个字符串一直在拼接过程附近,那就是 6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE ,经过多次测试发现这个字符串是固定的,通过执行 charAt 得到需要拼接的结果,如下图最后 D 的生成

  那么这个 37 又是哪里来的那?往上找。37 是通过 9950754 >>> 18 & 63 生成的

   继续向上找 9950754,找到了 9 是如何生成的

   继续向上找9950754,找到了 I 是如何生成的

   还是没找到 9950754 如何生成的,那就继续向上找,图太长就不截图了

1
2
3
4
5
6
7
34 ^ 0 = 34
214 << 8 = 54784
34 | 54784 = 54818
151 << 16 = 9895936
54818 | 9895936 = 9950754
9950754 & 63 = 34
'6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE'.charAt(34) = 0

  至此,加密结果的最后 4 位结果就找到了。然后就发现,上面出现的数字有的是生成的,但是 151,34,214 是没找到啊!别急,我们待会再看。继续往上找规律

  从最开始生成加密的位置寻找,经过观察日志发现,加密结果可以分为 16 组,每组先算出第一个数(同上,每组都有3个未知的数),然后再计算另外3个数;而且这16组又分成4组,每个计算略有差异,也就是重复 1 - 4 组逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 1 组第一个数
92 ^ 58 = 102
165 << 8 = 42240
102 | 42240 = 42342
173 << 16 = 11337728
42342 | 11337728 = 11380070
11380070 & 63 = 38
'6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE'.charAt(38) = a


// 2 组第一个数
174 ^ 0 = 174
56 ^ 58 = 2
2 << 8 = 512
174 | 512 = 686
121 << 16 = 7929856
686 | 7929856 = 7930542
7930542 & 63 = 46
'6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE'.charAt(46) = 7


// 3 组第一个数
168 ^ 0 = 168
66 << 8 = 16896
168 | 16896 = 17064
40 ^ 58 = 18
18 << 16 = 1179648
17064 | 1179648 = 1196712
1196712 & 63 = 40
'6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE'.charAt(40) = W

// 4 组第一个数
178 ^ 0 = 178
202 << 8 = 51712
178 | 51712 = 51890
108 << 16 = 7077888
51890 | 7077888 = 7129778
7129778 & 63 = 50
'6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE'.charAt(50) = b

...

// 16 组第一个数
34 ^ 0 = 34
214 << 8 = 54784
34 | 54784 = 54818
151 << 16 = 9895936
54818 | 9895936 = 9950754
9950754 & 63 = 34
'6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE'.charAt(34) = 0

  每组的另外3个的计算如下,先计算数字再由 6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE 去计算最终结果,在日志中已经很清楚了

1
2
3
每组第一个计算出的数 >>> 6 & 63
每组第一个计算出的数 >>> 12 & 63
每组第一个计算出的数 >>> 18 & 63

  将上面的逻辑使用代码实现,即完成了算法还原的第一步。下面就需要搞清楚,那些不知来源的3个一组的数是哪里来的。

加密数组

计算 md5 得到最初的 32 位数组

  到现在都没看到最初 md5 这不合理啊,我们去搜索一下,找到它最后出现的位置,可以看到这是将 md5 的 32 个字符挨个取出去计算 charCodeAt 的值,得到一个 32 位数组,继续向下看

加密值变化的原因及 34 位数组

  加密值变化的原因就在这里,随机值乘以固定值 127 ,通过 floor 向下取整;向最初的32位数组使用 unshift 在最前面添加一个 0,然后再使用 unshift 添加刚才取整得到的数,至此生成了 34 位数组

拼接的 48 位数组

  上面我们提到了,加密结果是计算了16组,每组的第一个数都有3个未知来源的数参与计算,那么我们需要的应该是一个48位的数组,34位还不够,继续向下找。向34位的数组添加 14 ,直到数组的长度为 48 位。

最终的 48 位数组

  继续向下,将上一步生成的 48 位数组通过 slice 截取 32 位,得到一个新的数组

  继续向下看,进入到了一个方法中,有两个参数,一个是参数 e,上面生成的 32 位数组,另一个参数 t,未知的 16 位数组,往上找找看

  可以看到 16 位数组是由这个方法计算得出的,那么它的 16 位数组入参还没看到怎么来的,继续向上找

  这是将之前生成的 48 位数组截取了前 16 位,然后每一位都再进行运算,得到一个新的 16 位数组,这里的计算在日志中即可找到,就不一一列举了

写在最后

  至此整个算法流程结束,上面 最终的 48 位数组 提到的方法扣下来后缺什么补什么即可。需要注意的是,在计算的时候是从最终的 48 位数组倒序取的,每三个一组,正好16组。如果觉得这篇文章有不足之处,欢迎您的留言。

参考:
js逆向JSVMP篇新版某乎_x-zes-96算法还原 - 时光依旧不在
【JS逆向系列】某乎x96参数3.0版本与jsvmp进阶 - 漁滒