【JS 逆向百例】Vaptcha V4 手势验证码逆向分析

声明

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

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

前言

近期 vaptcha 验证码迎来了一次重大迭代,正式推出全新的 V4 版本。相较于迭代稳定、逻辑固化的旧版,V4 彻底重构了整套验证架构与算法流程,其将行为轨迹、生物特征、设备指纹、POW 与 AI 风控模型整合到统一验证链路。V4 版本由 AI 动态生成手势背景图,叠加点状、线状噪声(水彩溅点、墨点、火花、星点等等)、诱导线,轨迹同源伪装、软边发光、羽化,进一步提升了识别路径的复杂度。本文将对新版本进行相关分析:

逆向目标

  • 目标:Vaptcha V4 手势验证码
  • 网址:aHR0cHM6Ly93d3cudmFwdGNoYS5jb20v

抓包分析

本文分析官网 demo,已经有站点用上 V4 版本了,与官网的流程略有差别,仅供参考,只作学习研究。

之前 K 哥也写过 vaptcha 的分析文章,感兴趣的小伙伴可以去回顾下,对比看看有哪些改动:

mp.weixin.qq.com/s/gTP5GSImU...

V4 版本的侧重点更多的放在了图像识别的复杂度上,不像之前重风控,算法中各种环境参数、IP 相关联。当然,算法风控层面的难度是降低了,但是想要训练出一个成功率高的识别模型,也是较为困难的。

言归正传,现在来具体分析下该验证码。

老方案,先打开开发者工具抓包(避免漏了关键接口),再进入网页,点击右侧的 体验 VAPTCHA 即可触发验证码,未触发就是 IP 被风控了。

需要先从 /api/v1/config 接口拿到响应 cookies,该接口的请求参数和响应内容都经过 base64 编码:

可以去 K 哥的工具站,解码看看都是些什么:

www.kgtools.cn/secret/base...

Payload 中的几个非空参数,根据该接口上的 /v4j 接口可知,分别对应:

  • vid:盾 id,固定值,各站点不同;
  • sdkv:版本号;
  • tag:动态值,后文分析;
  • _t:13 位时间戳。

/api/v1/knock 接口返回了一些关键参数,后续会用到,请求参数 d 中则是一大堆环境相关的值:

其中 trajectory 为验证码背景图片的链接,和之前一样,还是经过乱序处理,需要还原,后文分析:

/api/v1/validate 即校验接口,请求中包含轨迹及一些环境指纹参数,和之前一样,若校验通过,会响应返回 token 值:

逆向分析

先来看看 /api/v1/config 接口的请求参数,从堆栈处跟到 v4.js 文件中,ctrl + f 搜索 "4.0.0",定位到了唯一的位置:

几个非空参数依次为 vid、sdkv、tag 和 _t,除了时间戳外,tag 参数也是动态变化,搜索定位,下图红框处即生成算法,生成了一个 32 位、小写的随机十六进制字符串,可直接用 python 复现:

python 复制代码
import secrets

def random_hex():
    return secrets.token_hex(16)

print(random_hex())  # 输出示例 -> d517feb8d8a8456a043197a5ce353d54

/api/v1/knock 接口主要就是一堆环境参数,跟栈下断后,即可跟到下图位置,可以自行逐一分析下,tag 和 knock 值需要与上一个接口一致,否则会报错 {"code":12,"msg":"config_required"}

该接口响应返回的参数都与验证码背景图片相关,直接拿到的背景图链接 trajectory 是乱序的,需要还原。

根据以往的经验,验证码图片大概率是通过 canvas 绘制的,下个 canvas 断点尝试下,重新触发图片,发现断到了 verify.html 文件中:

经过分析,背景图片被分成了 2 行 5 列,主要的还原逻辑如下:

JavaScript 复制代码
preparePow()
  // 1. 准备计算 PoW。
  //    只有当前还没有 orderStr,并且 boot 里有 result / salt 时才继续。
  //    orderStr 是图片还原顺序,有了它才能拼回 trajectory 图片。

  -> solvePow(...)
  // 2. 调用 window.VaptchaPow.solve(data)。
  //    核心算法在 pow.js 文件中。
  //    输入 pow_start、result、salt。
  //    它会计算出 PoW 结果,其中包含 powInfo.order。
  //    order 就是乱序图片的还原顺序。

  -> 得到 powInfo.order
  // 3. powInfo.order 一般是 0-9 的排列。
  //    例如 "3142098765"。
  //    因为图片被切成 5 x 2,一共 10 块。

  -> orderStr = String(powInfo.order)
  // 4. 把 order 转成字符串保存到全局变量 orderStr。
  //    后面还原图片和提交验证都会用到它。

  -> applySolvedOrder()
  // 5. 开始应用已经算出来的顺序。
  //    停止加载阶段的随机打乱动画。
  //    然后调用 renderRestoredBackground() 还原背景图。

  -> renderRestoredBackground(currentLoadUrl, orderStr)
  // 6. 根据 currentLoadUrl 和 orderStr 创建 10 个 div 切片。
  //    每个 div 都用同一张 trajectory 图片作为 background-image。
  //    通过 background-position 显示不同切片区域。

  -> getRestorePositions(orderStr)
  // 7. 把 orderStr 转成每个原始块对应的乱序位置。
  //    例如 originalIdx=0 时,查 order.indexOf(0),找到第 0 块现在在哪。
  //    返回形如 [{col, row}, ...] 的位置数组。

  -> 显示还原后的图片
  // 8. renderRestoredBackground 按原始顺序 append 10 个 div。
  //    div 内容来自乱序图中的对应位置。
  //    最终 CSS grid 显示成完整还原图。

Python 复现如下,完整算法已分享到知识星球中:

python 复制代码
def restore_trajectory_image(image, order_str):
    cols, rows = 5, 2
    order = [int(x) for x in str(order_str)]

    w, h = image.size
    piece_w = w // cols
    piece_h = h // rows

    restored = Image.new("RGB", (w, h))

    for original_idx in range(cols * rows):
        shuffled_idx = order.index(original_idx)

        src_col = shuffled_idx % cols
        src_row = shuffled_idx // cols

        dst_col = original_idx % cols
        dst_row = original_idx // cols

        box = (
            src_col * piece_w,
            src_row * piece_h,
            (src_col + 1) * piece_w,
            (src_row + 1) * piece_h,
        )

        piece = image.crop(box)
        restored.paste(piece, (dst_col * piece_w, dst_row * piece_h))

    return restored

最终的验证接口 /api/v1/validate 和前文提到的 /api/v1/knock 一样,也是一些环境参数(上量存在风控、一致性校验,参数不对,轨迹正确也会验证失败),主要多了加密后的轨迹和乱序还原顺序:

环境及轨迹加密位置,下断后(可以先通过 canvas 断点断进去)简单跟栈即可定位到:

JavaScript 复制代码
function encodeTrajectory(pts) {
    if (!pts || pts.length < 2)
        return '';
    var result = '';
    for (var i = 0; i < pts.length; i++) {
        var p = pts[i];
        result += Math.max(0, Math.min(65535, Math.round(p.x))).toString(16).padStart(4, '0').toUpperCase();
        result += Math.max(0, Math.min(65535, Math.round(p.y))).toString(16).padStart(4, '0').toUpperCase();
        if (i === 0)
            result += '00000';
        else
            result += Math.max(0, Math.min(1048575, Math.round(p.t - pts[i - 1].t))).toString(16).padStart(5, '0').toUpperCase()
    }
    return result
}

相关算法已分享到知识星球中,仅供学习交流。

结果验证

相关推荐
用户921080262861 小时前
复盘 AI Coding 工作台的流式链路:从 Sender 输入到 SSE 分流,再到 BubbleList 和右侧预览
前端
了不起的小明1 小时前
SwiftMesh:本地 3D 模型查看器,加密交付 + 转盘录屏一站式搞定,开源免费
前端
qq_185198691 小时前
SpringBoot-五-AOT
java·spring boot·后端
默_笙1 小时前
👍 我的代码被 ESLint 抓包了:单引号、var、没分号——全被当场点名
前端·javascript
搞个锤子哟1 小时前
vue项目引入icon的问题
前端
用户29975997127071 小时前
Service 层到底该 throw 异常还是 return 错误码?我在两个团队各推行过一种,最后被一个事务 Bug 说服了
后端
树上有只程序猿1 小时前
当低代码遇上AI,一个开发者的真实感悟
前端
郭邯1 小时前
从零手写一个 UA 解析工具:我用 AI 辅助开发,踩了这些坑
前端
小月土星1 小时前
吞噬星空 RAG 项目 MySQL 实战:从硬编码登录到数据库鉴权(Day 1)
后端