ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

滑块验证码全解析:从行为特征到后端判定与落地实践

滑块验证码全解析:从行为特征到后端判定与落地实践

前几天登录一个内部系统,连着拖了三次滑块才通过,第二次明明已经对准了缺口,页面还是提示“请重试”。我一边骂骂咧咧一边职业病犯了——这玩意儿的校验逻辑大概率是哪个参数没调好。作为被验证码折磨过无数次的开发者,我觉得有必要把滑块验证码这套东西从头到尾捋一遍:它是怎么工作的、为什么有时候明明对准了还不让过、真正的核心逻辑到底在前端还是后端、生产环境里哪些坑是必踩的。这篇文章适合两类人:一类是把滑块验证码当纯前端组件接一下就完事的业务开发,另一类是准备自己造轮子、想搞清人机校验原理的工程师。

1. 滑块验证码为什么会出现:一次安全与体验之间的权衡

1.1 字符验证码的快被嫌弃史

最早接触验证码的时候,页面弹出来的是四位扭曲字母,歪歪扭扭带着噪点,有时候还有一条横线穿过字符。那时候我为了登录一个系统,经常要眯着眼睛看半天,输错两三次才能过。这种验证码的本质是让用户完成一次“图像识别”,通过识别难度来区分人和机器。问题在于,机器识别的能力越来越强,想拦住它们就只能不断加大扭曲和干扰,结果人也被拦住了。我见过一个极端案例,某个老系统的验证码字符扭曲到连运维自己都认不出来,只能靠刷新换一张。

到了后来,点选文字验证码出现了:给出一个汉字,让用户在图片里找出对应的文字点击。这种交互比输入字母稍微友好一点,但图片里如果混入四五个干扰因素,用户的认知负担依然不小。点错的挫败感很强,尤其是在移动端小屏幕上,手指点偏一点就要重来。这一阶段的核心矛盾非常明显:验证码的安全强度越高,用户体验就越差,而用户不会认为这是“安全机制”,只会觉得这个网站不好用。

1.2 滑块验证码的本质:用“行为特征”取代“识别能力”

滑块验证码出现以后,交互方式一下子变简单了:页面上有一张带缺口的背景图,左边放着一块拼图,你把拼图拖到缺口位置,松手,完事。用户不需要动用任何视觉识别能力去解读扭曲字符,只需要做一个几乎不需要思考的拖拽动作。这个设计初看像是一种妥协,但仔细想想,它的核心思路其实发生了本质变化——它不再考验“你能不能认出图片内容”,而是考验“你的拖动行为像不像一个真实的人”。

计算机程序找缺口位置很容易,图像识别技术在工业界已经很成熟,一个模板匹配或者边缘检测算法就能拿到缺口坐标,但这恰恰不是滑块验证码的命门。真正难的是模拟人类拖动时的行为特征:手指或者鼠标从起点出发时有一个缓慢启动的过程,中间会加速,接近目标时会减速微调,整个过程中还会出现一些无意识的微小抖动,轨迹也不一定是一条直线。这些特征叠加在一起,形成了一种类似生物签名的行为模式。机器生成的轨迹往往过于平滑、过于精准,反而暴露了身份。

以我实际观察到的实现来看,服务端对一次滑块行为的判定很少只看最终坐标,更多是综合“结果是否正确”和“过程是否像人”两个维度。后面我会详细拆解这两个维度各自的实现方式。

1.3 接入与否,是个业务决策而不是技术决策

很多团队选择滑块验证码的理由很粗暴——“大家都在用,我们也上一个”。但我想说,验证码不是越多越好,接入之前最好想清楚三件事:你的业务被自动化攻击骚扰的频率有多高?你对真实用户误杀的容忍度有多低?你的用户主要用什么设备访问?

举个直观的例子,一个需要登录才能发布内容的社区,撞库和垃圾注册是主要威胁,滑块验证码放在登录和注册两个节点是合理的。一个纯粹的内部工具系统,本身不对外暴露,用户量只有几百人,这种情况下加验证码就是在给自己人添堵。还有一个很容易被忽视的点:如果你的主力用户是老年人或者体验相对较小的群体,滑块验证码的误杀率会让你迅速流失用户。产品有时会提出“先加了再说,不行再关掉”的想法,但验证码一旦上线,误杀数据会直接影响转化率,再撤下来已经造成损失了。所以接入与否,建议由业务方而不是开发方来做决定。

