滑動驗證碼 (如淘寶/騰訊/網易易盾) 已成反爬蟲、防機器註冊標配。本文深度解析滑動驗證碼服務端核心邏輯:拼圖生成、軌跡特徵提取、風險評分模型、對抗策略,並提供 Java 實現關鍵代碼骨架。
┌─────────────┐ 1. 請求驗證碼 ┌─────────────┐
│ 前端 (JS) │ ─────────────────────▶ │ 服務端 │
│ │ ◀───────────────────── │ (Java) │
└─────────────┘ 2. 返回圖片+元數據 └─────────────┘
│ │
│ 3. 用戶滑動 → 收集軌跡點 │
▼ ▼
┌─────────────┐ 4. 提交軌跡數據 ┌─────────────┐
│ 前端 (JS) │ ─────────────────────▶ │ 服務端 │
│ │ ◀───────────────────── │ 風控引擎 │
└─────────────┘ 5. 返回驗證結果 └─────────────┘
mousedown/mousemove/mouseup (PC) 或 touchstart/touchmove/touchend (移動端),記錄 {x, y, timestamp} 序列。
@Component
public class CaptchaImageGenerator {
private static final int BG_WIDTH = 320;
private static final int BG_HEIGHT = 160;
private static final int PUZZLE_SIZE = 48; // 滑塊邊長
// 背景圖池 (生產環境用 CDN + 定期更新)
private final List backgroundPool = loadBackgrounds();
public CaptchaChallenge generate() {
// 1. 隨機選背景圖
BufferedImage bg = randomChoice(backgroundPool);
bg = resize(bg, BG_WIDTH, BG_HEIGHT);
// 2. 隨機缺口位置 (避開邊緣)
int gapX = ThreadLocalRandom.current().nextInt(40, BG_WIDTH - PUZZLE_SIZE - 40);
int gapY = ThreadLocalRandom.current().nextInt(20, BG_HEIGHT - PUZZLE_SIZE - 20);
// 3. 裁剪滑塊 (帶陰邊/凸邊形狀)
BufferedImage puzzle = cutPuzzle(bg, gapX, gapY, PUZZLE_SIZE);
// 4. 在背景上製造缺口 (填充鄰近像素或噪點)
BufferedImage bgWithGap = createGap(bg, gapX, gapY, PUZZLE_SIZE);
// 5. 添加干擾項 (噪點、干擾線、隨機文字)
addNoise(bgWithGap);
addDistractors(bgWithGap);
// 6. 加密缺口坐標 (AES + 隨機 IV,前端解密後驗證)
String encryptedGap = encryptGap(gapX, gapY);
return new CaptchaChallenge(
imageToBase64(bgWithGap), // 背景圖 (含缺口)
imageToBase64(puzzle), // 滑塊圖
encryptedGap, // 加密真實坐標
UUID.randomUUID().toString(), // challengeId
System.currentTimeMillis() // timestamp
);
}
// 核心:切割不規則滑塊 (貝塞爾曲線/正弦波邊緣)
private BufferedImage cutPuzzle(BufferedImage src, int x, int y, int size) {
BufferedImage puzzle = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = puzzle.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 構建滑塊形狀 Path (頂部凸起、底部凹陷等)
Path2D path = new Path2D.Double();
path.moveTo(0, 0);
path.lineTo(size, 0);
path.lineTo(size, size);
path.lineTo(0, size);
path.closePath();
// 添加特徵性凸起/凹陷 (關鍵:增加機器識別難度)
// 此處省略複雜 Path 構建代碼...
g2d.setClip(path);
g2d.drawImage(src, -x, -y, null); // 只繪製缺口區域
g2d.dispose();
return puzzle;
}
}
@Data @Builder @AllArgsConstructor @NoArgsConstructor
public class CaptchaChallenge {
private String bgImageBase64; // 背景圖 (含缺口)
private String puzzleImageBase64; // 滑塊圖
private String encryptedGap; // 加密的真實縫隙坐標
private String challengeId; // 唯一標識
private long timestamp; // 生成時間戳
}
@Data @Builder @AllArgsConstructor @NoArgsConstructor
public class SlideTrackPoint {
private double x; // 相對起點 X
private double y; // 相對起點 Y (通常接近 0)
private long timestamp; // 毫秒時間戳
}
@Data @Builder @AllArgsConstructor @NoArgsConstructor
public class SlideVerifyRequest {
private String challengeId;
private List tracks; // 軌跡點序列
private long clientTimestamp; // 客戶端提交時間
private String userAgent; // 瀏覽器指紋
private String fingerprint; // 設備指紋 (Canvas/WebGL/字體等)
}
@Service
public class SlideVerificationEngine {
// 閾值配置 (生產環境動態調整)
private static final double MIN_DURATION_MS = 500; // 太快非人
private static final double MAX_DURATION_MS = 15000; // 太慢可疑
private static final double MAX_AVG_SPEED = 2000; // 像素/秒
private static final double MIN_JITTER = 0.5; // 最小抖動
private static final double MAX_ACCELERATION = 5000; // 像素/秒²
public VerificationResult verify(SlideVerifyRequest request, CaptchaChallenge challenge) {
// 1. 基礎校驗
if (System.currentTimeMillis() - challenge.getTimestamp() > 60_000) {
return VerificationResult.expired("驗證碼已過期");
}
if (request.getTracks() == null || request.getTracks().size() < 10) {
return VerificationResult.fail("軌跡點過少");
}
// 2. 解密真實縫隙坐標
Point realGap = decryptGap(challenge.getEncryptedGap());
// 3. 特徵工程
TrackFeatures features = extractFeatures(request.getTracks(), realGap);
// 4. 規則引擎評分 (基礎防禦)
RuleScore ruleScore = ruleEngine(features, request);
// 5. 機器學習模型評分 (核心防禦,此處為邏輯框架)
double mlScore = mlModelPredict(features, request);
// 6. 綜合決策
double finalScore = ruleScore.getScore() * 0.4 + mlScore * 0.6;
boolean passed = finalScore >= 0.7; // 閾值可配置
// 7. 風控標籤
List tags = buildTags(features, ruleScore, mlScore);
return VerificationResult.builder()
.passed(passed)
.score(finalScore)
.tags(tags)
.challengeId(challenge.getChallengeId())
.build();
}
private TrackFeatures extractFeatures(List tracks, Point realGap) {
TrackFeatures f = new TrackFeatures();
// 時間特徵
long startTime = tracks.get(0).getTimestamp();
long endTime = tracks.get(tracks.size() - 1).getTimestamp();
f.setDuration(endTime - startTime);
f.setPointCount(tracks.size());
// 位移特徵
double totalX = tracks.get(tracks.size() - 1).getX();
f.setTotalDisplacement(totalX);
f.setGapDeviation(Math.abs(totalX - realGap.x)); // 偏離真實縫隙
// 速度/加速度序列
List velocities = new ArrayList<>();
List accelerations = new ArrayList<>();
for (int i = 1; i < tracks.size(); i++) {
SlideTrackPoint p0 = tracks.get(i - 1);
SlideTrackPoint p1 = tracks.get(i);
double dt = (p1.getTimestamp() - p0.getTimestamp()) / 1000.0; // 秒
if (dt <= 0) continue;
double dx = p1.getX() - p0.getX();
double dy = p1.getY() - p0.getY();
double dist = Math.hypot(dx, dy);
double v = dist / dt;
velocities.add(v);
if (i > 1) {
double a = (v - velocities.get(velocities.size() - 2)) / dt;
accelerations.add(a);
}
}
f.setVelocities(velocities);
f.setAccelerations(accelerations);
f.setAvgVelocity(velocities.stream().mapToDouble(d -> d).average().orElse(0));
f.setMaxVelocity(velocities.stream().mapToDouble(d -> d).max().orElse(0));
f.setMaxAcceleration(accelerations.stream().mapToDouble(d -> d).max().orElse(0));
// 抖動/平滑度 (Y 軸變化、方向變化率)
double yVariance = tracks.stream()
.mapToDouble(SlideTrackPoint::getY)
.map(y -> y - tracks.get(0).getY())
.map(y -> y * y)
.average().orElse(0);
f.setYVariance(yVariance);
// 停頓檢測 (速度極低持續)
int pauseCount = 0;
for (int i = 0; i < velocities.size(); ) {
if (velocities.get(i) < 10) { // 閾值
int j = i;
while (j < velocities.size() && velocities.get(j) < 10) j++;
if ((j - i) >= 3) pauseCount++; // 連續 3 點低速視為停頓
i = j;
} else i++;
}
f.setPauseCount(pauseCount);
// 軌跡擬合度 (多項式/貝塞爾曲線擬合殘差)
f.setFitResidue(polyFitResidue(tracks));
return f;
}
private RuleScore ruleEngine(TrackFeatures f, SlideVerifyRequest req) {
double score = 1.0;
List reasons = new ArrayList<>();
// 時長檢查
if (f.getDuration() < MIN_DURATION_MS) { score -= 0.3; reasons.add("TOO_FAST"); }
if (f.getDuration() > MAX_DURATION_MS) { score -= 0.2; reasons.add("TOO_SLOW"); }
// 速度檢查
if (f.getAvgVelocity() > MAX_AVG_SPEED) { score -= 0.25; reasons.add("SPEED_ANOMALY"); }
if (f.getMaxVelocity() > MAX_AVG_SPEED * 2) { score -= 0.2; reasons.add("SPEED_SPIKE"); }
// 加速度檢查
if (f.getMaxAcceleration() > MAX_ACCELERATION) { score -= 0.15; reasons.add("ACCEL_ANOMALY"); }
// 精度檢查 (是否精準對齊縫隙)
if (f.getGapDeviation() > 5) { score -= 0.2; reasons.add("GAP_MISALIGN"); }
// 抖動檢查 (太完美 = 機器)
if (f.getYVariance() < MIN_JITTER) { score -= 0.25; reasons.add("TOO_SMOOTH"); }
// 停頓檢查 (人類通常有微停頓)
if (f.getPauseCount() == 0 && f.getDuration() > 2000) { score -= 0.1; reasons.add("NO_PAUSE"); }
// 設備指紋一致性
if (!isFingerprintConsistent(req)) { score -= 0.3; reasons.add("FP_INCONSISTENT"); }
return RuleScore.builder().score(Math.max(0, score)).reasons(reasons).build();
}
// 機器學習模型預測接口 (實際接入 Python 模型服務 / ONNX Runtime / PMML)
private double mlModelPredict(TrackFeatures f, SlideVerifyRequest req) {
// 特徵向量化
double[] featureVector = vectorize(f, req);
// 呼叫模型服務 (gRPC/HTTP)
// return modelService.predict(featureVector);
return 0.85; // 模擬返回
}
}
| 攻擊手段 | 防禦策略 | 實現要點 |
|---|---|---|
| 腳本直接計算縫隙坐標 (圖像識別) | 圖片動態干擾 + 縫隙坐標加密 | Canvas 指紋混淆、WebGL 干擾、AES 加密坐標、前端解密驗證 |
| 模擬人類軌跡 (貝塞爾曲線 + 隨機抖動) | 多維特徵 + ML 模型 + 行為指紋 | 速度/加速度/抖動/停頓/擬合度/設備指紋/IP 信譽 |
| 重放攻擊 (錄製真實軌跡回放) | 挑戰碼機制 + 時間窗 + 軌跡唯一性 | challengeId 一次性、時間戳驗證、軌跡哈希去重 |
| 批量驗證 (驗證碼農場) | 頻率限制 + IP/設備指紋封禁 + 行為聚類 | Redis 計數器、設備指紋庫、異常聚類告警 |
| 逆向工程前端 JS | 代碼混淆 + 關鍵邏輯 Wasm 化 + 動態加載 | javascript-obfuscator、關鍵算法編譯為 Wasm、運行時完整性檢查 |
requestAnimationFrame 或 10-20ms 間隔,平衡精度與數據量。滑動驗證碼核心不在「圖片生成」,而在「軌跡風險評分」。生產級系統需構建:
本文提供的代碼骨架展示了服務端核心邏輯結構,實際落地需接入成熟風控平台或深度定制 ML 模型。安全是系統工程,單點技術無法完美防禦,需從客戶端加固、網絡層防禦、業務層風控、運營層響應多維構建。