jsvmp-某乎 x-zes-96 算法还原

文章目录

        • 1. 找到关键入口
        • 2. 分析流程
        • 3. 算法还原

前言

​ 仅作学习交流,非商业用途,如侵删。

​ 记一次算法还原,手撕vmp的过程。

网站链接

aHR0cHM6Ly93d3cuemhpaHUuY29tL3NlYXJjaD9xPXB5dGhvbiZ0eXBlPWNvbnRlbnQ=

1. 找到关键入口

​ 我们选择直接使用粗暴的搜索方法,要解密的 x-zes-96 在这个url header 里面。

// 隐藏域名 防止帖子暴毙
https://www.xxx.com/api/v4/search_v3?gk_version=gz-gaokao&t=general&q=python&correction=1&offset=0&limit=20&filter_fields=&lc_idx=0&show_all_topics=0&search_source=Normal

​ 直接搜 x-zse-96 找到入口,只有两个位置统统打上断点。

jsvmp-某乎 x-zes-96 算法还原_第1张图片

2. 分析流程

​ 多打印两次看看 s 和 f()(s) 的值是不是值是否是不是动态的,防止走入误区。

​ 经过验证发现这是个标准md5可以使用标准库。那我们就没必要在这个上面浪费时间,我们主要分析x-zst-96所以跳过这个。

jsvmp-某乎 x-zes-96 算法还原_第2张图片

​ 然后再来多打印两次看看 (0,F®.encrypt)(f()(s)) 的值是不是值是否是不是动态的,防止走入误区。

​ 是个动态的,我们先给它留意下,继续往下走。

