乐趣区

关于python:JS-逆向百例某音-XBogus-逆向分析JSVMP-纯算法还原

申明

本文章中所有内容仅供学习交换应用,不用于其余任何目标,不提供残缺代码,抓包内容、敏感网址、数据接口等均已做脱敏解决,严禁用于商业用途和非法用处,否则由此产生的所有结果均与作者无关!

本文章未经许可禁止转载,禁止任何批改后二次流传,擅自应用本文解说的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K 哥爬虫】分割作者立刻删除!

逆向指标

  • 指标:某音网页端用户信息接口 X-Bogus 参数
  • 接口:aHR0cHM6Ly93d3cuZG91eWluLmNvbS9hd2VtZS92MS93ZWIvdXNlci9wcm9maWxlL290aGVyLw==

什么是 JSVMP?

JSVMP 全称 Virtual Machine based code Protection for JavaScript,即 JS 代码虚拟化爱护计划。

JSVMP 的概念最早应该是由西北大学 2015 级硕士研究生匡开圆,在其 2018 年的学位论文中提出的,论文题目为:《基于 WebAssembly 的 JavaScript 代码虚拟化爱护办法钻研与实现》,同年还申请了国家专利,专利名称:《一种基于前端字节码技术的 JavaScript 虚拟化爱护办法》,网上能够间接搜到,也可在公众号【K 哥爬虫】后盾回复 JSVMP,收费获取原版高清无水印的论文和专利。本文就简略介绍一下 JSVMP,想要具体理解,当然还是倡议去读一下这篇论文。

JSVMP 的外围是在 JavaScript 代码爱护过程中引入代码虚拟化思维,实现源代码的虚拟化过程,将指标代码转换成自定义的字节码,这些字节码只有非凡的解释器能力辨认,暗藏指标代码的要害逻辑。在匡开圆的论文中,利用 WebAssembly 技术实现了非凡的虚构解释器,通过编译暗藏解释器的执行逻辑。JSVMP 的爱护流程如下图所示:

一个残缺的 JSVMP 爱护零碎,大抵的架构应该是这样子的:服务器端读取 JavaScript 代码 —> 词法剖析 —> 语法分析 —> 生成 AST 语法树 —> 生成公有指令 —> 生成对应公有解释器,将公有指令加密与公有解释器发送给浏览器,而后一边解释,一边执行。

JSVMP 有哪些学习材料?

除了匡开圆的论文以外,还有以下文章也值得学习:

  • H5 利用加固防破解 -js 虚拟机爱护计划浅谈
  • JS 加密?用虚拟机 opcode 爱护 JS 源码
  • 给 ” 某音 ” 的 js 虚拟机写一个编译器

JSVMP 逆向办法有哪些?

就目前来讲,JSVMP 的逆向办法有三种(自动化不算):RPC 近程调用,补环境,日志断点还原算法,其中日志断点也称为插桩,找到要害地位,输入要害参数的日志信息,从后果往上倒推生成逻辑,以达到算法还原的目标,RPC 技术 K 哥以前写过文章,补环境的形式当前有工夫再写,本文次要介绍如何应用插桩来还原算法。

抓包状况

轻易来到某个博主主页,抓包后搜寻可发现一个接口,返回的是 JSON 数据,外面蕴含了博主某音号,认证信息、签名,关注、粉丝、获赞等,申请 Query String Parameters 里蕴含了一个 X-Bogus 参数,每次申请会扭转,此外还有 sec_user_id 是博主主页 URL 前面那一串,webid 间接申请主页返回内容里就有,msToken 与 cookie 无关,革除 cookie 拜访,就没这个参数了,实测该接口不验证 webidmsToken,间接置空即可。

逆向剖析

这条申请是 XHR 申请,所以间接下个 XHR 断点,当 URL 中蕴含 X-Bogus 参数时就断下:

往前跟栈,来到一个叫 webmssdk.js 的 JS 文件,这里就是生成参数的次要 JS 逻辑了,也就是 JSVMP,整体上做了一个混同,这里能够应用 AST 来解混同,K 哥以前同样也写过 AST 的文章,这里还原混同不是重点,咱们间接应用 V 佬的插件 v_jstools 来还原:

还原后应用浏览器的 Overrides 替换性能将 webmssdk.js 替换掉,往上跟栈,如下图所示,到 W 这里就曾经生成了 X-Bogus 了,this.openArgs[1] 就是携带了 X-Bogus 的残缺 URL,仔细观察这段代码,有很多三元表达式,当 M 的值为 15 时,就会走到这段逻辑,U 的值生成之后,有一个 S[C] = U 的操作。

再往上看代码,S 是一个数组,单步调试的话会发现代码会始终走这个 if-else 的逻辑,简直每一步都有 S 数组的参加,一直往里面增删改查值,for 循环外面的 I 值,决定着后续 if 语句的走向,这里也就是插桩的关键所在,如下图所示:

插桩剖析

大的 for 循环和 if-else 逻辑有两个中央,为了保障最初的日志更加具体残缺,在这两个中央都下个日志断点(右键 Add logpoint),断点内容为:

"地位 1", "索引 I", I, "索引 A", A, "值 S:", JSON.stringify(S, function(key, value) {if (value == window) {return undefined} return value})

"地位 2", "索引 I", I, "索引 A", A, "值 S:", JSON.stringify(S, function(key, value) {if (value == window) {return undefined} return value})

插桩输入 S 的时候为什么要写这么长一串呢?首先 JSON.stringify() 办法的作用是将 JavaScript 值转换为 JSON 字符串,根底语法是 JSON.stringify(value[, replacer [, space]]),如果不将其转换成 JSON,那么 S 的值,输入可能是这样的:[empty, Array(26), 1, Array(0)],你看不到 Array 数组外面具体的值,该办法有个可选参数 replacer,如果 replacer 为函数,则 JSON.stringify 将调用该函数,并传入每个成员的键和值,在函数中能够对成员进行解决,最初返回解决后的值,如果此函数返回 undefined,则排除该成员,举个例子:

var obj1 = {key1: 'value1', key2: 'value2'}
function changeValue(key, value) {if (value == 'value2') {return '公众号:K 哥爬虫'} return value
}
var obj2 = JSON.stringify(obj1, changeValue)
console.log(obj2)

// 输入:{"key1":"value1","key2":"公众号:K 哥爬虫"}

下面的代码中 JSON.stringify 传入了一个函数,当 valuevalue2 的时候就将其替换成字符串 公众号:K 哥爬虫 ,接下来咱们演示一下当 valuewindow 时,会产生什么:

依据报错咱们能够看到这里因为循环援用导致异样,要晓得在插桩的时候,如果插桩内容有报错,就会导致不能失常输入日志,这样就会缺失一部分日志,这种状况咱们就能够加个函数解决一下,让 value 为 window 的时候,JSON 解决的时候函数返回 undefined,排除该成员,其余成员失常输入,如下图所示:

以上就是日志断点为什么要这样写的起因,下好日志断点后,留神后面咱们下的 XHR 断点不要勾销,而后刷新网页,控制台就开始打印日志了,因为有很多 XHR 申请都蕴含了 X-Bogus,如果你 XHR 断点勾销了,日志就会始终打印直到卡死。日志输入结束后,大概有 8 千多条,搜寻就能看到最初一条日志 X-Bogus 曾经生成了:

28 个字符生成逻辑

间接在打印的日志页面右键 save as..,将日志导出到本地进行剖析。X-Bogus 由 28 个字符组成,当初要做的就是看 DFSzswVOAATANH89SMHZqF9WX7n6 这 28 个字符是怎么来的,在日志里搜寻这个字符串,找到第一次呈现的中央,察看一下能够发现,他是一一字符顺次生成的,如下图红框所示:

在上图中,第 8511 行,X-Bogus 字符串的下一个元素是 null,到了第 8512 行,就生成数字 6 了,那么在这两步之间就是数字 6 的生成逻辑,这个时候咱们看第 8511 行的日志断点是 地位 2 索引 I 16 索引 A 738,那么咱们回到原网页,在地位 2,下一个条件断点(右键 Add conditional breakpoint),当 I == 16 && A == 738 && S[7] && S[7] == 21 时就断下。之所以要加 S[7] 是因为 索引 I 16 索引 A 738 的地位有很多,在日志里搜一下大略有 40 多个,多加个限度条件就能够放大范畴,当然有可能加了多个条件依然有多个地位都满足,这就须要你仔细察看了,通过断点断下的时候看看控制台后面输入的日志来判断是不是咱们想要的地位。这也是一个小细节,肯定要找准地位,千万别搞混了。(提醒一下,像我这样下断点的话,个别状况下会断下两次,第二次是满足要求的)

(留神:本文形容的日志的多少行、断点的具体位置、变量的具体值,可能会有所变动,以你的理论状况为准,但思路是一样的)

刷新网页,断下之后开始单步跟,来到下图所示的中央:

到这里之后,就不要下一步了,再下一步有可能整个语句就执行结束了,其中的细节你看不到,所以这里咱们在控制台挨个输出看看:

能够看到实际上的逻辑就是返回指定地位的字符,y 的值就是 S[5],m 的值就是 S[4],通过屡次调试发现 m 的值是固定的,M 就是 charAt() 办法,咱们再看看咱们本地的日志,S[5] 的值为 [20]charAt() 取值进去就是 6,逻辑完全正确。

当初咱们还须要晓得这个 20 是怎么来的,持续往上看,找到 20 第一次呈现的中央,在第 8510 行,那么咱们就要使其在上一步断下,也就是第 8509 行,如下图所示:

第 8509 行的索引信息为 地位 2 索引 I 47 索引 A 730,同样的下条件断点察看怎么生成的:

能够看到逻辑是 S[5] & S[6],再看咱们本地 S[5] = 5647508S[6] = 63 5647508 & 63 = 20,逻辑正确,20 就是这么来的。接下来又开始找 564750863 是怎么生成的,同样在生成的上一步,也就是 8508 行下个条件断点,这行的索引为 地位 2 索引 I 72 索引 A 726

能够看到 63 是间接 q[A] 生成的,q 是一个大数组,A 就是索引为 726,q 这个大数组怎么来的先不必管,而 5647508 这个大数字,搜寻一下,发现有很多,咱们也先放着,到这里咱们能够总结一下最初一个字符的生成步骤如下:

short_str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="

q[726] = 63
5647508 & 63 = 20
short_str.charAt(20) = '6'

而后接日志着往上看,看倒数第二个字母是怎么来的,办法也和后面演示的一样,一直往前下条件断点,这里就不再逐渐演示了,当你找完四个数字后,就能够开始看 5647508 这个大数字怎么来的了,搜寻这个数字,同样的找到第一次呈现的中央,在其前一步下条件断点,步骤捋进去会发现有一个乱码字符串通过 charCodeAt() 操作,再加上一些位运算失去的,乱码字符串相似下图所示:

至于这个乱码字符串怎么来的,咱们前面再讲,到这里先总结一下,首先咱们的 X-Bogus = DFSz swVO AATA NH89 SMHZ qF9W X7n6,将其看成每四个为一组,之所以这么分组,是因为你通过剖析后会发现,每一组的每一个字符生成流程都是一样的,这里以最初两组为例,流程大抵如下:

short_str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="
X-Bogus = DFSz swVO AATA NH89 SMHZ qF9W X7n6

============== 第 6 组【qF9W】==============

