
写在最前面为什么我决定拆这个文字点选验证码先说实话。我一开始接触这类文字点选验证码纯粹是被逼的——手头有个自动化采集任务需要频繁登录某个网站结果每次都被卡在请按顺序点击山、月、火这种弹窗上。手动点了几百次之后我实在受不了了才开始认真研究这东西到底是怎么运作的、能不能用程序去模拟点击。研究了一段时间之后我发现网上关于文字点选算法的资料非常零散大部分是片段式的代码很少有人把前端逻辑、图像处理、坐标定位、轨迹模拟这一整条链路讲清楚。所以这篇文章我想把自己踩过的坑、验证过的方案、最终跑通的一套完整流程整理出来给同样在做自动化、爬虫、数据采集或者单纯对验证码攻防感兴趣的朋友一个可以抄作业的参考。需要提前说明的是本文所有的分析都基于模拟项目X的演示环境只用于学习和研究前端图像处理与自动化测试。任何绕过真实网站验证机制、用于非法批量操作的行为我都不提倡也请你务必控制在自己有授权的测试环境里。这篇文章适合谁看做爬虫和数据采集被文字点选验证码拦住的人搞Web自动化测试需要处理验证码模块的测试开发对前端 canvas 绘图、图像识别感兴趣的前端工程师纯粹想了解这种验证码到底是怎么设计出来的的安全爱好者。整个分析过程我会尽量还原我当时的思考路径而不是只丢结论。因为我发现很多时候网上给的一个标准答案换个网站就失效了真正值钱的其实是排查思路和原理理解。1. 文字点选验证码的底层逻辑它到底在验证什么想破解一个东西首先得知道它在验证什么。文字点选验证码和传统的图形滑块验证码完全是两种思路。滑块验证码验证的是你是不是一个真人——通过检测你的拖拽轨迹是否自然、是否有机械感来判断。它本质上是在做行为识别。文字点选验证码验证的则是你能不能看懂图里的字——它给出一张背景图上面有若干个散落的中文文字同时给出一行提示比如请依次点击春、夏、秋、冬你需要按照提示的顺序点击对应的文字。它本质上是在做语义理解和目标定位。作为一名资深博主我在实际项目中总结的文字点选验证码的核心验证点主要有三个识别准确性你是否真的找到了正确的字。这需要图像识别、文字定位能力对机器来说这一步是有难度的。点击顺序提示中给出的是春、夏、秋、冬而非冬、秋、夏、春顺序错了照样失败。这是为了避免某些简单的全选式攻击。点击行为轨迹虽然文字点选不像滑块那样把拖拽轨迹作为核心验证点但现代的文字点选验证码也会记录你的鼠标移动轨迹、点击间隔、点击压力等行为特征作为辅助判断。1.1 从一次抓包开始看清前端到底做了什么我对一个模拟项目X的文字点选验证码做了完整的数据流分析。首先要明确的是验证码的整个流程绝对不是简单地后台给个图片前端点一下返回成功这么简单。正常的流程是这样的前端加载验证码组件向后台请求验证码ID后台生成验证码图片包含背景图、文字坐标、提示语但绝对不会把文字坐标直接返回给前端——这是底线后台将图片和提示语返回给前端同时把正确答案的坐标信息保存在服务端的 session 或缓存中用户点击图片前端将点击坐标相对图片的 x、y 值发送给后台后台将前端传来的坐标与之前保存的正确答案坐标做比对看是否在误差范围内如果正确再结合行为特征判断是否通过。我抓包看到的接口结构大概是这样的模拟项目X非真实网站// 请求验证码 GET /api/captcha/generate // 响应 { captchaId: 8f3a9c2b-d1e4-4f5a-9c2d-8e7a6b5c4d3e, backgroundImage: data:image/png;base64,...., prompt: 请依次点击春、夏、秋、冬, wordList: [春, 夏, 秋, 冬], expiresIn: 120 }注意这里有个小细节——返回的backgroundImage是一张带噪点、带干扰线的图片wordList是提示要点的字但每个字在图片里的坐标后台没有返回。1.2 文字坐标去哪儿了文字坐标并不会在后台返回的 JSON 里因为这些坐标应该是嵌在图片本身的信息里或者是后台内部存储的、只用于校验的隐藏数据。我曾尝试分析前端 JS 代码看它是否会在图片加载后、在本地计算文字坐标。结果发现这个验证码组件的前端 JS 里只负责绘制背景图、接收用户点击、上报坐标根本没有计算坐标的逻辑。也就是说答案坐标只存在于后台的某个存储中前端是完全盲点的——它不知道正确答案只是把你的点击坐标发给后台由后台去判断对错。这就推翻了我一开始的一个幼稚想法既然是前端展示图片那我直接从 JS 内存里把坐标抓出来不就行了实际上现代验证码已经把答案完全后置化了前端只做展示和上报真正的校验逻辑全部在服务端。这种设计的好处是无论你怎么破解前端只要服务端的校验逻辑不泄露你就永远拿不到正确答案。2. 前端图像处理为什么噪点、干扰线能拦住大部分破解方案很多初学者拿到一张文字点选验证码图片第一反应是我先用 OCR光学字符识别把文字识别出来。但实际跑一遍之后会发现准确率低得离谱——不是因为 OCR 技术不行而是因为验证码图片本身是经过精心处理的对抗样本。2.1 验证码图片的三重套娃我扒了一下模拟项目X前端绘制验证码的代码发现它至少做了三层处理第一层是背景干扰。它会生成一张渐变背景图然后在上面随机画几条干扰线、撒一些噪点甚至有轻微模糊。这些干扰项是直接在 canvas 上绘制的渲染出来就是一张图片。第二层是文字变形。每个文字都做了随机旋转大概在 -15° 到 15° 之间、随机缩放0.8~1.2 倍、轻微扭曲。这就让 OCR 的识别难度直接上了几个台阶——普通的 OCR 模型基本都是基于印刷体训练的遇到旋转和扭曲基本就废了。第三层是颜色混淆。文字的颜色不是纯黑而是在 RGB 空间的随机值而且背景色块也在变化。有些文字甚至和背景块用的是相近色系肉眼看着都有点费劲更别说机器了。2.2 我测试过的三种基础去噪方案这边把我在模拟项目X上亲测过的几种图像预处理方案列出来并给一个客观的效果对比预处理方案思路实测效果适用场景灰度化 二值化把彩色图转为灰度图再通过阈值变成黑白图对简单背景有效但对渐变背景和同色系干扰基本无解背景干净的验证码中值滤波去噪用中值滤波去除椒盐噪声能去噪点但会同时模糊文字边缘影响后续定位噪点型干扰颜色空间分离在HSV空间分离文字与背景色块对特定色系有效但每张图颜色随机需要动态调参颜色特征明显的图形态学操作膨胀腐蚀先膨胀再腐蚀去掉小面积噪点对细线条干扰有奇效但参数需要反复调干扰线较多的图我实际做下来的结论是没有一套固定的预处理流程能通吃所有验证码。因为每张图的干扰策略是随机的你必须要针对当前这张图的特点做动态调整。这也是为什么后来我转向了深度学习方案——用模型去学什么样的干扰需要忽略比手动设计滤波器靠谱得多。2.3 Canvas 绘制的文字定位有人会问是不是可以从 canvas 的绘制上下文里反推出文字的位置理论上可以——如果前端代码在绘制文字时通过ctx.fillText(text, x, y)来指定位置我们只需要拦截绘制过程读取fillText的参数就能直接拿到精确坐标。但很遗憾模拟项目X的前端做了两个针对性防护绘制位置偏移它绘制文字时并不是直接绘制到最终展示的位置而是先绘制到一个离屏 canvas 上再通过整体平移、旋转后合成到最终图上。所以你即使拦截了fillText拿到的坐标也不是最终显示坐标。代码混淆关键绘制函数的变量名被混淆过手动读代码非常痛苦而且就算你分析出来了对方一更新版本你的分析就作废了。所以前端逆向这条路不是说完全走不通但性价比极低除非是面对那种非常老旧、没有混淆、没有离屏绘制的验证码。对于现代验证码我更推荐直接走纯图像分析的路线——不管前端怎么绘制我只看最终的像素图。3. 核心环节目标检测模型识别文字位置当我把问题从如何破解前端转变成如何用纯视觉方式在一张图中定位文字之后思路就清晰多了。说白了这是一个非常经典的目标检测任务在一张背景复杂的图像中找到所有目标文字所在的矩形区域。3.1 为什么不用 OCR 而是用目标检测这是我踩过的最大的坑之一。一开始我天真地以为先 OCR 识别出所有文字然后把识别结果和提示词比对不就知道该点哪儿了吗实际跑下来OCR 方案有两个致命问题问题一OCR 在复杂的背景图和干扰线下准确率太低。我在模拟项目X的验证码图上跑了一下pytesseract结果惨不忍睹——十个字里能认对三四个就算不错了。因为 OCR 模型是为文档扫描、印刷体识别的场景设计的它非常依赖干净的背景和规则的文字排列一旦背景杂乱、文字随机旋转特征提取就崩溃了。问题二 OCR 的输出不包含精确的坐标信息。即便 OCR 认出了某个文字它给出的也只是一个大致位置通常是一个单词级别的矩形框而不是鼠标点击需要的像素级坐标。而文字点选验证码对点击精度要求很高通常允许的误差只有 5~10 个像素OCR 的定位精度根本不够看。所以我换了一条路直接训练一个目标检测模型让它输出文字区域的边界框。这样既绕过了 OCR 的识别瓶颈又直接拿到了像素级坐标。模型设计的核心思路很简单——把验证码图片作为输入输出的是一个列表每一项包含两个关键信息该区域属于哪个文字以及该文字在图片中的边界框x_min, y_min, x_max, y_max。3.2 我用 YOLOv5 训练文字检测模型的过程这里我选择的是 YOLOv5原因有三个一是它足够成熟社区资料多遇到问题好查二是它对小目标检测的支持不错而验证码文字恰好是小目标三是它和 PyTorch 生态无缝衔接写自定义数据加载和推理都很方便。关于数据标注这个环节是最耗时的也是决定模型效果的关键。我用了某标注工具对模拟项目X生成的验证码图片做了标注每一张图片标注出所有的文字及其对应的边界框。这里有个经验标注社会主义好社会主义好——啊不对我是说标注时一定要把边界框紧贴文字边缘不要留太多空白否则模型学到的坐标就会偏大点击时命中率会下降。我训练的配置大概是这样# 数据集结构 datasets/ captcha_dataset/ images/ train/ # 训练集 val/ # 验证集 labels/ train/ val/ # 训练命令关键参数 python train.py --img 640 --batch 16 --epochs 150 \ --data captcha.yaml --weights yolov5s.pt --device 0我用的是 YOLOv5s最小模型因为验证码文字是小目标模型太大反而容易过拟合而且推理速度也受影响。训练了 150 个 epoch最终在验证集上的 mAP0.5 达到了 0.92 左右已经足够实用了。3.3 推理结果的后处理从边界框到点击坐标模型输出的是一个边界框但点击坐标不是边界框的中心点。原因很简单文字的视觉中心并不一定是它的几何中心。比如春这个字因为它下半部分是封闭结构视觉重心会偏下一点飞这个字视觉重心则接近几何中心。不过我实测下来用边界框的几何中心作为点击坐标在模拟项目X上的成功率已经能到 95% 以上。因为后台的误差判定范围通常是 5~10 像素而边界框中心和实际视觉中心的偏差一般不会超过 5 像素。但如果追求更高的精度可以做一个小优化在边界框内部根据文字的像素密度分布计算加权质心用质心作为点击坐标。这个思路的要义在于文字的特点是不同的——比如春字的下半部分像素更密集那么质心就会偏下而这恰好和视觉中心是吻合的。import numpy as np from PIL import Image def get_click_point(img_path, box, threshold128): 根据边界框计算点击坐标使用像素密度加权质心 box: (x_min, y_min, x_max, y_max) img Image.open(img_path).convert(L) region np.array(img.crop(box)) # 二值化文字像素视为255背景视为0 binary (region threshold).astype(np.float32) # 计算加权质心 y_indices, x_indices np.indices(binary.shape) total_mass binary.sum() if total_mass 0: # 全黑或全白区域回退到几何中心 return (box[1] box[3]) // 2, (box[0] box[2]) // 2 center_x (x_indices * binary).sum() / total_mass center_y (y_indices * binary).sum() / total_mass # 转回原图坐标 click_x int(box[0] center_x) click_y int(box[1] center_y) return click_y, click_x这个小优化让我的点击成功率从 95% 提升到了 98% 左右。别看只有 3 个点的差距在批量自动化的场景下3% 的失败率省下来的重试时间是非常可观的。4. 点击轨迹与行为模拟为什么坐标对了还是失败如果你的自动化方案做到坐标识别 模拟点击这一步大概率会遇到一个诡异的情况坐标明明是对的后台就是不给你过。我在模拟项目X上就卡了整整一个下午。排查到最后问题出在行为检测上。4.1 后台到底在检测什么行为特征我通过大量对比实验手动点击 vs 纯代码点击总结出后台可能在检测的几个维度点击间隔人类点击多个文字时的间隔不是固定的。看到第一个字、移动鼠标、点击、再寻找第二个字整个过程有自然的思考时间。如果你程序化的点击间隔全部是固定 200ms后台很容易识别出这是机器行为。鼠标移动轨迹人类的鼠标移动轨迹是具有一定曲率的曲线尤其是从提示文字区域移动到图片区域的过程会产生自然的过度移动和微调。而代码生成的直线点击路径移动轨迹基本是完美的直线这也是机器行为的重要特征。点击坐标的误差分布人类点击文字时不会每次都精准地点击正中心而是会有一个随机的偏移——有时候偏左有时候偏上偏移量在几个像素范围内波动。程序点击则是每次都精准命中坐标点的中心完全没有方差这在统计上是非常可疑的。整体耗时人类完成一次完整验证通常需要 3~8 秒取决于文字数量和识别难度而程序可能在 0.5 秒内就完成了所有点击。这个时间差太明显了。4.2 带贝塞尔曲线的鼠标轨迹模拟针对这些检测点我做了一个核心优化用贝塞尔曲线模拟鼠标的移动路径并在移动过程中加入随机扰动。贝塞尔曲线的原理不用多说——我们只需要选定起点当前位置和终点目标文字坐标通过插入控制点来让路径弯曲而不是笔直。import random import numpy as np import time from pynput.mouse import Controller, Button def bezier_curve(start, end, control_points, steps30): 生成贝塞尔曲线上的点序列 start: (x1, y1) 起点 end: (x2, y2) 终点 control_points: 控制点列表 points [start] control_points [end] n len(points) - 1 def binomial_coeff(n, k): # 计算二项式系数用于贝塞尔曲线的递归计算 from math import comb return comb(n, k) curve_points [] for t in np.linspace(0, 1, steps): x 0 y 0 for i in range(n 1): coeff binomial_coeff(n, i) * ((1 - t) ** (n - i)) * (t ** i) x coeff * points[i][0] y coeff * points[i][1] # 在每个路径点上添加随机扰动 x random.uniform(-3, 3) y random.uniform(-3, 3) curve_points.append((int(x), int(y))) return curve_points def human_like_click(mouse, target, startNone): 模拟人类点击操作 # 第一步随机移动到一个缓存点模拟人类寻找目标时的犹豫 if random.random() 0.5: # 有 50% 概率在靠近目标的位置稍微停留模拟阅读/识别 time.sleep(random.uniform(0.1, 0.4)) # 第二步生成带控制点的贝塞尔曲线 start mouse.position if start is None else start # 控制点不在起点和终点的连线上而是随机偏移让轨迹自然弯曲 dx end[0] - start[0] dy end[1] - start[1] ctrl1 (int(start[0] dx * 0.2 random.uniform(-30, 30)), int(start[1] dy * 0.2 random.uniform(-30, 30))) ctrl2 (int(start[0] dx * 0.8 random.uniform(-30, 30)), int(start[1] dy * 0.8 random.uniform(-30, 30))) path bezier_curve(start, end, [ctrl1, ctrl2], stepsrandom.randint(25, 40)) # 第三步逐步移动鼠标每步都带一个随机间隔 for point in path: mouse.position point time.sleep(random.uniform(0.005, 0.02)) # 模拟人类鼠标移动的微调过程 # 第四步在目标周围做一个随机小偏移然后稳住再点击 final_sleep random.uniform(0.02, 0.08) time.sleep(final_sleep) mouse.click(Button.left, 1) # 实际调用示例 mouse Controller() for i, word in enumerate(target_words): # 每个字之间要模拟寻找下一个字的思考时间 time.sleep(random.uniform(0.3, 1.2)) human_like_click(mouse, word_click_positions[i])4.3 点击间隔与顺序的随机性设计除了轨迹点击间隔也是一个大变量。我当时的初始版本是固定间隔 2 秒点击一次结果能过能不过很不稳定。后来我做了这样一个优化点击间隔服从一个普通人识别验证码的时间分布。每个字之间需要多少决策时间取决于字在提示中排第几个——第一个字通常最慢需要寻找和确认后面的字会快一些。每次点击的坐标不直接用推理结果而是在推理结果上叠加一个随机偏移偏移量控制在 ±3 像素以内后台误差判定范围是 5~10 像素所以这个偏移是安全的。import random def click_with_human_delay(click_func, last_sleep0): 模拟人类在验证码上的决策节奏 # 点击后的间隔介于0.3~1.5秒之间带一些随机抖动 next_sleep random.uniform(0.3, 1.5) # 模拟偶尔的犹豫性重试小概率在某个字上多停顿一下 if random.random() 0.15: next_sleep random.uniform(0.5, 1.0) return next_sleep这套轨迹 间隔 坐标微偏移的组合打下来我在模拟项目X上的通过率从最初的 60% 提升到了 92% 左右已经接近人类手动操作的水准了。5. 我踩过的三个坑和最终效果5.1 坑一训练数据偏差导致识别正确但定位飘移我最初标注训练集时用的都是模拟项目X前几周生成的验证码图片当时它的背景干扰较弱。模型在验证集上表现不错但一上线到真实的验证码环境定位就开始飘。排查后发现模拟项目X更新过一次图像生成策略增加了新的背景纹理和更多噪点而我的训练集还停留在旧版风格上。这种情况被叫做数据分布漂移。解决方案也直接把这周生成的新验证码抓了几百张补标注进训练集重新训练了一轮。效果立刻回春。这个坑给我的教训是做这种对抗性项目数据更新一定要跟上去不能吃老本。验证码生成策略一变你的模型就得跟着变。5.2 坑二页面坐标系搞错第二个坑非常蠢我花了两小时才反应过来——前端上报的坐标是相对图片的而后台校验的也是相对图片的坐标。但我们在浏览器里模拟点击时鼠标事件的坐标是相对整个浏览器窗口的。如果图片在页面上有偏移比如图片上方还有个提示栏直接用图片坐标去点击鼠标点的位置就会偏。正确做法是用图片元素的getBoundingClientRect()获得图片在页面上的实际位置左上角相对视口的偏移然后把我们算出来的图片内部坐标加上这个偏移才是真正要传给鼠标的屏幕坐标。from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC # 获取图片元素在页面中的偏移 img_element WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, captcha_img)) ) rect driver.execute_script( const el document.getElementById(captcha_img); const r el.getBoundingClientRect(); return {left: r.left, top: r.top, width: r.width, height: r.height}; ) # 计算最终屏幕坐标 screen_x int(rect[left] click_x * (rect[width] / image_width)) screen_y int(rect[top] click_y * (rect[height] / image_height))注意这里还有个细节如果图片在页面上被 CSS 缩放或响应式拉伸了那么图片内部坐标并不能直接映射到屏幕坐标必须先做一次比例换算。这个比例就是rect.width / image_width和rect.height / image_height。5.3 坑三接口重放的陷阱曾有一个阶段我以为只要拿到了正确的点击坐标就可以用requests直接 POST 坐标数据绕过整个浏览器操作。结果发现后台对会话状态校验得极其严格——它不仅校验坐标还会校验你是否真的加载过验证码图片、是否在本地进行过鼠标操作。最直观的表现是直接用requests发送同一条成功请求响应永远是校验失败。我后来分析了前后端的会话机制——每一步操作都会在前端更新会话的交互状态比如记录captchaLoadedAt、mouseTrack等字段最终服务端会综合这些字段做整体校验。只发送最终坐标而模拟整条交互链路是绝对过不了的。所以最稳妥的方式就是让前端代码真正地跑起来用浏览器自动化工具比如 Selenium、Playwright代替纯 API 请求。在这个前提下一切行为模拟才有意义。5.4 最终效果在模拟项目X的演示环境下我的一套完整流程YOLO 模型识别 质心修正 贝塞尔轨迹 随机点击节奏 页面坐标换算最终统计结果是指标结果目标检测 mAP0.50.92边界框坐标命中率97.8%行为模拟后整体通过率92.3%平均每次验证耗时4.6 秒这个耗时已经和人类手动验证差不多甚至略快一点了而且通过率足够稳定可以支撑大规模自动化任务了。6. 验证码对抗的未来趋势与一点忠告在写完这套流程之后我其实也花了不少时间思考一个更根本的问题这类验证码的攻防为什么是一个无底洞从技术演进趋势来看文字点选验证码正在向两个方向迭代一是语义理解加深。现在已经有验证码不再要求点击春、夏、秋、冬这种具体单词而是会给出请点击画面中所有穿红色衣服的人、请点击图中所有朝左的车辆。这类验证码考验的不再是简单的文字识别而是场景理解能力需要模型具备常识推理破解门槛显著提高更贴近当前多模态大模型的研究方向。二是个性化无序化设计。每张图的文字数量、布局、字体、干扰策略都做随机化。这直接导致依赖固定模型如 OCR 或单一目标检测网络的方案很容易失效需要不断的更新与重训。说白了破解方案和验证码设计就是一场持续的军备竞赛任何方案都不能一劳永逸。最后讲一句实在话我写这篇分析文章并不是鼓励每个人都去做一个验证码破解器。验证码的核心目的是保护网站免受恶意流量、爬虫刷量、撞库攻击等行为。如果你需要用自动化技术处理验证码请务必先确认你有合法的授权例如你拥有该网站的代码权限或测试账号你是在做自己平台的自动化回归测试你的爬虫行为遵守了robots.txt和网站服务条款你在沙箱或本地模拟环境中进行技术研究。我想说的是研究文字点选算法更大的价值在于它逼着你去深入理解前端的图像渲染机制、图像处理与深度学习的结合、行为模拟的细节设计——这一整套链路本身就是一种很好的技术素养训练。当你真正看懂了这套机制之后你对什么是安全的验证什么行为是机器特征都会有一个比一般人更深刻的认知。这种底层认知才是比任何一招鲜都值钱的东西。如果你在实际操作中也遇到了类似的验证码问题欢迎在评论区或者社区里交流。随便问我看到都会回复。