滑动验证码前端原理:轨迹采集、缺口定位与服务端复核全解析
2026/9/23 14:49:37 网站建设 项目流程

先聊一个有意思的现象:我平时审查前端代码时,经常看到有人把滑动验证码的“判断逻辑”完全写在浏览器端,比如某个文件里直接写死了从 x=120 滑到 x=280 就通过。这种实现表面上看很顺畅,拖一下滑块就弹绿勾,但它其实是把“验证”做成了“装饰”。真正的滑动验证码,特别是现在大厂在用的那些,前端要做的事远不止“监听拖拽”,它的每一步——从按下鼠标开始、到轨迹采样、再到图片缺口定位、环境特征收集,全都在回答一个问题:屏幕这一头的操作,到底来自身经百战的真人手掌,还是来自一段按部就班的脚本?

这篇文章我会从一段最典型的滑动验证码前端代码出发,逐个拆解它背后的判断维度。跟市面上那些“画个 canvas、拖一下就通过”的 Demo 不同,我会把隐藏在前端编码里的行为分析、像素比对、服务端复核这层逻辑摊开讲,希望给前端开发、测试同学,以及所有想了解“验证码到底凭什么拦住机器人”的人一份可以直接理解的参考。

1. 一段滑动验证码的核心代码:从按下到松开的全过程

1.1 简化后的滑动监听与轨迹采集代码

无论你用的是极验、腾讯滑块还是自研组件,最底层一定有一段类似这样的代码。先看整体结构,再逐个字段解释:

const slider = document.getElementById('slider'); const track = document.getElementById('track'); const trace = []; slider.addEventListener('mousedown', (e) => { const startTime = Date.now(); const startX = e.clientX; const startY = e.clientY; const startPageY = e.pageY; // 按下瞬间就抓几个关键信息 trace.push({ event: 'down', t: 0, x: startX, y: startY, pageY: startPageY, button: e.button, timeStamp: e.timeStamp }); function onMove(ev) { trace.push({ event: 'move', t: Date.now() - startTime, x: ev.clientX, y: ev.clientY, // 水平/垂直方向相对于按下的偏移 dx: ev.clientX - startX, dy: ev.clientY - startY, // 移动事件自带的时间戳,精度比 Date.now() 更高 timeStamp: ev.timeStamp }); } function onUp(ev) { const duration = Date.now() - startTime; const endX = ev.clientX; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); trace.push({ event: 'up', t: duration, x: endX, y: ev.clientY, dx: endX - startX, dy: ev.clientY - startY }); // 把轨迹、耗时、起点终点一起交给校验函数 sendVerification(trace, { start: { x: startX, y: startY }, end: { x: endX, y: ev.clientY }, duration, targetDistance: getTargetDistance() // 尝试获取预期的滑动距离 }); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); });

1.2 每个字段都在回答什么

这段代码里有几个字段,外行看起来只是坐标,但在后端眼里是“行为指纹”:

  • t:每次事件距离按下时刻的时间差。这决定了整条轨迹的时间分布,真人不会均匀触发 mousemove,事件间隔会忽长忽短;脚本模拟时却是规则间隔。
  • dxdy:相对起始位置的偏移量。dy尤其关键,真人拖动时手指/鼠标很难完全水平,会在垂直方向随机抖动;机械模拟往往是一条笔直的 y=0 水平线。
  • timeStamp:事件自带的高精度时间戳。脚本可以伪造Date.now(),但伪造Event.timeStamp的成本更高,不少服务端会拿两者交叉验证。
  • button:鼠标按键编号。部分脚本用dispatchEvent伪造事件时,这个值经常被漏掉或传错,成了低级破绽。

1.3 移动端上的差异

滑动验证码在手机上同样常见,但事件体系从mousedown换成了touchstarttouchmovetouchend。移动端多了几个更“隐私”的信息,比如touch.force(按压力度)、touch.radiusX/radiusY(手指接触面积)。Android 和 iOS 的按压数据分布差异明显,甚至不同型号手机触摸屏的采样率都不一样。

我在真实项目中见过一个很有意思的案例:测试用 iOS 模拟器跑自动化,结果滑块永远过不去,后来查日志发现模拟器上报的touch.force恒为 0,而真实 iPhone 上报的值通常在 0.1~0.6 之间波动。这就属于“环境指纹”层面的暴露,不是轨迹算法能救回来的。

2. 缺口怎么定位:canvas 像素扫描与坐标下发的两条路线

2.1 浏览器端本地算缺口:canvas 像素差遍历

很多滑动验证码的背景是一张带缺口的风景图,滑块是一小块拼图。前端要判断“滑到哪才算对接”,最直接的办法是拿滑块图和背景图做像素级比对。

示意代码,只是帮你建立概念:

function findGapX(bgCanvas, gapCanvas) { const bgCtx = bgCanvas.getContext('2d'); const gapCtx = gapCanvas.getContext('2d'); const bgW = bgCanvas.width; const bgH = bgCanvas.height; const gapW = gapCanvas.width; const gapH = gapCanvas.height; const bgImageData = bgCtx.getImageData(0, 0, bgW, bgH).data; const gapImageData = gapCtx.getImageData(0, 0, gapW, gapH).data; // 滑动搜索窗口,从左侧往右扫 for (let offset = 0; offset < bgW - gapW; offset++) { let diffScore = 0; for (let y = 0; y < gapH; y += 8) { // 隔行采样,减少计算量 for (let x = 0; x < gapW; x += 8) { const gapIdx = (y * gapW + x) * 4; const bgIdx = (y * bgW + (x + offset)) * 4; const r = Math.abs(bgImageData[bgIdx] - gapImageData[gapIdx]); const g = Math.abs(bgImageData[bgIdx + 1] - gapImageData[gapIdx + 1]); const b = Math.abs(bgImageData[bgIdx + 2] - gapImageData[gapIdx + 2]); diffScore += r + g + b; } } // 分数最低的位置,就是缺口对齐处 if (diffScore < threshold) { return offset; } } return -1; }

这段代码是“前端本地判断缺口”这一类方案的思路浓缩。真实实现会更复杂,比如先用灰度化、二值化、边缘检测过滤干扰纹理,再找缺口边界峰,但本质还是像素差比对。开发者之所以愿意在前端跑这么重的计算,是为了省一次网络请求、降低服务端压力。但这种方案有个致命弱点:用户在浏览器里打开 DevTools,打断点、改内存、直接调findGapX(),缺口位置就暴露了。只要把计算出来的offset交给脚本,滑块就能精准位移。

2.2 服务端下发坐标:把“答案”藏起来

正因为前端算缺口不可控,很多成熟方案改为服务端在生成验证码时就确定缺口位置,并把坐标用签名加密后带给前端。

流程大致是:

  1. 页面请求验证码,服务端生成背景图、缺口图和滑块图,随机决定缺口 x 坐标。
  2. 服务端把三张图下发给前端,坐标不出现在初始请求里,而是藏在后续交互 token 中。
  3. 用户完成拖动后,前端把终点坐标、轨迹、耗时与临时 token 一起上报。
  4. 服务端校验上报坐标与真实坐标的差值,以及轨迹行为是否像真人。

坐标下发也有几个绕不开的细节:

  • 坐标不能原样下发。至少要跟一个随机 key 或 session 绑定,否则别人直接在接口里读到真实答案。
  • 下发坐标要有偏移容忍区间。真人很难精确停在像素级目标上,通常允许 5~10px 误差,这个区间本身也要随机化,不能每张图都是 5px。
  • 服务端密钥校验是关键。如果前端能轻易篡改“终点坐标”参数,再好的行为分析也没用。

2.3 为什么很多验证码故意不告诉你目标距离

另一个常见设计是:前端代码里没有targetDistance,拖动全程你就只知道“往右拉”,不知道要拉多少像素。它的好处在于防止脚本直接读取距离后一次性滑动到位。真实的拖动过程必然包含试探、中途修正,如果脚本拿到了精确距离,模拟“逼近目标”时总是太果断,很容易在加速度曲线上暴露。

不过这里有个度的问题。如果完全不返回任何距离信息,用户体验也会下降,尤其在小屏幕上,用户不知道要拖多远。折中方案是把距离模糊成若干档位(短/中/长),前端只展示“这段路程长短”的语义,精确值存服务端。

3. 判断你不是机器人的关键指标:轨迹、时间与微小抖动

3.1 人类轨迹和机器轨迹的差异在哪

这是验证码的核心战场。前端采集到一堆轨迹点之后,不管是本地打分还是上报给服务端打分,都会从这些维度去问同一个问题:这段轨迹是不是人拉出来的?

特征维度真人轨迹脚本模拟轨迹
按压到启动的反应时间300ms~1.5s,有思考延迟常常小于 50ms,甚至 0ms
速度曲线先加速后减速,有波动,偶尔停顿再加速匀速或直接线性变化,加速度曲线平滑得不像话
是否“过冲”经常滑过头几像素,再回拉修正很少有回拉,或回拉过于规整
y 轴抖动明显,尤其在按下和松开瞬间几乎恒定,y 值是一条直线
点击滑块的位置随机的,有时偏向滑块边缘每次都命中滑块正中心
轨迹点密度不均匀,快速移动时点稀疏,停滞时点密集均匀采样,等间隔输出

这六个维度里,最容易被忽略的是“反应时间”。人在看到滑块后,要经过视觉识别、思考、运动规划才按下鼠标,这个过程通常在 300ms 以上;脚本则是在页面加载完的刹那触发事件。单纯看耗时就把大量低级脚本过滤掉了。

3.2 用代码分析一条轨迹的行为分

实际项目中,轨迹分析通常不会只抽一个指标,而是多个维度综合打分。下面这个代码片段,展示了服务端或前端分析轨迹的核心思路:

function analyzeTrace(trace) { if (trace.length < 3) return { score: 0, reason: 'too_short' }; // 1. 总耗时:太短直接低分 const totalTime = trace[trace.length - 1].t; if (totalTime < 200) return { score: 0, reason: 'too_fast' }; // 2. 加速度方差:真人轨迹的加速度方差较大 const accList = []; for (let i = 2; i < trace.length; i++) { const dt = (trace[i].t - trace[i - 1].t) / 1000; if (dt <= 0) continue; const v1 = (trace[i - 1].x - trace[i - 2].x) / dt; const v2 = (trace[i].x - trace[i - 1].x) / dt; accList.push((v2 - v1) / dt); } const accVariance = calculateVariance(accList); // 3. y 轴抖动幅度:真人会有上下偏移 const yValues = trace.map(p => p.y); const yRange = Math.max(...yValues) - Math.min(...yValues); // 4. 中途停顿次数:真人偶尔会停一下再走 const pauseCount = trace.filter((p, i) => i > 0 && p.t - trace[i - 1].t > 300 ).length; // 5. 过冲/回拉:终点附近是否有反向位移 let backCount = 0; for (let i = 1; i < trace.length; i++) { if (trace[i].dx < trace[i - 1].dx) backCount++; } return { score: computeScore(totalTime, accVariance, yRange, pauseCount, backCount), features: { totalTime, accVariance, yRange, pauseCount, backCount } }; }

这套代码的思想是:把人类操作的“粗糙感”量化。真人拖动滑块时点数越多、轨迹越丰富、越不规则,反而得分越高;相反,一条横平竖直、无抖动、无停顿、一次到位的轨迹,得分会非常低。

3.3 为什么“完美匀速”反而更像机器人

很多写模拟脚本的人,第一版会尝试用“匀速直线”拖动,认为这样最稳定。但在验证码语境里,人恰恰不会那么稳。你可以试试自己拖一次滑块,大概率会经历三个阶段:按下后有短暂停顿;起始速度较慢;中途加速;接近缺口时减速微调,甚至来回蹭两下。

这种“不完美”是几十年运动习惯在身体里的沉淀,很难被脚本复现。现代验证码系统会刻意训练一个模型,专门找轨迹里的“非自然完美点”。如果你拖动 200px 的距离,耗时稳定在 300ms,并且中间没有任何速度波动,这本身就是最大的异常信号。

4. 环境指纹和自动化痕迹:前端判断里那些“隐藏牌”

4.1 滑块区域只是个幌子,整个页面都在被监听

很多人以为验证码脚本只监听滑块按钮上的事件,所以模拟时也只往按钮上派发mousedownmousemovemouseup,结果还是被识别。

实际上,页面加载后就有若干监听器挂在windowdocument、甚至看不见的透明层上。它们不参与滑块逻辑,但会默默记录你在整个页面上的移动轨迹、滚动行为、键盘输入节奏、点击坐标分布。真人进入页面后,鼠标是自由移动的,轨迹遍布整个视口,时不时误触、悬停、停顿;自动化浏览器则往往直接定位到滑块位置,光标轨迹几乎是一条从页面边缘直插目标点的线段。

4.2 canvas 指纹、webdriver 标记与事件触发顺序

这部分是前端比浏览器自动化工具更“难骗”的地方:

  • canvas 指纹:验证码初始化时,会在后台绘制一段带有随机字符或图形的 canvas,然后把 base64 图像哈希。不同浏览器、不同显卡、不同渲染模式,生成的结果差异很大,同一台机器通过无头浏览器运行时,经常因为没有 GPU 渲染而出错,产生异常指纹。
  • 浏览器自动化标记:无论 WebDriver 还是 Puppeteer,都会在navigator对象上留下痕迹,比较经典的有 long,navigator.webdriver属性为 true,还可以进一步检测window.cdc_前缀对象(ChromeDriver 注入的标记)。
  • 事件触发顺序异常:真人操作时,必然是先mousedown,再一系列mousemove,最后mouseup。脚本伪造事件时,偶尔会漏掉中间的mousemove,或顺序颠倒。这类异常会被浏览器内核直接捕获并上报。

这些信息的前端采集代码,通常藏在压缩混淆后的 JS 里。它们看起来与验证码无关,却在请求 token 时悄悄附加到参数里,发送给服务端做二次评分。

4.3 设备指纹的组合打分

单个环境信息很难说明问题,比如某用户用的恰好是HeadlessChrome标志、又恰好屏幕分辨率是 1920x1080、语言是 en-US、时区是 UTC,这些单独出现都可能是真人,但如果它们全在一份请求里叠加出现,就非常可疑了。

服务端会把前端上报的数据组合成特征向量:

UA + 屏幕分辨率 + 时区偏移 + 语言列表 + canvas哈希 + webdriver标记 + 已安装字体 + 音频上下文指纹

然后跟“历史上被判为真人的请求”做相似度匹配。如果某些特征出现频率过高,还会触发风险名单,下次这个设备再访问时,难度自动调高。

5. 服务端收到轨迹数据后会复核什么:绕不开的二次校验

5.1 为什么前端判断结果不可信

这是所有前端工程师在接触验证码时必须想通的问题:前端是运行在用户机器上的代码,任何运行在用户机器上的逻辑,用户都有办法改。

无论前端把评分算法写得多么精妙,只要它发生在浏览器环境里,就能被断点、钩子、内存篡改等方式干扰。所以成熟的验证码体系,把前端视为“信息采集器”,而不是“决策器”。前端负责的是把轨迹、行为、环境信息尽量完整、忠实地传回去;真正的判断,发生在你看不到的服务端。

5.2 服务端复核的几个信号

服务端拿到前端上报的 token 和轨迹数据后,通常会做几件事:

  1. 验签与时间窗:token 是否有效、是否过期、是否已被使用过。一次性 token 是标配。
  2. 缺口坐标比对:用事前保存的偏移 key 解开真实坐标,判断上报终点是否落在容忍区间内。
  3. 轨迹重放分析:重新跑一遍轨迹特征提取,不看前端给的结果,只信原始采样点。
  4. 设备指纹交叉验证:IP 地理位置、历史行为、UA 与设备指纹是否自洽。
  5. 频次与并发检测:同一 IP 或设备短时间内的验证码请求次数是否异常,是否同时有多个不同 session 在请求。

这些信号里,最容易被前端忽略却又最致命的是验签与时间窗。比如某次实现中,前端上报终点坐标时,只是简单地把数字附到表单里,没有任何签名,那我直接在提交前改这个值就完事了。服务端一旦只在“差值是否小于 5px”这个层面校验,整个验证码形同虚设。所以服务端一定要自己保存真实坐标,用一个随机 key 关联,而不是信任前端任何自报字段。

5.3 风险评分与动态策略

现代验证码不会一刀切地返回“通过/不通过”。它能接受验证码“可能有点风险但不确定”这个中间状态,并基于风险分做动态策略:

风险评分处理策略
极低(0-30)直接放行,用户无感知
中低(30-60)弹一次简单滑块验证
中高(60-80)弹滑块 + 拼图 + 字体点选等更高难度的二次验证
极高(80-100)直接拒绝,或要求短信/扫码人工确认

这也是为什么有时候你第一次访问某个网站不需要拖滑块,只有在连续刷新、行为模式异常时才冒出验证码。那不是“随机出现的”,是风险评分把你推进了更高档位。

6. 验证码对抗的成本博弈与前端落地建议

6.1 为什么验证码升级永远没有终点

验证码本质上不是“能不能被破解”的问题,而是“破解成本”与“防护价值”之间的博弈。一套验证码设计得越复杂,真人用户的操作成本越高,流失风险越大;设计得越简单,脚本模拟门槛越低。

这里没有完美解,只能朝两个方向努力:一是降低真人交互成本(所以现在流行无感验证、一键验证),二是提高脚本模拟成本(所以轨迹分析、设备指纹、服务端复核要组合使用,而不是只靠某一个点)。

6.2 自研验证码时的几条落地建议

如果团队需要自研一套滑动验证码,我建议前端至少注意以下几点:

  • 不要把缺口坐标明文放在前端配置里。坐标必须由服务端生成并通过签名下发,前端只负责展示和采集,不负责“决定正确答案”。
  • 轨迹采样别只做滑块区域的。至少在进入验证码流程后,采集一段时间内的全局鼠标/触摸移动,加入提交参数,它比滑块局部轨迹更有区分度。
  • 上报要做好防重放和防篡改。token 要一次性,请求要带随机数,所有业务参数参与签名,服务端验证签名后再做业务判断。
  • 别把判断结果只放在前端回调里。前端弹出“校验通过”绿勾没有任何权威性,后续真正要保护的操作(登录、发帖、领券)必须由服务端再校验一次 token 有效期和状态。

6.3 实测中的一些体会

我实际调试过不少自研和商业版滑块验证码,一个比较深的感受是:前端采集到的原生数据远比你想的更丰富,也远比你想的更脆弱

丰富是因为,浏览器提供了大量可以交叉验证的信息,比如event.timeStampperformance.now()requestAnimationFrame的频率,甚至一次 mousemove 触发间隔的微妙差异,都能被统计模型利用。脆弱是因为,所有这些信息都建立在浏览器 API 没有被篡改的前提下。如果用户安装了自动化对抗插件,或者在受控环境里运行脚本,很多数据点本身就是被伪造的。

这也是为什么最终结论往往不是靠某一个“神奇参数”一锤定音,而是靠几十个弱信号加权综合。

另外还有个小细节:验证码前端上报的数据,最好做一下采样频率控制,不要每一下 mousemove 都发一个请求,那样既浪费流量,又容易被服务端当成“事件过于密集”的异常。一个常见的做法是,在前端缓存轨迹数组,松手后通过一次 fetch 或 sendBeacon 批量提交。

这个思路同样适用于移动端 H5 场景——触摸事件的采样频率本来就比桌面鼠标低,批量上报能减少不少后端的解析压力。

我在写验证码相关功能时,最后还会做一轮“换位测试”:假设我是个只懂基础 JS 的脚本小子,能不能从上手到破解你的验证码不超过 10 分钟?如果能,就说明代码里给了太多信息——要么目标距离太容易猜,要么校验逻辑压根没走服务端。只有把自己放在攻击者的位置上测一遍,才能真正理解验证码每一层设计到底在防什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询