傳統字符驗證碼易被 OCR 破解,現代驗證碼轉向**語義識別、圖片點選、行為分析**。本文完整復刻 12306 風格「九宮格點選」驗證碼,涵蓋**後端圖片拼接與語義分類、前端 Canvas/絕對定位點選、座標映射驗證、Blob 流刷新、模糊化防 AI 識別**全棧實現,並給出生產級安全加固建議。
[用戶請求驗證碼]
│
▼
[Controller] ──► [ImageSelectHelper.getEightImages()] ──► 從分類目錄隨機抽 8 張不重複圖片
│ │
│ └─► [ImageSelectHelper.getLocation(tips)] ──► 計算目標圖片位置 (1-based)
│ │
│ ▼
│ [Session] 存儲 targetTip + targetPositions
│ │
▼ ▼
[ImageMerge.mergeImage()] ◄────────────────────────────── 8 張圖片 File[]
│
▼
[HttpServletResponse OutputStream] ──► 合成大圖 (JPG) 返回瀏覽器
│
▼
[前端 onclick] ──► 記錄點擊座標 (相對大圖左上角) ──► 隱藏域/JS 陣列
│
▼
[提交驗證] ──► [ImageVerifyHelper.isPass(coords)] ──► 座標映射至 1~8 位置 ──► 與 Session 比對
│
▼
[驗證通過/失敗] ──► 返回業務結果
resources/
└── captcha/
├── dog/ # 分類目錄 = 語義標籤
│ ├── dog1.jpg
│ └── dog2.jpg
├── beer/
│ └── ...
└── cat/
└── ...
public class ImageSelectHelper {
private static final File ROOT_DIR = new File("classpath:captcha");
private static final Random RANDOM = new Random();
/** 返回 [File[8], String[8] tips] */
public static Object[] getEightImages() {
File[] categories = ROOT_DIR.listFiles(File::isDirectory);
if (categories == null || categories.length < 8)
throw new IllegalStateException("分類目錄不足 8 個");
List selected = new ArrayList<>(8);
String[] tips = new String[8];
Set usedPaths = new HashSet<>();
for (int i = 0; i < 8; i++) {
File cat;
File img;
do {
cat = categories[RANDOM.nextInt(categories.length)];
File[] imgs = cat.listFiles(f -> f.getName().matches("(?i).+\\.(jpg|png|jpeg)"));
if (imgs == null || imgs.length == 0) continue;
img = imgs[RANDOM.nextInt(imgs.length)];
} while (usedPaths.contains(img.getAbsolutePath())); // 去重
usedPaths.add(img.getAbsolutePath());
selected.add(img);
tips[i] = cat.getName(); // 語義標籤
}
return new Object[]{selected.toArray(new File[0]), tips};
}
/** 計算目標圖片位置 (1-based) */
public static List getTargetPositions(String[] tips) {
String targetTip = tips[RANDOM.nextInt(tips.length)]; // 隨機選一個作為目標
List positions = new ArrayList<>();
for (int i = 0; i < tips.length; i++) {
if (targetTip.equals(tips[i])) positions.add(i + 1); // 1-based
}
return positions; // 可能多張同類圖片
}
}
public class ImageMerge {
private static final int TILE_W = 200, TILE_H = 200;
private static final int COLS = 4, ROWS = 2; // 4x2 九宮格 (最後一格可留白)
public static void merge(File[] images, HttpServletResponse resp) throws IOException {
BufferedImage canvas = new BufferedImage(COLS * TILE_W, ROWS * TILE_H, BufferedImage.TYPE_INT_RGB);
Graphics2D g = canvas.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
for (int i = 0; i < images.length; i++) {
BufferedImage tile = ImageIO.read(images[i]);
// 縮放填滿 200x200
BufferedImage scaled = new BufferedImage(TILE_W, TILE_H, BufferedImage.TYPE_INT_RGB);
scaled.getGraphics().drawImage(tile, 0, 0, TILE_W, TILE_H, null);
int x = (i % COLS) * TILE_W;
int y = (i / COLS) * TILE_H;
g.drawImage(scaled, x, y, null);
}
g.dispose();
// 模糊化防 AI 識別 (可選)
// applyGaussianBlur(canvas);
resp.setContentType("image/jpeg");
resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
ImageIO.write(canvas, "JPEG", resp.getOutputStream());
}
}
public class ImageVerifyHelper {
private static final int TILE_W = 200, TILE_H = 200;
private static final int COLS = 4;
/** 前端傳格式: "x1|y1,x2|y2,..." (相對大圖左上角) */
public static boolean verify(String coordsParam, HttpSession session) {
List targetPositions = (List) session.getAttribute("captcha_target_positions");
if (targetPositions == null || targetPositions.isEmpty()) return false;
String[] pairs = coordsParam.split(",");
Set clickedPositions = new HashSet<>();
for (String pair : pairs) {
String[] xy = pair.split("\\|");
if (xy.length != 2) continue;
int x = Integer.parseInt(xy[0]);
int y = Integer.parseInt(xy[1]);
// 邊界檢查
if (x < 0 || x >= COLS * TILE_W || y < 0 || y >= ROWS * TILE_H) continue;
int col = x / TILE_W; // 0-based
int row = y / TILE_H; // 0-based
int pos = row * COLS + col + 1; // 1-based
clickedPositions.add(pos);
}
// 核心判定:點選集合必須完全包含所有目標位置,且不包含非目標位置 (嚴格模式)
// 寬鬆模式:只要包含所有目標位置即可
return clickedPositions.containsAll(targetPositions); // 寬鬆模式
// return clickedPositions.equals(new HashSet<>(targetPositions)); // 嚴格模式
}
}
請點選所有
const canvas = document.getElementById('captchaCanvas');
const ctx = canvas.getContext('2d');
const markLayer = document.getElementById('markLayer');
const coordsInput = document.getElementById('captchaCoords');
const tipEl = document.getElementById('targetTip');
let clickCount = 0;
const MAX_CLICKS = 4; // 防暴力點選
async function loadCaptcha() {
const resp = await fetch('/captcha/image', {credentials: 'include'});
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
};
img.src = url;
// 獲取提示語 (需單獨接口或隨圖片返回 Header)
const tipResp = await fetch('/captcha/tip', {credentials: 'include'});
tipEl.textContent = await tipResp.text();
markLayer.innerHTML = '';
clickCount = 0;
coordsInput.value = '';
}
canvas.addEventListener('click', (e) => {
if (clickCount >= MAX_CLICKS) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor(e.clientX - rect.left);
const y = Math.floor(e.clientY - rect.top);
// 畫標記
const mark = document.createElement('div');
mark.className = 'captcha-mark';
mark.style.left = (x - 10) + 'px';
mark.style.top = (y - 10) + 'px';
mark.textContent = ++clickCount;
markLayer.appendChild(mark);
// 記錄座標
const prev = coordsInput.value;
coordsInput.value = prev + (prev ? ',' : '') + x + '|' + y;
});
document.getElementById('refreshBtn').addEventListener('click', loadCaptcha);
// 頁面載入初始化
loadCaptcha();
.captcha-mark {
position: absolute;
width: 20px; height: 20px;
background: rgba(255,0,0,0.8);
color: white;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: bold;
pointer-events: none;
transform: translate(-50%, -50%); /* 以中心點對齊點擊座標 */
z-index: 10;
}
@Controller
@RequestMapping("/captcha")
public class CaptchaController {
@GetMapping("/image")
public void image(HttpServletResponse resp, HttpSession session) throws IOException {
Object[] result = ImageSelectHelper.getEightImages();
File[] images = (File[]) result[0];
String[] tips = (String[]) result[1];
List targetPositions = ImageSelectHelper.getTargetPositions(tips);
String targetTip = tips[targetPositions.get(0) - 1]; // 取第一個目標的提示
session.setAttribute("captcha_target_positions", targetPositions);
session.setAttribute("captcha_target_tip", targetTip);
ImageMerge.merge(images, resp);
}
@GetMapping("/tip")
@ResponseBody
public String tip(HttpSession session) {
return (String) session.getAttribute("captcha_target_tip");
}
@PostMapping("/verify")
@ResponseBody
public Map verify(@RequestParam String coords, HttpSession session) {
boolean pass = ImageVerifyHelper.verify(coords, session);
Map res = new HashMap<>();
res.put("success", pass);
res.put("message", pass ? "驗證通過" : "驗證失敗,請重試");
if (!pass) {
// 失敗自動刷新
try { image(resp, session); } catch (IOException ignored) {}
}
return res;
}
}
// 生成 Token (含目標位置、過期時間、隨機數)
String token = Jwts.builder()
.claim("targets", targetPositions)
.claim("tip", targetTip)
.setExpiration(Date.from(Instant.now().plusSeconds(120)))
.setId(UUID.randomUUID().toString())
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
// 返回給前端:圖片流 + Header: X-Captcha-Token:
// 前端驗證時攜帶 Token + 座標,後端解析 Token 驗證,無需 Session
💡 核心心法:驗證碼不是銀彈,而是「提高攻擊成本」的層級防禦之一。圖片點選提高 OCR 成本,行為分析提高腳本模擬成本,限流/設備指紋提高分發成本,**多層疊加才能達到商業級安全**。