案例地址:极验行为验证·在线体验
郑重声明:本文只做学习研究分享,若有人用文章技术做出不法行为与本人无关,如有侵权联系作者立即删除。
这一例是极验 4.0(gcaptcha4)里的消消乐验证:九宫格图案,让你交换相邻的两个,使三个一样的连成一条线。之前写过它的无感(044)和滑块(045),这次把这套里最"不像验证码"的玩法拆开。
先说结论,免得大家白跑:参数链路我全部还原了,本地相互证明四项全对(MATCH: True),但服务端对当前这个自动化会话直接返回forbidden,前端表现为60500 极验封禁。这个卡点是怎么定位到的、以及怎么判断"不是参数问题",我会写在最后一节——这其实比"跑通了"更值得讲。
一、先认识一下这个验证码
打开案例地址,往下滚到「在线体验」,左边有一块配置区,「验证形式」下面有四个按钮:一点即过、滑动拼图、文字点选、消消乐。点消消乐验证,再点右边的验证按钮,就会弹出九宫格。
弹出来的面板长这样,提示语是"请点击交换相邻图案一次,使三个相同图案连成一条直线":
这里有个很关键的认知:消消乐不需要图像识别。滑块你得算缺口位置,点选你得认字,而消消乐的图案是服务器直接用数组告诉你的(下面第二节就能看到ques)。所以这一例的难度不在"看图",全在参数线上:w怎么加密、pow_sign怎么算、td和td_sign是什么关系。
二、抓包:只有 load 和 verify 两个接口
按 F12 打开开发者工具(浏览器自带的调试面板),切到Network(网络)面板,勾Fetch/XHR,然后点验证按钮。会看到两条发往gcaptcha4.geetest.com的请求:
GET /load:拿这一局的图案矩阵、lot_number、payload、process_token、pow_detailGET /verify:把你交换的两格、行为轨迹交上去校验
小知识:这两条请求在 Network 里的类型是
script而不是xhr,因为极验用的是 JSONP——往页面里插一个<script src="...&callback=geetest_xxx">,服务端返回geetest_xxx({...})这样一段 JS,浏览器执行它就把数据交给了页面。所以你在 XHR 断点上是抓不到它的,别问我怎么知道的。
load的真实返回(今天 14:00 这一局,我原样贴出来):
geetest_1793512842697({"status": "success", "data": { "lot_number":"ceed89f022534306b1abb22a315197d3", "captcha_type":"match", "imgs":["/nerualpic/v4_test/v4_match_test/robot/robot_emoji_01.png", "/nerualpic/v4_test/v4_match_test/robot/robot_emoji_06.png", "/nerualpic/v4_test/v4_match_test/robot/robot_emoji_03.png", "/nerualpic/v4_test/v4_match_test/robot/robot_emoji_05.png"], "ques":[[1,0,2],[0,3,0],[3,1,2]], "pt":"1","lt":"1","captcha_mode":"risk_manage","check_device":true, "pow_detail":{"version":"1","bits":10,"datetime":"2026-11-01 11:10:27.624155+08:00","hashfunc":"sha256"}, "payload":"AgFD8gWUUuHFx-XvpP7J2d_uDgq3WMeo...(省略)", "process_token":"cc34ed08d8d7ad049eead3e83337ef7ded0df9eec9efb94fff63047dac76151e", "payload_protocol":1}})三个要盯的东西:
ques:3×3 的数字矩阵,数字是imgs数组的下标。imgs[0]就是 emoji_01,imgs[1]是 emoji_06……lot_number:这一局的编号,后面 PoW、td_sign、指纹切片全要用它。pow_detail:告诉你这道"证明你算过"的题怎么做——bits:10、hashfunc:sha256、还有一个datetime。
三、ques 的坐标方向,这一步我踩了坑
矩阵有了,怎么变成"交换哪两格"?我一开始按直觉把ques当成ques[行][列],结果提交的答案被判无效。回到页面上对着 DOM 一格一格核对才发现:
面板里每个格子的 class 名是geetest_item-x-y,而它的 CSS 是left: x*33.4%、top: y*33.4%。也就是说——
ques[x][y],第一个下标是列(左→右),第二个下标是行(上→下)。
规则是:交换相邻(上下或左右)的一对格子,让某一行或某一列三个相同。斜线不算。
求解代码就是暴力枚举,二十来行:
def solve_match(ques): """ques[x][y]=图标下标;返回相邻交换的一对坐标 [[x1,y1],[x2,y2]],只认横/竖三连""" lines = [] for y in range(3): lines.append([(x, y) for x in range(3)]) # 三条横线 for x in range(3): lines.append([(x, y) for y in range(3)]) # 三条竖线(注意:斜线不算) def has_line(g): for line in lines: v = g[line[0][0]][line[0][1]] if all(g[a][b] == v for a, b in line): return True return False for x in range(3): for y in range(3): for dx, dy in ((1, 0), (0, 1)): # 只和右邻、下邻交换就够 nx, ny = x + dx, y + dy if nx < 3 and ny < 3: g = copy.deepcopy(ques) g[x][y], g[nx][ny] = g[nx][ny], g[x][y] if has_line(g): return [[x, y], [nx, ny]] return None相互证明(第一处):上面那局ques=[[1,0,2],[0,3,0],[3,1,2]],我本地算出来的是[[0,1],[1,1]];而浏览器真实提交的userresponse(断点里读到的,下一节贴原文)也正是[[0,1],[1,1]]。
本地求解: [[0, 1], [1, 1]] 浏览器真实提交: [[0, 1], [1, 1]] → MATCH: True顺便说那个坑:我最早写的版本把两条对角线也算进去了,结果服务端回fail。所以斜线不算,这是实测出来的,不是猜的。
四、w 参数:先定位,再断点读明文
verify请求里最吓人的还是w,一千多个十六进制字符。定位它的位置很简单:全局搜索(Ctrl+Shift+F)一个只在这里出现的参数名payload_protocol,一下就命中拼请求对象的那段代码:
// gcaptcha4.js(美化后)—— 拼 verify 请求的地方,w 就是 c var _ = { callback: ..., captcha_id: ..., challenge: ..., client_type: ..., lot_number: ..., risk_type: ..., payload: ..., process_token: ..., payload_protocol: ..., pt: ..., w: c };c是上一行算出来的:c = 加密函数(JSON.stringify(参数对象), config)。在这行下断点,然后点一次九宫格触发 verify,就停在加密函数里了。
断点处最有价值的三个局部变量(今天真实读到的,原样抄):
pt(加密前的明文,583 字节): {"passtime":2146,"userresponse":[[0,1],[1,1]],"device_id":"","lot_number":"7674cbf5cde0480484ea0a366a681168","pow_msg":"1|10|sha256|2026-11-01 11:10:27.624155+08:00|24f56dc13c40dc4a02fd0318567caef5|7674cbf5cde0480484ea0a366a681168||c663a0f8ebcc6686","pow_sign":"00321df96703fc25c7e1e5e577bdb03fff17c863f25e1e79fa54e401a2c3208d","geetest":"captcha","lang":"zh","ep":"123","biht":"1426265548","dQFB":"BoHp","bf55cd":{"0a366a68":{"e445":"5cde0480"}},"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},"td_sign":"dcdc43b6da06fdc0057a86d8e15688e58737190d46c6f170c363e15f5f335dff"} s(这一次的 AES key): ed9587dff393a086 u(RSA 密文,256 个 hex 字符 = 128 字节): 903c22fa8a9e5d1ede45e8edbb67e5ca19de...再往里看加密函数(我把混淆的变量名换成人能读的名字):
var s = guid(); // 随机 16 位 hex,AES 的 key var r = { "1": { symmetrical: AES实现, asymmetric: new RSA实现 }, "2": { symmetrical: new AES实现({key: s, mode: "cbc", iv: "0000000000000000"}), asymmetric: ... } }; var u = 非对称.encrypt(s); // 用 RSA 公钥加密那个随机 key while (o && (!u || 256 !== u.length)) { s = guid(); u = new RSA().encrypt(s); } var c = 对称.encrypt(明文, s); // 用 key 做 AES-CBC 加密明文 return arrayToHex(c) + u; // w = AES密文hex + RSA密文hex所以w的结构是:前段是 AES-CBC 密文(hex),后 256 个字符是 RSA 密文(hex)。服务端拿 RSA 私钥解出那 16 字符的 key,再解 AES。
这里就是老脚本最大的坑:网上很多极验 4 的旧代码把 AES key 写死成3a5d80e34b95d532。现在 key 是每次随机的,写死 key 解不开任何东西——我拿固定 key 去解今天抓到的真实w,直接Padding is incorrect。
RSA 公钥模数也不用去字符串里搜(搜不到,它是运行时拼出来的),直接在断点里new RSA().n.toString(16)读出来,是 1024 位:
c1e3934d1614465b33053e7f48ee4ec87b14b95ef88947713d25eecbff7e74c7977d02dc1d9451f79dd5d1c10c29acb6a9b4d6fb7d0a0279b6719e1772565f09af627715919221aef91899cae08c0d686d748b20a3603be2318ca6bc2b59706592a9219d0bf05c9f65023a21d2330807252ae0066d59ceefa5f2748ea80bab81 e = 65537相互证明(第二处,最关键的一处):拿断点里读到的 keyed9587dff393a086,在本地用 AES-CBC(iv 是 16 个字符0,PKCS7)去解浏览器真实发出去的w的前段密文:
ct = bytes.fromhex(w[:-256]) # 去掉尾部 256 个字符的 RSA 密文 pt = unpad(AES.new(b"ed9587dff393a086", AES.MODE_CBC, b"0"*16).decrypt(ct), 16)解出来 583 字节,和断点里看到的明文一字不差:
AES DECRYPT OK, len 583 {"passtime":2146,"userresponse":[[0,1],[1,1]],"device_id":"","lot_number":"7674cbf5cde0480484ea0a366a681168","pow_msg":"1|10|sha256|2026-11-01 11:10:27.624155+08:00|...||c663a0f8ebcc6686","pow_sign":"00321df96703fc25c7e1e5e577bdb03fff17c863f25e1e79fa54e401a2c3208d",...,"td_sign":"dcdc43b6da06fdc0057a86d8e15688e58737190d46c6f170c363e15f5f335dff"}这就是"标准 AES-CBC/PKCS7、没魔改"的硬证据:不是"看着像",是真解开了。
五、pow:它不是签名,是一道算力题
pow_detail里bits:10、hashfunc:sha256、datetime。明文里的pow_msg长这样:
1|10|sha256|2026-11-01 11:10:27.624155+08:00|24f56dc13c40dc4a02fd0318567caef5|7674cbf5cde0480484ea0a366a681168||c663a0f8ebcc6686 └v └bits└算法 └datetime(来自 load 的 pow_detail,原样) └captcha_id └lot_number └随机16位keypow_sign就是它的 sha256。但注意看:浏览器算出来的pow_sign全都以00开头——
00321df96703fc25c7e1e5e577bdb03fff17c863f25e1e79fa54e401a2c3208d 002adaee3a7e9abb79492f3a098cba9376c4ad18d839ee6c458ad22bb53c5b01 0006206ccfd06391fc6c567e179df08eb58d36f1e10d1cb6961e09456aa74600这就是bits:10的含义:哈希值的前 10 个二进制位必须是 0。所以客户端要不断换那个随机 key 重算,直到命中——平均试 2^10 = 1024 次,几十毫秒的事。这就是 PoW(Proof of Work,工作量证明):服务端验一下很便宜,你想批量刷就得付出算力。
while True: pow_key = rand_hex(16) pow_msg = f"{version}|{bits}|{hashfunc}|{datetime}|{captcha_id}|{lot}||{pow_key}" pow_sign = hashlib.sha256(pow_msg.encode()).hexdigest() if int(pow_sign, 16) >> (256 - bits) == 0: # 前 bits 位全 0 break相互证明(第三处):把浏览器那条pow_msg原样再算一遍 sha256:
browser pow_sign: 00321df96703fc25c7e1e5e577bdb03fff17c863f25e1e79fa54e401a2c3208d local sha256 : 00321df96703fc25c7e1e5e577bdb03fff17c863f25e1e79fa54e401a2c3208d → MATCH: True两个提醒:datetime必须原样用 load 里给的那个,不能填当前时间(我一开始拿datetime.now()格式化,白排了半天);bits是服务端下发的,哪天它改成 16,你的循环就要多试 64 倍。
六、td 与 td_sign:轨迹是单独的 query 参数
极验 4 新一点版本把轨迹拆出来了:verify的 URL 里除了w还有一个td,它是一段gzip 压缩后 base64url的字符串。把它解开(真实数据):
{"m":1,"w":300.375,"h":259.9125061035156,"s":1793502648600,"e":1793502650748, "p":[[0,0.1631,0.4963,0],[2,0.1631,0.4963,3],[2,0.1631,0.4963,2], [2147,0.496,0.4963,1],[2148,0.496,0.4963,3],[2148,0.496,0.4963,2]]}读法:w/h是面板宽高,s/e是起止毫秒时间戳,p是点数组,每个点是[相对毫秒, x/面板宽, y/面板高, 事件类型],类型里1=移动、3=按下、2=抬起。所以这就是"鼠标怎么挪到第一格、点下去、挪到第二格、点下去"。
而w明文里那个td_sign,是把td字符串签一道。断点里那段代码是:
var n = payload["td"]; if (n) { delete payload["td"]; payload["td_sign"] = new HMAC().hmac(key = lot_number, msg = n); // 用 lot_number 当 key return n; }相互证明(第四处):用真实的lot_number当密钥、真实td当消息,本地重算 HMAC-SHA256:
td_sign 重算: dcdc43b6da06fdc0057a86d8e15688e58737190d46c6f170c363e15f5f335dff 真实 : dcdc43b6da06fdc0057a86d8e15688e58737190d46c6f170c363e15f5f335dff → MATCH: True七、那几个"乱码字段"其实是 lot_number 的切片
明文里有两处看着像乱码:
"dQFB":"BoHp", "bf55cd":{"0a366a68":{"e445":"5cde0480"}}dQFB/BoHp、biht:1426265548我在四局里读到的都是同一组常量,直接抄。但那个三层嵌套的 key/value 每局都变,一开始我以为是随机串。断点往上翻,在拼参数的那个函数里看到:
var i = getStringByIndexes(config.lot, config.lotNumber); // 生成三层 key var r = getStringByIndexes(config.lotRes, config.lotNumber); // 生成 value var o = i.split("."); // 按点拆成三层,reduce 成嵌套对象而config.lot/config.lotRes是 SDK 里的下标表,断点里读出来长这样:
lot = [[[5,7],[7,9]], [[20,27]], [[10,10],[12,12],[3,3],[7,7]]] lotRes = [[[7,14]]]意思就是按这些下标去lot_number里取字符(区间是闭区间)。对着lot_number = 7674cbf5cde0480484ea0a366a681168手动验一遍:
lot[5:8] + lot[7:10] = "bf5" + "5cd" = "bf55cd" ← 第一层 key lot[20:28] = "0a366a68" ← 第二层 key lot[10]+lot[12]+lot[3]+lot[7] = "e"+"4"+"4"+"5" = "e445" ← 第三层 key lot[7:15] = "5cde0480" ← value和真实明文里的"bf55cd":{"0a366a68":{"e445":"5cde0480"}}完全对上。我又拿另外三局的 lot_number 复核,规则一致。所以这不是随机数,是从 lot_number 派生的校验位,服务端能反查你有没有真按规则拼。
def fingerprint_from_lot(lot): k1 = lot[5:8] + lot[7:10] k2 = lot[20:28] k3 = lot[10] + lot[12] + lot[3] + lot[7] return {k1: {k2: {k3: lot[7:15]}}}八、脚本与运行结果
把上面几节拼起来就是完整链路:
load(risk_type=match) → 读 ques,枚举相邻交换求 userresponse(只认横/竖) → pow:换随机 key 直到 sha256(pow_msg) 前 bits 位为 0 → td:gzip+base64url 的鼠标轨迹;td_sign = HMAC-SHA256(lot_number, td) → 明文 = {passtime, userresponse, ..., lot切片指纹, em, td_sign} → w = AES-CBC(明文, 随机key, iv="0000000000000000", PKCS7).hex + RSA(随机key).hex → verify脚本放在案例目录055-geetest消消乐/55纯算_20261101.py,--selftest只做本地相互证明(不联网),默认还会真实打一次。今天实跑输出:
== 自检:本地重算 + 回读,证明参数线自洽 == pow_sign: 002f97034c00d17f2ddb5ab952aab1cd8e485542d506f96f08bb1fa3b8c1e420 → MATCH: True 本地求解: [[0, 1], [1, 1]] 浏览器真实提交: [[0, 1], [1, 1]] → MATCH: True td_sign 重算: dcdc43b6da06fdc0057a86d8e15688e58737190d46c6f170c363e15f5f335dff → MATCH: True w 长度: 1440 (AES 部分 1184 hex + RSA 256 hex) 用同一 key 解回 w → MATCH: True 自检结论: 全部对齐 == 真实链路 == [load] captcha_type=match lot=8ebd7e90c7dd40b38d654ac73b3e63b6 [load] imgs = ['robot_emoji_02.png', 'robot_emoji_03.png', 'robot_emoji_05.png', 'robot_emoji_01.png'] [load] ques = [[2, 0, 3], [0, 1, 0], [3, 2, 3]] [load] pow_detail = {'version': '1', 'bits': 10, 'datetime': '2026-11-01 14:05:25.122230+08:00', 'hashfunc': 'sha256'} [solve] userresponse = [[0, 1], [1, 1]] [pow ] pow_sign = 0024457df36a96f586aa437fac5a1e0fcb856e91942b0f68ed68e292eb366cb8 [td ] 轨迹点数 = 61 [w ] 本地重算 w 并解密回读 → MATCH: True 明文长度 = 583 [verify] result = forbidden fail_count = 0九、卡点:答案对了,却被风控拦下(这节最值得看)
最后一行是forbidden,不是success。我没有假装它跑通了,而是花时间去确认到底是哪一环不对。判断过程是这样的:
第一步,看它认不认我的答案。故意把userresponse换成一个不成立的交换,服务端回的是:
[verify] result = fail fail_count = 1也就是说:答案错 →fail;而我按规则算出来的答案 →forbidden,而且fail_count一直是 0(服务端没把它当成"答错")。说明参数被正常解密、答案被判为正确,是正确之后被风控拦了。
第二步,排除自己少传了东西。我逐个补过:
- 补
td(从 6 个点的极简轨迹,到 61 个点的带抖动轨迹)→ 还是 forbidden - 干脆不传
td和td_sign→ 还是 forbidden - 补
lot_number切片指纹(第七节那套精确规则)→ 还是 forbidden - 补 PoW 难度(前 10 位为 0)→ 还是 forbidden
- 换
curl_cffi伪装 Chrome 的 TLS 指纹(防 JA3 识别)→ 还是 forbidden - 拉取 demo 页面 + gt4.js + gcaptcha4.js + css + 四个图标,模拟真实资源加载顺序 → 还是 forbidden
- 把 load 到 verify 的间隔拉到 9~12 秒、passtime 也拉到 7~10 秒 → 还是 forbidden
第三步,反过来验浏览器。同一个页面、同一台机器,我用调试浏览器在真实面板上点了正确答案(就是第三节那套坐标方向核对之后算出来的两格),前端直接弹:
极验封禁 请点击重试 60500|8c1c3b6c44cd49fa8ca966b40def9c74而同一时刻这个浏览器的无感(ai)验证是能过的,返回result: "success"、score: "1"。所以不是断网、不是 IP 不通,是极验对"消消乐这类需要交互答案"的验证挂了更强的风控:自动化会话(调试浏览器 / 无头特征 / 本机出口 IP 的历史行为)一旦被判可疑,答对也给你 forbidden,答错只给你 fail。
这就是我想留给你的一个重要习惯:验证码类案例,"参数全对"和"能过"是两件事。参数对不对,用 fail / forbidden 的区别就能判断;能不能过,还得看行为和环境。所以这篇的交付状态我如实写清楚:
- 已完成并相互证明:
ques求解方向与规则、w的 AES-CBC+RSA 结构(用真实 key 解开真实密文)、pow的难度公式、td_sign的 HMAC 公式、lot 切片指纹规则 - 未完成:
verify拿到success。原因不在脚本参数,在服务端风控判定 - 要复现"过"这一步,可行方向有三个:换网络出口(别用已经被标记的 IP);用日常在用的普通浏览器 profile 手工过(我在调试浏览器里点正确同样被 60500,说明调试浏览器本身也被识别);或者把这套参数接进一个未被标记的真实浏览器会话里,只让它替你点两下
顺带一个通用提醒(和 054 那篇一样):看到可疑参数先验证它作不作数。比如td,我一度以为它是决定成败的关键,结果不传也照样走到 forbidden 这一步——它在这条链路上并不是我原来以为的那道门。
十、小结
这一例真正的收获其实不是"过了验证码",而是把极验 4 现在这套参数结构彻底钉死了:
w = AES-CBC密文hex + RSA密文hex,AES key 每次随机、由 RSA 保护,iv 是 16 个字符0,明文里带lot_number派生的指纹字段;pow_sign不是签名,是 PoW,难度由服务端pow_detail.bits决定,datetime必须原样回带;td独立成 query 参数,td_sign = HMAC-SHA256(key=lot_number, msg=td);- 消消乐的
ques是ques[列][行],只认横线竖线,斜线不算; - 答对却拿到
forbidden时,先用"故意答错看是不是 fail"来区分参数问题和风控问题,别一上来就改加密。
如果你在本机换网络后真拿到了success,或者发现我上面哪一步判断错了,麻烦在评论区告诉我,我把这篇补成完整版。
如有问题请及时提出,加油加油。