吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 106|回复: 0
上一主题 下一主题
收起左侧

[Web逆向] 【逆向分析】某验4代滑块验证码纯算还原——从抓包到全参数纯算

  [复制链接]
跳转到指定楼层
楼主
403Forbidden 发表于 2026-9-17 01:29 回帖奖励
本帖最后由 403Forbidden 于 2026-9-17 09:59 编辑

【逆向分析】某验4代滑块验证码纯算还原——从抓包到全参数纯算,把 w / td / td_sign 扒个底朝天

免责声明:本文仅用于网络安全技术学习、逆向原理研究交流,禁止用于任何商业、爬虫绕过、非法自动化场景,请勿用于未经授权的网站操作,一切违规使用由使用者自行承担法律责任。

文中靶站为某验官方公开 Demo(匿名、无需登录、无业务数据),所有票据类参数均为一次性样本。文章重推理过程与调试思路,代码片段仅作佐证,不提供完整可运行工程。

网站:aHR0cHM6Ly9ndDQuZ2VldGVzdC5jb20v


一、前言:小弟的第一帖,从一块滑不过去的拼图说起

先交代下背景:小弟第一次在吾爱发帖,文笔一般,排版尽力了,写得不好大家多担待,有说错的地方欢迎各位大佬随时拍砖,轻点哈。

说回正题。最近学爬虫进阶,撞上了某验4代(gt4)这块硬骨头。网上搜了一圈教程,好家伙,一半是几年前的,加密位置对不上;另一半直接甩一坨成品代码,加密是能跑,但鬼知道为什么这么写。最气人的是照着抄还翻车——后来才明白不是我的问题,是人家 SDK 升级了,字段全变了。得,求人不如求己,干脆自己从抓包开始,从头到尾磨了一遍,磨完把整个过程记了下来,就是这篇。

我一直觉得逆向这活儿跟破案差不多:抓包是勘察现场,调用栈是查监控,断点是审讯嫌疑人,混淆代码是嫌疑人满嘴黑话。你不可能一上来就知道真相(w 是怎么加密的),只能一点点收集线索,排除不可能的假设,最后把证据链串起来。gt4 这案子该有的套路全有:AES + RSA 双保险、工作量证明(PoW)、轨迹加密、动态键名防篡改块、蝌蚪文混淆……一个不少,当练习题再合适不过。

这篇按"接案 → 勘察 → 排查 → 收网"的顺序摊开来讲,重点是"怎么想到的、怎么定位的、踩了什么坑",而不是甩一坨成品代码——成品代码网上烂大街了,但调试时候脑子里那根线索,才是真东西。


二、靶场环境与工具

  • 靶站:某验官方在线体验站(上方 base64,解码即得),页面可以切换验证形式(一点即过 / 滑动拼图 / 文字点选等),本文只玩滑动拼图risk_type=slide)。
  • 这是官方公开 Demo,匿名可访问、无需注册,验证通过也拿不到任何业务数据,属于"打靶场",不是打真人。
  • 本轮用的 captcha_id54088bb07d2df3c46b79f80300b0abbe(官方 Demo id);官网另一个体验页用的是 24f56dc13c40dc4a02fd0318567caef5,两套协议一模一样,换个 id 照跑。
  • 工具清单:
    • Chrome DevTools(F12)+ 一点耐心
    • 抓包工具随意(Fiddler / Charles / Reqable)
    • Python 本地验证环境(requests + pycryptodome + ddddocr,仅用于验证推理结论)

三、勘察现场:先把网络请求流程捋顺

老规矩,先抓个包,分析一下整个网络请求流程。

打开网站,F12 调出开发者工具,刷新网页,得到如下抓包结果:

3.1 load 接口:要题

其中 load 接口就是获取验证码相关信息,分析之后可知:

请求网址:https://gcaptcha4.geetest.com/load

请求方法:GET

请求参数:

"callback": "geetest_1789540273446",
"captcha_id": "54088bb07d2df3c46b79f80300b0abbe",
"challenge": "fb3faf6a-89f8-4418-b631-58e85da59b2e",
"client_type": "web",
"risk_type": "slide",
"lang": "zh"

逐个参数过一遍:

参数 含义 补充说明
callback JSONP 回调函数名 geetest_ + 13 位时间戳。JSONP 跨域方案,服务端返回 geetest_xxx({...}) 字符串,前端执行这个回调拿到验证码数据
captcha_id 验证码业务 ID(公钥) 固定不变,网站在某验后台申请的 32 位唯一标识。一个业务场景对应一个 captcha_id,用来告诉某验是哪个网站的验证码,不同站点 id 不一样
challenge 本次验证会话流水号 前端本地生成 UUID v4,单次验证会话唯一标识,用于会话绑定、防重放。每次 load 请求都会变
client_type 客户端类型 web=PC 网页浏览器;可选还有 h5(移动端网页)、native(APP 原生)
risk_type 指定验证交互类型 slide = 滑动拼图验证码;其他常见:ai无感验证、icon图标点选等
lang 验证码界面语言 zh= 中文;en= 英文,控制弹窗提示文字语种

返回结果如下:

返回体(剥掉 JSONP 壳后)信息量很大,挑重点说,后面逆向全靠这几样"原料":

