前言

当我们突破初级的反爬防线(User-Agent、IP 频率限制、简单的 Cookie 校验)后,真正的战场才刚刚开始。中型以上的网站和几乎所有 App,都会在验证码和请求参数加密上做文章。本文将从实战角度出发,系统梳理验证码识别与加密参数破解的核心技术与工具链。

声明:本文仅用于技术学习与交流,请勿将文中技术用于非法爬取或破坏他人系统的行为。

一、验证码类型全景

验证码演进的本质是成本博弈——攻击者识别成本 vs 普通用户通过成本。当前主流类型可归为四代:

| 类型 | 代表产品 | 破解难度 | 用户体验 |

|------|----------|----------|----------|

| 图形验证码 | 标准文字/数字 | ★☆☆☆ | 差 |

| 滑块验证码 | 极验、腾讯防水墙 | ★★☆☆ | 较好 |

| 点选验证码 | 12306、顶象 | ★★★☆ | 中等 |

| 行为验证码 | 无感、滑动轨迹 | ★★★★ | 极好 |

1.1 图形验证码

传统方案,字符扭曲加噪点干扰线。识别技术已非常成熟,ddddocr 在普通图形码上准确率超过 98%。

1.2 滑块验证码

极验三代/四代最典型。核心难点不在于识别缺口位置,而在于模拟人手的拖动轨迹——直线拖动会被秒封。

1.3 点选验证码

要求按顺序点击图中指定文字/图像。需要先做目标检测(YOLO 系模型),再匹配点击顺序。

1.4 行为验证码

无感验证码,收集鼠标移动轨迹、页面停留时间、滚动行为等数十个特征。这类验证码的对抗已进入机器学习层面,需要构造逼真的行为序列


二、图形验证码识别(OCR 方案)

2.1 ddddocr — 开箱即用

ddddocr(带带弟弟 OCR)是目前最流行的 Python 图形验证码库,基于 ONNX 推理,无需配置 GPU。


import ddddocr

ocr = ddddocr.DdddOcr(show_ad=False)

with open("captcha.png", "rb") as f:
    image_bytes = f.read()

result = ocr.classification(image_bytes)
print(f"识别结果: {result}")

对于带颜色干扰的验证码,预处理通常能大幅提升准确率:


import cv2
import numpy as np

def preprocess(image_bytes: bytes) -> bytes:
    """灰度化 + 二值化 + 去噪点"""
    img = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_GRAYSCALE)
    # 高斯模糊去噪
    img = cv2.GaussianBlur(img, (3, 3), 0)
    # 二值化(OTSU 自动阈值)
    _, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
    # 去除小面积噪点
    contours, _ = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    for c in contours:
        if cv2.contourArea(c) < 30:
            cv2.drawContours(img, [c], -1, 255, -1)
    _, buffer = cv2.imencode(".png", img)
    return buffer.tobytes()

2.2 深度学习方案(CNN 自训练)

当通用 OCR 在特定验证码上准确率不足时,需要自训练模型。以 PyTorch 为例的轻量 CNN:


import torch.nn as nn

class CaptchaCNN(nn.Module):
    def __init__(self, num_chars=36, max_length=4):
        super().__init__()
        self.cnn = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.fc = nn.Linear(128 * 8 * 24, num_chars * max_length)
        self.max_length = max_length
        self.num_chars = num_chars

    def forward(self, x):
        x = self.cnn(x)
        x = x.view(x.size(0), -1)
        x = self.fc(x)
        return x.view(-1, self.max_length, self.num_chars)

训练时使用 CTC Loss 可解决不定长字符对齐问题,样本方面建议:

  • 最少 2000 张标注样本起步
  • 使用 `imgaug` / `albumentations` 做数据增强(旋转 ±15°、透视变换、亮度抖动)
  • 验证集准确率 > 95% 即可投入生产

三、滑块验证码破解

滑块破解的核心流程:获取背景图 → 计算缺口偏移 → 构造轨迹 → 提交验证

3.1 缺口识别

使用 OpenCV 的边缘检测 + 模板匹配定位缺口:


import cv2
import numpy as np