2. 一条滑块验证码的完整生命周期:从图片生成到最终判定

2.1 前端交互链路:用户看到的只是冰山一角

用户看到的只是一个拖拽动画,但一次正常请求从加载到判定,内部链路大概是这样的:页面初始化时向服务端申请一次验证码会话,服务端生成一张带缺口的背景图、一块滑块图、一个唯一的会话标识(比如一个随机 token),并把正确的缺口坐标存起来;前端拿到图片后在画布或者 DOM 中渲染,用户开始拖动;松手后前端把最终位移数据、拖动过程采集的轨迹点、会话标识以及其他辅助信息一起提交给后端;后端根据会话标识找到之前存的正确坐标,再结合轨迹点做综合判定,返回通过或失败。

注意一个关键点:正确的缺口坐标从头到尾都没有直接出现在前端代码里,它要么只存在服务端的会话缓存中,要么经过某种方式加密和扰动后才下发到前端。用户拖动时看到的“拼图对齐”只是视觉上的对齐,前端本身并不知道这个位置在服务端看来是否是“正确”的。这个细节决定了一个常见问题的答案:为什么我明明看着对齐了,提交以后还是提示失败。因为你看着对齐的位置,很可能在服务端看来偏差超过了允许范围。

2.2 缺口定位的几种实现思路:预置模板与动态生成

缺口坐标是怎么来的?不同实现方式差别很大,我把常见的三种方案列一下,你可以根据自己的场景选。

第一种是预置模板随机摆放。事先准备多张背景图,开发或设计阶段就把每张图切成多个候选位置。用户请求验证码时,服务端从模板库里随机选一张图、随机选一个缺口位置,直接记录这个坐标。这种方案实现最简单,运行开销几乎为零,缺点是背景图固定,时间长了可能被爬虫拉取模板库做离线分析。

第二种是服务端动态切割。每次请求时对一张完整底图进行图像处理,用算法自动找一个区域作为缺口,切出一块作为滑块图。这类实现可以用简单的边缘检测或者显著度检测来完成,也可以用更复杂的图像分割模型。动态切割的好处是每张图的缺口位置都不一样,安全性高;缺点是服务端做图像处理需要额外计算资源,高峰期要留意接口耗时和 CPU 占用。

第三种方案是前端渲染时做坐标扰动。服务端生成的真实坐标是 A,但下发给前端用于渲染的坐标是 A 加一个随机偏移。前端把滑块拖到用户看到的缺口位置后,上报的位移数据换算回服务端坐标系时会落到真实坐标附近的一个允许范围内。这样即使攻击者从前端拿到了相关参数,也不能直接推导出服务端的判定基准值。我见过一些团队的实现里坐标是经过自定义编码后下发到前端的,前端提交时带着编码后的数据,服务端解码后再比对,目的都是为了增加伪造难度。

2.3 服务端三要素:位移、轨迹、风险上下文

服务端拿到一次提交后到底在看什么?我理解为一个三要素模型:结果对不对、过程像不像人、上下文可疑不可疑。三者的权重不是平均分配的,通常结果和过程的权重最高,上下文作为辅助分参与综合判断。

第一要素是位移校验。前端最终拖动的距离会被换算成实际像素值,与服务端记录的缺口位置比对,允许一定的误差。这个误差值非常讲究,设得太松容易被简单枚举攻击钻空子,设得太紧又容易误杀真实用户。我见过一些实现给到 ±5 像素,也见过更严格的 ±2 像素,具体看图片尺寸和缺口大小。如果背景图是 300 像素宽,缺口本身有 60 像素,那 ±2 到 ±3 像素的容差是比较常见的。

第二要素是轨迹行为。前端会把拖动过程中采集到的坐标点序列和时间戳一起提交,服务端检查这个序列的物理合理性。比如总时长是不是落在 800 毫秒到 5 秒这个区间,轨迹点数量是不是太少,相邻点之间的位移增量有没有出现突兀的跳变。这些检查背后都有一个朴素假设:人类拖动是一段连续、有加速度变化的物理运动,而程序模拟往往要么过于匀速,要么过于精准。

