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

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

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


四、关键突破: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_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 实现:

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

最早以为 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_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",而是形成了一套通用的协议分析方法:

  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 模型来源)

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