ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

滑动验证码前端原理:轨迹采集、缺口定位与服务端复核全解析

滑动验证码前端原理:轨迹采集、缺口定位与服务端复核全解析 先聊一个有意思的现象我平时审查前端代码时经常看到有人把滑动验证码的“判断逻辑”完全写在浏览器端比如某个文件里直接写死了从 x120 滑到 x280 就通过。这种实现表面上看很顺畅拖一下滑块就弹绿勾但它其实是把“验证”做成了“装饰”。真正的滑动验证码特别是现在大厂在用的那些前端要做的事远不止“监听拖拽”它的每一步——从按下鼠标开始、到轨迹采样、再到图片缺口定位、环境特征收集全都在回答一个问题屏幕这一头的操作到底来自身经百战的真人手掌还是来自一段按部就班的脚本这篇文章我会从一段最典型的滑动验证码前端代码出发逐个拆解它背后的判断维度。跟市面上那些“画个 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事件间隔会忽长忽短脚本模拟时却是规则间隔。dx、dy相对起始位置的偏移量。dy尤其关键真人拖动时手指/鼠标很难完全水平会在垂直方向随机抖动机械模拟往往是一条笔直的 y0 水平线。timeStamp事件自带的高精度时间戳。脚本可以伪造Date.now()但伪造Event.timeStamp的成本更高不少服务端会拿两者交叉验证。button鼠标按键编号。部分脚本用dispatchEvent伪造事件时这个值经常被漏掉或传错成了低级破绽。1.3 移动端上的差异滑动验证码在手机上同样常见但事件体系从mousedown换成了touchstart、touchmove、touchend。移动端多了几个更“隐私”的信息比如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 服务端下发坐标把“答案”藏起来正因为前端算缺口不可控很多成熟方案改为服务端在生成验证码时就确定缺口位置并把坐标用签名加密后带给前端。流程大致是页面请求验证码服务端生成背景图、缺口图和滑块图随机决定缺口 x 坐标。服务端把三张图下发给前端坐标不出现在初始请求里而是藏在后续交互 token 中。用户完成拖动后前端把终点坐标、轨迹、耗时与临时 token 一起上报。服务端校验上报坐标与真实坐标的差值以及轨迹行为是否像真人。坐标下发也有几个绕不开的细节坐标不能原样下发。至少要跟一个随机 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 滑块区域只是个幌子整个页面都在被监听很多人以为验证码脚本只监听滑块按钮上的事件所以模拟时也只往按钮上派发mousedown、mousemove、mouseup结果还是被识别。实际上页面加载后就有若干监听器挂在window、document、甚至看不见的透明层上。它们不参与滑块逻辑但会默默记录你在整个页面上的移动轨迹、滚动行为、键盘输入节奏、点击坐标分布。真人进入页面后鼠标是自由移动的轨迹遍布整个视口时不时误触、悬停、停顿自动化浏览器则往往直接定位到滑块位置光标轨迹几乎是一条从页面边缘直插目标点的线段。4.2 canvas 指纹、webdriver 标记与事件触发顺序这部分是前端比浏览器自动化工具更“难骗”的地方canvas 指纹验证码初始化时会在后台绘制一段带有随机字符或图形的 canvas然后把 base64 图像哈希。不同浏览器、不同显卡、不同渲染模式生成的结果差异很大同一台机器通过无头浏览器运行时经常因为没有 GPU 渲染而出错产生异常指纹。浏览器自动化标记无论 WebDriver 还是 Puppeteer都会在navigator对象上留下痕迹比较经典的有 longnavigator.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 和轨迹数据后通常会做几件事验签与时间窗token 是否有效、是否过期、是否已被使用过。一次性 token 是标配。缺口坐标比对用事前保存的偏移 key 解开真实坐标判断上报终点是否落在容忍区间内。轨迹重放分析重新跑一遍轨迹特征提取不看前端给的结果只信原始采样点。设备指纹交叉验证IP 地理位置、历史行为、UA 与设备指纹是否自洽。频次与并发检测同一 IP 或设备短时间内的验证码请求次数是否异常是否同时有多个不同 session 在请求。这些信号里最容易被前端忽略却又最致命的是验签与时间窗。比如某次实现中前端上报终点坐标时只是简单地把数字附到表单里没有任何签名那我直接在提交前改这个值就完事了。服务端一旦只在“差值是否小于 5px”这个层面校验整个验证码形同虚设。所以服务端一定要自己保存真实坐标用一个随机 key 关联而不是信任前端任何自报字段。5.3 风险评分与动态策略现代验证码不会一刀切地返回“通过/不通过”。它能接受验证码“可能有点风险但不确定”这个中间状态并基于风险分做动态策略风险评分处理策略极低0-30直接放行用户无感知中低30-60弹一次简单滑块验证中高60-80弹滑块 拼图 字体点选等更高难度的二次验证极高80-100直接拒绝或要求短信/扫码人工确认这也是为什么有时候你第一次访问某个网站不需要拖滑块只有在连续刷新、行为模式异常时才冒出验证码。那不是“随机出现的”是风险评分把你推进了更高档位。6. 验证码对抗的成本博弈与前端落地建议6.1 为什么验证码升级永远没有终点验证码本质上不是“能不能被破解”的问题而是“破解成本”与“防护价值”之间的博弈。一套验证码设计得越复杂真人用户的操作成本越高流失风险越大设计得越简单脚本模拟门槛越低。这里没有完美解只能朝两个方向努力一是降低真人交互成本所以现在流行无感验证、一键验证二是提高脚本模拟成本所以轨迹分析、设备指纹、服务端复核要组合使用而不是只靠某一个点。6.2 自研验证码时的几条落地建议如果团队需要自研一套滑动验证码我建议前端至少注意以下几点不要把缺口坐标明文放在前端配置里。坐标必须由服务端生成并通过签名下发前端只负责展示和采集不负责“决定正确答案”。轨迹采样别只做滑块区域的。至少在进入验证码流程后采集一段时间内的全局鼠标/触摸移动加入提交参数它比滑块局部轨迹更有区分度。上报要做好防重放和防篡改。token 要一次性请求要带随机数所有业务参数参与签名服务端验证签名后再做业务判断。别把判断结果只放在前端回调里。前端弹出“校验通过”绿勾没有任何权威性后续真正要保护的操作登录、发帖、领券必须由服务端再校验一次 token 有效期和状态。6.3 实测中的一些体会我实际调试过不少自研和商业版滑块验证码一个比较深的感受是前端采集到的原生数据远比你想的更丰富也远比你想的更脆弱。丰富是因为浏览器提供了大量可以交叉验证的信息比如event.timeStamp、performance.now()、requestAnimationFrame的频率甚至一次 mousemove 触发间隔的微妙差异都能被统计模型利用。脆弱是因为所有这些信息都建立在浏览器 API 没有被篡改的前提下。如果用户安装了自动化对抗插件或者在受控环境里运行脚本很多数据点本身就是被伪造的。这也是为什么最终结论往往不是靠某一个“神奇参数”一锤定音而是靠几十个弱信号加权综合。另外还有个小细节验证码前端上报的数据最好做一下采样频率控制不要每一下 mousemove 都发一个请求那样既浪费流量又容易被服务端当成“事件过于密集”的异常。一个常见的做法是在前端缓存轨迹数组松手后通过一次 fetch 或 sendBeacon 批量提交。这个思路同样适用于移动端 H5 场景——触摸事件的采样频率本来就比桌面鼠标低批量上报能减少不少后端的解析压力。我在写验证码相关功能时最后还会做一轮“换位测试”假设我是个只懂基础 JS 的脚本小子能不能从上手到破解你的验证码不超过 10 分钟如果能就说明代码里给了太多信息——要么目标距离太容易猜要么校验逻辑压根没走服务端。只有把自己放在攻击者的位置上测一遍才能真正理解验证码每一层设计到底在防什么。
返回列表