"\u0002ÿ-%.*yê^s6ðýǞýœV,”".charCodeAt(15) = 158
q[342] = 16
158 << 16 = 10354688
"\u0002ÿ-%.*yê^s6ðýǞýœV,”".charCodeAt(16) = 253
q[408] = 8
253 << 8 = 64768
10354688 | 64768 = 10419456
"\u0002ÿ-%.*yê^s6ðýǞýœV,”".charCodeAt(17) = 156
156 | 10419456 = 10419612

q[520] = 16515072
10419612 & 16515072 = 10223616
q[532] = 18
10223616 >> 18 = 39
short_str.charAt(39) = 'q'

q[590]= 258048
10419612 & 258048 = 192512
q[602] = 12
192512 >> 12 = 47
short_str.charAt(47) = 'F'

q[660] = 4032
10419612 & 4032 = 3456
q[668] = 6
3456 >> 6 = 54
short_str.charAt(54) = '9'

q[726] = 63
10419612 & 63 = 28
short_str.charAt(28) = 'W'

============== 第 7 组【X7n6】==============

"\u0002ÿ-%.*yê^s6ðýǞýœV,”".charCodeAt(18) = 86
q[342] = 16
86 << 16 = 5636096
"\u0002ÿ-%.*yê^s6ðýǞýœV,”".charCodeAt(19) = 44
q[408] = 8
44 << 8 = 11264
5636096 | 11264 = 5647360
"\u0002ÿ-%.*yê^s6ðýǞýœV,”".charCodeAt(20) = 148
148 | 5647360 = 5647508

q[520] = 16515072
5647508 & 16515072 = 5505024
q[532] = 18
5505024 >> 18 = 21
short_str.charAt(21) = 'X'

q[590] = 258048
5647508 & 258048 = 139264
q[602] = 12
139264 >> 12 = 34
short_str.charAt(34) = '7'

q[660] = 4032
5647508 & 4032 = 3200
q[668] = 6
3200 >> 6 = 50
short_str.charAt(50) = 'n'

q[726] = 63
5647508 & 63 = 20
short_str.charAt(20) = '6'

将流程比照一下就能够发现,每个步骤 q 外面的取值都是一样的,这个能够间接写死,不同之处就在于最开始的 charCodeAt() 操作,也就是返回乱码字符串指定地位字符的 Unicode 编码,第 7 组顺次是 18、19、20,第 6 组顺次是 15、16、17,以此类推,第 1 组刚好是 0、1、2,如下图所示:

每一组的逻辑都是一样的,咱们就能够写个通用办法,顺次生成七组字符串,最初拼接成残缺的 X-Bogus,代码如下:(乱码字符串的生成后文会讲)

function getXBogus(originalString){
    // 生成乱码字符串
    var garbledString = getGarbledString(originalString);
    var XBogus = "";
    // 顺次生成七组字符串
    for (var i = 0; i <= 20; i += 3) {var charCodeAtNum0 = garbledString.charCodeAt(i);
        var charCodeAtNum1 = garbledString.charCodeAt(i + 1);
        var charCodeAtNum2 = garbledString.charCodeAt(i + 2);
        var baseNum = charCodeAtNum2 | charCodeAtNum1 << 8 | charCodeAtNum0 << 16;
        // 顺次生成四个字符
        var str1 = short_str[(baseNum & 16515072) >> 18];
        var str2 = short_str[(baseNum & 258048) >> 12];
        var str3 = short_str[(baseNum & 4032) >> 6];
        var str4 = short_str[baseNum & 63];
        XBogus += str1 + str2 + str3 + str4;
    }
    return XBogus;
}

乱码字符串生成逻辑

在进行下一步之前,咱们要留神两点:

  • 文章演示有些变量前后不对应,因为每次插桩的值都是会变的,看流程就行了,流程是正确的;
  • 咱们日志输入是通过 JSON.stringify 解决了的,有些步骤是向某个函数传入乱码字符串进行解决,你会发现解决后的后果和日志不统一,这是失常的。

