免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除!
逆向目标
目标:抖音网页端用户信息接口 X-Bogus 参数
接口:aHR0cHM6Ly93d3cuZG91eWluLmNvbS9hd2VtZS92MS93ZWIvdXNlci9wcm9maWxlL290aGVyLw==
参数:
X-Bogus: DFSzswVYwR2ANJV5ttm-TDok/RBL
msToken: XdvBn3ow8atTxF5IT6Nozn_D976Sh-fQQais1pUkC0U-…3iUKTT_yGo4Q1A9KBUXxYALyw==
ttwid: 1%7CwRK6LHw2rKAyHM8EeD4WLyVctABf-…5f35595a6e1f587dcc9f09b4fe
虽然接口入参有很多,但是实际上必不可少且需要逆向的就只有X-Bogus,msToken和Cookie中的ttwid。本文主要介绍X-Bogus的逆向,关于msToken和ttwid,后续会发文。
逆向过程 基本分析 首先抓包定位到我们需要的接口,如图:
这个请求是一个XHR请求,我们打上一个XHR断点,选择URL中包含X-Bogus,然后刷新页面重新请求主页,如图:
成功打上了断点,并且此时已经生成了msToken和X-Bogus参数。往前跟栈,来到一个叫 webmssdk.js 的 JS 文件,这里就是生成参数的主要 JS 逻辑了,也就是 JSVMP,整体上做了一个混淆如图:
我们用v_jstools 做一个简单的还原,还原之后用浏览器的override功能替换,之后重新加载,如图:
往上跟栈到w,如图:
可以看到w是一个数组,此时X-Bogus已经生成。
console控制台看下其长度为28,X-Bogus长度是否固定为28?我们不妨在这个地方设置一个条件断点,让它的长度为28的时候断一下,如下图:
断上之后有一个黄色的?标志,如图:
刷新发现成功断上,此时console中继续查看X-bogus长度,发现果然是28位,如下图:
并且X-bogus每次都是不一样的。
继续往上跟栈,来到一个符号函数,如图:
这个符号函数就是JSVMP的初始化函数,这些参数就是字节码和函数机址等。
在最后一行打上断点,单步向下,发现这里就是生成X-Bogus的地方,如图:
可以看到w函数就是调用入口。
接着往上跟栈,如图:
可以看到这里的b是一个数组,包含了X-Bogus。
接着往上跟栈,如图:
image-20230526151501816
前面一个三元表达式是字节系常见的环境检测,判断window对象的类型检测,检测是否在浏览器环境中运行,如果global只在node环境中才有,如果补环境的话需要注意这个检测。后边有一个webrt,在巨量或者头条里面,这里直接就写的jsvmprt了。jsvmp构建了一个运行js的vm,它有个最大的特点,就是一长串的字节码。
插桩分析 前面说过,$符号函数就是JSVMP初始化的地方,而w方法就是调用入口,我们抠出w方法看下,如下:
可以看到基本上整个流程就是在2个大的if分支中走动,这个y控制走哪个分支,y是什么呢?看下调用w的地方:
可以看到y是一个布尔值,为0或者1。
单步调试的话会发现代码会一直走这个 if-else
的逻辑,几乎每一步都有O数组的参与,不断往里面增删改查值,for循环里面的j值,决定着后续if语句的走向,这里也就是插桩的关键所在,如下图所示:
接下来对if-else两个分支进行插桩,如下:
说一下这个日志格式,位置1和2用来区分if和else分支,索引j和索引A为关键索引,所以需要打印出来,o数组转化成JSON字符串,方便查看内容。
简单说一下JSON.stringify传入的那个函数有什么作用。stringify方法原型为:JSON.stringify(value[, replacer [, space]])
,如果 replacer 为函数,则 JSON.stringify
将调用该函数,并传入每个成员的键和值,在函数中可以对成员进行处理,最后返回处理后的值,如果此函数返回 undefined,则排除该成员,举个例子:
1 2 3 4 5 6 7 var a = { "k1" : "v1" , "k2" : "v2" } ; var b = JSON.stringify(a, function(k, v) { if (v === "v2" ) return "changed" ; return v; } )console.log(b);
接下来我们演示一下当 value
为 window
时,会发生什么:
可以看到这里由于循环引用导致异常,要知道在插桩的时候,如果插桩内容有报错,就会导致不能正常输出日志,这样就会缺失一部分日志,这种情况我们就可以加个函数处理一下,让 value 为 window 的时候,JSON 处理的时候函数返回 undefined,排除该成员,其他成员正常输出,如下图所示:
回到正题,插好桩之后,去掉其它无关的断点,然后刷新主页,console控制台就会一直打印日志,直到断点断住,如图:
保存日志并用编辑器打开,然后拉到最后一条日志,如下图:
可以看到X-Bogus已经生成。
分析第七组字符如何生成 将X-Bogus每四个一组,分成七组,即:DFSz swVY zpUA NJV5 tSwJ Rfok /RsR
。先来看看第七组字符也就是最后一组字符如何生成。
搜索X-Bogus第一次生成的位置,如下图:
可以看到最后一个R是在索引j为10,索引A为714的地方生成的。
接下来根据这两个索引打下条件断点,位置为2则断点应该断在else分支中。然后索引j为10,索引A为714在编辑器中查找了一下匹配到很多,所以仅靠j和A并不能唯一确定。我们拷贝下来数组O,看下其内容:
最后一个元素o[7]为21,我们把这个条件带上,然后再打上条件断点:j==10 && A==714 && O[7]==21
,然后刷新就断住了:
单步往下走,断到这个地方:
依次看下m,w还有S:
image-20230526193146347
可以看到S初始值为5,w是在O的第5个位置索引,m是在O的第4个位置索引, 所以w=O[5],m=O[4],P方法是charAt方法。
从之前的日志中复制出数组O,然后调试:
这个R刚好是生成的X-Bogus最后一个字符。
接下来看下24是怎么来的。一样的方法,找到前面一行日志:
重新打上条件断点j==47 && A==708 && O[7]==21
,然后刷新,如下:
image-20230526203407368
断上之后,单步跟踪,跟到这里:
image-20230526202719857
看下O[S],g以及O[S]&g的值,如下:
回到日志,用3768920&63正好是24。
image-20230526203908745
再看下上一行的63是如何生成的。依据同样的方法,单步运行到如下:
可以看到取F数组704的位置,其中F是一个大数组,F[704]刚好是63。
整理下到目前为止生成X-Bogus最后一个字符的逻辑:
1 2 3 4 5 6 倒数第一个字符R F = [null,...9]; str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=" F[704] ----> 63 3768920&63 ----> 24 str.charAt(24) ----> "R"
先挖个坑在这里,3768920这个数字怎么生成的先放着,下边会填坑。
x-bogus倒数第二个字符是s,去掉最后一个字符之后看看第一次出现的位置,按之前的方式单步跟踪,如下图:
image-20230526235442697
S为5,取出当前日志下的O数组,发现w为9,m依然为那固定的字符串Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=
,p方法也依然为charAt,得到的结果刚好是s。
接下来看下这个9,找到紧挨着的第一次出现9的上面一行,如图:
image-20230527000130596
同样的断点然后单步跟踪到相应的地方:
image-20230527001359345
可以看到O[S]是用通过右移运算得到,将日志中的576>>6刚好就是9。
再看6,方法一样,跟踪到如下地方:
F依然是那个大数组,A是646,F[646]刚好为6。
用同样的方式,可以算出576是由3768920&4032得出。而4032则是由F[A]得出,此时的A为638。
整理下到目前为止生成X-Bogus最后2个字符的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 倒数第二个字符s F[638] ----> 4032 3768920&4032 ----> 576 F[646] ----> 6 576 >> 6 ----> 9 str.charAt(9) ----> "s" 倒数第一个字符R F = [null,...9]; str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=" F[704] ----> 63 3768920&63 ----> 24 str.charAt(24) ----> "R"
用同样的方法,整理第七组剩下的两个字符R和/:
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 X-Bogus: DFSz swVY zpUA NJV5 tSwJ Rfok /RsR F = [null,...9]; str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=" ----------------------------------第7组 /RsR ------------------------------------- F[498] ----> 16515072 3768920&16515072 ----> 3670012 3670016>>18 ----> 18 str.charAt(14) ----> "/" F[568] ----> 258048 3768920&258048 ----> 98304 98304>>12 ----> 24 str.charAt(24) ----> "R" F[638] ----> 4032 3768920&4032 ----> 576 F[646] ----> 6 576 >> 6 ----> 9 str.charAt(9) ----> "s" F[704] ----> 63 3768920&63 ----> 24 str.charAt(24) ----> "R"
分析第六组字符如何生成 同样的方法,逆向最需要的是耐心,整个过程就不贴了,直接贴结果,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 X-Bogus: DFSz swVY zpUA NJV5 tSwJ Rfok /RsR F = [null,...9]; str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=" ----------------------------------第6组 Rfok ------------------------------------- F[498] ----> 16515072 6359425&16515072 -----> 6291456 6291456>>18 ----> 24 str.charAt(24) ----> "R" F[568] ----> 258048 6359425&258048 ----> 65536 65536>>12 ----> 12 str.charAt(16) ----> "f" F[638] ----> 4032 6359425&4032 ----> 2432 F[646] ----> 6 2432>>6 ----> 38 str.charAt(38) ----> "o" F[704] ----> 63 6359425&63 ----> 1 str.charAt(1) ----> "k"
每一个字符生成都有一个常数6359425,这个放在下边分析。
填之前的坑 之前挖过2个坑,现在来填坑,看看3768920和6359425这2个数字是怎么来的。同样为了节省篇幅,直接上分析结果:
1 2 3 4 5 6 7 8 9 10 11 str2 = "\u0002ÿ-%.(´7^�\u001e\u001a÷ıa\t�9�X" str2.charCodeAt(18) ----> 57 F[320] ----> 16 57<<16 ----> 3735552 str2.charCodeAt(19) ----> 130 F[386] ----> 8 130<<8 ----> 33280 3735552|33280 ----> 3768832 str2.charCodeAt(20) ----> 88 88|3768832 ----> 3768920
1 2 3 4 5 6 7 8 9 10 11 str2 = "\u0002ÿ-%.(´7^�\u001e\u001a÷ıa\t�9�X" str2.charCodeAt(15) ----> 97 F[320] ----> 16 97<<16 ----> 6356992 str2.charCodeAt(16) ----> 9 F[386] ----> 8 9<<8 ----> 2304 6356992|2304 ----> 6359296 str2.charCodeAt(17) ----> 129 129|6359296 ----> 6359425
填完坑这里再埋个坑,先不管这个乱码字符串怎么来的,就假设这个乱码字符串是一个永远不变的常量,我们后边再分析。到此第七组和第六组字符生成逻辑就全部分析完了。我们整理一下,然后对比一下这两组伪代码:
image-20230527200739957
将流程对比一下就可以发现,每个步骤F里面的取值都是一样的,这个可以直接写死,不同之处就在于最开始的 charCodeAt()
操作,也就是返回乱码字符串指定位置字符的Unicode编码,第7组依次是18、19、20,第6组依次是15、16、17,以此类推,第1组刚好是0、1、2。
还可以看到,每一组的逻辑都是一样的,我们就可以写个通用方法,依次生成七组字符串,最后拼接成完整的 X-Bogus
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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 ; }
分析乱码字符串如何生成 用同样的方法,找到这串乱码字符串第一次出现的地方:
image-20230527013146563
注意到这里位置变为1,断点需要断到if分支,数组O的长度也变了,变为22了,且最后一个元素为62,断点的时候要注意。此时条件断点为:j==16 && A==2038 && O[22]==62
,单步跟踪如下:
image-20230527014949639
看下w,m以及P分别代表什么:
可以看到这里通过调用自定义的方法生成乱码字符串,传入的参数分别为2,255和另一串乱码。抠出这个方法如下:
1 2 3 4 5 6 function _0x94582 (a, b, c ) { return _0x86cb82 (a) + _0x86cb82 (b) + c; } function _0x86cb82 (a ) { return String .fromCharCode (a); }
现在又产生了2,255以及新的乱码字符。先看看255如何生成:同理找到第一次255变为null的日志处:
image-20230527121043553
断点后单步跟踪:
整理逻辑为:
1 2 3 4 5 6 7 a ----> "484..." A ----> 2020 l = function(a, b) { return parseInt("" + a[b] + a[b + 1], 16); } T = l(a, A); O[S][T] ----> 255
同样看下2是如何生成的,方法不变,整理下逻辑为:
1 2 3 4 a ----> "484..." A ----> 2012 T = l(a, A) O[S][T] ----> 2
跟255的生成逻辑是一致的。
再看下乱码字符串-%.(´7^\u001e\u001a÷ıa\t9X
如何生成:
整理逻辑为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function _0x25788b(a, b) { for (var c, e = [], d = 0, t = "", f = 0; f < 256; f++) { e[f] = f; } for (var r = 0; r < 256; r++) { d = (d + e[r] + a.charCodeAt(r % a.length)) % 256, c = e[r], e[r] = e[d], e[d] = c; } var n = 0; d = 0; for (var o = 0; o < b.length; o++) { d = (d + e[n = (n + 1) % 256]) % 256, c = e[n], e[n] = e[d], e[d] = c, t += String.fromCharCode(b.charCodeAt(o) ^ e[(e[n] + e[d]) % 256]); } return t; } _0x25788b('ÿ', "@\u0000\u0001\fÄdE?'Qdpu\u000eÔ\u001dÑ>¨") ----> '-%.(´7^\u001e\u001a÷ıa\t9X'
整理下’ÿ’和”@\u0000\u0001\fÄdE?’Qdpu\u000eÔ\u001dÑ>¨”的生成逻辑:
1 2 3 4 5 6 7 8 9 a ----> 255 固定值 String.fromCharCode(a) ----> 'ÿ'; function _0x398111(a, b, c, e, d, t, f, r, n, o, i, _, x, u, s, l, v, h, p) { var y = new Uint8Array(19); return y[0] = a, y[1] = i, y[2] = b, y[3] = _, y[4] = c, y[5] = x, y[6] = e, y[7] = u, y[8] = d, y[9] = s, y[10] = t, y[11] = l, y[12] = f, y[13] = v, y[14] = r, y[15] = h, y[16] = n, y[17] = p, y[18] = o, String.fromCharCode.apply(null, y); } ----> "@\u0000\u0001\fÄdE?'Qdpu\u000eÔ\u001dÑ>¨"
_0x398111方法,看下入参:[64,0.00390625,1,12,196,100,69,63,39,81,100,112,117,14,212,29,209,62,16]
,数组里面每一个value值可能是动态的,具体的每一个值接下来都会一一分析。
动态数组成员分析 分析数组arr1 = [64,1,196,69,39,100,117,212,209,168,0.00390625,12,100,63,81,112,14,29,62]
生成规则,结果如下:
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 62: l(a, 1922) ----> T (59) O[S][T] ----> 62 (S=22) 29: l(a, 1914) ----> T (57) O[S][T] ----> 29 (S=21) 14: l(a, 1906) ----> T (55) O[S][T] ----> 14 (S=20) 112: l(a, 1898) ----> T (53) O[S][T] ----> 112 (S=19) 81: l(a, 1890) ----> T (51) O[S][T] ----> 81 (S=18) 63: l(a, 1882) ----> T (49) O[S][T] ----> 63 (S=17) 100: l(a, 1874) ----> T (47) O[S][T] ----> 100 (S=16) 12: l(a, 1866) ----> T (45) O[S][T] ----> 12 (S=15) 0.00390625: l(a, 1858) ----> T (43) O[S][T] ----> 0.00390625 (S=14) 168: l(a, 1850) ----> T (60) O[S][T] ----> 168 (S=13) 209: l(a, 1842) ----> T (58) O[S][T] ----> 209 (S=12) 212: l(a, 1834) ----> T (56) O[S][T] ----> 212 (S=11) 117: l(a, 1826) ----> T (54) O[S][T] ----> 117 (S=10) 100: l(a, 1818) ----> T (52) O[S][T] ----> 100 (S=9) 39: l(a, 1810) ----> T (50) O[S][T] ----> 39 (S=8) 69: l(a, 1802) ----> T (48) O[S][T] ----> 69 (S=7) 196: l(a, 1794) ----> T (46) O[S][T] ----> 196 (S=6) 1: l(a, 1786) ----> T (44) O[S][T] ----> 1 (S=5) 64: l(a, 1778) ----> T (42) O[S][T] ----> 64 (S=4)
不难看出这一段逻辑是将一个数组的奇数索引与偶数索引重新组成一个新的数组,arr1就是重组之后的数组。
将arr1重新排列,得到arr2为arr2=[64,0.00390625,1,12,196,100,69,63,39,81,100,112,117,14,212,29,209,62,168]
。
arr2的索引值从T=42~T=60,刚好长度为19 。
接下来就是分析这个arr2数组了。方法依旧是一模一样,先看最后一个也就是索引为18的元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 168: O[S][T] ----> 64 (T=42, arr2[42]) 64 O[S][T] ----> 0.00390625 (T=43, arr2[43]) 64^0.00390625 -----> 64 O[S][T] ----> 1 (T=44, arr2[44]), 64^1 ----> 65 O[S][T] ----> 12 (T=45, arr2[45]), 65^12 ----> 77 O[S][T] ----> 196 (T=46, arr2[46]), 77^196 ----> 137 O[S][T] ----> 100 (T=47, arr2[47]), 137^100 ----> 237 O[S][T] ----> 69 (T=48, arr2[48]), 237^69 ----> 168 O[S][T] ----> 63 (T=49, arr2[49]), 168^63 ----> 151 O[S][T] ----> 39 (T=50, arr2[50]), 151^39 ----> 176 O[S][T] ----> 81 (T=51, arr2[51]), 176^81 ----> 225 O[S][T] ----> 100 (T=52, arr2[52]), 225^100 ----> 133 O[S][T] ----> 112 (T=53, arr2[53]), 133^112 ----> 245 O[S][T] ----> 117 (T=54, arr2[54]), 245^117 ----> 128 O[S][T] ----> 14 (T=55, arr2[55]), 128^14 ----> 142 O[S][T] ----> 212 (T=56, arr2[56]), 142^212 ----> 90 O[S][T] ----> 29 (T=57, arr2[57]), 90^29 ----> 71 O[S][T] ----> 209 (T=58, arr2[58]), 71^209 ----> 150 O[S][T] ----> 62 (T=59, arr2[59]), 150^62 ----> 168
看下逻辑,就是对arr2数组从前往后做位运算。代码为:
1 arr2.reduce (function (a, b ) { return a ^ b; })
测试一下:
没有问题。
接着分析索引为14~17的元素的生成逻辑,伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 3558723902>>24 ----> -44 -44&255 ----> 212 3558723902>>16 ----> -11235 -11235&255 ----> 29 3558723902>>8 ----> -2875951 -2875951&255 ----> 209 3558723902>>0 ----> -736243394 -736243394&255 ----> 62
这四个元素生成逻辑大同小异,都是将固定数字3558723902
经过2次位运算得到。
而这个固定字符3558723902
是调用_0x5bc542
生成的。抠出代码并微做调整如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function _0x5bc542 ( ) { const canvas = createCanvas (48 , 16 ); var e = canvas.getContext ("2d" ); e.font = "14px serif" ; e.fillText ("龘ฑภ경" , 2 , 12 ); e.shadowBlur = 2 ; e.showOffsetX = 1 ; e.showColor = "lime" ; e.arc (8 , 8 , 8 , 0 , 2 ); e.stroke (); const b = canvas.toDataURL (); for (var d = 0 ; d < 32 ; d++) { a = 65599 * a + b.charCodeAt (a % b.length ) >>> 0 ; } return a; }
用到了canvas库,需要安装依赖:npm i canvas
然后分析索引为10~13的元素的生成逻辑,伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 1685091598.763>>24 ----> 100 100&255 ----> 100 1685091598.763>>16 ----> 25712 25712&255 ----> 112 1685091598.763>>8 ----> 6582389 6582389&255 ----> 117 1685091598.763>>0 ----> 1685091598 1685091598&255 ----> 14
逻辑跟上边四个字符一样,只不过固定的字符串生成规则不一样,是由一个13位的时间戳/1000得到。
索引为6~9和0~3的元素为定值。
最后分析下索引为4~5的元素:
1 2 3 4 originalString为URL后边的请求字符串拼接 _0x1f3b8d(md5(_0x1f3b8d(md5(originalString)))) ----> uint8Array uint8Array[14] ----> 196 uint8Array[15] ----> 100
uint8Array
方法为:
1 2 3 4 5 6 7 _0x1f3b8d = function (a ) { const _0x19ae48 = []; for (var b = a.length >> 1 , c = b << 1 , e = new Uint8Array (b), d = 0 , t = 0 ; t < c; ) { e[d++] = _0x19ae48[a.charCodeAt (t++)] << 4 | _0x19ae48[a.charCodeAt (t++)]; } return e; }
到此乱码字符串的生成逻辑就完成了,总结下来就是X-Bogus会对params,form-data,user-agent,时间,canvas进行校验。
运行与测试 运行结果如下:
成功生成X-Bogus,通过生成的X-Bogus拿到博主主页数据。
若需要完整代码或者讨论,扫描加微信。