返回字段 作用 推理时怎么用的
lot_number 32 位 hex,本轮验证主键 后面一半的参数都拿它当原料(PoW、防篡改块、td_sign),每轮必变
slice / bg 拼图块和背景图的相对路径 拼上 https://static.geetest.com/ 下载,用来识别缺口
ypos 缺口纵向位置 可以拿来校验自己生成的轨迹 y 坐标合不合理
pow_detail {"version":"1","bits":10,"datetime":"...","hashfunc":"sha256"} 工作量证明参数,服务端动态下发,bits 我实测见过 8 和 10,写死必翻车
payload / process_token / pt / payload_protocol 服务端签发的票据四件套 verify 时原样带回去,一个都不能少、不能改
static_path SDK 资源版本号 排查协议变更先看它,版本别信教程、以自己抓包的 load 返回为准(协议是活的,隔一段时间就会变动一次)

3.2 verify 接口:交卷

勾选滑块拼图,点验证按钮,拖一下滑块,得到如下抓包结果:

请求参数分析:

{
    "callback": "geetest_1789540382923",                             # geetest_13位时间戳
    "captcha_id": "54088bb07d2df3c46b79f80300b0abbe",                # 固定值
    "client_type": "web",                                            # 固定值
    "lot_number": "bea1b709c39f4963b2851b9bde91e5c0",                # 验证码接口返回的lot_number
    "risk_type": "slide",                                            # 固定值
    "payload": "AgFD8gWUUuHFx-XvpP7J2VzBsP9pBscs",                   # 验证码接口返回的payload
    "process_token": "085ca65894e92d947fc324048...",                 # 验证码接口返回的process_token
    "payload_protocol": "1",                                         # 固定值
    "pt": "1",                                                       # 固定值
    "w": "6650d532bf6dac9ce4067f2661255b283c84e6...",                # 逆向值
    "td": "H4sIABs4qmoAA12TO67bQAxF96JaMEjOh6S..."                   # 逆向值
}

除了一堆"票据原样带回"的参数,真正要逆向的就俩:w(一长串小写 hex)和 td(一串 base64url 样子的东西)。

verify 的返回有两种形态,新手最容易栽在判定上

// 成功(注意 JSONP 外层 status 和业务层 result 是两层!)
{"status":"success","data":{"result":"success",
 "seccode":{"pass_token":"...","captcha_output":"...","gen_time":"..."},
 "score":"1","payload":"<新>","process_token":"<新>"}}

// 失败
{"status":"success","data":{"result":"fail","fail_count":1,
 "payload":"<新>","process_token":"<新>"}}

两个点划重点:①HTTP 层照样 200、JSONP 层照样 success,别拿 status 当通过判据,要看 data.result;②失败也发新票据——这是重试链的钩子,后面踩坑篇细说。验证成功的最终产物 seccode.captcha_output + pass_token + gen_time + lot_number 才是给业务后端做二次校验的凭据,本 demo 场景到拿到 seccode 就算通关。

3.3 整体流程图

过滤 gcaptcha4.geetest.com,你会发现整个验证拢共就两个关键请求,外加一堆静态资源:

1. GET https://gcaptcha4.geetest.com/load    ← 要题(验证码图片、难度参数、票据)
2. GET https://static.geetest.com/xxx.png    ← 下载背景图/拼图块(×2)
3. GET https://gcaptcha4.geetest.com/verify  ← 交卷(把滑动结果加密上交)

画成完整的流程图:

┌─ 1. load ────────────────────────────────────────────────┐
│ GET /load?callback&captcha_id&challenge&client_type=web   │
│          &risk_type=slide&lang=zh                         │
│ ← lot_number / slice / bg / ypos / pow_detail(bits,       │
│   datetime, hashfunc) / payload / process_token / pt      │
└───────────────────────────────────────────────────────────┘
┌─ 2. 取图并识别缺口 ──────────────────────────────────────┐
│ GET https://static.geetest.com/<slice>                    │
│ GET https://static.geetest.com/<bg>                       │
│ ddddocr.slide_match(slice, bg) → 缺口距离 xy              │
└───────────────────────────────────────────────────────────┘
┌─ 3. 本地构造(纯算) ────────────────────────────────────┐
│ a. 生成类人轨迹 track → td = b64url(gzip(json(track)))    │
│ b. PoW 暴力循环: sha256(pow_msg) 前缀零 bit 数 ≥ bits     │
│ c. data 明文组装(setLeft/userresponse/pow/em/防篡改块...) │
│ d. w = hex(AES-CBC(key, iv, json(data)))                  │
│        + hex(RSA(N, e=65537, key))                        │
└───────────────────────────────────────────────────────────┘
┌─ 4. verify ──────────────────────────────────────────────┐
│ GET /verify?callback&captcha_id&client_type=web           │
│          &lot_number&risk_type=slide&payload              │
│          &process_token&payload_protocol=1&pt=1&w=...     │
│          &td=...                                          │
│ ← result:"success" + seccode 或 result:"fail" + 新票据    │
│      → 失败时拿新payload调 load(...&pt=1&payload=...) 换题 │
└───────────────────────────────────────────────────────────┘

再仔细看,load 和 verify 都是 JSONP 风格的 GET:URL 里带 callback=geetest_xxx,返回体是 geetest_xxx({...}) 这种字符串——某验为了跨域走了 script 标签加载,这个细节后面写 Python 复现时要记得剥壳。