乱码字符串的生成相对来说略微简单一点,但思路依然一样,这里就不一一截图展现了,间接用日志形容一下关键步骤,留神以下日志是正向的步骤,就不逆着推了,倡议本人先逆着把流程走一走,再来看这个步骤就看得懂了。

Step1:首先对 URL 前面的参数,也就是 Query String Parameters 进行两次 MD5、两次转 Uint8Array 解决,最初失去的 Uint8Array 对象在前面的步骤中用失去,步骤如下:

 地位 1 索引 I 4  索引 A 134:将 URL 前面的参数进行 MD5 加密失去字符串
地位 1 索引 I 16 索引 A 460:将上一步的字符串转换为 Uint8Array 对象
地位 1 索引 I 4  索引 A 134:将上一步的 Uint8Array 对象进行 MD5 加密,失去字符串
地位 1 索引 I 29 索引 A 472:将上一步的字符串转换为 Uint8Array 对象 

上述步骤中,咱们将最终失去的后果命名为 uint8Array,要害代码实现如下:

var md5 = require("md5");

// 字符串转换为 Uint8Array 对象,缺失的变量自行补齐
_0x5960a2 = function(a) {for (var c = a.length >> 1, e = c << 1, b = new Uint8Array(c), d = 0, f = 0; f < e; ) {b[d++] = _0x511f86[a.charCodeAt(f++)] << 4 | _0x511f86[a.charCodeAt(f++)];
    }
    return b;
}

// originalString: URL 前面的原始参数
var uint8Array = _0x5960a2(md5(_0x5960a2(md5(originalString))));

Step2:生成两个大数,一个是工夫戳,咱们称之为 fixedString1,另一个调用某个办法生成,咱们称之为 fixedString2

fixedString1
地位 1 索引 I 43 索引 A 806:1663385262240 / 1000 = 1663385262.24

fixedString2
地位 1 索引 I 16 索引 A 834:M.apply(null, []) = 536919696

上述步骤中,M 对应以下办法,缺失的办法自行补齐(其中 _0x229792 是创立 canvas):

function _0x2996f8() {
    try {return _0x4b3b53 || (_0xb55f3e.perf ? -1 : (_0x4b3b53 = _0x229792(3735928559), _0x4b3b53));
    } catch (a) {return -1;}
}

Step3:先后生成两个数组,咱们称之为 array1array2array2 就是由 array1 的元素地位变换后得来的,严格来讲,array1 不是一个残缺的数组,而是一个个数字,这一点能够在日志中体现进去,为了不便咱们就间接将其视为一个数组,两个数组都有 19 个元素,步骤如下:

array1[0] 至 array1[3] 为定值

array1[4]
地位 1 索引 I 25 索引 A 946:uint8Array[14]

array1[5]
地位 1 索引 I 25 索引 A 970:uint8Array[15]

array1[6] 至 array1[7] 为定值,8、9 与 ua 无关

array1[10]
地位 1 索引 I 52 索引 A 1090:fixedString1 >> 24 = 99
地位 1 索引 I 47 索引 A 1098:99 & 255 = 99

array1[11]
地位 1 索引 I 52 索引 A 1122:fixedString1 >> 16 = 25417
地位 1 索引 I 47 索引 A 1130:25417 & 255 = 73

array1[12]
地位 1 索引 I 52 索引 A 1154:fixedString1 >> 8 = 6506755
地位 1 索引 I 47 索引 A 1162:6506755 & 255 = 3

array1[13]
地位 1 索引 I 52 索引 A 1186:fixedString1 >> 0 = 241
地位 1 索引 I 47 索引 A 1194:241 & 255 = 241

array1[14]
地位 1 索引 I 52 索引 A 1218:fixedString2 >> 24 = 32
地位 1 索引 I 47 索引 A 1226:32 & 255 = 32

