5 天逆向极验4滑块验证码:从 30 万行混淆 JS 到纯协议 5/5 success
本文记录了一次完整的极验4(Geetest v4)滑块验证码纯协议逆向过程。不使用浏览器自动化,仅通过静态分析、抓包对照和算法还原,实现了从请求构造到验证通过的全链路。
一、起因
事情是这样的:有个需求要对接极验4的滑块验证码,但不想用 Selenium/Puppeteer 这种重量级方案。于是想看看能不能纯协议搞定。
一开始以为就是"识别缺口位置 + 发个请求"的事,结果一做才发现,这玩意比想象中复杂得多。极验4 相比 v3 做了大量升级:混淆 JS、动态字段、AES+RSA 加密、PoW 工作量证明、设备指纹校验……每一层都不是省油的灯。
最终花了 5 天,从零开始把整条链路逆了出来。纯协议 5 轮测试,全部 success。
这篇文章就是这 5 天的完整复盘。
二、先看极验4到底做了什么
在动手之前,先搞清楚极验4的完整流程。抓包一看,核心就两个接口:
GET /load → 获取 lot_number、payload、process_token、bg(背景图)、pow_detail GET /verify → 提交 w 参数,返回 success/fail/forbidden看起来简单?坑全在w这个参数里。
w是一个加密后的字符串,里面包含了滑动轨迹、缺口位置、PoW 证明、设备指纹等所有信息。服务端用 RSA 私钥解密后逐一校验。
所以问题变成了:怎么构造一个合法的w?
三、第一步:抓样本,不要急着写代码
这是我的第一条经验:先抓样本,再反推逻辑。
我用 Chrome DevTools 抓了 5 组完整的请求-响应对,记录每一步的参数。然后开始对照差异。
抓包发现w的明文(通过 hookencodeURIComponent拿到)长这样:
{"setLeft":223,"passtime":1064,"userresponse":223.68173262996052,"device_id":"","lot_number":"6c04e401b1ca493cba5dc5b42503d94f","pow_msg":"1|8|sha256|2026-07-01T21:04:06.889891+08:00|54088bb07d2df3c46b79f80300b0abbe|6c04e401b1ca493cba5dc5b42503d94f||1842e618606dd118","pow_sign":"0055e47c211a88d83e7013489b0746820eca822adcf21643570e5e3e1080353e","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"}},"jCpk":"yZ7D","cba4":"ba5dc5","em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0}}一眼看上去,有些字段是固定的(geetest、lang、ep),有些是服务器返回的(lot_number、pow_msg),有些需要计算(setLeft、userresponse、w)。
但有个字段引起了我的注意:userresponse的值是223.68173262996052,而setLeft是223。这俩之间有什么关系?
四、关键突破:userresponse 公式
这是整个项目最关键的一个发现。
一开始我以为userresponse = setLeft + random(),因为看起来像是加了个小数。但用这个公式跑,结果永远是fail。
于是我换了思路:从混淆 JS 里找公式。
极验4的前端 JS(gcaptcha4.js)有大约 30 万行,经过了控制流平坦化、字符串加密、变量名混淆等多重保护。直接看是看不懂的,但可以搜索关键字。
搜userresponse没结果(被混淆了),但搜setLeft能找到关键函数。在提交逻辑附近定位到了这段:
// 混淆后的代码(简化)varsetLeft=parseInt(拖动距离,10);varobj={setLeft,passtime,userresponse:setLeft/this[1461]+2};this[1461]是什么?继续追,发现它等于:
this[1461]=0.8876*Math.min(340,容器宽度)/图片naturalWidth容器宽度固定 340,图片 naturalWidth 固定 300(极验4的背景图尺寸),所以:
this[1461] = 0.8876 * 340 / 300 = 1.0059466666666665最终公式:
userresponse=setLeft/1.0059466666666665+2用 5 组真实抓包样本验证:
| setLeft | 公式计算 | 真实抓包值 | 匹配 |
|---|---|---|---|
| 223 | 223.68173262996052 | 223.68173262996052 | ✅ |
| 39 | 40.76945099806485 | 40.76945099806485 | ✅ |
| 207 | 207.77631683588265 | 207.77631683588265 | ✅ |
| 112 | 113.3379105585452 | 113.3379105585452 | ✅ |
| 214 | 214.73493624579171 | 214.73493624579171 | ✅ |
5/5 全部精确到小数点后 14 位。
之前用setLeft + random()的时候,每次都是 fail。换成这个公式之后,第一次出现了forbidden而不是fail。
这说明什么?fail是答案错误,forbidden是答案正确但被设备指纹拦了。公式对了。
五、加密:AES + RSA
w参数的加密封装流程从 JS 里逆出来是这样的:
# 1. 轨迹 JSON → UTF-8 字节data_bytes=json.dumps(trajectory,separators=(",",":")).encode("utf-8")# 2. 生成随机 AES key(16 字符 hex = 128 bit)aes_key=secrets.token_hex(8)# 例如 "393961bfec0fafa2"# 3. AES-128-CBC 加密# IV = "0000000000000000"(16 个字符 '0',即 0x30,不是 0x00!)cipher=AES.new(aes_key.encode(),AES.MODE_CBC,b"0000000000000000")encrypted_data=cipher.encrypt(pad(data_bytes,AES.block_size))# 4. RSA-1024 加密 AES key(PKCS1 v1.5 填充)rsa_key=RSA.construct((RSA_MODULUS,RSA_EXPONENT))cipher=PKCS1_v1_5.new(rsa_key)encrypted_key=cipher.encrypt(aes_key.encode()).hex()# 5. 拼接w=encrypted_data.hex()+encrypted_key这里有个大坑:IV 是 16 个字符'0'(ASCII 0x30),不是 16 个零字节(0x00)。
一开始我用b'\x00' * 16做 IV,加密出来的结果和 JS 完全不一样。后来用 openssl 交叉验证才发现,JS 里的'0000000000000000'是字符串,不是空字节。
Python 实现:
fromCrypto.CipherimportAES,PKCS1_v1_5fromCrypto.PublicKeyimportRSAfromCrypto.Util.Paddingimportpad RSA_MODULUS=int("00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74""C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F""09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B597065""92A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81",16,)RSA_EXPONENT=0x10001AES_IV=b"0000000000000000"# 16 字节 0x30defgenerate_w(trajectory:dict)->str:data=json.dumps(trajectory,separators=(",",":")).encode()aes_key=secrets.token_hex(8).encode()encrypted_data=AES.new(aes_key,AES.MODE_CBC,AES_IV).encrypt(pad(data,16))encrypted_key=PKCS1_v1_5.new(RSA.construct((RSA_MODULUS,RSA_EXPONENT))).encrypt(aes_key).hex()returnencrypted_data.hex()+encrypted_key六、PoW 工作量证明
极验4还加了一层 PoW(Proof of Work),防止暴力请求。逻辑是 hashcash 风格:
importhashlibimportsecretsdefcompute_pow(pow_detail:dict,lot_number:str)->tuple[str,str]:bits=pow_detail["bits"]# 通常为 8prefix=f"1|{bits}|sha256|{datetime}|{captcha_id}|{lot_number}||"target="0"*(bits//4)# bits=8 → 前 2 个 hex 字符为 "00"whileTrue:rand_hex=secrets.token_hex(8)msg=prefix+rand_hex sign=hashlib.sha256(msg.encode()).hexdigest()ifsign.startswith(target):returnmsg,signbits=8意味着 sha256 的前 2 个 hex 字符必须是00,概率约 1/256,通常几百次就能找到。
七、缺口识别:YOLOv8 上场
协议层搞定了,但还有一个核心问题:缺口在哪?
极验4的背景图故意用了高纹理彩色背景——3D 立方体、密集图案、文字干扰——来对抗传统 CV 算法。我试了一堆方法:
| 方法 | 简单背景 | 复杂背景 |
|---|---|---|
| 亮度差 | 部分准 | ❌ |
| Canny 边缘模板匹配 | 部分准 | ❌ 常偏右 |
| ddddocr | ❌ | ❌ |
| 多算法投票 | 不稳定 | ❌ |
最后直接上深度学习:YOLOv8 ONNX 模型。
importcv2importnumpyasnpimportonnxruntimeclassGapDetector:def__init__(self,model_path="yolo.onnx"):self.sess=onnxruntime.InferenceSession(model_path)self.input_name=self.sess.get_inputs()[0].namedefdetect_setleft(self,bg_bytes:bytes)->int:bg=cv2.imdecode(np.frombuffer(bg_bytes,np.uint8),cv2.IMREAD_ANYCOLOR)h,w=bg.shape[:2]# 预处理:resize 到 320x320,归一化img=cv2.resize(bg,(320,320))/255.0img=np.transpose(img,(2,0,1))[None].astype(np.float32)# 推理out=self.sess.run(None,{self.input_name:img})outs=np.transpose(np.squeeze(out[0]))# 后处理:NMSxf,yf=w/320,h/320boxes,scores=[],[]forrowinouts:score=float(row[4:].max())ifscore>=0.6:cx,cy,bw,bh=row[:4]boxes.append([int((cx-bw/2)*xf),int((cy-bh/2)*yf),int(bw*xf),int(bh*yf)])scores.append(score)idx=cv2.dnn.NMSBoxes(boxes,scores,0.6,0.8)best=idx[np.argmax([scores[i]foriinidx])]raw_x=boxes[best][0]# 坐标校正:YOLO 坐标与 JS clientX 存在系统偏差returnint(raw_x*0.9862-11.317)模型来自 ravizhan/geetest-v4-slide-crack,80MB 的 YOLOv8 ONNX,在各种复杂背景上置信度 0.94+。
坐标校正公式setLeft = raw_x * 0.9862 - 11.317是线性拟合出来的,补偿 YOLO 检测框左边缘与 JSclientX之间的系统偏差。
八、最隐蔽的坑:动态字段
到这里,协议、加密、识别全搞定了。但跑起来,答案对了也是forbidden。
这说明设备指纹层在拦。但奇怪的是,参考实现(ravizhan 的项目)用预标注的 100% 正确坐标也是forbidden。
我开始怀疑是不是某个字段有问题。于是仔细对比了 4 份真实抓包样本,发现了一个规律:
轨迹里有个随机字段,看起来每次 key 都不一样:
样本1: "cba4": "ba5dc5" 样本2: "a1b2": "ba5dc5" 样本3: "x9y8": "ba5dc5" 样本4: "m3n7": "ba5dc5"value 永远是lot_number[16:22](即ba5dc5),但 key 每次不同。
之前我一直以为 key 是随机的,所以用了随机 4 位 hex。但仔细看 JS,发现这个字段的生成逻辑在getStringByIndexes函数里:
// gcaptcha4.js 中的配置{"n[20:20]+n[8:8]+n[11:11]+n[30:30]":"n[16:21]"}其中n就是lot_number,n[a:b]是闭区间(含两端)。所以:
# key 的 spec: n[20:20]+n[8:8]+n[11:11]+n[30:30]# 即 lot[20]+lot[8]+lot[11]+lot[30],4 个字符拼接key=lot_number[20]+lot_number[8]+lot_number[11]+lot_number[30]# value 的 spec: n[16:21]# 即 lot[16:22](闭区间,Python 切片 end+1)value=lot_number[16:22]Python 实现:
defeval_index_spec(spec:str,n:str)->str:"""还原 getStringByIndexes:把 "n[20:20]+n[8:8]" 对 n 求值"""out=[]forseginspec.split("+"):a,b=re.match(r"n\[(\d+):(\d+)\]",seg.strip()).groups()out.append(n[int(a):int(b)+1])# 闭区间return"".join(out)dyn_key=eval_index_spec("n[20:20]+n[8:8]+n[11:11]+n[30:30]",lot_number)dyn_val=eval_index_spec("n[16:21]",lot_number)这个发现直接决定了成败:用随机 key → 答案正确也forbidden;用正确 key →success。
验证方式也很简单:
# 实验1:故意错误 setLeft=5结果:fail(fail_count=1)← 请求被处理,只是答案错# 实验2:YOLO 正确 setLeft + 随机 key结果:forbidden(fail_count=0)← 答案对,但动态字段 key 错# 实验3:YOLO 正确 setLeft + 正确 key结果:success ← 全部正确九、Session Cookie:最容易被忽视的细节
还有一个坑:Session Cookie。
浏览器第一次访问/load时,服务端会通过Set-Cookie返回一个captcha_v4_user。后续所有请求必须携带这个 Cookie,否则直接fail。
一开始我用requests.get()每次独立请求,看起来每个接口都对,但就是过不了。后来改成requests.Session()才解决:
SESSION=requests.Session()def_get(url,**kw):returnSESSION.get(url,headers=HEADERS,timeout=20,**kw)# 预热:先访问一次,让 Session 拿到 Cookiedefwarmup_cookie():params={"callback":f"geetest_{int(time.time()*1000)}","captcha_id":CAPTCHA_ID,"challenge":str(uuid.uuid4()),"client_type":"web","risk_type":"slide","lang":"zho",}_get(f"{BASE_URL}/load",params=params)这个 bug 修掉之后,"莫名其妙失败"的情况立刻收敛了。
十、完整流程串起来
最终的主流程:
defsolve(detector:GapDetector)->dict:# 1. 加载验证码(获取 lot_number、bg、pow_detail 等)data=load()lot=data["lot_number"]# 2. YOLO 识别缺口bg_bytes=_get(f"{STATIC_HOST}/{data['bg']}").content set_left=detector.detect_setleft(bg_bytes)# 3. PoW 工作量证明pow_msg,pow_sign=compute_pow(data["pow_detail"],lot)# 4. 动态字段dyn_key=eval_index_spec(DYN_KEY_SPEC,lot)dyn_val=eval_index_spec(DYN_VAL_SPEC,lot)# 5. 构造轨迹trajectory={"setLeft":set_left,"passtime":random.randint(600,2500),"userresponse":set_left/A_RATIO+2,# 精确公式"device_id":"","lot_number":lot,"pow_msg":pow_msg,"pow_sign":pow_sign,"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"}},"jCpk":"yZ7D",dyn_key:dyn_val,# 动态字段"em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0},}# 6. AES + RSA 加密w=generate_w(trajectory)# 7. 提交验证returnverify(data,w)5 轮测试结果:
[1/5] 结果: success [2/5] 结果: success [3/5] 结果: success [4/5] 结果: success [5/5] 结果: success 成功率: 5/5十一、踩坑总结
坑1:userresponse 不是 setLeft + random
最早以为userresponse是setLeft加个随机小数,结果一直fail。后来从 30 万行混淆 JS 里逆出精确公式setLeft / 1.0059466666666665 + 2,才过。
教训:不要猜,要从源码里找。
坑2:AES IV 不是 0x00,是字符 ‘0’
JS 里写的是'0000000000000000',这是 16 个 ASCII 字符'0'(0x30),不是 16 个空字节。用错了加密结果完全不一样。
教训:JS 字符串和字节数组不是一回事。
坑3:动态字段的 key 不是随机的
看起来像随机 4 位 hex,实际上是从lot_number按固定规则切出来的。用随机 key 会导致答案正确也forbidden。
教训:所有"看起来随机"的字段,都要验证是不是真的随机。
坑4:Session Cookie 必须维持
每次独立请求和用 Session 发请求,结果完全不同。captcha_v4_userCookie 必须从第一次/load拿到并一直携带。
教训:验证码是会话制的,不是单次请求。
坑5:fail 和 forbidden 是两个不同的错误
fail= 答案错误(setLeft 不对)forbidden= 答案对了,但设备指纹/环境校验没过
用这个分流方法可以快速定位问题在哪一层。
十二、工程架构
最终的文件结构:
极验4/ ├── new.py # 主流程(load → YOLO → PoW → 轨迹 → 加密 → verify) ├── encrypt.py # AES/RSA 加密模块(独立可验证) ├── pow_msg.py # PoW 计算模块 ├── collect_fingerprint.py # Playwright 设备指纹采集器 ├── yolo.onnx # YOLOv8 缺口检测模型(80MB) ├── data.json # 已知图像 MD5 → setLeft 映射 ├── gcaptcha4_raw.js # 原始混淆 JS(~30万行) ├── biji.md # 调试笔记 ├── 逆向报告.md # 协议层分析报告 └── 交付报告.md # 最终交付说明模块化设计的好处是:图像识别、PoW、加密、会话维持都是独立模块,可以单独替换。比如今天用 YOLO,明天可以换成打码平台 API,不影响其他层。
十三、写在最后
这个项目最大的收获不是"搞定了极验4",而是形成了一套通用的协议分析方法:
- 先抓样本,再写代码。5 组抓包样本比 100 次猜测有用。
- 分层验证。把系统拆成网络层、识别层、参数层、加密层、环境层,逐层突破。
- 用错误分流定位问题。错答案和对答案的不同响应,能告诉你卡在哪一层。
- 所有"看起来随机"的字段都要验证。很多"随机"其实是确定性映射。
验证码逆向的本质不是"写个脚本碰运气",而是把一个黑箱系统拆成若干个可以独立验证的模块。当你把每一层都搞清楚之后,很多原来看上去"玄学"的失败,都会变成具体、可定位的问题。
项目状态:纯协议层 100% 完成,5/5 success。代码结构清晰,可维护、可复用。
技术栈:Python / JavaScript / YOLOv8 / AES-128-CBC / RSA-1024 / SHA256 PoW
参考:ravizhan/geetest-v4-slide-crack(YOLO 模型来源)