还有一个容易忽略的暗线:验证失败之后,前端会再发一次 load,这次参数里多了 pt=1&payload=xxx&lot_number=xxx(注意语言参数还会从 zh 变成 zho)——这是"换题"请求。很多兄弟复现时失败一次就废了,就是因为没发现 verify 的失败响应里塞了新的 payload/process_token,得拿新的去换题,旧票据直接作废。这条暗线后面踩坑篇还会提。

到这里,现场勘察完毕。明面上的参数都认识了,剩下的疑点就俩:w 到底是什么(本案主犯),以及 td 里面装的是什么(案中案)。


四、w 参数逐层拆解:本案主犯

4.1 先量体征:不看代码也能猜个八九不离十

把几次请求的 w 拉出来对比,先做三条基本观察:

  1. 全是小写 hex,长度很长且每次都不一样
  2. 同一轮里,滑动的距离、时间变了,w 就变——说明明文里包含行为数据;
  3. 长度变化有规律:前面一段总是 16 字节的整数倍 ×2,末尾固定多出 256 个 hex 字符(×2 是 hex 编码)。

第三条是破案关键:256 hex = 128 字节 = 1024 bit RSA 的密文长度!于是有了第一个假设:

w = hex( 某种分组加密(明文) ) + hex( RSA( 解密那个分组加密用的钥匙 ) )

分组加密块大小 16 字节,候选就 AES/DES 那几个,gt4 这个量级基本就是 AES。这是纯黑盒从长度特征推出来的结构,一行代码没看就先把 w 的骨架定了——这就是我想说的"侦探式推理",接下来去 JS 里找证据验证它。

4.2 关键字定位加密入口

直接通过关键字搜索即可找到加密位置 w:

w 的生成位置:(0,ᕺᖈᕴᖁ[ᕺᖆᕸᕷ(13)])(ᖆᖃᖉᖉ[ᕺᖆᕸᕷ(13)]_ᖁᕹᖘᖁ(582), _ᖀᖚᖙᖈ)

满屏 _ᕾᖗᖃᖃ$_CICk 这种蝌蚪文 + 字符串表混淆,直接读是读不了的,但没关系,先看它吃的是什么参数。接下来看看它的入参分别是什么:

参数1:ᖆᖃᖉᖉ[ᕺᖆᕸᕷ(13)]_ᖁᕹᖘᖁ(582):涉及滑块滑动的一些明文信息(命名为 data)

参数2:_ᖀᖚᖙᖈ:暂时不知道是什么对象。

先进入 ᕺᖈᕴᖁ[ᕺᖆᕸᕷ(13)] 函数内部看看怎么个事:

由此可知:w =  (0,ᖂᖁᖀᚚ[ᖁᕹᖘᖁ(140)])(c) + u

看到了吗兄弟们,跟我们 4.1 黑盒猜的结构对上了:w = c 段 + u 段,两段拼接。接下来就是各个击破,先啃 u。

4.3 u 值逆向:RSA 包钥匙

向上找所需要的参数(u 和 r):

u = r[a][ᖁᕹᖘᖁ(960)][ᕺᖆᕸᕷ(913)](s);

参数:s = "64cf21ca79b84bff",生成位置:(0,ᖂᖁᖀᚚ[ᖁᕹᖘᖁ(196)])()

所以 s 等价于 (65536 * (1 + Math.random()) | 0).toString(16).substring(1),一共生成 4 个这样的值,拼接在一起(16 个字符),作为明文进行传参。这个写法挺有意思的:利用 1 + Math.random() 保证结果落在 [65536, 131072) 区间,十六进制表示恰好 5 位,再 substring(1) 掐掉首位,剩下固定 4 位 hex——一个不用取模的随机 4 位 hex 生成器。

重点是 u 的加密方法:r[a][ᖁᕹᖘᖁ(960)][ᕺᖆᕸᕷ(913)],发现是调用了上面的一个对象中的方法:

查看方法,观察到 RSA 加密的特征 65537setPublic

看到 65537 基本可以报出嫌疑人姓名了。跟一下上面对象中 new (_ᕺᕷᖉᖘ[_ᕺᖆᕸᕷ(13)]) 实例化的过程,可以找到 setPublic 设置公钥所需要的模数和指数,抠出来的公钥(1024 bit,e=65537,padding 是 PKCS1_v1_5 ),可以自行还原算法:

所以 u = 小写hex( RSA-1024-PKCS1v1.5( aes_key ) ),加密的就是那把 AES 钥匙。一把钥匙锁箱子里,另一把钥匙开箱——经典的混合加密,HTTPS 同款思路。

4.4 c 值逆向:AES 装货

c = r[a][ᖁᕹᖘᖁ(1029)][ᖁᕹᖘᖁ(913)](_ᖄᖃᖙᖁ, s);

入参1:_ᖄᖃᖙᖁ:滑块滑动的一些明文信息(data)

入参2:s:前面由 4 个 4 位十六进制拼接生成的随机字符串(和 u 里那把钥匙是同一把,保持一致)

