抖音滑块验证码 captchaBody 逆向分析,JSVMP 纯算法还原


captcha_reverse

文章目录



声明

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

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请通过邮件 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 应该是用来区分不同网站的,每个网站都不一样,msTokenX-Bogus 参数在这里不校验,置空就行。

返回的 verify_center_decision_conf 里,有 detail 等值,后续接口会用到。

01

02

captcha/get 接口,请求参数里包含前面接口返回的 detailserver_sdk_env 等参数,fp 参数可通过 JS 生成,后续会讲怎么来的。返回数据里包含了验证码图片 URL、challenge_codeidtip_y 等参数,这些后续同样也会用到的。

03

04

captcha/verify 接口,提交验证,xx-tt-dd 为定值,captchaBody 是重点,通过 vmp 将轨迹等参数进行了加密处理,返回值 code 为 200 则表示通过。

05

06

参数逆向

其他参数没啥可讲的,很简单,这里讲一下 fpcaptchaBody

fp

fp 参数可以下个 XHR 断点,然后往前跟栈,栈可能有点多,但可以发现主要逻辑都在 captcha.js 里,同时 fp 参数前面有固定字符串 verify_,所以可以直接搜索这个字符串就能找到生成的位置。

07

08

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 的方法来快速定位。

09

captchaBody

captchaBody 是字节滑块的重点,跟栈可以发现主要逻辑在 captcha.js 里。

10

从最后一个 captcha.js 的栈开始,也就是 send 方法这里,可以看到 f 就是 captchaBody,如下图所示:

11

再往上走几步,就可以看到和 X-Bogus 一毛一样的 VMP 了,下图中的 w 就有 captchaBody 的值:

12

然后就正式进入插桩找逻辑的流程了,和之前 X-Bogus 一样,在两个大的 if 这里下日志断点:

13

14

这里就有很多细节了,首先是为什么这么多 ifreturn undefined,这是为了防止循环引用导致异常,有异常日志就不能正常输出,导致缺失一部分日志,下图可以解释这一现象:

15

还有就是我怎么知道 key == '13' 等等就会发生异常呢?当然一开始并不知道,不加这些 if,在控制台会看到一些报错,每一个报错修复一下就多了这些 if 判断了。

16

关于这个日志断点的写法,之前 X-Bogus 的文章有非常详细的介绍,当然这个写法多种多样的,可能还有更优的写法,能实现目标就行,这里就不再啰嗦了。

还有一个细节就是 位置 2-1位置 2-2,通过调试、观察、搜索可以发现 VMP 一共有七处,在不知道到底在哪个 VMP 生成 captchaBody 的情况下,可以在这七个地方都下日志断点,分别用位置 1 至 7 来区分。

17

当然也不能太死板,日志太多了也不好,比如第三个 VMP 的第二个 if 语句,也就是 位置 3-2 这里,日志会巨多,会给你卡死,这种就不要输出了,经过调试,大多数的逻辑在 位置 7-2,可以先从这里开始看。

滑动验证码,等待日志输出完毕,搜索 captchaBody 的值,看看第一次出现的位置是哪里,这里又双叒叕得注意,由于日志内容太长,控制台会自动折叠一部分,即便你右键 save 日志,折叠的部分也是没有的,在搜索的时候,折叠部分也不在搜索范围内,所以我们需要把折叠的展开,才能正确找到第一次出现的地方!

如下图所示,没展开之前搜索结果 34 个,第一次出现的位置是 位置 7-2 索引C 0 索引S 2952 值w

18

展开之后搜索结果 63 个,第一次出现的位置是 位置 7-2 索引C 10 索引S 2876 值w,这个位置才是正确的地方:

19

注意啦,这个索引值,有可能并不是每个人都一样的,但按道理来讲只要 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

20

位置 7-2 索引C 28 索引S 2792 值w: 第一次出现完整 Uint8Array 数组 A。