def find_gap(bg: np.ndarray, slide: np.ndarray) -> int:
    """返回缺口左侧的 x 坐标"""
    # 转为灰度
    bg_gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
    # 边缘检测
    bg_edge = cv2.Canny(bg_gray, 200, 400)
    slide_edge = cv2.Canny(slide, 200, 400)

    # 模板匹配
    result = cv2.matchTemplate(bg_edge, slide_edge, cv2.TM_CCOEFF_NORMED)
    _, _, _, max_loc = cv2.minMaxLoc(result)
    return max_loc[0] + slide.shape[1] // 2

极验等主流平台的缺口位置还做了随机凹凸和**阴影干扰**,精确匹配时建议结合 YOLO 目标检测做端到端预测。

3.2 轨迹模拟 — 关键中的关键

伪造轨迹时不可使用匀速直线,服务端会收集鼠标的加速度变化率和**运动方向熵**。下面是一个被大量实战验证过的轨迹生成函数:


import random
import math
import numpy as np

def generate_track(distance: int) -> list:
    """
    生成类人鼠标拖动轨迹
    :param distance: 需要拖动的总像素距离
    :return: [(x1, y1, t1), (x2, y2, t2), ...]
    """
    track = []
    current = 0
    t = 0
    # 前段加速、中段匀速、末段减速微调
    while current < distance:
        if current < distance * 0.3:
            # 加速阶段
            speed = random.uniform(2, 4)
        elif current < distance * 0.8:
            # 匀速阶段
            speed = random.uniform(3, 5)
        else:
            # 减速阶段,接近目标时微调
            speed = random.uniform(0.3, 1.5)
            if distance - current < 3:
                speed = random.uniform(0.1, 0.5)

        current += speed
        if current > distance:
            current = distance

        # Y 轴轻微抖动(模拟人手不可避免的上下偏移)
        y_jitter = random.gauss(0, 1.5)
        t += random.randint(8, 20)
        track.append((round(current, 1), round(y_jitter, 1), t))

    return track

3.3 加速度计算

服务端通常会校验加速度曲线的合理性。真实人类拖动的特征是加速度先正后负,且过零点在总行程的 60%~75% 区间。验证自己的轨迹是否合理时,可以绘制速度曲线检查:


def validate_track(track: list) -> bool:
    """简单检查轨迹合理性"""
    if len(track) < 10:
        return False
    # 计算速度序列
    speeds = []
    for i in range(1, len(track)):
        dx = track[i][0] - track[i-1][0]
        dt = track[i][2] - track[i-1][2]
        if dt > 0:
            speeds.append(dx / dt)

    # 检查是否有明显的加速和减速阶段
    mid = len(speeds) // 2
    avg_first_half = sum(speeds[:mid]) / mid if mid > 0 else 0
    avg_second_half = sum(speeds[mid:]) / (len(speeds) - mid) if (len(speeds) - mid) > 0 else 0

    # 前半段平均速度应大于后半段(先加速后减速)
    return avg_first_half > avg_second_half * 0.8

四、常见加密算法特征识别

在浏览器 JS 或 App 的 SO 层逆向时,快速识别加密算法能大幅缩小分析范围。

4.1 AES 特征


// 特征:模式、填充、密钥长度
// CBC 模式必有 IV(初始化向量)
function aesEncrypt(data, key, iv) {
    const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
    // ... 明显有 key 和 iv 两个参数
}

识别要点:

  • 调用 `createCipheriv` 或类似 API
  • 算法名含 `aes`、`AES`、`aes-128/192/256`
  • 通常伴随 `pkcs7` 或 `pkcs5` 填充处理
  • 密钥长度 16/24/32 字节

4.2 RSA 特征


// 特征:公钥指数 e(通常 65537)、模数 n(2048/4096 位)
// 或者从 PEM 格式公钥中提取
function rsaEncrypt(data, publicKey) {
    const encrypted = crypto.publicEncrypt(publicKey, Buffer.from(data));
    return encrypted.toString('base64');
}

识别要点:

  • 关键词 `publicEncrypt`、`privateDecrypt`
  • 出现 `setPublicKey`、`RSAPublicKeyBlob`
  • 存在 PEM 格式字符串(`-----BEGIN PUBLIC KEY-----`)
  • 配合 btoa/atob 做 Base64 输出

4.3 SM4(国密)特征


// 特征:SM4 算法名,密钥 16 字节
// 在 JS 中通常由 sm-crypto 库实现
const sm4 = require('sm-crypto').sm4;
const encrypted = sm4.encrypt(data, key);