重要加密方法:r[a][ᖁᕹᖘᖁ(1029)][ᖁᕹᖘᖁ(913)],iv 的值及 16 字节特征为:"0000000000000000",跟进加密方法:v[_ᖈᕸᕷᕵ(913)],观察发现 AES 加密的特征:key、iv、mode、padding,可以暂且确定为标准的 AES 加密算法(建议固定入参在本地进行测试,确认是否是标准算法——我这边固定 key/iv/明文跑了一遍,和 Python pycryptodome 的 AES-128-CBC-PKCS7 输出逐字节一致,标准实现没魔改):

这里插一个我卡了半小时的坑:IV 是字符串 "0000000000000000" 的 UTF-8 字节(16 个 ASCII 字符 '0'),不是 16 个空字节 \x00!我一开始想当然拿 \x00*16 去解,死活对不上,差点怀疑算法被魔改了。看混淆代码时把字符串字面量当字节看,别想当然。

就此可以确认 w 参数的值:

w = 小写hex(AES-128-CBC(data, key=16位随机hex串, iv="0000000000000000"))
  + 小写hex(RSA-1024-PKCS1v1.5(aes_key))
  • AES:CBC 模式 + PKCS7 填充,key 就是那 16 个随机字符按 UTF-8 直接用;
  • 序列化用紧凑 JSON(无空格分隔符),字段顺序影响密文,复现时要对齐;
  • 服务端流程:拿私钥解出 RSA 段得到 key → 再解 AES 段拿到明文 data。两段各司其职。

五、data 明文:真正的宝藏图

骨架定了,接下来就要确认整个验证码中最关键的入参 data:

{
    "setLeft": 211,                                                                                                                                                                        # 滑动距离
    "passtime": 726,                                                                                # 滑动时间
    "userresponse": 211.7526707844021,                                                              # 逆向值
    "device_id": "",                                                                                # 固定值
    "lot_number": "e85ee3f7361e4437b50204d375e8ac40",                                               # 验证码接口返回的lot_number
    "pow_msg": "1|8|sha256|2026-09-16 16:47:44.036619+08:00|54088bb07d2df3c46b79f80300b0abbe|e85ee3f7361e4437b50204d375e8ac40||02674575f28618b3",  # 逆向值
    "pow_sign": "00570809e4128377ed3d00348fbe1da471067693333327f52dc7c10e52e02c14",                 # 逆向值
    "geetest": "captcha",                                                                           # 固定值
    "lang": "zh",                                                                                   # 固定值
    "ep": "123",                                                                                    # 固定值
    "biht": "1426265548",                                                                           # 固定值
    "gee_guard": {
        "roe": {
            "aup": "3",
            "sep": "3",
            "egp": "3",
            "auh": "3",
            "rew": "3",
            "snh": "3",
            "res": "3",
            "cdc": "3"
        }
    },                                                                                              # 固定值
    "dQFB": "BoHp",                                                                                 # 固定值(但是会随js文件动态变化)
    "3f7736": {
        "04d375e8": {
            "14e7": "7361e443"                                
        }
    },                                                                                              # 逆向值
    "em": {
        "ph": 0,
        "cp": 0,
        "ek": "11",
        "wd": 1,
        "nt": 0,
        "si": 0,
        "sc": 0
    },                                                                                              # 固定值
    "td_sign": "08f306c2f6b07c04e4047bea3d4e97f76734df12c605dad08298de96421306ab"                   # 逆向值
}

先给个"字段分桶表",复现的时候照着桶来,心里有数:

字段 处理
每轮必须更新 setLeftuserresponsepasstimelot_numberpow_msgpow_sign、防篡改块三层的键和值 全部由 lot_number / 识别结果 / pow_detail 驱动
可写死(demo) device_id:""geetest:"captcha"lang:"zh"epbihtem 整块 SDK 版本相关,升级时需复查
每轮签名 td_sign 用同轮 lot_number/td 计算,随 data 一起进 w

版本提醒:站点端 data 还出现过另一种形态——固定对是 "fX1g": "Uq0E"没有 gee_guard 整块,我这轮抓到的是 "dQFB": "BoHp" + gee_guard 都在。两种形态服务端都收(gee_guard 估计跟 load 返回的 guard 开关有关)。verify 批量 fail 又排查无果时,优先复查这两个版本相关点。

5.1 userresponse:一道小学换算题

可以直接搜索或者查看参数赋值的地方:userresponse: setLeft(滑动距离) / 1.0059466666666665 + 2

userresponse = setLeft / 1.0059466666666665 + 2,分母是滑轨有效长度与容器宽度的固定比例(SDK 常量),web 端长期没变。抓两组样本反解一下就能验证这个系数,这里不展开。

5.2 pow_msg / pow_sign:服务端给你出的一道题

pow_msg 前面片段由验证码接口返回字段与固定常量拼接而成:

pow_msg = '1|{bits}|{hashfunc}|{datetime}|{captcha_id}|{lot_number}||' + salt(16位随机hex)
          ↑版本  ↑难度  ↑算法      ↑load下发      ↑业务id     ↑本轮主键      ↑自己随机的盐

# salt:16 位随机 hex,浏览器端用的是同款"4 段 4 位 hex 拼接"的生成逻辑(每次独立随机,
# 和 AES key 不是同一个值!),Python 端 uuid4().hex[:16] 这种也行,随机即可

需要注意的是:并不是生成随机串直接拼接就完事,拼接完整字符串后会执行校验逻辑;若不满足校验条件,则重新生成随机串再次拼接,循环往复,直到产出符合校验规则的结果才返回最终数据(也就是说开头前 2 位必须为 '00',本轮 bits=8):

