前言
当我们突破初级的反爬防线(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 和模式逻辑
逆向的本质是对抗成本博弈。当你的破解成本超过对方升级反爬的成本时,就需要考虑是否值得继续投入。合理的策略是评估目标数据的价值,选择性价比最高的方案。
路漫漫其修远兮,祝各位在爬虫逆向的路上不断精进。
*本文首发于技术博客,转载请注明出处。欢迎在评论区交流实战中遇到的逆向难题。*
评论