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 的明文(通过 hook encodeURIComponent 拿到)长这样:
{
"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 能找到关键函数。在提交逻辑附近定位到了这段:
// 混淆后的代码(简化)
var setLeft = parseInt(拖动距离, 10);
var obj = {
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 实现:
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad
RSA_MODULUS = int(
"00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74"
"C7977D02DC1D9451F79DD5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F"
"09AF627715919221AEF91899CAE08C0D686D748B20A3603BE2318CA6BC2B597065"
"92A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81",
16,
)
RSA_EXPONENT = 0x10001
AES_IV = b"0000000000000000" # 16 字节 0x30
def generate_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()
return encrypted_data.hex() + encrypted_key
六、PoW 工作量证明
极验4还加了一层 PoW(Proof of Work),防止暴力请求。逻辑是 hashcash 风格:
import hashlib
import secrets
def compute_pow(pow_detail: dict, lot_number: str) -> tuple[str, str]:
bits = pow_detail["bits"] # 通常为 8
prefix = f"1|{bits}|sha256|{datetime}|{captcha_id}|{lot_number}||"
target = "0" * (bits // 4) # bits=8 → 前 2 个 hex 字符为 "00"
while True:
rand_hex = secrets.token_hex(8)
msg = prefix + rand_hex
sign = hashlib.sha256(msg.encode()).hexdigest()
if sign.startswith(target):
return msg, sign
bits=8 意味着 sha256 的前 2 个 hex 字符必须是 00,概率约 1/256,通常几百次就能找到。
七、缺口识别:YOLOv8 上场
协议层搞定了,但还有一个核心问题:缺口在哪?
极验4的背景图故意用了高纹理彩色背景——3D 立方体、密集图案、文字干扰——来对抗传统 CV 算法。我试了一堆方法:
| 方法 | 简单背景 | 复杂背景 |
|---|---|---|
| 亮度差 | 部分准 | ❌ |
| Canny 边缘模板匹配 | 部分准 | ❌ 常偏右 |
| ddddocr | ❌ | ❌ |
| 多算法投票 | 不稳定 | ❌ |
最后直接上深度学习:YOLOv8 ONNX 模型。
import cv2
import numpy as np
import onnxruntime
class GapDetector:
def __init__(self, model_path="yolo.onnx"):
self.sess = onnxruntime.InferenceSession(model_path)
self.input_name = self.sess.get_inputs()[0].name
def detect_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.0
img = 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]))
# 后处理:NMS
xf, yf = w / 320, h / 320
boxes, scores = [], []
for row in outs:
score = float(row[4:].max())
if score >= 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] for i in idx])]
raw_x = boxes[best][0]
# 坐标校正:YOLO 坐标与 JS clientX 存在系统偏差
return int(raw_x * 0.9862 - 11.317)
模型来自 ravizhan/geetest-v4-slide-crack,80MB 的 YOLOv8 ONNX,在各种复杂背景上置信度 0.94+。
坐标校正公式 setLeft = raw_x * 0.9862 - 11.317 是线性拟合出来的,补偿 YOLO 检测框左边缘与 JS clientX 之间的系统偏差。
八、最隐蔽的坑:动态字段
到这里,协议、加密、识别全搞定了。但跑起来,答案对了也是 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 实现:
def eval_index_spec(spec: str, n: str) -> str:
"""还原 getStringByIndexes:把 "n[20:20]+n[8:8]" 对 n 求值"""
out = []
for seg in spec.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):
return SESSION.get(url, headers=HEADERS, timeout=20, **kw)
# 预热:先访问一次,让 Session 拿到 Cookie
def warmup_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 修掉之后,"莫名其妙失败"的情况立刻收敛了。
十、完整流程串起来
最终的主流程:
def solve(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. 提交验证
return verify(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_user Cookie 必须从第一次 /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 模型来源)
更多推荐
所有评论(0)