第三要素是风险上下文。这个维度通常包括设备指纹、浏览器环境、IP 维度频率、账号历史行为等。比如同一个 IP 在一分钟内发起了 10 次验证请求,即使前面几轮轨迹都通过了,服务端也会自动提高风险分。再比如检测到当前浏览器环境存在自动化控制特征,也会直接标记为高风险。

下面这张表是我在具体项目中常用的一组初始经验值,仅供参考,真实上线一定要用自己的真机样本重新标定。

校验维度典型参数经验初始值说明
位移容差像素±3 px与图片宽度、缺口尺寸相关
总时长下限毫秒800 ms低于这个值基本不可能是手工拖拽
总时长上限毫秒5000 ms太慢通常意味着脚本在等待或者模拟延迟
轨迹点数量个不少于 30真实拖动过程会持续产生采样点
相邻帧最大位移像素50~80 px单帧跳变过大属于异常遥测
置信阈值风险分80 分拒绝综合分高于阈值直接拦截

3. 手写一个最小可用的滑块验证码

3.1 整体架构与数据流

理论讲完了,下面动手写一个能用的简化版本。先声明一下,这个版本的定位是“教学演示”,用来帮助理解核心逻辑,生产环境建议在此基础上做大量加固,或者直接采用成熟商用方案。

整个系统的数据流我建议这样设计:服务端生成验证码会话,把背景图、滑块图、会话 id、真实坐标写入分布式缓存(用 Redis 比较合适),设置几分钟的过期时间;前端渲染图片并采集轨迹;提交校验时服务端取出会话数据,先校验 token 存在性,再校验位移,再校验轨迹,全部通过后签发一个短时效的通过凭证。

为了控制篇幅,服务端我使用 Python 写核心逻辑,配合伪代码描述前端部分。真实项目里你可以用任何后端语言实现,逻辑是一样的。

3.2 前端拖拽与轨迹采集

前端的核心工作是三件事:渲染图片、监听拖动、打包上报。监听部分我建议使用指针事件而不是传统的鼠标事件,因为用户可能在触屏设备上用触摸操作,指针事件可以统一适配鼠标、触摸和触控笔。

let dragData = []; const slider = document.getElementById('slider'); const gap = document.getElementById('gap'); slider.addEventListener('pointerdown', (e) => { const startX = e.clientX; const startTime = Date.now(); dragData = [{ x: 0, y: 0, t: 0 }]; const onMove = (ev) => { const dx = ev.clientX - startX; const dy = ev.clientY - startY; dragData.push({ x: dx, y: dy, t: Date.now() - startTime }); slider.style.transform = `translateX(${dx}px)`; }; const onUp = async (ev) => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); const finalDx = ev.clientX - startX; const resp = await fetch('/api/verify-slide', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: window.slideToken, dx: finalDx, points: dragData }) }); const result = await resp.json(); if (result.passed) { document.getElementById('captcha').innerHTML = '验证通过'; window.verifyToken = result.verifyToken; } else { location.reload(); } }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); });

注意一个细节:代码里上报的 points 不是原始坐标序列,而是相对拖动起点的位移序列,加上相对时间戳。这么做的原因很简单,服务端判断轨迹时只关心相对运动形态,不关心用户从屏幕上哪个具体位置开始拖,相对坐标还能降低跨端差异带来的干扰。

3.3 后端校验实现

后端校验接口是整个系统的心脏。第一步先查缓存,拿到正确的缺口坐标;第二步做位移比对;第三步做轨迹合理性分析。我把核心逻辑写成一个独立的校验函数,方便单元测试。