pow_sign 的值是 64 位,64 位一般我们就会联想到 SHA256 加密,验证码接口返回的 hashfunc:sha256 是否也暗示了加密的方式是 SHA256?

进行验证即可:

可以得出 pow_sign = SHA256(pow_msg),要求哈希值的前 bits 个二进制位全为 0,等价于整数比较 int(pow_sign, 16) < 2^(256-bits)

三个实战细节,都是我自己踩出来的:

  1. bits 是 load 动态下发的,同一靶站不同轮次我拿到过 8 和 10,老教程写死 bits=8 偶发翻车就是这个原因——每轮老老实实读 pow_detail
  2. 判定建议用整数比较而不是数 hex 前缀零的个数——bits 不是 4 的倍数时(比如 10)数前缀零会数错;
  3. 成本不用担心:bits=8 平均约 256 次、bits=10 约 1024 次 sha256,Python 毫秒级就出。

推理点:为什么要 PoW?——提高协议层重放/批量请求的成本。让你每次请求前必须先干一点"苦力",脚本的成本曲线就上去了。

5.3 动态防篡改块:键名都是活的

这是 gt4 最阴的一招。data 里有一块嵌套字典,外层键、中层键、内层键和值,全是 lot_number 按固定下标切片拼出来的。就是 data 里那个 "3f7736": {"04d375e8": {"14e7": "7361e443"}}

键值对是对验证码接口返回的 lot_number 值进行切割得到的:

lot_number[5:8] + lot_number[7:10]: {
    lot_number[20:28]: {
        lot_number[10] + lot_number[12] + lot_number[3] + lot_number[7]: lot_number[7:15]
    }
}

所以完整规则是:

外层键(6位) = lot[5:8] + lot[7:10]           # 两段切片在 index 7 重叠!
中层键(8位) = lot[20:28]                     # 连续切片
内层键(4位) = lot[10]+lot[12]+lot[3]+lot[7]  # 散点下标拼接
内层值(8位) = lot[7:15]

也就是说每一轮这个字段的键名都不一样,写死的模板下一轮直接失效。而且下标规则本身也是版本相关的——老教程里 [14:20][3]+[8]+[12]+[0] 那套在我这轮 SDK 已经对不上了,升级后拿新样本重新回归一遍就行。想偷懒的话,v1.9.x 可以试试在 Console 里执行 window.lib._abo,能把切片规则直接打印出来。

这招防的是什么?防"抓包 → 抠模板 → 改几个字段就重放"的低成本伪造,逼你必须理解生成逻辑、逐轮重算。respect,是真的阴。


六、td(new_track)逆向:藏在 data 里的案中案

6.1 疑点浮现:data 里没有 td_sign,却多了个 new_track

回看 4.4 断点里截到的加密前明文,发现一个怪事:在 c(AES 加密)之前,加密的参数中并没有看到生成的 td_sign,而是多了一个 new_track

而经过 (0,_ᕵᕸᕷᕴ[_ᖁᕹᖘᖁ(13)])(_ᖄᖃᖙᖁ, _ᖃᕹᖙᕶ[_ᖁᕹᖘᖁ(526)]) 之后的参数里就携带了 td_sign。可以推断出 td_sign 就是由这个方法里某个位置生成的;并且 new_track 值和结构(H4 开头)和 td 参数一样,可以推测出 td_sign 的值和 new_track 有关:

先把 new_track 的来路查清楚。跟栈:

6.2 跟栈:appendTrack 现形

找到一开始 data 赋值的地方往下看,发现下方有对 data 进行一些处理,非常可疑,继续往下看:

经过 (0,_ᕴᖘᖃᕹ[_ᖁᕹᖘᖁ(336)])(_ᕴᖗᖗᖆ, _ᕺᖆᕸᕷ[_ᖄᖃᖙᖁ(1454)], _ᕺᕶᕹᕸ) 生成之后可以明显看到 data 里面生成了 new_track:

其中参数1:_ᕴᖗᖗᖆ 是一开始赋值的生成的一段信息(就是 data 本尊):

参数2:ᕺᖆᕸᕷ[ᖄᖃᖙᖁ(1454)],携带了图片宽度、高度、开始/结束时间、鼠标(指针)轨迹组成的一段字符串:

参数3:_ᕺᕶᕹᕸ:暂时不知道是什么对象:

6.3 轨迹处理:从对象到数组

先往函数内部里跟:

跟进去发现中间还对轨迹进行了处理:

跟进去发现对每一组轨迹坐标做了压缩处理:

处理前的轨迹(对象):

{
    "x": 118,                                # 指针在验证区域内的水平像素坐标(clientX − 验证区域元素左边框)
    "y": 261,                                # 指针在验证区域内的垂直像素坐标(clientY − 区域顶边框)
    "width": 300.03125,                      # 采样时验证区域元素宽度
    "height": 261.53125,                     # 区域高度
    "t": 0,                                  # 相对轨迹起点的毫秒偏移
    "type": "start",                         # 事件类型:start(合成起点)/down(按下)/move(移动)/end(抬起结束)
    "source": 1,                             # 输入设备:{unknown:0, mouse:1, touch:2, pen:3}
    "pressure": 0                            # 触控压力(0~1),鼠标恒 0、按下时 pointer 事件报 0.5
},