国密在政务和金融类网站越来越普及,识别信号词为 sm2、`sm3`、`sm4`、`国密`。

4.4 自定义/混合加密

业界还有一种常见做法:RSA 加密 AES 密钥,AES 加密业务数据。抓包时如果发现请求体是一个 Base64 字符串而响应却不定长,极大概率是这种混合方案。


五、加密参数逆向实战

以某资讯类 App 的 API 加密为例,演示完整逆向链路。

5.1 抓包确定目标

通过 Charles / Fiddler / mitmproxy 抓取到请求:


POST /api/v1/feed/list HTTP/1.1
X-Sign: 7a8f3b2c1d9e0f4a...
Content-Type: application/json; charset=utf-8

{"data":"U2FsdGVkX1+9Jk3f..."}

明显 X-Sign 是签名,`data` 是加密业务参数。

5.2 JS 逆向定位加密函数

使用 AST 还原或直接搜索关键词 sign、`encrypt`、`appSecret`。在混淆后的 JS 中定位到核心函数:


// 定位到的加密函数(反混淆后)
function encryptRequest(params) {
    var key = CryptoJS.enc.Utf8.parse("a1b2c3d4e5f6g7h8");
    var iv  = CryptoJS.enc.Utf8.parse("1234567890abcdef");
    var encrypted = CryptoJS.AES.encrypt(
        JSON.stringify(params),
        key,
        { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
    );
    return {
        data: encrypted.ciphertext.toString(CryptoJS.enc.Base64),
        sign: md5(encrypted.ciphertext.toString() + "secret_salt")
    };
}

关键信息已暴露:

  • 算法:AES-128-CBC
  • 密钥:`a1b2c3d4e5f6g7h8`
  • IV:`1234567890abcdef`
  • Sign 算法:MD5(ciphertext + `secret_salt`)

5.3 Python 还原


import hashlib
import json
from Crypto.Cipher import AES
from base64 import b64encode, b64decode

KEY = b"a1b2c3d4e5f6g7h8"
IV  = b"1234567890abcdef"
SECRET_SALT = "secret_salt"

def pkcs7_pad(data: bytes) -> bytes:
    pad_len = AES.block_size - len(data) % AES.block_size
    return data + bytes([pad_len] * pad_len)

def encrypt_request(params: dict) -> dict:
    plaintext = json.dumps(params, separators=(",", ":")).encode()
    cipher = AES.new(KEY, AES.MODE_CBC, IV)
    encrypted = cipher.encrypt(pkcs7_pad(plaintext))
    data_b64 = b64encode(encrypted).decode()

    sign = hashlib.md5((data_b64 + SECRET_SALT).encode()).hexdigest()
    return {"data": data_b64, "sign": sign}

# 调用
result = encrypt_request({"page": 1, "size": 20})
print(result)

5.4 APP 场景:从 SO 层逆向

如果是 App 的加密,加密逻辑通常在 Native 层(SO 文件)。用 ida 或 `Ghidra` 分析后,典型的调用模式是:


// Java 层调用
String sign = nativeSign(params, timestamp);

对应的 SO 层实现一般是 C++ 的 OpenSSL 调用或自定义算法。此时有两种方案:

1. Unidbg 模拟执行:不逆向算法细节,直接调用 SO 中的函数

2. Frida Hook:运行时 hook 加密函数的输入输出,直接复用


# 示例:使用 unidbg 调用 SO 中的签名函数
# 这是简化示意,实际需要配合 unidbg 的 Python bridge
def get_sign_from_so(params: dict, timestamp: int):
    # 调用 unidbg 桥接
    sign = unidbg_bridge.call_function(
        so_path="libcore.so",
        function_name="Java_com_example_nativeSign",
        args=[json.dumps(params), timestamp]
    )
    return sign

六、WebSocket 协议逆向

越来越多的实时应用(直播间、行情推送、协同编辑)使用 WebSocket 替代 HTTP 轮询。抓包方式不同:

6.1 抓包配置

  • Charles:菜单 `Proxy → WebSocket Settings` 勾选
  • Chrome DevTools:切换到 `WS` 标签页查看帧

6.2 解码分析

WebSocket 消息通常是二进制帧(Buffer)。搜索 onmessage 定位解析函数:


ws.onmessage = function(event) {
    var buffer = event.data;        // ArrayBuffer
    var decrypted = decryptMsg(buffer, sessionKey);
    var json = JSON.parse(decrypted);
    handleMessage(json);
};

定位到 decryptMsg 即可复用前面章节的 AES/RSA 逆向方法。

6.3 Python 模拟


import asyncio
import websockets

async def listen_ws():
    # 握手时携带的 token 需提前从 HTTP 接口获取
    headers = {"Authorization": "Bearer " + token}
    async with websockets.connect("wss://api.example.com/ws", extra_headers=headers) as ws:
        async for message in ws:
            # 如果消息是二进制的,按照逆向得到的算法解密
            raw = decrypt_aes(message, session_key)
            print(raw)

asyncio.run(listen_ws())

七、自动化验证码处理方案集成

将上述技术整合为一个完整的流水线:


class CaptchaPipeline:
    """统一验证码处理管道"""

    def __init__(self):
        self.ocr = ddddocr.DdddOcr(show_ad=False)
        self.yolo_model = None  # 可按需加载 YOLO 点选模型

    def solve(self, captcha_type: str, images: dict) -> dict:
        handlers = {
            "graphic": self._solve_graphic,
            "slide": self._solve_slide,
            "click": self._solve_click,
            "behavior": self._solve_behavior,
        }
        handler = handlers.get(captcha_type)
        if not handler:
            raise ValueError(f"不支持的验证码类型: {captcha_type}")
        return handler(images)

    def _solve_graphic(self, images: dict) -> dict:
        result = self.ocr.classification(images["captcha"])
        return {"text": result}

    def _solve_slide(self, images: dict) -> dict:
        gap_x = find_gap(images["background"], images["slider"])
        track = generate_track(gap_x)
        return {"track": track, "gap": gap_x}

    # _solve_click / _solve_behavior 类似,按需实现

方案选型建议

| 验证码类型 | 推荐方案 | 成本 |

|------------|----------|------|

| 简单图形码 | ddddocr | 免费 |

| 复杂图形码 | 自训练 CNN + CTC | 需要标注数据 |

| 滑块(极验) | OpenCV + 轨迹生成 | 免费 |

| 滑块(自适应) | 对接打码平台 | 按次付费 |

| 点选验证码 | YOLOv5 + 语义匹配 | 较高 |

| 行为验证码 | Playwright 模拟真人操作 | 开发成本高 |

当自建方案成本超过打码平台费用时,建议直接接入第三方打码平台(如 2captcha、超级鹰),一个简单适配器即可:


class ThirdPartyCaptchaSolver:
    def __init__(self, api_key: str, platform: str = "2captcha"):
        self.api_key = api_key
        self.platform = platform

    def solve_recaptcha_v2(self, site_key: str, url: str) -> str:
        """返回用于提交的 g-recaptcha-response token"""
        if self.platform == "2captcha":
            return self._twocaptcha_recaptcha(site_key, url)
        # 其他平台适配...

八、总结与建议

回顾全文,爬虫逆向进阶之路可以提炼为三条原则:

1. 先看流量,再看代码:抓包分析永远是最直观的切入点,不要一头扎进混淆代码里

2. 从易到难,逐步深化:先尝试 ddddocr 再训练模型,先搜索关键词再分析 AST 混淆

3. 工程化思维:将验证码处理、加密还原、参数签名封装为可插拔的中间件,方便维护和切换

推荐的持续学习路径

  • **逆向工具**:熟练掌握 Fiddler Everywhere / Charles / mitmproxy 的 HTTPS 和 WS 抓包
  • **JS 逆向**:学习 AST(抽象语法树)操作,使用 `@babel/core` 处理混淆 JS
  • **Native 层**:掌握 ARM64 汇编基础、Frida 动态插桩、Unidbg 模拟执行
  • **算法基础**:理解 AES/RSA/SM4 的加解密流程,能手动实现 padding 和模式逻辑

逆向的本质是对抗成本博弈。当你的破解成本超过对方升级反爬的成本时,就需要考虑是否值得继续投入。合理的策略是评估目标数据的价值,选择性价比最高的方案。

路漫漫其修远兮,祝各位在爬虫逆向的路上不断精进。


*本文首发于技术博客,转载请注明出处。欢迎在评论区交流实战中遇到的逆向难题。*