import time def verify_slide(token, submitted_dx, points): # 第一步:会话校验 session = cache.get(f"slide:{token}") if not session: return False, "会话已过期或不存在" cache.delete(f"slide:{token}") # 一次性使用,防止重放 target_dx = session["target_dx"] # 第二步:位移比对 if abs(submitted_dx - target_dx) > 3: return False, "位移偏差过大" # 第三步:轨迹基础检查 if len(points) < 30: return False, "轨迹点太少" duration = points[-1]["t"] - points[0]["t"] if duration < 800 or duration > 5000: return False, "拖动时长异常" # 第四步:速度突变检查 diffs = [] for i in range(1, len(points)): diffs.append(points[i]["x"] - points[i-1]["x"]) max_velocity = max(abs(d) for d in diffs) if max_velocity > 80: return False, "存在异常速度突变" # 第五步:判断是否存在明显的加速-减速过程 # 真实拖动通常前段加速、后段减速,中段匀速 # 简化做法:前半段平均速度小于后半段平均速度 half = len(diffs) // 2 first_half_avg = sum(abs(d) for d in diffs[:half]) / half second_half_avg = sum(abs(d) for d in diffs[half:]) / max(1, len(diffs) - half) if first_half_avg >= second_half_avg: return False, "缺少减速阶段特征" return True, "通过"

这个校验函数在真实项目中还有很大的演进空间,比如加速度的连续变化、停顿点的分布、纵轴方向的自然抖动范围、轨迹的弯曲度等等。但它的骨架已经能反映服务端判定的基本思路:不只关心你有没有拖到位置,更关心你是怎么拖的。

3.4 阈值从哪里来:样本采集与调参

前面代码里的阈值比如 30 个轨迹点、800 毫秒、3 像素,都不是拍脑袋定的,而是通过对真实用户行为采样统计出来的。但很多团队上线验证码时根本没有这一步,直接抄了一个开源实现的阈值,结果误杀率奇高。

正确的做法是分两步走。第一步在测试环境找 20 到 30 个不同背景、不同习惯的同事,让他们在真实设备上各拖拽几十次,把 passes 的轨迹数据全部落盘。第二步对这批数据做分布统计:轨迹点数量的 P5 到 P95 区间是多少,总时长的分布形态是什么样的,相邻帧位移的峰值大概在什么范围。然后把阈值设定在分布的两端之外留一点余量。比如统计出来真人轨迹点数量最少是 35 个,那服务端校验的硬下限可以设在 25 到 30 个,留出容错空间。

这里有一个我特别想强调的点:阈值不是一成不变的。如果上线后某个版本改了前端采集频率,比如从每 16 毫秒采一次改成每 50 毫秒采一次,轨迹点数量会明显下降,旧阈值会开始误杀。所以任何跟前端采集相关的改动,都要重新走一遍样本采集流程,用回归集验证后再发。

4. 生产环境里的四个老问题:误杀、重放、缓存与降级

4.1 误杀真人是验证码最大的隐性成本

很多人关注验证码能不能拦住攻击者,但我在实际项目里遇到最多的麻烦恰恰相反:验证码把真实用户给拦了。误杀的表现有很多种,最常见的是老年人或者不常使用鼠标的用户,他们拖动速度慢,轨迹点虽然多但停顿特别长,总时长超过 5 秒直接被拒;还有触屏设备上手指拖动的采样频率远低于鼠标,轨迹点数量经常不到 30 个;更隐蔽的是有些用户的鼠标带加速度曲线,物理上会产生单帧大位移,被速度突变检查误判。

处理误杀没有一个万能公式,只能从两个方向同时下手。第一个方向是放宽硬性条件,把那些“低于某个值就拒绝”的硬阈值改成“低于某个值则提高风险分”,让单点异常不至于直接判死。第二个方向是给真实用户留重试机会,一次失败后允许再试一次或者更换一张更简单的图,而不是立刻进入冷却时间。我见过一个很务实的策略:第一次失败只提示“请重试”,第二次失败才进入短时冷却,第三次失败才要求更换验证方式。这个策略既保住了大部分真实用户,又不会给攻击者太多尝试窗口。

4.2 防重放与一次性凭证设计

滑块验证码有一个很容易被忽视的漏洞:如果会话凭证不做过期和一次性处理,攻击者可以拿到一张合法验证码的完整请求记录,反复重放。