处理后的轨迹(数组):

[
    0,                                           # 相对轨迹起点的毫秒偏移
    0.3933,                                      # x 水平比例  (x/width :118/300.03125→0.3933)
    0.998,                                       # y 垂直比例  (y/height:261/261.53125→0.998)
    0                                            # 类型码 start=0, move=1, end=2, down=3
],

说白了就是三步:x、y 除以宽高变成 0~1 的比例(保留 4 位小数,注意是比例不是百分比),type 字符串换成数字码,t 原样透传;width/height/source 上提到 payload 顶层变成 w/h/m,pressure 直接丢弃。单个点位从约 130 字节缩到约 25 字节,三十多个点累计省掉三分之二体积,后面再叠 gzip,传输开销压到最小。

补几个断点里挖出来的轨迹采集细节,模拟轨迹时很有用:

  1. 轨迹是鼠标指针的轨迹,不是滑块按钮的——y 值全程自由抖动就是证据,按钮被滑槽锁住是不可能有垂直漂移的;
  2. move 点有采样过滤:距上个采样点不足约 16.7ms(60fps 一帧)的丢、位移小于 2px 且间隔小于 80ms 的"静止点"丢——所以真实轨迹的间隔和位移分布都有下限;
  3. 点数超过 150 会触发抽稀(保首点、压尾段、保留 end 点),自己生成时控制在 150 以内最稳;
  4. 有个偷懒定位技巧:{start:0, move:1, end:2, down:3} 这个类型映射表和 maxPoints:150percentPrecision:4 这些配置全是明文,在混淆文件里直接全局搜 maxPoints 就能命中轨迹类的老巢,都不用解字符串表。

6.4 收网:gzip + base64url

继续跟进发现 td(newtrack)的生成位置为:`(0,ᖀᖚᖙᖈ[ᖁᕹᖘᖁ(13)])(n[ᖁᕹᖘᖁ(1434)](n_ᕺᖆᕸᕷ(1407)))`

把索引解出来,这行蝌蚪文翻成人话就是:gzipSync( strToU8( JSON.stringify(input_payload) ) )——三个都是 fflate 压缩库的标准方法名(strToU8 用 TextEncoder 转 UTF-8 字节)。

观察入参(命名为 inputpayload)以及生成的值,可以发现符合 base64url 编码的特征:"H4sI":gzip 的魔数头,只包含 `A-Z a-z 0-9 - ,**没有+ /**,并且**没有=` 填充**。input_payload 长这样:

{
    "m": 1,                                      # 输入设备类型:{unknown:0, mouse:1, touch:2, pen:3}
    "w": 300.03125,                              # 验证区域宽度(px)
    "h": 261.53125,                              # 验证区域高度(px)
    "s": 1789568784317,                          # 轨迹开始时刻(Unix 绝对毫秒)
    "e": 1789568784949,                          # 轨迹结束时刻(Unix 绝对毫秒)
    "p": [[0,0.3933,0.998,0],...]                # 处理后的指针轨迹点数组
}

所以可得:

td = new_track = base64url无填充( gzip( json_dumps(input_payload) ) )

纯编码没加密,软柿子一枚。但别高兴太早,这里有个我实际踩的坑:这里的 gzip 是 JS fflate 库压的,头部的 mtime 字段和 OS 字节跟 Python gzip 模块默认行为不一致(fflate 无参调用写 Unix 秒级 mtime + OS=3,Python gzip 默认行为不同)。如果你要本地生成 td 跟浏览器逐字节对拍,用 zlib.compressobj(6, zlib.DEFLATED, -15) 自己拼头尾:

co = zlib.compressobj(6, zlib.DEFLATED, -15)           # 原始 deflate, level 6
body = co.compress(data) + co.flush()
header = b"\x1f\x8b\x08\x00" + struct.pack("<I", int(time.time())) + b"\x00\x03"
tail = struct.pack("<II", zlib.crc32(data) & 0xFFFFFFFF, len(data))
gzip_bytes = header + body + tail                      # fflate 等价输出

当然——纯解密分析的话无所谓,gzip 解压端不校验这些字段,直接 gzip.decompress 就能看明文。

6.5 new_track 的最终去向:一套漂亮的"明密文绑定"

这里把整个生命周期串一下,这个设计其实挺漂亮的:

① 轨迹 payload → gzip+base64url → 产出字符串 new_track
② appendTrack 把 new_track 挂到 data 上(data.new_track = xxx)
③ verify 组装时,有个"提取器"函数做三件事:
    a. 取出 data.new_track
    b. delete data.new_track        ← 从 data 上删掉!
    c. data.td_sign = HMAC-SHA256(lot_number, new_track)
④ 然后才走 AES 加密生成 w
⑤ 被删掉的 new_track 本体,以明文形式放进 verify 的 query 里,改名 td

所以 new_track 和 td 是同一个值:它在 AES 加密前被"偷梁换柱"——本体明文放 query(td),只留一个 HMAC 签名(td_sign)进密文 w。服务端从 query 拿明文 td,从 w 解密拿 td_sign,用同一轮 lot_number 算一遍 HMAC 对拍——明文轨迹和密文投票数据就这样绑死了,想分开篡改都没门。这也解释了为什么你在 w 的解密结果里永远找不到 new_track 字段(我就在这疑惑了半天,还以为断点截错了)。