​ F11 跟进去 F函数 就是这个 F®.encrypt)(f()(s) 所以直接 分析这个 encrypt: u.a

jsvmp-某乎 x-zes-96 算法还原_第3张图片

​ 打印 u.a 直接点进去 看到这个绿色的光 这就是导出方法。

jsvmp-某乎 x-zes-96 算法还原_第4张图片
打上断点执行到这里,那这个就是生成加密的方法了,我们再重复运行两次试试验证猜想。
jsvmp-某乎 x-zes-96 算法还原_第5张图片
​ 我们继续跟试试看,这里new了一个 I 对象,我们进入这个I方法。
jsvmp-某乎 x-zes-96 算法还原_第6张图片
​ 这里会循环几万甚至几十万次,加入了大量的无用代码和逻辑。并且如果不熟悉ast的coder不要轻易尝试。

​ 接下来通过Debugger断点调试jsvmp就不太可行了,套用下渔哥的解释,本文末尾有参考链接。

​ jsvmp就是将js源代码首先编译为字节码,得到的这种字节码就变成只有操作码(opcode)和操作数(Operands),这是其中一个前端代码的保护技术。

​ 整体架构流程是服务器端通过对JavaScript代码词法分析 -> 语法分析 -> 语法树->生成AST->生成私有指令->生成对应私有解释器,将私有指令加密与私有解释器发送给浏览器,就开始一边解释,一边执行。
jsvmp-某乎 x-zes-96 算法还原_第7张图片
​ 接下来就不继续通过Debugger调试了,既然可以在控制台通过调用 F®.encrypt)(f()(s) 来实现调用,这是一个webpack打包的项目那么我们找到它的加载器就可以调用,或者在这个模块内部没有如果通过加载器引用其它模块的话,把它直接变成全局的也是可以运行的。

​ 本次为了考虑到文章主要是算法还原,直接改成全局调用,有兴趣的可以通过加载器方法,不在此增加工作量了。
jsvmp-某乎 x-zes-96 算法还原_第8张图片
​ 新的某乎增加环境检测,要打开知乎界面调用,不然会走错误流程。请求结果是403。
​ 掐头去尾留中间,删除这个匿名函数头部和尾部还有导出函数 exports,直接放到浏览器调用。
jsvmp-某乎 x-zes-96 算法还原_第9张图片
​ 新手按箭头数字提示操作,老手直接跳过。
​ 出意外的报错了,我们点击报错点跟过去注释它。
jsvmp-某乎 x-zes-96 算法还原_第10张图片
​ 运行成功,至此分析流程结束开始进入算法还原。
jsvmp-某乎 x-zes-96 算法还原_第11张图片

3. 算法还原

​ 前面分析过得,l.prototype.O = function (A, C, s) { 在这个方法的大循环内生成算法,所以代码逻辑也肯定在这个里面,我们给它插桩打印日志看看。
jsvmp-某乎 x-zes-96 算法还原_第12张图片
​ 除了知道一个 S 看起来像时间戳之外,密密麻麻的基本没有有用信息。不过通过展开的case分支发现,频繁的出现了一个变量 this.C 和 this.C[this.c] 我们来打印它试试看。记得先清空之前的日志,在调用加密参数的前面加上 console.clear() 过滤掉生成算法之前走的其它初始化或无用逻辑。

​ 插桩如下代码到循环处

console.log(`索引--> case ${this.T}: this.C-->${this.C}, this.C[this.c]-->${this.C[this.c]}`)

​ 好消息我们看到了,this.C[this.c] 返回的结果和最后生成结果一致,证明我们的猜想是对的。全部复制到本地文本中分析。

​ 坏消息是密密麻麻的信息28000多行日志,我们从后往前来慢慢
jsvmp-某乎 x-zes-96 算法还原_第13张图片​ 可以看到加密在这里不一样。

Mh0gk2Mj76=d0Ccqp0v/F+0QSfx+V0SPzin6ig1fmzpTi8uQiDMrAQ81oT4FclX q
Mh0gk2Mj76=d0Ccqp0v/F+0QSfx+V0SPzin6ig1fmzpTi8uQiDMrAQ81oT4FclXq

​ 明显有个算法拼接的过程 ,很难让人想到不是 + 拼接的,为了印证在网上继续找不过是拿着下面的去搜

Mh0gk2Mj76=d0Ccqp0v/F+0QSfx+V0SPzin6ig1fmzpTi8uQiDMrAQ81oT4FclX

​ 排除常规的标准算法,我们事先通过老版本也得知这是一个自定义算法,不用去猜想是不是aes des 这种。

​ 和猜想的一样的就是通过慢慢拼接起来的,这种一般按照经验都是循环生成的。我们可以看到字符串长度是48,开发程序员不可能一个一个去拼接,肯定是遵循某一种规律或者特定条件循环的,所以我们不忙着扣代码,直接往上找。同时先记住这个case168 两次生成最后两位都是同一个case 分支 改变的。我们继续网上找,找到加密拼接的头部

​ 我们直接搜算法开头的第一位M全匹配,可以看到case 168 和 465 都有可能是重点位置,为什么不是case 352呢?

​ 还记得开始的插桩

console.log(`索引--> case ${this.T}: this.C-->${this.C}, this.C[this.c]-->${this.C[this.c]}`)

​ 它是在 switch (this.T) 上面, this.C 的值 是上一次给 有效代码运算过后给 this.C 的有效赋值。我们去 case 168 下插桩试试看

                case 168:
                    console.log(`case ${this.T}: this.C[${this.c}] = ${this.C[this.I]} + ${this.C[this.F]} --> ${this.C[this.I] + this.C[this.F]}`)
                    this.C[this.c] = this.C[this.I] + this.C[this.F];
                    this.T = 2 * this.T + 16;
                    break;

jsvmp-某乎 x-zes-96 算法还原_第14张图片

​ 和猜想的一模一样,是通过拼接完成的。

jsvmp-某乎 x-zes-96 算法还原_第15张图片

​ 加密第一位也是如此 我们再去看看465是否也是如此。

                case 465:
                    this.C[3] = this.C[this.W][Q](G[+[]]);
                    console.log(`case ${this.T}: this.C[3] = ${this.C[this.W]}.${Q}(${G[+[]]}) --> ${this.C[3]}`)
                    this.T -= 13 * G.length + 100;
                    break;

jsvmp-某乎 x-zes-96 算法还原_第16张图片

​ case 465 果然也是如此, 那我们依法炮制。按照上面这种方法搜索然后在生成出插桩

​ 重复步骤不在截图。依次顺序是

//  加密值字符串拼接 BsdBVlB5vVIR=TbdMQh2skhsHK4scwNOWSamRia2YOaH+LCWTlURM4I/XKjQsHM + c
168,
//  生成拼接的加密值 6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE.charAt(11) --> c
465,
//  生成 11 & 63 --> 11  ========> 63 定值
78,
//  生成 2922411 >>> 18 --> 11  ========> 0 6 12 18 定值
57,
//  生成 38827 | 2883584 --> 2922411
50,
//  生成参数2 44 << 16 --> 2883584   ========> 44 是数组的值 16 定值
64,

​ 完成了以上步骤之后就可以从下往上拿到一个很长的数组,但是中间无用代码太多。

​ 我们从上往下看大概可以猜到这个数组是通过运算得到的。

jsvmp-某乎 x-zes-96 算法还原_第17张图片

​ 但是依然无法清晰的看到整个加密过程,因为还有很多插桩没有完整。我们给它整个补完,又是一个漫长的过程,注释掉无用的逻辑比如 352 368 这种。拿到一份完全逻辑清晰可见的日志。

解压之后 打开 日志6.log

​ 可以看到完整的环境检测,运算逻辑。

​ case 368那里做了一个校验,只在debugger住并且时间超过500毫秒才会生效,估计是走向错误分支,我们只需要固定住就可以。但是我们都是分析日志所以并没有触发这个条件。

       case 368:
                    // this.T -= 500 < S - this.a ? 24 : 8;
                    this.T -= 8;
                    break;

​ 接着我们研究研究加密为什么会变得问题。通过两次运行比对日志发现了,算法为何会变得原因。

​ 生成了一个随机数,然后取整把它unshift到了数组的开头参与了运算。

jsvmp-某乎 x-zes-96 算法还原_第18张图片

​ 有意思的是时间戳并没有参与运算,为了方便调试我们还是写死。

​ 如果想用我这份环境调试可以固定 md5 和时间戳 随机数即可用我这份代码日志分析比对,每一行都会相同。

md5 f1fa96c714c6752f28b162fda60ded03
Date.now 1661986251253
Math.random 0.08636862211354912

​ 接下来进行最后的算法还原,用 日志6.log 这个日志记录来分析还原算法。

1 - 142 之前都是环境检测 我们直接跳过。

143 - 271 取md5字符串长度 循环每一位 charCodeAt(i) 压入数组

// 定义一个空数组
var md5_charCodeAt_arr = []
// md5 转 charCodeAt 存放到数组
for (let i = 0; i < md5_str.length; i++) {
    md5_charCodeAt_arr.push(md5_str.charCodeAt(i))
}

272 - 281 继续压入 通过两次对比发现时间戳不影响最后的计算结果

// 向数组开头添加一个新的元素 0
md5_charCodeAt_arr.unshift(0)
// 向数组开头添加一个新的元素 17,也就是上面计算出来的 可随机可固定
md5_charCodeAt_arr.unshift(17)

283 - 310 通过两次对比发现 固定值循环14次.push(14)

// 往数组中放14个14,此时数组的长度为48
for (let i = 0; i < 14; i++) {
	md5_charCodeAt_arr.push(14)
}

311 - 393
改变md5 通过两次对比发现 分析发现 this.C[1] 是一个固定数组 48,53,57,48,53,51,102,55,100,49,53,101,48,49,100,55
这里取md5数组的 slice(0, 16) 然后用 this.C[0] 和 this.C[1] 数组每一位进行运算之后 ^ 42

// 之后通过slice取0-16位
var md5_charCodeAt_arr1 = md5_charCodeAt_arr.slice(0, 16)
// md5_charCodeAt_arr1 -> 10, 0, 102, 49, 102, 97, 57, 54, 99, 55, 49, 52, 99, 54, 55, 53

// 固定值
var charCodeAt_arr_1 = [48, 53, 57, 48, 53, 51, 102, 55, 100, 49, 53, 101, 48, 49, 100, 55];

var new_md5_charCodeAt_arr = [];
for (var key in md5_charCodeAt_arr1) {
    new_md5_charCodeAt_arr.push(md5_charCodeAt_arr1[key] ^ charCodeAt_arr_1[key] ^ 42);
}
// 因为是16位的数组,每个值都需要计算,所以相当于是分了16组,每组计算的结果都放入了一个新的数组中
// new_md5_charCodeAt_arr -> 16, 31, 117, 43, 121, 120, 117, 43, 45, 44, 46, 123, 121, 45, 121, 40

394 - 401

// 我们给上面的结果定义一个变量不然下面容易看的人头晕 
// 283 - 310 md5_charCodeAt_arr 
// 311 - 393 new_md5_charCodeAt_arr
// 调用 __g.r 方法
var __g_r_res = __g.r(new_md5_charCodeAt_arr)
var md5_charCodeAt_arr2 = md5_charCodeAt_arr.slice(16, 48)
var __g_x_res = __g.x(md5_charCodeAt_arr2, __g_r_res);
// 拿到结果 下面会用这个长度48的数组进行大量的运算 这里不就是我们需要的那个数组
var in_calculation = __g_r_res.concat(__g_x_res)

404 - 407

// 通过两次对比发现 这是一个固定值可以写死 它是通过在变量中取出的值 拼接起来的,发现是固定值之后我没继续跟参数了
case 168: this.C[3] = 6fpLR + qJO8M/c3j --> 6fpLRqJO8M/c3j
case 168: this.C[3] = 6fpLRqJO8M/c3j + nYxFkUV --> 6fpLRqJO8M/c3jnYxFkUV
case 168: this.C[3] = 6fpLRqJO8M/c3jnYxFkUV + C4ZIG12SiH=5v0mXDazWB --> 6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWB
case 168: this.C[3] = 6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWB + Tsuw7QetbKdoPyAl+hN9rgE --> 6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE

​ 最后一步,还记得刚开始猜的循环吗,这里开始了。

​ 开始咯,下面就是又臭又长的分析流程,依然是通过两次对比流程发现其中的规律

​ 第一组 第二组

jsvmp-某乎 x-zes-96 算法还原_第19张图片

​ 第三组 第四组

jsvmp-某乎 x-zes-96 算法还原_第20张图片

​ 大量的对比发现以下规律这是一个循环

​ 每次生成4个字符串,前面拿到的那个大数组会参与运算,从数组 pop() 一个出来

jsvmp-某乎 x-zes-96 算法还原_第21张图片

​ 通过这张整理过后的对比可以得出循环,已第一组数据为例

var i = 0; var pop = 211;
var c_3_1 = i % 4;
var c_3_2 = 8 * i;
var c_3_3 = 58 >>> c_3_2;
var c_3_4 = c_3_3 & 255;
var c_3_5 = pop ^ c_3_4

var a = c_3_5 // 这个值要参与运算 先保留起来
console.log("c_3_5", c_3_5);

i = 1; var pop = 74;
c_3_1 = i % 4;
c_3_2 = 8 * i;
c_3_3 = 58 >>> c_3_2;
c_3_4 = c_3_3 & 255;
c_3_5 = pop ^ c_3_4
var b1 = c_3_5 << 8 // 74 << 8 -- > 18944

console.log("c_3_5", c_3_5);
console.log("b1", b1);
var a1 = a | b1 // 233 | 18944 -- > 19177
console.log("a1", a1);


i = 2; var pop = 167;
c_3_1 = i % 4;
c_3_2 = 8 * i;
c_3_3 = 58 >>> c_3_2;
c_3_4 = c_3_3 & 255;
c_3_5 = pop ^ c_3_4
console.log("c_3_5", c_3_5);


var c = c_3_5 << 16 // 10944512
console.log("c", c);
var d = a1 | c; // 10963689
console.log("d", d);

console.log(encode(d));
// 10963689 = > BsdB
function encode(param) {
    var salt = '6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE'
    let ret = ''
    // 这里对应点在 case 57
    for (x of [0, 6, 12, 18]) {
        let a = param >>> x
        let b = a & 63
        let c = salt.charAt(b)
        ret = ret + c
    }
    // console.log(ret)
    return ret
}

jsvmp-某乎 x-zes-96 算法还原_第22张图片

然后我们把它封装成一个函数 请求一下试试看
jsvmp-某乎 x-zes-96 算法还原_第23张图片
​ 完全成功 和日志6.log的一模一样

x_zse_96 2.0_BsdBVlB5vVIR=TbdMQh2skhsHK4scwNOWSamRia2YOaH+LCWTlURM4I/XKjQsHMc

​ 最后闲谈:讲道理还原纯算是比较浪费时间成本的,像这样已经做过一次知道流程的情况下,整理笔记资料和截图,完全搞清楚每个分支干什么了,写完这篇文章差不多花了10个小时。这种方案对抗vmp显然是不太划算的,相比较而言补环境应该是一个不错的选择。可能大佬都喜欢手撕vmp的快感吧,不过作为学习技术本身就是为了吃懂吃透,为了更好的对抗vmp,实际生产业务肯定还是以最快完成为主。一个练手demo还原纯算的话分析加上还原还是参考其它文章都需要花费1-2天,还是自己还是太菜了。

最后推荐一下蔡老板的星球,好用不贵。

参考文章:

蔡老板 vip群 : ) 佬 的 demo

渔滒 - 【JS逆向系列】某乎x96参数与jsvmp初体验

时光依旧不在 - js逆向JSVMP篇新版某乎_x-zes-96算法还原

你可能感兴趣的:(web逆向,javascript,jsmvp,web逆向)