防护手段是三层。第一层是凭证一次性,验证码生成时下发一个 token,服务端保存它的有效状态,校验接口一旦收到这个 token,立即从缓存删除,后续再用同一个 token 必然失败。上面的代码里已经实现了这个逻辑。第二层是时效性,token 的过期时间不宜太长,我建议 3 到 5 分钟,既给真实用户留了充足操作时间,又避免了长期有效的凭证被囤积。第三层是绑定签名,前端提交的数据里可以加一个基于 token、目标坐标和请求时间的签名参数,服务端验签后再执行业务逻辑,防止请求内容被篡改。

这里还要提醒一句:校验通过后下发的“通过凭证”本身也要设计有效期。我见过一些系统验证码通过后就直接放行,没有签发短期凭证,导致攻击者把一次通过的响应记录下来反复使用。正确的做法是服务端在验证通过后生成一个短时效令牌,业务接口必须校验这个令牌,令牌有效期内只能用一次或者只在很短时间内有效。

4.3 图片缓存与 CDN 带来的坐标错位

这个坑特别隐蔽,值得单独拿出来说。背景图和滑块图是每个会话动态生成的,图片 URL 如果设计得不够讲究,很容易被浏览器或者 CDN 缓存。一旦图片被缓存,而会话里的坐标是新的,就会出现一个诡异现象:用户看到的缺口位置和真实校验坐标对不上,明明拖到缺口了却提示失败,刷新页面又好了。

问题根源在于很多开发者习惯性地给图片资源设置强缓存,或者 URL 没有带唯一参数。图片 URL 本身是静态路径,浏览器按照标准缓存策略直接复用了。解法也很简单:每次请求验证码时,图片 URL 后面加一个随机且唯一的查询参数,同时设置响应头里的缓存策略为 no-cache。如果你用了 CDN,还需要确认 CDN 不会因为缓存问题把旧图片批量地分发出去。我建议上线前专门做一次图片 URL 的缓存测试,用不同会话看图片内容是否一致。

4.4 高可用降级与监控指标

验证码服务一旦挂了,影响面是整个登录注册流程,所以它必须有一套独立的降级方案和监控指标。

降级方案最好做成开关式的,遇到验证码服务故障、网络分区、后端依赖的图库不可用等情况时,自动或者人工切换为降级模式。降级模式的常见做法是直接放行,不做人机校验,但这会产生安全风险,所以降级通道需要加额外的频率限制兜底。比如验证码服务不可用时,同一 IP 一分钟内最多允许 10 次登录尝试,超过直接拒绝。这样既保证了可用性,又没有完全敞开大门。

监控方面重点看四个指标:请求量、通过率、误杀率、服务耗时。通过率掉得太多说明可能被攻击或者参数调坏了;误杀率异常上升说明判定逻辑误伤了真实用户;服务耗时突增说明图像处理逻辑可能成为瓶颈。这四个指标建议都接入告警,并且要有日报或者周报的统计。我个人特别重视误杀率这个指标,通过率下降不一定是坏事,可能只是在拦截攻击,但误杀率上升几乎一定是坏事,它代表你在伤害自己的真实用户。

5. 实施排坑实录:我踩过的和你们会踩的

5.1 缺口坐标对不上,问题出在哪

我在一个模拟项目里实现动态缺口生成时,碰到过一个特别让人抓狂的问题:前端拖到肉眼可见的缺口位置,后端始终报位移偏差过大,差了大概 6 个像素。排查了很久发现,生成缺口时用的图像坐标系参考点是图片的左上角,但前端拖拽的位移基准是滑块容器的左上角,而滑块容器内有 padding 和边框,导致实际位移被系统性偏移。

这种坐标系不一致的问题很难靠肉眼发现,建议在服务端校验时增加一个自检逻辑:记录前端提交的位移和坐标之间的差值,按固定区间统计偏差分布。如果所有请求的偏差都是一个稳定的正值或者负值,那大概率就是坐标系基准不一致,而不是用户的拖动问题。很多第三方验证码服务之所以要求前端上报的不是位移而是绝对坐标,也是为了避免这类偏移。

另一个常见原因是生成缺口的算法本身有定位误差。用边缘检测找缺口时,检测到的边缘坐标可能和视觉中心有偏差,尤其是缺口边缘有阴影或者模糊特效时。解决思路是把最终视觉中心作为目标坐标,而不是边缘坐标。我在实现时先计算出缺口的精确边缘框,再根据滑块图本身的视觉尺寸换算中心点,这样误差缩小到了 1 个像素以内。