七、td_sign:给轨迹上把锁

既然已经得到 new_track 的值,那么重新回到刚开始推测生成 td_sign 值的位置:

入参1:_ᖄᖃᖙᖁ:携带 new_track 的 data(滑块滑动的一些明文信息):

入参2:ᖃᕹᖙᕶ[ᖁᕹᖘᖁ(526)]:"de8b78784dd941ada6158465b70ea31e"(验证码接口返回的 lot_number)

关键加密位置:ᕵᕸᕷᕴ[ᖁᕹᖘᖁ(13)],进去看看怎么个事:

发现 tdsign 的生成位置是:`(new (ᖀᖚᖙᖈ[ᖁᕹᖘᖁ(13)][ᕺᖆᕸᕷ(851)]))[ᕺᖆᕸᕷ(697)](ᖄᖃᖙᖁ, n)`

把两个索引解出来:851 是 SHA256,697 是 hex_hmac。通过 JS 源码特征识别,该加密为 HMAC-SHA256

  1. 特征:HMAC-SHA256 输出结果固定为 64 位 16 进制字符串;算法需要原始消息 (入参) + 密钥两个输入。
  2. 变量映射推断:
    • 密钥:lot_number → 对应源码第 1 个参数 _ᖄᖃᖙᖁ
    • 待加密原文 (消息):new_track → 对应源码第 2 个参数 n
  3. 算法调用逻辑:实例化 SHA256,调用 hex_hmac 方法,以 lot_number 作为密钥,对 new_track 消息执行 HMAC-SHA256 运算,返回 64 位 hex 小写哈希串。

Python 一行等价:hmac.new(lot_number.encode(), td.encode(), hashlib.sha256).hexdigest()

三个实战结论(都是拿真实请求喂出来的):

  1. 自洽是硬约束:密钥必须用同轮 load 下发的 lot_number,明文必须是与 verify query 里 td 参数同一条轨迹串——跨轮复用或与 td 不一致直接失效;
  2. 服务端按"可选增强信号"校验:带且自洽 → 校验通过;不带 → 跳过。我实测带 td+td_sign 和只带 td 两种形态都能 result:"success"
  3. 采集器策略建议:带上。多一层与轨迹绑定的自洽签名,服务端哪天开强校验也不会被动翻车,成本就一次 HMAC 计算,没有省它的理由。

至此,本案例所有需要逆向的值已经完成纯算,总结:

{    
    "w": "6650d532bf6dac9ce4067f2661255b283c84e6...",       # 逆向值 (小写hex(AES-128-CBC(data))+小写hex(RSA(aes_key)))
    "td": "H4sIABs4qmoAA12TO67bQAxF96JaMEjOh6S..."          # 逆向值 (base64url(gzip(json_dumps(input_payload))))
}

八、效果验证:纯算跑通才是硬道理

推理链闭环不算完,得让 Python 全流程跑通才算结案。整个复现流程六步:

lot_number, slice, bg, datetime, process_token, payload, bits, hashfunc = first_request()
                                 # 1. load:拿 lot/pow_detail/图片路径/票据,难度必须动态读
xy = int(get_slide(slice, bg))   # 2. 下载双图 + ddddocr.slide_match 识别缺口距离
track = gen_human_track(xy, width=300.03125, height=261.53125)   # 3. 生成类人轨迹
td = gen_td(track)               # 4. gzip + base64url 纯算出 td
w = get_w(xy, lot_number, datetime, td, bits, hashfunc)
                                 # 5. PoW 暴力循环 + data 组装(含防篡改块/td_sign) + AES + RSA
second_request(lot_number, payload, process_token, w, td)   # 6. GET /verify → result

几个验证期的实测结论:

  • 请求头很宽容:Python 端 Referer 用 https://gt4.geetest.com/、常规 Chrome UA 就行,Sec-Fetch-* 不带也过;Cookie 全不带也过——匿名 demo 链路的状态全走 lot_number/payload/process_token,不依赖 Cookie 会话(注意:这是官方 demo 的宽松策略,真实业务站点的风控等级、Cookie/IP/频控要重新验证);
  • 判定要显式断言 data.result == "success",别看 HTTP 200 或外层 status 就庆祝;
  • 轨迹设计对着校验维度来:速度曲线四段式(慢起→加速→减速→末端微调,人类没有匀速拖滑块的)、采样间隔 16ms 上下、总时长 600~1100ms 和 passtime 同量级、y 基线落在区域高约 88% 处带 ±1px 抖动;
  • 一致性三件套别穿帮:passtime ≈ 轨迹时长setLeft ≈ 轨迹终点位移ypos 量级合理,任何一项自相矛盾都是白给。

跑通的瞬间,返回 {"result":"success", "seccode":{...}},收工。


九、踩坑总结(每一条都是真金白银的头发)

按翻车顺序排,兄弟们引以为戒:

  1. *AES 的 IV 用了 `\x0016`:折腾半天死活对不上,才发现 IV 是字符串 "0000000000000000"**(16 个 ASCII '0')。看混淆代码时把字符串字面量当字节看,别想当然;
  2. PoW 难度写死bits 是 load 动态下发的,见过 8 也见过 10,读 pow_detail,别信教程;判定用整数比较,别数 hex 前缀零(bits=10 时会数错);
  3. 防篡改块照抄老教程下标:切片下标是版本相关的,教程里 [14:20] 那套在我这轮 SDK 已经对不上(现行规则见 5.3)。对不上别怀疑人生,抓两组新样本重新回归;特别注意外层键是"两段切片重叠拼接"这种非直觉形态——单段切片永远解释不了键名里的重复字符;
  4. w 解密结果里死活找不到 new_track:人家在 AES 加密前就 delete data.new_track 了,只在这两处之间打断点才能看到它存在的一瞬间。td 明文在 query 里,td_sign 在密文里,这是刻意的明密文绑定设计;
  5. 失败重试链没跟:verify 失败后直接重发旧 payload → 无限 fail。正确姿势:拿失败响应里的新 payload/process_token,走一次带 pt=1&payload=...(注意语言码会变 zho)的刷新 load 换新题。另外 fail_count 是累计的,失败后狂重试会抬风控等级;
  6. JSONP 壳没剥:Python 拿到响应直接 json.loads 报错——外面还有一层 geetest_xxx(...),正则剥壳再解析;
  7. gzip 字节对不上:fflate 和 Python gzip 的 mtime/OS 字段差异,自拼 gzip 头尾解决(见 6.4),解压分析则无所谓;
  8. 判定只看 HTTP 200 / status:verify 业务失败时 HTTP 层照样 200、status 照样 success,必须断言 data.result === "success"
  9. DOM 选择器想当然[class*="geetest_btn"] 能同时匹配到 300px 宽的 svg 容器和真正的滑块按钮,自动拖拽脚本算出负距离。写自动化先 getBoundingClientRect 打印确认选中了哪个元素;
  10. dQFB/gee_guard 形态差异:data 里这俩版本相关的字段,不同轮次形态可能不一样(fX1g/Uq0E vs dQFB/BoHp+gee_guard),批量 fail 且其他都排查过时,回头查这里。

十、写在最后

整案串起来回顾一下推理链:

w 长度特征(256 hex = RSA-1024)→ 黑盒猜出 AES+RSA 混合结构
关键字搜索 + 跟栈 → 锁定 w = AES(data) + RSA(key) 拼接
data 字段逐个击破 → userresponse 换算 / PoW 循环 / 防篡改块切片
data 里离奇的 new_track → 跟栈 appendTrack → 轨迹压缩处理 → gzip+base64url 秒破 td
new_track 加密前消失 → 顺藤摸瓜 → HMAC-SHA256 的 td_sign,明密文绑定的收尾设计

这套"黑盒观察 → 假设 → 断点取证 → 交叉验证"的流程,比结论本身值钱。gt4 会升级,字段会变(我这轮的防篡改块切片跟两年前的教程就完全不一样了),但方法论不会过期。

几句感悟:

  • 混淆不可怕,可怕的是不看就用别人的结论。教程版本的 SDK 和你面对的版本可能差着好几个协议变更,每一步都要自己取证;
  • 服务端的"宽容"也是线索:td 这类行为字段可带可不带,说明它是加分项不是门槛——风控产品要在体验和安全之间做平衡,这种平衡点就是逆向时的缝隙;
  • 尊重对手:某验这套组合拳(混合加密 + PoW + 行为指纹 + 动态键名 + 原生函数防篡改)在同类产品里完成度相当高,值得当教材研读。

学习延伸方向

  • 顺着 em 字段往下挖:环境探针都探了哪些点(webdriver、headless 特征);
  • load 里 gct_path 下发的 gct4.js 是独立的行为采集模块,和 gcaptcha4.js 的数据流向值得单独开一案;
  • 把本文方法迁移到其他 JSONP + 混合加密的站点练手(选公开靶场或自己搭的服务);
  • 读 fflate、RSA PKCS1_v1_5 的 RFC,把"会用库"升级成"懂原理"。

个人练习注意事项

  • 只打官方 Demo、自建环境或已授权靶场,别对真实业务站点下手;
  • 控制请求频率,别把人家 demo 打出限流,影响别人学习;
  • 一次性票据(lot_number/payload)用完即弃,别囤;
  • 结论注明取证时间和 SDK 版本(协议是活的,文档会过期,你的版本号就是别人的避坑指南)。

最后,第一次发帖,排版要是哪里乱了还请海涵。文中所有截图都是我调试过程中的真实现场,行号和索引对着你手里的 js 文件应该都能对上(记得先看自己抓包里 load 返回的 static_path 确认 SDK 版本,版本不同字符串表索引和行号会整体漂移,但方法论通用)。

如果觉得这篇对你有点帮助,麻烦给个吾爱币 +1,评论区聊聊都行;哪里写错了、有更优雅的思路,欢迎各位大佬随时拍砖,小弟虚心接菜。

再次声明:本文仅供技术学习与交流,请勿用于任何未经授权的场景。转载请注明出处。

免费评分

参与人数 6吾爱币 +6 热心值 +5 收起 理由
changle520 + 1 + 1 我很赞同!
zhangjiaheng + 1 + 1 我很赞同!
John_Mac + 1 我很赞同!
xcbjfy + 1 + 1 用心讨论,共获提升!
Fanqim + 1 + 1 我很赞同!
xinly + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-9-18 19:58

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表