声明

本文章中所有内容仅供学习交流使用不用于其他任何目的不提供完整代码抓包内容、敏感网址、数据接口等均已做脱敏处理严禁用于商业用途和非法用途否则由此产生的一切后果均与作者无关

本文章未经许可禁止转载禁止任何修改后二次传播擅自使用本文讲解的技术而导致的任何意外作者均不负责若有侵权请在公众号【K哥爬虫】联系作者立即删除

逆向目标

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

什么是 JSVMP

JSVMP 全称 Virtual Machine based code Protection for JavaScript即 JS 代码虚拟化保护方案。

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

01

JSVMP 的核心是在 JavaScript 代码保护过程中引入代码虚拟化思想实现源代码的虚拟化过程将目标代码转换成自定义的字节码这些字节码只有特殊的解释器才能识别隐藏目标代码的关键逻辑。在匡开圆的论文中利用 WebAssembly 技术实现了特殊的虚拟解释器通过编译隐藏解释器的执行逻辑。JSVMP 的保护流程如下图所示

02

一个完整的 JSVMP 保护系统大致的架构应该是这样子的服务器端读取 JavaScript 代码 —> 词法分析 —> 语法分析 —> 生成AST语法树 —> 生成私有指令 —> 生成对应私有解释器将私有指令加密与私有解释器发送给浏览器然后一边解释一边执行。

03

JSVMP 逆向方法有哪些

就目前来讲JSVMP 的逆向方法有三种自动化不算RPC 远程调用补环境日志断点还原算法其中日志断点也称为插桩找到关键位置输出关键参数的日志信息从结果往上倒推生成逻辑以达到算法还原的目的RPC 技术K哥以前写过文章补环境的方式以后有时间再写本文主要介绍如何使用插桩来还原算法。

抓包情况

随便来到某个博主主页抓包后搜索可发现一个接口返回的是 JSON 数据里面包含了博主某音号认证信息、签名关注、粉丝、获赞等请求 Query String Parameters 里包含了一个 X-Bogus 参数每次请求会改变此外还有 sec_user_id 是博主主页 URL 后面那一串webid 直接请求主页返回内容里就有msToken 与 cookie 有关清除 cookie 访问就没这个参数了实测该接口不验证 webidmsToken直接置空即可。

04

05

逆向分析

这条请求是 XHR 请求所以直接下个 XHR 断点当 URL 中包含 X-Bogus 参数时就断下

06

07

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

08

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

09

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

10

插桩分析

大的 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})

11

插桩输出 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 时会发生什么

12

根据报错我们可以看到这里由于循环引用导致异常要知道在插桩的时候如果插桩内容有报错就会导致不能正常输出日志这样就会缺失一部分日志这种情况我们就可以加个函数处理一下让 value 为 window 的时候JSON 处理的时候函数返回 undefined排除该成员其他成员正常输出如下图所示

13

以上就是日志断点为什么要这样写的原因下好日志断点后注意前面我们下的 XHR 断点不要取消然后刷新网页控制台就开始打印日志了因为有很多 XHR 请求都包含了 X-Bogus如果你 XHR 断点取消了日志就会一直打印直到卡死。日志输出完毕后大约有8千多条搜索就能看到最后一条日志 X-Bogus 已经生成了

14

28个字符生成逻辑

直接在打印的日志页面右键 save as..将日志导出到本地进行分析。X-Bogus 由28个字符组成现在要做的就是看 DFSzswVOAATANH89SMHZqF9WX7n6 这28个字符是怎么来的在日志里搜索这个字符串找到第一次出现的地方观察一下可以发现他是逐个字符依次生成的如下图红框所示

15

在上图中第8511行X-Bogus 字符串的下一个元素是 null到了第8512行就生成数字6了那么在这两步之间就是数字6的生成逻辑这个时候我们看第8511行的日志断点是 位置 2 索引I 16 索引A 738那么我们回到原网页在位置2下一个条件断点右键 Add conditional breakpointI == 16 && A == 738 && S[7] && S[7] == 21 时就断下。之所以要加 S[7] 是因为 索引I 16 索引A 738 的位置有很多在日志里搜一下大概有40多个多加个限制条件就可以缩小范围当然有可能加了多个条件仍然有多个位置都满足这就需要你细心观察了通过断点断下的时候看看控制台前面输出的日志来判断是不是我们想要的位置。这也是一个小细节一定要找准位置千万别搞混了。提示一下像我这样下断点的话一般情况下会断下两次第二次是满足要求的

注意本文描述的日志的多少行、断点的具体位置、变量的具体值可能会有所变化以你的实际情况为准但思路是一样的

16

刷新网页断下之后开始单步跟来到下图所示的地方

17

到这里之后就不要下一步了再下一步有可能整个语句就执行完毕了其中的细节你看不到所以这里我们在控制台挨个输入看看

18

可以看到实际上的逻辑就是返回指定位置的字符y 的值就是 S[5]m 的值就是 S[4]经过多次调试发现 m 的值是固定的M 就是 charAt() 方法我们再看看我们本地的日志S[5] 的值为 [20]charAt() 取值出来就是6逻辑完全正确。

19

现在我们还需要知道这个20是怎么来的继续往上看找到20第一次出现的地方在第8510行那么我们就要使其在上一步断下也就是第8509行如下图所示

20

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

21

22

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

23

24

25

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

short_str = "Dkdpgh2ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="

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

然后接日志着往上看看倒数第二个字母是怎么来的方法也和前面演示的一样不断往前下条件断点这里就不再逐步演示了当你找完四个数字后就可以开始看 5647508 这个大数字怎么来的了搜索这个数字同样的找到第一次出现的地方在其前一步下条件断点步骤捋出来会发现有一个乱码字符串经过 charCodeAt() 操作再加上一些位运算得到的乱码字符串类似下图所示

26

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

short_str = "Dkdpgh2ZKsQB80/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如下图所示

27

每一组的逻辑都是一样的我们就可以写个通用方法依次生成七组字符串最后拼接成完整的 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 8061663385262240 / 1000 = 1663385262.24

fixedString2
位置 1 索引I 16 索引A 834M.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 946uint8Array[14]

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

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

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

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

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

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

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

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

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

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

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

array1 完整值如下
位置 1 索引I 27 索引A 153864,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[c] + a.charCodeAt(c % a.length)) % 256,
        e = b[c],
        b[c] = 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 也可以完善代码后随便请求一个博主主页简单解析几个数据输出正常

28

29

作者:|K哥爬虫|,原文链接: http://www.imooc.com/article/331131

文章推荐

docker安装jenkins

Redis源码之SDS简单动态字符串

Oracle JDK 和 OpenJDK 有什么区别?

Shifu高级功能:命令行中间件之HTTP 到 SSH 的中间件

GPT-4 来了!这些开源的 GPT 应用又要变强了

读懂React原理之调和与Fiber

java第一课之与js不一样的地方

SpringCloud 客户端负载均衡:Ribbon

TornadoFx实现侧边栏菜单效果

Linux部署rocketmq和可视化客户端

基于SqlSugar的开发框架循序渐进介绍(6)-- 在基类接口中注...

python 如何在多层循环中使用break/continue