5.2 轨迹判定总误杀,如何回归验证

轨迹误杀是我在这个领域遇到最多的问题,没有之一。第一次上线时我们把轨迹点下限设在 50,结果移动端直接哀鸿遍野,因为手指或者触控笔的采样频率根本没那么高,正常拖一次只有二三十个点。后来改为 20,又发现脚本模拟器轻松就能生成足够多的点,区分度反而下降了。

正确的思路不是说“点越多越像人”或者“点越少越像机器”,而是结合采样频率来判断。前端采集轨迹时记录每个点的时间戳,服务端先判断平均采样间隔是否符合预期。如果前端设定是每 16 毫秒采一次,但收到的轨迹里平均间隔是 100 毫秒,那大概率不是正常设备上报的。之后再判断轨迹形态,也就是我在 3.3 节写的加速减速逻辑。

调阈值之前,一定要先建回归集。我的习惯是每轮调整后,把之前采集的几百条真人轨迹和几十条明显异常的模拟轨迹全部跑一遍,确保新规则没有破坏旧样本的判定结果。这听起来很基础,但很多团队都是等上了线才发现某条规则误伤,才想起来找历史样本来验证。

5.3 移动端与无障碍场景的特殊处理

移动端和 PC 端的差异不只是采样频率,还有拖动行为的形态。鼠标拖动的典型特征是水平方向位移占绝对主导,纵轴方向会有一点无意识抖动,但幅度很小。触屏拖动的纵轴抖动则明显更大,因为手指与屏幕的接触面积大,轻微的施力变化都会反映在轨迹上。如果你的轨迹判定里对纵轴抖动范围有严格要求,移动端用户分分钟被误杀。

另外一个特别容易被忽略的场景是无障碍用户。许多依赖屏幕阅读器或者键盘操作的用户根本无法完成滑块拖拽,从产品合规和用户体验角度,都应该提供一个替代方案。常见做法是允许在滑块旁边点击“刷新换个图”的同时,提供语音验证码或者普通的算术题验证。如果只是为了降低开发成本而不做替代方案,最后的结果往往是这部分用户被彻底挡在门外。

5.4 常见问题速查表

我把实际运维中高频出现的问题整理成了一个速查表,团队排查问题时可以先对着它看一圈。

现象可能原因快速处理办法
滑块拖到缺口仍提示失败位移容差过小或坐标系偏移先查前后端坐标基准,再微调容差值
移动端大量验证失败轨迹点数量阈值过高接入指针事件,按真实采样频率调整阈值
页面刷新后验证码总是失效token 过期时间过短检查 Redis 过期时间,适当延长到 5 分钟
部分用户拖动很慢被拦截总时长上限设置过低放宽到 5 到 8 秒,或改成风险分机制
图片被缓存导致坐标对不上图片 URL 未带唯一参数添加随机查询参数并设置 no-cache
高峰期校验接口超时图像生成逻辑消耗过大图像预生成到缓存池,或缩短超时等待
攻击者用同一会话反复尝试会话未做一次性删除校验接口收到 token 后立即删除缓存
通过率突然飙升校验逻辑可能被绕过检查签名验证与重放防护是否生效

最后说点个人体会。做滑块验证码这么久,我最大的感受是它本质上不是一道安全组件,而是一个体验与安全之间的折中产品。你不可能做到 100% 拦截攻击而不误伤真人,目标应该是把绝大部分低成本的自动化攻击挡在门外,同时把真实用户的误杀率控制在一个业务可接受的范围内。这个平衡点没有通用答案,必须靠自己的样本数据和监控指标慢慢调。还有一个小技巧分享给大家:验证码校验接口记得单独打监控埋点,把通过率和误杀率分开报警,这两条曲线能非常直观地反映线上验证码的健康状态,比等用户投诉再来排查要省心得多。如果你们团队正在做验证码相关功能,我建议先花一个星期采集真实样本,把所有阈值都用数据定下来,再谈上线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进