抖音滑块验证码 captchaBody 逆向分析,JSVMP 纯算法还原
文章目录
声明
本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!
本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请通过邮件 admin@itbob.cn 联系我立即删除!
目标
- 目标:字节系滑块验证码,某音、某店、某头条、某量算数、某瓜视频等通用,captchaBody 参数逆向分析,JSVMP 纯算法还原
- 主页:
aHR0cHM6Ly9meGcuamlucml0ZW1haS5jb20v
字节的滑块参数也是 VMP,和某音的 X-Bogus
类似的,本文有些细节就不再重复描述了,有不了解的推荐先看看以前的文章:《【JS 逆向百例】某音 X-Bogus 逆向分析,JSVMP 纯算法还原》,验证码有个 fp
参数,这个参数过了验证码之后,重命名为 s_v_web_id
加到 cookie 里去请求,就可以不带 X-Bogus
拿数据。本文的案例以某店的登录为例对验证码进行逆向分析。
抓包情况
send_activation_code/v2
接口,请求参数包含了加密后的手机号、aid
等参数,其中 aid
应该是用来区分不同网站的,每个网站都不一样,msToken
、X-Bogus
参数在这里不校验,置空就行。
返回的 verify_center_decision_conf
里,有 detail
等值,后续接口会用到。
captcha/get
接口,请求参数里包含前面接口返回的 detail
、server_sdk_env
等参数,fp
参数可通过 JS 生成,后续会讲怎么来的。返回数据里包含了验证码图片 URL、challenge_code
、id
、tip_y
等参数,这些后续同样也会用到的。
captcha/verify
接口,提交验证,xx-tt-dd
为定值,captchaBody
是重点,通过 vmp 将轨迹等参数进行了加密处理,返回值 code 为 200 则表示通过。
参数逆向
其他参数没啥可讲的,很简单,这里讲一下 fp
和 captchaBody
。
fp
fp
参数可以下个 XHR 断点,然后往前跟栈,栈可能有点多,但可以发现主要逻辑都在 captcha.js
里,同时 fp
参数前面有固定字符串 verify_
,所以可以直接搜索这个字符串就能找到生成的位置。
function k() {
var e = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("")
, t = e.length
, n = (new Date).getTime().toString(36)
, r = [];
r[8] = r[13] = r[18] = r[23] = "_",
r[14] = "4";
for (var o, i = 0; i < 36; i++)
r[i] || (o = 0 | Math.random() * t,
r[i] = e[19 == i ? 3 & o | 8 : o]);
return "verify_" + n + "_" + r.join("")
}
除此之外,前面已经提到过了,cookie 里的 s_v_web_id
值也就是 fp
的值,所以还可以通过 Hook cookie 的方法来快速定位。
captchaBody
captchaBody
是字节滑块的重点,跟栈可以发现主要逻辑在 captcha.js
里。
从最后一个 captcha.js
的栈开始,也就是 send 方法这里,可以看到 f 就是 captchaBody
,如下图所示:
再往上走几步,就可以看到和 X-Bogus
一毛一样的 VMP 了,下图中的 w 就有 captchaBody
的值:
然后就正式进入插桩找逻辑的流程了,和之前 X-Bogus
一样,在两个大的 if 这里下日志断点:
这里就有很多细节了,首先是为什么这么多 if
、return undefined
,这是为了防止循环引用导致异常,有异常日志就不能正常输出,导致缺失一部分日志,下图可以解释这一现象:
还有就是我怎么知道 key == '13'
等等就会发生异常呢?当然一开始并不知道,不加这些 if,在控制台会看到一些报错,每一个报错修复一下就多了这些 if 判断了。
关于这个日志断点的写法,之前 X-Bogus
的文章有非常详细的介绍,当然这个写法多种多样的,可能还有更优的写法,能实现目标就行,这里就不再啰嗦了。
还有一个细节就是 位置 2-1
和 位置 2-2
,通过调试、观察、搜索可以发现 VMP 一共有七处,在不知道到底在哪个 VMP 生成 captchaBody
的情况下,可以在这七个地方都下日志断点,分别用位置 1 至 7 来区分。
当然也不能太死板,日志太多了也不好,比如第三个 VMP 的第二个 if 语句,也就是 位置 3-2
这里,日志会巨多,会给你卡死,这种就不要输出了,经过调试,大多数的逻辑在 位置 7-2
,可以先从这里开始看。
滑动验证码,等待日志输出完毕,搜索 captchaBody
的值,看看第一次出现的位置是哪里,这里又双叒叕得注意,由于日志内容太长,控制台会自动折叠一部分,即便你右键 save 日志,折叠的部分也是没有的,在搜索的时候,折叠部分也不在搜索范围内,所以我们需要把折叠的展开,才能正确找到第一次出现的地方!
如下图所示,没展开之前搜索结果 34 个,第一次出现的位置是 位置 7-2 索引C 0 索引S 2952 值w
:
展开之后搜索结果 63 个,第一次出现的位置是 位置 7-2 索引C 10 索引S 2876 值w
,这个位置才是正确的地方:
注意啦,这个索引值,有可能并不是每个人都一样的,但按道理来讲只要 JS 文件一样,断点一样,这些索引值应该也是一样的,我这里的 JS 版本为:https://lf-cdn-tos.bytescm.com/obj/static/secsdk-captcha/cn2/2.26.17/captcha.js
后续还是老套路,依次往上找每个值的第一次出现的位置,在相应的索引处下条件断点,然后单步跟逻辑,把每个参数都理清楚就行了,步骤有点多,后续我就直接展示最关键的步骤了,不需要什么高级技能,最重要的就是细心+耐心!
三个数组
位置 7-2 索引C 10 索引S 2876 值w
:第一次出现 captchaBody。
位置 7-2 索引C 4 索引S 2874 值w
:一个 Uint8Array 数组,先经过 String.fromCharCode()
方法将其转换为字符,再经过 window.btoa()
方法,也就是 base64 生成 captchaBody,我们将该数组称为数组 A。
位置 7-2 索引C 28 索引S 2792 值w
: 第一次出现完整 Uint8Array 数组 A。
位置 7-2 索引C 16 索引S 2790 值w
:两个 Uint8Array 数组合并组成数组 A。其中一个数组前 38 位有值,剩下的都是 0 填充,如下图所示的 g 值,另一个数组长度不一定,但通常是好几千,如下图所示的 m 值,我们将其称为数组 3。
位置 7-2 索引C 16 索引S 2750 值w
:两个 Uint8Array 数组合并组成上一步的 38 位数组,其中一个数组前 6 位有值,且为定值,剩下的都是 0 填充,如下图所示的 g 值,我们将其称为数组 1,另一个数组长度 32 位,如下图所示的 m 值,我们将其称为数组 2。
这段步骤附上我的分析日志:
====================================== 合并数组 1、2、3 ======================================
数组 1 为定值:[116, 99, 6, 16, 0, 0]
位置 7-2 索引C 16 索引S 2750 值w: 合并第1和第2数组
位置 7-2 索引C 16 索引S 2790 值w: 合并第2和第3数组
位置 7-2 索引C 28 索引S 2792 值w: 第一次出现完整大数组 A
====================================== 生成 captchaBody ======================================
位置 7-2 索引C 4 索引S 2874 值w: E(大数组 A) => btoa(大数组)
位置 7-2 索引C 10 索引S 2876 值w: 第一次出现 captchaBody
总结一下三个数组处理逻辑:
数组 1:6 位,定值 [116, 99, 6, 16, 0, 0];
数组 2:32 位;
数组 3:数组长度不一定,但通常是好几千;
数组 A:由 数组1 + 数组2 + 数组3 组成;
captchaBody:由数组 A 先经过 String.fromCharCode() 方法转换为字符,再由 base64 编码得到。
数组 1 是定值,所以接下来只需要查找数组 2、3 是怎么来的就行了。
数组 2(32位)
先看数组 2,也就是 32 位的这个数组怎么来的。
位置 7-2 索引C 29 索引S 2526 值w
:第一次出现数组 2。
位置 7-2 索引C 4 索引S 2524 值w
:将一个 32 位长度的字符串,每一个字符转换成对应的 Unicode 编码,得到数组 2。
然后就是往上找这个 32 位字符串怎么来的,这里就直接贴上我找的日志:
====================================== 数组 2 逻辑======================================
位置 7-2 索引C 16 索引S 880 值w: w[2] = Math.random() => 0.09380310471283848
位置 7-2 索引C 24 索引S 882 值w: w[3] = ['0','1','2'...'y','z'] => 0-9、A-Z、a-z 组成 62 位数组
位置 7-2 索引C 30 索引S 886 值w: w[3] = w[3].length = 62
位置 7-2 索引C 42 索引S 892 值w: w[2] = w[3] * w[2] => 62 * 0.09380310471283848 = 5.815792492195985
位置 7-2 索引C 48 索引S 894 值w: w[1] = w[1] | w[2] => 0 | 5.815792492195985 = 5
位置 7-2 索引C 31 索引S 896 值w: y = w[1], l[10] = w[y] => l[10] = 5
位置 7-2 索引C 24 索引S 900 值w:
位置 7-2 索引C 24 索引S 904 值w:
位置 7-2 索引C 24 索引S 908 值w:
位置 7-2 索引C 24 索引S 912 值w: y = l[10], w[4] = y => w[4] = 5
位置 7-2 索引C 25 索引S 916 值w: w[3] = w[3][w[4]] => w[3] = ['0','1','2'...'y','z'][5] = '5'
位置 7-2 索引C 13 索引S 918 值w: w[1][0] = w[3] => w[1] = ['5']
上述步骤一直重复,w[1][0] = w[3]、w[1][1] = w[3]、 w[1][2] = w[3] 以此类推
直到 w[1] 累积成一个 32 位数组后:["5","B","q","j","3",...,"T","Z","v","K","j"]
位置 7-2 索引C 16 索引S 966 值w: g.join(m) => ["5","B","q","j","3",...,"T","Z","v","K","j"].join("")
位置 7-2 索引C 4 索引S 2524 值w: E(m) => v("5Bqj3BmgfWfa7W6AnapzbdqbO0lTZvKj")
位置 7-2 索引C 29 索引S 2526 值w: 第一次出现数组 2
归纳一下数组 2 的生成步骤:
Math.random() 方法生成一个随机数;
生成一个由 0-9、A-Z、a-z 组成 62 位数组 ['0','1','2'...'y','z'];
取 62 位数组的长度为定值 62;
0 | 62 * 随机数,得到一个整数;
上一步整数当作索引,取 62 位数组里的一个元素,放到一个新数组里;
重复以上步骤,直到取了 32 个元素,即新数组长度为 32;
将新 32 位数组经过 .join("") 方法转换为 32 位的字符串;
将 32 位字符串,每一个字符转换成对应的 Unicode 编码,得到数组 2
进一步总结:实际上这么多骚操作,就是在 62 位数组里随机取值,组成 32 位字符串,再转 Unicode 就行了,本地复现很简单:
function getRandomStr(count) {
var arr = ["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z"];
var i = arr.length, min = i - count, index;
var str = ""
while (i-- > min) {
index = Math.floor((i + 1) * Math.random());
str += arr[index]
}
return str
}
var randomStr = getRandomStr(32)
var array2 = new Uint8Array(Buffer.from(randomStr));
数组 3(上千位)
再来看数组 3,也就是有好几千位的这个数组怎么来的。
位置 7-2 索引C 31 索引S 614 值w
:第一次出现数组 3,到这里就可以看到有 AES
相关字样,稍微往前翻翻还能看到 AES-GCM
、iv
、key
等字样,这里其实就可以大胆猜测一下了,这个数组 3 的长度很长但不是固定的,肯定是和轨迹之类的有关,会随着轨迹的长短变化而变化,中间还可能用到了 AES 或者 AES-GCM 加密算法。
这里直接搜索这个数组 3,会发现就只有这一个结果,那么就看上一步,下条件断点看看。
位置 7-2 索引C 26 索引S 610 值w
:这里的 w[1] 为 Uint8Array()
方法,w[2] 为 ArrayBuffer
对象,new Uint8Array(ArrayBuffer)
就得到数组 3 了。
接下来的重点就是找一下 ArrayBuffer
对象怎么来的,这个对象在日志里是搜索不到的(当然也可以改一下日志的写法,判断一下类型,是 ArrayBuffer
对象的话,给它转成 Uint8Array
打印出来,不过太麻烦了没这必要),一般的思路是按照打印的日志,挨个往上找,看看在哪里生成这个 ArrayBuffer
,不过这样稍稍有点儿麻烦,下面介绍一个取巧的思路。
观察日志,位置 7-2 索引C 31 索引S 614 值w
这个地方是第一次出现数组 3 的地方,往前几步,位置 7-2 索引C 0 索引S 642 值w
这个地方又有一个大数组,我们称其为数组 3-A,如下图所示:
大胆猜测 ArrayBuffer
的生成,与数组 3-A 有关,接下来的思路就是从数组 3-A 第一次出现的地方开始,一直单步跟,看能不能生成,以及怎么生成 ArrayBuffer
的。
位置 7-2 索引C 10 索引S 568 值w
:第一次出现数组 3-A。
位置 7-2 索引C 16 索引S 574 值w
:这里出现了 SubtleCrypto.encrypt()
方法,查一下文档:https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto ,可以知道这里就是使用了 AES-GCM 加密算法,返回的是一个 Promise
异步对象,而加密的对象正是数组 3-A,如下图所示:
位置 7-2 索引C 16 索引S 640 值w
:走到这里的时候 Promise.then()
将 AES-GCM 加密结果给到 arguments
,这里第一次出现了 ArrayBuffer
,如下图所示:
确定了是 AES-GCM 加密,我们本地应该怎样实现呢?这里给出 JS 和 Python 版本的实现方法(PS:不知道怎么实现建议直接问 ChatGPT):
const Crypto = require('crypto');
function aesEncrypt(text, key, iv) {
// 创建加密器
const cipher = Crypto.createCipheriv('aes-256-gcm', key, iv);
// 加密明文
const ciphertextPart1 = cipher.update(text, 'utf8');
const ciphertextPart2 = cipher.final();
// 获取 GCM 校验值
const tag = cipher.getAuthTag();
// 将加密结果和 GCM 校验值拼接
const ciphertext = Buffer.concat([ciphertextPart1, ciphertextPart2, tag]);
return ciphertext;
}
const key = Buffer.from('adaae7a5ef9f301a445888beb9205be2f83660246b8153d8a677794e0d2af7de', 'hex')
const iv = Buffer.from('b963490c4e5b50bce2c77d06', 'hex')
const plaintext = 'This is a secret message.';
const encryptedData = aesEncrypt(plaintext, key, iv)
console.log(encryptedData)
console.log(new Uint8Array(encryptedData))
console.log(encryptedData.toString('hex'))
from binascii import hexlify
from Crypto.Cipher import AES
key = bytes.fromhex('adaae7a5ef9f301a445888beb9205be2f83660246b8153d8a677794e0d2af7de')
nonce = bytes.fromhex('b963490c4e5b50bce2c77d06')
plaintext = b'This is a secret message.'
crypto = AES.new(key=key, mode=AES.MODE_GCM, nonce=nonce)
ciphertext, tag = crypto.encrypt_and_digest(plaintext)
encrypted_data = hexlify(ciphertext + tag).decode('utf-8')
print(encrypted_data)
走到这儿了,我们还需要搞定三个东西:AES-GCM 的 key 和 iv,以及被加密对象数组 3-A 是怎么来的。套路都是一样的,这里我也直接贴上我的分析笔记了:
====================================== 数组 3 逻辑======================================
// AES-GCM key、iv 逻辑
位置 6-1 索引C 2 索引S 184 值w: String.fromCharCode 生成 128 位字符串 s4, 为固定值 => 4dd4c2e6b83...38aadd58
位置 6-1 索引C 31 索引S 190 值w: 第一次出现固定 128 位字符串 s4
位置 6-1 索引C 4 索引S 206 值w: SHA512(前面数组 2 里的随机字符串) => SHA512("5Bqj3BmgfWfa7W6AnapzbdqbO0lTZvKj") 生成数组 3-D3
位置 6-1 索引C 34 索引S 208 值w: 第一次出现数组 3-D3
位置 6-1 索引C 16 索引S 232 值w: 数组 3-D3.toString() 生成字符串 s3
位置 6-1 索引C 31 索引S 234 值w: 第一次出现字符串 s3
位置 6-1 索引C 40 索引S 246 值w: 字符串 s3 + 字符串 s4 得到长字符串 s2
位置 6-1 索引C 16 索引S 280 值w: CryptoJS.enc.Hex.parse(长字符串 s2) 得到数组 3-D2
位置 6-1 索引C 31 索引S 282 值w: 第一次出现数组 3-D2
位置 6-1 索引C 4 索引S 298 值w: SHA512(数组 3-D2) 得到数组 3-D1
位置 6-1 索引C 34 索引S 300 值w: 第一次出现数组 3-D1
位置 6-1 索引C 16 索引S 324 值w: 数组 3-D1.toString() 生成长字符串 s1
位置 6-1 索引C 31 索引S 326 值w: 第一次出现长字符串 s1
位置 6-1 索引C 16 索引S 450 值w: key = 长字符串 s1.substring(0, 64)
位置 6-1 索引C 16 索引S 484 值w: iv = 长字符串 s1.substring(64, 88)
// 数组 3 逻辑
位置 7-2 索引C 16 索引S 1838 值w: CryptoJS.enc.Utf8.parse(unescape(encodeURIComponent(JSON.stringify(轨迹)))) 得到数组 3-C2
位置 7-2 索引C 29 索引S 1840 值w: 第一次出现数组 3-C2
位置 7-2 索引C 4 索引S 1866 值w: SHA512(JSON.stringify(轨迹)) 得到数组 3-C1-1
位置 7-2 索引C 34 索引S 1868 值w: 第一次出现数组 3-C1-1
位置 7-2 索引C 16 索引S 1892 值w: 数组 3-C1-1.toString() 生成一个字符串
位置 7-2 索引C 29 索引S 1894 值w: 第一次出现字符串
位置 7-2 索引C 16 索引S 1936 值w: CryptoJS.enc.Hex.parse 方法将字符串处理成数组 3-C1
位置 7-2 索引C 29 索引S 1938 值w: 第一次出现数组 3-C1
位置 7-2 索引C 16 索引S 1998 值w: concat 方法拼接数组 3-C1 和数组 3-C2 得到数组 3-B
位置 7-2 索引C 29 索引S 2000 值w: 第一次出现数组 3-B
位置 7-2 索引C 16 索引S 564 值w: 数组 3-B .toString() 生成一个大字符串
位置 7-2 索引C 4 索引S 566 值w: 大字符串经过 g() 方法生成数组 3-A
位置 7-2 索引C 10 索引S 568 值w: 第一次出现数组 3-A
位置 7-2 索引C 16 索引S 574 值w: SubtleCrypto.encrypt() => AES-GCM 加密数组 3-A, 返回 Promise
位置 7-2 索引C 34 索引S 576 值w: w[2] = Promise
位置 7-2 索引C 30 索引S 578 值w: w[2] = Promise.then()
位置 7-2 索引C 36 索引S 584 值w: y = w[2], w[2] = w[1], w[1] = y => w[1] = Promise.then(), w[2] = Promise
位置 7-2 索引C 2 索引S 586 值w: w[3] = ""
位置 7-2 索引C 37 索引S 592 值w: w[3] = t()
位置 7-2 索引C 10 索引S 634 值w: w[3] = [t()]
位置 7-2 索引C 16 索引S 640 值w: 执行 Promise.then(), 加密结果赋值给 t() 里的 arguments, 第一次出现 ArrayBuffer
位置 7-2 索引C 0 索引S 642 值w: 最后一次出现数组 3-A
位置 7-2 索引C 27 索引S 598 值w: 没啥用
位置 7-2 索引C 11 索引S 602 值w: 没啥用
位置 7-2 索引C 24 索引S 606 值w: 没啥用
位置 7-2 索引C 26 索引S 610 值w: new w[1](w[2]) => new Uint8Array(ArrayBuffer)
位置 7-2 索引C 31 索引S 614 值w: 第一次出现数组 3
以上日志中涉及到的部分方法:
function u(e) {
var t = e.map;
return e === Array.prototype || e instanceof Array && t === Array.prototype.map ? t : t
}
function g(e) {
var t;
return new Uint8Array(u(t = e.match(/[\da-f]{2}/gi)).call(t, function(e) {
return parseInt(e, 16)
}))
}
总结一下数组 3 的生成步骤:
固定字符串 4dd4c2e6b83...38aadd58 + SHA512(数组 2 里的随机字符串) 组成一个长字符串 s2;
SHA512(CryptoJS.enc.Hex.parse(长字符串 s2)).toString() 得到长字符串 s1;
AES-GCM key 的值为:长字符串 s1.substring(0, 64);
AES-GCM iv 的值为:长字符串 s1.substring(64, 88);
CryptoJS.enc.Utf8.parse(unescape(encodeURIComponent(JSON.stringify(轨迹)))) 得到数组 3-C2;
CryptoJS.enc.Hex.parse(SHA512(JSON.stringify(轨迹)).toString()) 得到 数组 3-C1;
数组 3-C1.concat(数组 3-C2)得到数组 3-B;
g(数组 3-B.toString()) 得到数组 3-A;
new Uint8Array(AES-GCM 加密数组 3-A) 得到数组 3。
至此,captchaBody 的逻辑就找完了,还是比较简单的,现在就缺一个轨迹参数了。
轨迹生成
根据我们前面的分析日志可知,数组 3 的生成逻辑中,位置 7-2 索引C 16 索引S 1838 值w
和 位置 7-2 索引C 4 索引S 1866 值w
这两个地方对轨迹进行了加密,先将轨迹 copy 下来看看结构。
modified_img_width
、detRes
为定值,id
为 captcha/get
接口返回的,然后主要就是 reply
和 reply2
、models
和 models2
以及 log_params
。
轨迹里主要有两个记录点:
位置 4-1 索引j 0 索引C 2584 值x
:记录鼠标在验证码区域移动的轨迹,该轨迹为 models["z"]
的值。
位置 4-1 索引j 0 索引C 1776 值x
:记录鼠标滑动滑块的轨迹,该轨迹为 reply
的值,根据缺口距离伪造就行了。
位置 4-1 索引j 29 索引C 2134 值x
:第一次出现 models["x"]
,大致为鼠标第一次进入验证码弹框区域的坐标。
位置 4-1 索引j 29 索引C 2098 值x
:第一次出现 models["y"]
,大致为鼠标最后一次进入验证码滑条区域的坐标。
而 models["m"]
则可以在 models["z"]
的基础上随机增减一些值即可,至此 reply
和 models
就处理完了,还有对应的 reply2
和 models2
,都是在 reply
和 models
的基础上进行取值,自己找一下规律就可以了,也可以观察日志找一下怎么取值的,而 log_params
里,会有一些 challenge_code
等参数,都可以在之前的接口返回参数里找到值,log_params
里也有 models
和 models2
,区别就在于 log_params
里的没有 models["m"]
和 models2["m"]
,其他值都一样。
至此所有参数就都搞定了,还有一个细节需要注意一下,那就是提交验证太快了也不行,在请求 captcha/verify
接口之前睡眠两三秒就行了!
结果验证
测试通过率在 98% 左右:
测试将通过验证码的 fp
值作为 cookie 的 s_v_web_id
,可以免 X-Bogus
验证采集数据,以下是采集评论示例: