【逆向分析】某验4代滑块验证码纯算还原——从抓包到全参数纯算,把 w / td / td_sign 扒个底朝天
免责声明:本文仅用于网络安全技术学习、逆向原理研究交流,禁止用于任何商业、爬虫绕过、非法自动化场景,请勿用于未经授权的网站操作,一切违规使用由使用者自行承担法律责任。
文中靶站为某验官方公开 Demo(匿名、无需登录、无业务数据),所有票据类参数均为一次性样本。文章重推理过程与调试思路,代码片段仅作佐证,不提供完整可运行工程。
网站:aHR0cHM6Ly9ndDQuZ2VldGVzdC5jb20v
一、前言:小弟的第一帖,从一块滑不过去的拼图说起
先交代下背景:小弟第一次在吾爱发帖,文笔一般,排版尽力了,写得不好大家多担待,有说错的地方欢迎各位大佬随时拍砖,轻点哈。
说回正题。最近学爬虫进阶,撞上了某验4代(gt4)这块硬骨头。网上搜了一圈教程,好家伙,一半是几年前的,加密位置对不上;另一半直接甩一坨成品代码,加密是能跑,但鬼知道为什么这么写。最气人的是照着抄还翻车——后来才明白不是我的问题,是人家 SDK 升级了,字段全变了。得,求人不如求己,干脆自己从抓包开始,从头到尾磨了一遍,磨完把整个过程记了下来,就是这篇。
我一直觉得逆向这活儿跟破案差不多:抓包是勘察现场,调用栈是查监控,断点是审讯嫌疑人,混淆代码是嫌疑人满嘴黑话。你不可能一上来就知道真相(w 是怎么加密的),只能一点点收集线索,排除不可能的假设,最后把证据链串起来。gt4 这案子该有的套路全有:AES + RSA 双保险、工作量证明(PoW)、轨迹加密、动态键名防篡改块、蝌蚪文混淆……一个不少,当练习题再合适不过。
这篇按"接案 → 勘察 → 排查 → 收网"的顺序摊开来讲,重点是"怎么想到的、怎么定位的、踩了什么坑",而不是甩一坨成品代码——成品代码网上烂大街了,但调试时候脑子里那根线索,才是真东西。
二、靶场环境与工具
- 靶站:某验官方在线体验站(上方 base64,解码即得),页面可以切换验证形式(一点即过 / 滑动拼图 / 文字点选等),本文只玩滑动拼图(
risk_type=slide)。
- 这是官方公开 Demo,匿名可访问、无需注册,验证通过也拿不到任何业务数据,属于"打靶场",不是打真人。
- 本轮用的
captcha_id 是 54088bb07d2df3c46b79f80300b0abbe(官方 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 拉出来对比,先做三条基本观察:
- 全是小写 hex,长度很长且每次都不一样;
- 同一轮里,滑动的距离、时间变了,w 就变——说明明文里包含行为数据;
- 长度变化有规律:前面一段总是 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 加密的特征 65537 和 setPublic:
看到 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" # 逆向值
}
先给个"字段分桶表",复现的时候照着桶来,心里有数:
| 桶 |
字段 |
处理 |
| 每轮必须更新 |
setLeft、userresponse、passtime、lot_number、pow_msg、pow_sign、防篡改块三层的键和值 |
全部由 lot_number / 识别结果 / pow_detail 驱动 |
| 可写死(demo) |
device_id:""、geetest:"captcha"、lang:"zh"、ep、biht、em 整块 |
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)。
三个实战细节,都是我自己踩出来的:
- bits 是 load 动态下发的,同一靶站不同轮次我拿到过 8 和 10,老教程写死
bits=8 偶发翻车就是这个原因——每轮老老实实读 pow_detail;
- 判定建议用整数比较而不是数 hex 前缀零的个数——bits 不是 4 的倍数时(比如 10)数前缀零会数错;
- 成本不用担心: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,传输开销压到最小。
补几个断点里挖出来的轨迹采集细节,模拟轨迹时很有用:
- 轨迹是鼠标指针的轨迹,不是滑块按钮的——y 值全程自由抖动就是证据,按钮被滑槽锁住是不可能有垂直漂移的;
- move 点有采样过滤:距上个采样点不足约 16.7ms(60fps 一帧)的丢、位移小于 2px 且间隔小于 80ms 的"静止点"丢——所以真实轨迹的间隔和位移分布都有下限;
- 点数超过 150 会触发抽稀(保首点、压尾段、保留 end 点),自己生成时控制在 150 以内最稳;
- 有个偷懒定位技巧:
{start:0, move:1, end:2, down:3} 这个类型映射表和 maxPoints:150、percentPrecision: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:
- 特征:HMAC-SHA256 输出结果固定为 64 位 16 进制字符串;算法需要原始消息 (入参) + 密钥两个输入。
- 变量映射推断:
- 密钥:
lot_number → 对应源码第 1 个参数 _ᖄᖃᖙᖁ
- 待加密原文 (消息):
new_track → 对应源码第 2 个参数 n
- 算法调用逻辑:实例化 SHA256,调用
hex_hmac 方法,以 lot_number 作为密钥,对 new_track 消息执行 HMAC-SHA256 运算,返回 64 位 hex 小写哈希串。
Python 一行等价:hmac.new(lot_number.encode(), td.encode(), hashlib.sha256).hexdigest()
三个实战结论(都是拿真实请求喂出来的):
- 自洽是硬约束:密钥必须用同轮 load 下发的
lot_number,明文必须是与 verify query 里 td 参数同一条轨迹串——跨轮复用或与 td 不一致直接失效;
- 服务端按"可选增强信号"校验:带且自洽 → 校验通过;不带 → 跳过。我实测带 td+td_sign 和只带 td 两种形态都能
result:"success";
- 采集器策略建议:带上。多一层与轨迹绑定的自洽签名,服务端哪天开强校验也不会被动翻车,成本就一次 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":{...}},收工。
九、踩坑总结(每一条都是真金白银的头发)
按翻车顺序排,兄弟们引以为戒:
- *AES 的 IV 用了 `\x0016`:折腾半天死活对不上,才发现 IV 是字符串 "0000000000000000"**(16 个 ASCII '0')。看混淆代码时把字符串字面量当字节看,别想当然;
- PoW 难度写死:
bits 是 load 动态下发的,见过 8 也见过 10,读 pow_detail,别信教程;判定用整数比较,别数 hex 前缀零(bits=10 时会数错);
- 防篡改块照抄老教程下标:切片下标是版本相关的,教程里
[14:20] 那套在我这轮 SDK 已经对不上(现行规则见 5.3)。对不上别怀疑人生,抓两组新样本重新回归;特别注意外层键是"两段切片重叠拼接"这种非直觉形态——单段切片永远解释不了键名里的重复字符;
- w 解密结果里死活找不到 new_track:人家在 AES 加密前就
delete data.new_track 了,只在这两处之间打断点才能看到它存在的一瞬间。td 明文在 query 里,td_sign 在密文里,这是刻意的明密文绑定设计;
- 失败重试链没跟:verify 失败后直接重发旧 payload → 无限 fail。正确姿势:拿失败响应里的新
payload/process_token,走一次带 pt=1&payload=...(注意语言码会变 zho)的刷新 load 换新题。另外 fail_count 是累计的,失败后狂重试会抬风控等级;
- JSONP 壳没剥:Python 拿到响应直接
json.loads 报错——外面还有一层 geetest_xxx(...),正则剥壳再解析;
- gzip 字节对不上:fflate 和 Python gzip 的 mtime/OS 字段差异,自拼 gzip 头尾解决(见 6.4),解压分析则无所谓;
- 判定只看 HTTP 200 / status:verify 业务失败时 HTTP 层照样 200、
status 照样 success,必须断言 data.result === "success";
- DOM 选择器想当然:
[class*="geetest_btn"] 能同时匹配到 300px 宽的 svg 容器和真正的滑块按钮,自动拖拽脚本算出负距离。写自动化先 getBoundingClientRect 打印确认选中了哪个元素;
- 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,评论区聊聊都行;哪里写错了、有更优雅的思路,欢迎各位大佬随时拍砖,小弟虚心接菜。
再次声明:本文仅供技术学习与交流,请勿用于任何未经授权的场景。转载请注明出处。