5 天逆向极验4滑块验证码从 30 万行混淆 JS 到纯协议 5/5 success本文记录了一次完整的极验4Geetest v4滑块验证码纯协议逆向过程。不使用浏览器自动化仅通过静态分析、抓包对照和算法还原实现了从请求构造到验证通过的全链路。一、起因事情是这样的有个需求要对接极验4的滑块验证码但不想用 Selenium/Puppeteer 这种重量级方案。于是想看看能不能纯协议搞定。一开始以为就是识别缺口位置 发个请求的事结果一做才发现这玩意比想象中复杂得多。极验4 相比 v3 做了大量升级混淆 JS、动态字段、AESRSA 加密、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.88989108: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的前端 JSgcaptcha4.js有大约 30 万行经过了控制流平坦化、字符串加密、变量名混淆等多重保护。直接看是看不懂的但可以搜索关键字。搜userresponse没结果被混淆了但搜setLeft能找到关键函数。在提交逻辑附近定位到了这段// 混淆后的代码简化varsetLeftparseInt(拖动距离,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最终公式userresponsesetLeft/1.00594666666666652用 5 组真实抓包样本验证setLeft公式计算真实抓包值匹配223223.68173262996052223.68173262996052✅3940.7694509980648540.76945099806485✅207207.77631683588265207.77631683588265✅112113.3379105585452113.3379105585452✅214214.73493624579171214.73493624579171✅5/5 全部精确到小数点后 14 位。之前用setLeft random()的时候每次都是 fail。换成这个公式之后第一次出现了forbidden而不是fail。这说明什么fail是答案错误forbidden是答案正确但被设备指纹拦了。公式对了。五、加密AES RSAw参数的加密封装流程从 JS 里逆出来是这样的# 1. 轨迹 JSON → UTF-8 字节data_bytesjson.dumps(trajectory,separators(,,:)).encode(utf-8)# 2. 生成随机 AES key16 字符 hex 128 bitaes_keysecrets.token_hex(8)# 例如 393961bfec0fafa2# 3. AES-128-CBC 加密# IV 000000000000000016 个字符 0即 0x30不是 0x00cipherAES.new(aes_key.encode(),AES.MODE_CBC,b0000000000000000)encrypted_datacipher.encrypt(pad(data_bytes,AES.block_size))# 4. RSA-1024 加密 AES keyPKCS1 v1.5 填充rsa_keyRSA.construct((RSA_MODULUS,RSA_EXPONENT))cipherPKCS1_v1_5.new(rsa_key)encrypted_keycipher.encrypt(aes_key.encode()).hex()# 5. 拼接wencrypted_data.hex()encrypted_key这里有个大坑IV 是 16 个字符0ASCII 0x30不是 16 个零字节0x00。一开始我用b\x00 * 16做 IV加密出来的结果和 JS 完全不一样。后来用 openssl 交叉验证才发现JS 里的0000000000000000是字符串不是空字节。Python 实现fromCrypto.CipherimportAES,PKCS1_v1_5fromCrypto.PublicKeyimportRSAfromCrypto.Util.Paddingimportpad RSA_MODULUSint(00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81,16,)RSA_EXPONENT0x10001AES_IVb0000000000000000# 16 字节 0x30defgenerate_w(trajectory:dict)-str:datajson.dumps(trajectory,separators(,,:)).encode()aes_keysecrets.token_hex(8).encode()encrypted_dataAES.new(aes_key,AES.MODE_CBC,AES_IV).encrypt(pad(data,16))encrypted_keyPKCS1_v1_5.new(RSA.construct((RSA_MODULUS,RSA_EXPONENT))).encrypt(aes_key).hex()returnencrypted_data.hex()encrypted_key六、PoW 工作量证明极验4还加了一层 PoWProof of Work防止暴力请求。逻辑是 hashcash 风格importhashlibimportsecretsdefcompute_pow(pow_detail:dict,lot_number:str)-tuple[str,str]:bitspow_detail[bits]# 通常为 8prefixf1|{bits}|sha256|{datetime}|{captcha_id}|{lot_number}||target0*(bits//4)# bits8 → 前 2 个 hex 字符为 00whileTrue:rand_hexsecrets.token_hex(8)msgprefixrand_hex signhashlib.sha256(msg.encode()).hexdigest()ifsign.startswith(target):returnmsg,signbits8意味着 sha256 的前 2 个 hex 字符必须是00概率约 1/256通常几百次就能找到。七、缺口识别YOLOv8 上场协议层搞定了但还有一个核心问题缺口在哪极验4的背景图故意用了高纹理彩色背景——3D 立方体、密集图案、文字干扰——来对抗传统 CV 算法。我试了一堆方法方法简单背景复杂背景亮度差部分准❌Canny 边缘模板匹配部分准❌ 常偏右ddddocr❌❌多算法投票不稳定❌最后直接上深度学习YOLOv8 ONNX 模型。importcv2importnumpyasnpimportonnxruntimeclassGapDetector:def__init__(self,model_pathyolo.onnx):self.sessonnxruntime.InferenceSession(model_path)self.input_nameself.sess.get_inputs()[0].namedefdetect_setleft(self,bg_bytes:bytes)-int:bgcv2.imdecode(np.frombuffer(bg_bytes,np.uint8),cv2.IMREAD_ANYCOLOR)h,wbg.shape[:2]# 预处理resize 到 320x320归一化imgcv2.resize(bg,(320,320))/255.0imgnp.transpose(img,(2,0,1))[None].astype(np.float32)# 推理outself.sess.run(None,{self.input_name:img})outsnp.transpose(np.squeeze(out[0]))# 后处理NMSxf,yfw/320,h/320boxes,scores[],[]forrowinouts:scorefloat(row[4:].max())ifscore0.6:cx,cy,bw,bhrow[:4]boxes.append([int((cx-bw/2)*xf),int((cy-bh/2)*yf),int(bw*xf),int(bh*yf)])scores.append(score)idxcv2.dnn.NMSBoxes(boxes,scores,0.6,0.8)bestidx[np.argmax([scores[i]foriinidx])]raw_xboxes[best][0]# 坐标校正YOLO 坐标与 JS clientX 存在系统偏差returnint(raw_x*0.9862-11.317)模型来自 ravizhan/geetest-v4-slide-crack80MB 的 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: ba5dc5value 永远是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_numbern[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 个字符拼接keylot_number[20]lot_number[8]lot_number[11]lot_number[30]# value 的 spec: n[16:21]# 即 lot[16:22]闭区间Python 切片 end1valuelot_number[16:22]Python 实现defeval_index_spec(spec:str,n:str)-str:还原 getStringByIndexes把 n[20:20]n[8:8] 对 n 求值out[]forseginspec.split():a,bre.match(rn\[(\d):(\d)\],seg.strip()).groups()out.append(n[int(a):int(b)1])# 闭区间return.join(out)dyn_keyeval_index_spec(n[20:20]n[8:8]n[11:11]n[30:30],lot_number)dyn_valeval_index_spec(n[16:21],lot_number)这个发现直接决定了成败用随机 key → 答案正确也forbidden用正确 key →success。验证方式也很简单# 实验1故意错误 setLeft5结果:fail(fail_count1)← 请求被处理只是答案错# 实验2YOLO 正确 setLeft 随机 key结果:forbidden(fail_count0)← 答案对但动态字段 key 错# 实验3YOLO 正确 setLeft 正确 key结果:success ← 全部正确九、Session Cookie最容易被忽视的细节还有一个坑Session Cookie。浏览器第一次访问/load时服务端会通过Set-Cookie返回一个captcha_v4_user。后续所有请求必须携带这个 Cookie否则直接fail。一开始我用requests.get()每次独立请求看起来每个接口都对但就是过不了。后来改成requests.Session()才解决SESSIONrequests.Session()def_get(url,**kw):returnSESSION.get(url,headersHEADERS,timeout20,**kw)# 预热先访问一次让 Session 拿到 Cookiedefwarmup_cookie():params{callback:fgeetest_{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,paramsparams)这个 bug 修掉之后莫名其妙失败的情况立刻收敛了。十、完整流程串起来最终的主流程defsolve(detector:GapDetector)-dict:# 1. 加载验证码获取 lot_number、bg、pow_detail 等dataload()lotdata[lot_number]# 2. YOLO 识别缺口bg_bytes_get(f{STATIC_HOST}/{data[bg]}).content set_leftdetector.detect_setleft(bg_bytes)# 3. PoW 工作量证明pow_msg,pow_signcompute_pow(data[pow_detail],lot)# 4. 动态字段dyn_keyeval_index_spec(DYN_KEY_SPEC,lot)dyn_valeval_index_spec(DYN_VAL_SPEC,lot)# 5. 构造轨迹trajectory{setLeft:set_left,passtime:random.randint(600,2500),userresponse:set_left/A_RATIO2,# 精确公式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 加密wgenerate_w(trajectory)# 7. 提交验证returnverify(data,w)5 轮测试结果[1/5] 结果: success [2/5] 结果: success [3/5] 结果: success [4/5] 结果: success [5/5] 结果: success 成功率: 5/5十一、踩坑总结坑1userresponse 不是 setLeft random最早以为userresponse是setLeft加个随机小数结果一直fail。后来从 30 万行混淆 JS 里逆出精确公式setLeft / 1.0059466666666665 2才过。教训不要猜要从源码里找。坑2AES IV 不是 0x00是字符 ‘0’JS 里写的是0000000000000000这是 16 个 ASCII 字符00x30不是 16 个空字节。用错了加密结果完全不一样。教训JS 字符串和字节数组不是一回事。坑3动态字段的 key 不是随机的看起来像随机 4 位 hex实际上是从lot_number按固定规则切出来的。用随机 key 会导致答案正确也forbidden。教训所有看起来随机的字段都要验证是不是真的随机。坑4Session Cookie 必须维持每次独立请求和用 Session 发请求结果完全不同。captcha_v4_userCookie 必须从第一次/load拿到并一直携带。教训验证码是会话制的不是单次请求。坑5fail 和 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-crackYOLO 模型来源