位置 7-2 索引C 16 索引S 2790 值w:两个 Uint8Array 数组合并组成数组 A。其中一个数组前 38 位有值,剩下的都是 0 填充,如下图所示的 g 值,另一个数组长度不一定,但通常是好几千,如下图所示的 m 值,我们将其称为数组 3

21

位置 7-2 索引C 16 索引S 2750 值w:两个 Uint8Array 数组合并组成上一步的 38 位数组,其中一个数组前 6 位有值,且为定值,剩下的都是 0 填充,如下图所示的 g 值,我们将其称为数组 1,另一个数组长度 32 位,如下图所示的 m 值,我们将其称为数组 2

22

这段步骤附上我的分析日志:

====================================== 合并数组 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。

23

然后就是往上找这个 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-GCMivkey 等字样,这里其实就可以大胆猜测一下了,这个数组 3 的长度很长但不是固定的,肯定是和轨迹之类的有关,会随着轨迹的长短变化而变化,中间还可能用到了 AES 或者 AES-GCM 加密算法。

24

25

这里直接搜索这个数组 3,会发现就只有这一个结果,那么就看上一步,下条件断点看看。

位置 7-2 索引C 26 索引S 610 值w:这里的 w[1] 为 Uint8Array() 方法,w[2] 为 ArrayBuffer 对象,new Uint8Array(ArrayBuffer) 就得到数组 3 了。

26

接下来的重点就是找一下 ArrayBuffer 对象怎么来的,这个对象在日志里是搜索不到的(当然也可以改一下日志的写法,判断一下类型,是 ArrayBuffer 对象的话,给它转成 Uint8Array 打印出来,不过太麻烦了没这必要),一般的思路是按照打印的日志,挨个往上找,看看在哪里生成这个 ArrayBuffer,不过这样稍稍有点儿麻烦,下面介绍一个取巧的思路。

观察日志,位置 7-2 索引C 31 索引S 614 值w 这个地方是第一次出现数组 3 的地方,往前几步,位置 7-2 索引C 0 索引S 642 值w 这个地方又有一个大数组,我们称其为数组 3-A,如下图所示:

27

大胆猜测 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,如下图所示:

28

位置 7-2 索引C 16 索引S 640 值w:走到这里的时候 Promise.then() 将 AES-GCM 加密结果给到 arguments,这里第一次出现了 ArrayBuffer,如下图所示:

29

30

确定了是 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 下来看看结构。

31

32

modified_img_widthdetRes 为定值,idcaptcha/get 接口返回的,然后主要就是 replyreply2modelsmodels2 以及 log_params

轨迹里主要有两个记录点:

位置 4-1 索引j 0 索引C 2584 值x:记录鼠标在验证码区域移动的轨迹,该轨迹为 models["z"] 的值。

位置 4-1 索引j 0 索引C 1776 值x:记录鼠标滑动滑块的轨迹,该轨迹为 reply 的值,根据缺口距离伪造就行了。

33

34

位置 4-1 索引j 29 索引C 2134 值x:第一次出现 models["x"],大致为鼠标第一次进入验证码弹框区域的坐标。

位置 4-1 索引j 29 索引C 2098 值x:第一次出现 models["y"],大致为鼠标最后一次进入验证码滑条区域的坐标。

models["m"] 则可以在 models["z"] 的基础上随机增减一些值即可,至此 replymodels 就处理完了,还有对应的 reply2models2,都是在 replymodels 的基础上进行取值,自己找一下规律就可以了,也可以观察日志找一下怎么取值的,而 log_params 里,会有一些 challenge_code 等参数,都可以在之前的接口返回参数里找到值,log_params 里也有 modelsmodels2,区别就在于 log_params 里的没有 models["m"]models2["m"],其他值都一样。

至此所有参数就都搞定了,还有一个细节需要注意一下,那就是提交验证太快了也不行,在请求 captcha/verify 接口之前睡眠两三秒就行了!

结果验证

测试通过率在 98% 左右:

35

测试将通过验证码的 fp 值作为 cookie 的 s_v_web_id,可以免 X-Bogus 验证采集数据,以下是采集评论示例:

36