当前位置: 首页 > news >正文

5 天逆向极验4滑块验证码:从 30 万行混淆 JS 到纯协议 5/5 success

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}}

一眼看上去,有些字段是固定的(geetestlangep),有些是服务器返回的(lot_numberpow_msg),有些需要计算(setLeftuserresponsew)。

但有个字段引起了我的注意:userresponse的值是223.68173262996052,而setLeft223。这俩之间有什么关系?


四、关键突破: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公式计算真实抓包值匹配
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 + 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,sign

bits=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_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 个字符拼接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

最早以为userresponsesetLeft加个随机小数,结果一直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",而是形成了一套通用的协议分析方法:

  1. 先抓样本,再写代码。5 组抓包样本比 100 次猜测有用。
  2. 分层验证。把系统拆成网络层、识别层、参数层、加密层、环境层,逐层突破。
  3. 用错误分流定位问题。错答案和对答案的不同响应,能告诉你卡在哪一层。
  4. 所有"看起来随机"的字段都要验证。很多"随机"其实是确定性映射。

验证码逆向的本质不是"写个脚本碰运气",而是把一个黑箱系统拆成若干个可以独立验证的模块。当你把每一层都搞清楚之后,很多原来看上去"玄学"的失败,都会变成具体、可定位的问题。


项目状态:纯协议层 100% 完成,5/5 success。代码结构清晰,可维护、可复用。

技术栈:Python / JavaScript / YOLOv8 / AES-128-CBC / RSA-1024 / SHA256 PoW

参考:ravizhan/geetest-v4-slide-crack(YOLO 模型来源)

http://www.gsyq.cn/news/1637481.html

相关文章:

  • 数据库查询优化器<1>查询重写 / 逻辑优化
  • Meta Assistant / 告别命令行,我为一堆 Python 脚本做了一个 Windows 任务栏的“家”
  • 结合Nginx工作流程理解Epoll机制和Reactor模型
  • 设置Shell脚本开机自启
  • Python特征工程实战:从数据清洗到模型提效的完整流程
  • 开源项目C++ Workflow学习
  • 2026年避坑攻略:如何挑选性价比高的外墙保温装饰一体板厂家
  • GPT充值以后怎么用才不浪费?开发者把 ChatGPT 用进接口文档、代码审查和回归测试的 4 个工作流
  • Agent 架构
  • 手把手教你用8款一键生成论文工具,极速搞定各类论文
  • NSK滚珠丝杠W3205SS技术解析
  • Vite 环境变量治理:别把构建时配置当运行时开关
  • Linux syslog日志权限出错
  • 什么叫Padding Oracle
  • Wishbone BFM 设计与实现:从手写总线到自动化自检
  • 无货源自动拍单发货软件靠谱吗?新手先看货源关联和规格匹配一件代发工具教程解析
  • 课堂教学PPT模板推荐哪家?这6个平台教师亲测可用
  • 五大神经网络核心原理与实战:从CNN到GAN的直观理解与代码实现
  • 从离线分析到实时对话:JoyAI-VL-Interaction如何重塑视频AI交互范式
  • 自动扩缩容:3 种策略的适用场景
  • 【Aspose-CAD for Java】DWG转PDF实战:精准控制布局与图层,告别空白与错位
  • REACTOS RtlGetVersion 函数实现分析
  • 终极指南:如何用AI让Monika与你自由对话 - MonikA.I模组完全教程
  • 解决Ant发送邮件显示HTML源码问题:MIME类型配置详解
  • 三菱FX3U PLC运动轴控制与伺服调试实战
  • 王千源惊喜亮相HYROX杭州站 不止是演员,更是运动“源”
  • AIGC 内容指纹:生成内容入库前先做可追踪设计
  • 太香了!这个 GitHub 开源项目,让安卓模拟器直接跑在浏览器里,搞 AI 的必看
  • 基于单片机人脸识别电子密码锁智能门禁指纹识别语音提醒防盗成品12(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_
  • 【考研】2026/7/4