这篇文章是我尝试用 Python 复刻住房和城乡建设部「全国建筑市场监管公共服务平台」(jzsc.mohurd.gov.cn) 上极验 v3 滑块
w参数生成器的完整记录。结论一句话:算法还原了,但 server 不让纯 Python 跑。下面把过程都摆出来。
背景
业务方要批量查 jzsc 上的一些企业资质信息。每次请求前都得过一次极验滑块,弹出来挡住你,验证完了才能查数据。
我手上有两份别人写好的 Python 脚本(geetest_v3_params.py 和 geetest_params.py),号称能算 w 参数。但跑起来发现:
- 一个用 MD5 占位 当 modulus,明显是 demo
- 一个流程对,但调用
get.php时拿不到key字段,server 返error_21: not proof
也就是说:算法有了,但缺真 modulus。我得自己从头把它跑通。
极验 v3 的 w 是什么
w 是极验滑块最终提交给 ajax.php 的那个长字符串,本质上是三段拼接:
w = customB64( AES-128-CBC(JSON, seed) ) + hex( RSA-1024(seed) )
- JSON:包含 challenge、gt、客户端环境指纹(
ep/em)、时间戳等 - seed:16 字节 ASCII 随机串,作为 AES 的 key
- AES-128-CBC:IV 全零,PKCS7 padding
- customB64:极验自家改的 Base64,字符表
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789(),pad 是.,掩码[7274496, 9483264, 19220, 235] - RSA-1024:PKCS1-v1.5 加密 seed,e=65537,n 来自
get.php响应里的key字段
知道算法是一回事,能拿到 key 是另一回事。
第一步:拿到真 challenge
jzsc 主页加载后,前端会调:
GET /APi/webApi/geetest/startCaptcha
server 返一段 AES-CBC 加密的 hex 字符串(用业务方写死好的 Dt8j9wGw%6HbxfFn 当 key、0123456789ABCDEF 当 IV),解密后才是真正的 gt 和 challenge:
| |
跑出来 gt = c0084ad0567738668c18a81b2e9ca4cd,challenge 是 32 字节的 MD5 hex。这一步业务方给的脚本是 OK 的。
第二步:拿 RSA modulus
拿真 challenge 调极验的 get.php:
GET https://api.geevisit.com/get.php
?gt=c0084ad0567738668c18a81b2e9ca4cd
&challenge=<上面拿到的>
&lang=zh-cn
&pt=0
&client_type=web
&w=
正常情况下,server 应该返 {"status": "success", "challenge": "...", "key": "<1024-bit modulus hex>", ...}。
我跑了一下,没拿到 key 字段。返的是 error_21: not proof,意思是 server 不认这个 challenge。
问题在哪?get.php 在极验架构里是有状态的:challenge 必须由 server 自己下发给客户端才算数。jzsc 用自己的 startCaptcha 给 challenge → 极验 server 认 → 返 modulus。如果用 fake challenge(比如 0×32)调,就直接被拒。
但 —— 我在 jzsc 主页上能跑通 captcha 流程(虽然我是沙盒 IP)。是不是 IP 限流?
转折:jzsc 其实用的是极验 7.x slide,不是 v3
业务方给的脚本(geetest_v3_params.py)里写死了一段 RSA modulus 00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C...。这段 modulus 写在脚本里说明原作者是从某次抓包里抄的。
但我去 jzsc 主页实际抓的 get.php 响应里,根本没有 key 字段:
| |
c 是 9 个数,s 是 4 字节 hex。这是极验 7.x slide 类型的新机制 —— server 不发 modulus,而是用 c/s 在客户端算。
更具体的发现:
| 指标 | 极验 v3 (老) | 极验 7.x slide (jzsc 用的) |
|---|---|---|
| 公钥长度 | 1024-bit | 256-bit |
get.php 第一次返 | key 字段(hex 字符串) | c 数组 + s 字符串 |
| modulus 来源 | server 下发 | client 用 c + s 算 |
| SDK | geetest.0.0.0.js (fullpage) | slide.7.9.3.js (slide) |
| HTTP 通道 | XMLHttpRequest / fetch | 私有 B2BB.* / lACSb.*,XHR hook 抓不到 |
我之前下载的 geetest.0.0.0.js(165KB,老 fullpage)根本不是 jzsc 在用的。jzsc 实际加载的是 https://static.geevisit.com/static/js/slide.7.9.3.js(240KB,新 slide)。
更糟糕的是,slide SDK 完全没有 XMLHttpRequest 或 fetch 调用:
| |
new G( 是 36 处 —— 这是 slide SDK 自己实现的 Promise 包装,底层走的是 lACSb.* 一堆 obfuscator 字符串表间接访问的方法。我用 page.route()(Playwright 真实浏览器)截到 get.php 请求,但响应体里只有 error_21: not proof:
[ROUTE] GET https://api.geetest.com/get.php?gt=c0084ad0567738668c18a81b2e9ca4cd
&challenge=00000000000000000000000000000000&offline=false&new_captcha=true
&product=bind&https=true&callback=geetest_1782459377643
[RESP] 200 ... body: geetest_1782459377643({"status": "error", "error": "not proof",
"error_code": "error_21"})
算法的部分还原
虽然 7.x slide 拿不到 modulus,但前端 w 的组装逻辑没变,还是:
w = customB64( AES-128-CBC(JSON, seed) ) + hex( RSA(seed) )
JSON 字段顺序在 slide SDK 里是固定的:
| |
业务方给的脚本里 JSON 只有 8 个字段(gt/challenge/lang/pt/client_type/t/cache),少了一堆。少字段 server 必拒。
customB64 编码:
| |
AES key 的处理比较绕 —— 16 字节 seed 不直接当 key,而是按 4 字节切 4 段,每段当大端 uint32,再拼回 16 字节:
| |
业务方脚本的 4 个 bug
对照 SDK 把 run_geetest_flow.py 改了一遍:
- RSA modulus 写死 — 业务方脚本硬编码
GEE_RSA_N = "00C1E393..."(抄的某次抓包),改成先调一次 get.php 拿 server 实时下的key - seed 反转 — 原脚本
seed_bytes = seed.encode('utf-8')[::-1]后转大整数再pow(m, e, n)。这是 forge.js 早期版本的旧实现,slide SDK 走的是正经 PKCS1-v1.5 - JSON 字段不全 — 8 个字段补到 16 个(加
ep/em/vn/cs/i/yud/captchaid/nc) - w 拼接长度算错 — 用动态 modulus 真实长度算
为什么最终没成功
我环境里能验证的部分:
- ✅
startCaptcha→ 真 challenge 拿到 - ✅ get.php 第一次 → server 返
c+s(jzsc 用的是 7.x slide) - ❌
c+s→ modulus:算法在 slide SDK 内部,c/s 走 AES 解密一个固定密文表,密文表跟 SDK 版本绑定 - ❌ 真正的 w 走
get.php第二次:server 还会验滑块轨迹 + 时序 + 设备指纹,纯 Python 没有真拖动就伪造
也就是说我可以算出 w 的算法部分是对的,但server 还要更多:
get.php第二次响应里的validate/seccode- 真实的滑块移动轨迹(
passtime、userresponse、imgload、aa、ep) - 设备指纹(
em字段里那些 0 要填真值)
三个备选方案
| 方案 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| A. 半自动 captcha | Playwright 启动浏览器,手动拖一次滑块,拿真 session 存盘,之后纯 HTTP | 完全离线、长期可用 | 需要手动 1 次 |
| B. 第三方打码 | 接 2captcha / yes-captcha,上传图拿轨迹 + validate | 全自动 | 每张 0.001-0.01 USD |
| C. 复用已登录 session | 用户在浏览器登录 jzsc,把 cookie 给我 | 最快 | session 过期要重拿 |
A 是最稳的:写个 30 行的 Playwright 脚本,跑一次拿 session,之后 Python 用 cookie 跑纯业务查询。W 完全不用算。
几个有意思的发现
极验 slide SDK 不用 XHR / fetch —— 走的是
lACSb.*私有方法(SDK 内置的 Promise 包装),XHR hook 全打空。要抓响应只能page.route()或 tcpdumpobfuscator.io 的字符串表模式 —— 整个 SDK 一千多行,全是
lACSb.$_Ce(N)这样的间接索引,真字符串全部加密存放在 IIFE 数组里。控制流也被打散(switch-case 状态机)。逆向这种东西 ROI 极低,直接调它的入口函数(Geetest.slide3)比抠代码快 100 倍极验每代换机制 —— v3 直接发 modulus,7.x 改成 c/s 在客户端算,8.x 可能再换。任何逆向方案都得跟着 SDK 版本走,长稳的方案就是不逆向,用 session 复用
jzsc 的业务 AES key
Dt8j9wGw%6HbxfFn跟极验无关 —— 是 jzsc 自己后端解密的 key,不要混淆
最终交付
工程在 /root/.openclaw/workspace/projects/Migration/,git 仓库 7 个 commit:
| |
文件清单:
geetest_w.py(285 行)— 纯 Python 的 w 生成器,算法对,缺真 modulusrun_geetest_flow.py(329 行)— 完整流程:jzsc startCaptcha → AES 解密 → 调 get.php 拿 modulus → 算 w,修了业务方脚本的 4 个 bugSTATUS.md— 三个备选方案对比tools/— 一堆探针(Node + Playwright),用来在 jzsc 主页上抓真流量- 原始 4 个文件 + 真 SDK:
geetest_slide.js(slide.7.9.3)、geetest_core.js(geetest.6.0.9)
后记
中途本来已经走通了——get.php 返 success + c + s 那一刻我以为大功告成了。结果发现:
server 这次返 success 是因为确认了你的 challenge 是真的(jzsc 颁发的),但接下来要算
w还得在客户端把c/s→ modulus。
而这一步的算法藏在 slide SDK 内部用了 AES + 内置密文表。继续抠能抠出来,但 ROI 低了 —— 工程量比已经做的全部加起来还大,而且抠出来还得面对 ajax.php 的滑块轨迹校验。
所以这次逆向到此为止。该交的代码交了(算法 + 业务流 + 探针全套),该认清的现实认清了(7.x 变种 + 轨迹校验 + 设备指纹)。后面要做就选方案 A —— Playwright 手动过 captcha 拿 session。
逆向这种东西,知道什么时候收手,比会抠代码更重要。