array1[15]
地位 1 索引 I 52 索引 A 1250:fixedString2 >> 16 = 8192
地位 1 索引 I 47 索引 A 1258:8192 & 255 = 0

array1[16]
地位 1 索引 I 52 索引 A 1282:fixedString2 >> 8 = 2097342
地位 1 索引 I 47 索引 A 1290:2097342 & 255 = 190

array1[17]
地位 1 索引 I 52 索引 A 1314:fixedString2 >> 0 = 536919696
地位 1 索引 I 47 索引 A 1322:536919696 & 255 = 144

array1[18]
地位 1 索引 I 27 索引 A 1352:array1.reduce(function(a, b) {return a ^ b;}); = 100

array1 残缺值如下
地位 1 索引 I 27 索引 A 1538:64,1.00390625,1,8,9,185,69,63,74,125,99,73,3,241,32,0,190,144,100

array2 由 array1 元素替换地位而来:array2 = [array1[0], array1[2], array1[4], array1[6], array1[8], array1[10], array1[12], array1[14], array1[16], array1[18], array1[1], array1[3], array1[5], array1[7], array1[9], array1[11], array1[13], array1[15], array1[17]]

array2 残缺值如下
array2 = [64,1,9,69,74,99,3,32,190,100,1.00390625,8,185,63,125,73,241,0,144]

Step4:将 Step3 失去的 array2 通过转换失去乱码字符串,步骤如下:

 地位 1 索引 I 16 索引 A 1706:_0x2f2740.apply(null, array2) = "@\u0000\u0001\u000eíxE?\u0016c%>® \u0000¾ó"

地位 1 索引 I 16 索引 A 1760:_0x46fa4c.apply(null, ["ÿ", "@\u0000\u0001\u000e\t¹E?J}cI\u0003ñ \u0000¾d"]) = "\u0002ÿ-%.*yê^s6ðýǞýœV,”"

地位 1 索引 I 16 索引 A 1812:_0x2b6720.apply(null, [2, 255, "\u0002ÿ-%.*yê^s6ðýǞýœV,”"]) = "\u0002ÿ-%.*yê^s6ðýǞýœV,”"

其中用到的函数:

function _0x2f2740(a, c, e, b, d, f, t, n, o, i, r, _, x, u, s, l, v, h, g) {let w = new Uint8Array(19);
    return w[0] = a,
    w[1] = r,
    w[2] = c,
    w[3] = _,
    w[4] = e,
    w[5] = x,
    w[6] = b,
    w[7] = u,
    w[8] = d,
    w[9] = s,
    w[10] = f,
    w[11] = l,
    w[12] = t,
    w[13] = v,
    w[14] = n,
    w[15] = h,
    w[16] = o,
    w[17] = g,
    w[18] = i,
    String.fromCharCode.apply(null, w);
}

function _0x46fa4c(a, c) {let e, b = [], d = 0, f = "";
    for (let a = 0; a < 256; a++) {b[a] = a;
    }
    for (let c = 0; c < 256; c++) {d = (d + b + a.charCodeAt(c % a.length)) % 256,
        e = b,
        b = b[d],
        b[d] = e;
    }
    let t = 0;
    d = 0;
    for (let a = 0; a < c.length; a++) {t = (t + 1) % 256,
        d = (d + b[t]) % 256,
        e = b[t],
        b[t] = b[d],
        b[d] = e,
        f += String.fromCharCode(c.charCodeAt(a) ^ b[(b[t] + b[d]) % 256]);
    }
    return f;
}

function _0x583250(a) {return String.fromCharCode(a);
}

function _0x2b6720(a, c, e) {return _0x583250(a) + _0x583250(c) + e;
}

自此,整个流程就走完了。能够用 JavaScript 来实现整个算法,用 Python 也能够,欠缺代码后轻易申请一个博主主页,简略解析几个数据,输入失常:

退出移动版