ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

反垃圾注册实战:用行为分析+评分机制拦截注册机

反垃圾注册实战:用行为分析+评分机制拦截注册机 凌晨两点多我被手机通知震醒。后台的注册提醒一条接一条打开管理界面一看一分钟内涌进了四十多个新账号用户名全是“asdf”、拼音加随机数字的组合邮箱清一色来自几个临时邮箱域名。这不是第一次了但那天晚上数据量直接把用户表的查询拖慢了我正在做的一个公开注册产品第一次被注册机按在地上摩擦。那个周末我写了一套反垃圾注册的方案内部代号就叫 FckSignups。名字有点冲意思却很直接让这些批量注册的脚本从哪来回哪去。这篇文章把整套方案从思路到代码、从踩坑到优化完整展开适合被垃圾注册困扰的站长、独立开发者以及产品已经上线但还没搭风控的团队参考。1. 垃圾注册留下的烂摊子不是几行脏数据那么简单1.1 数据污染和账号滥用会让整个产品跟着遭殃那批数据落到用户表之后麻烦是滚雪球式的。最直观的表现是列表加载变慢后台搜索用户时要翻过几百个一眼假的账号。但真正头疼的还在后面这些批量注册的邮箱基本都是临时邮箱如果产品里带了“向新用户发送欢迎邮件”的功能每注册一个账号就会触发一次邮件服务调用按量计费的供应商直接扣钱。注册流程里如果还有短信验证那损失更明显平台收到的全是被薅走的验证码费用。更阴险的是僵尸账号会被拿去刷签到、刷投票、灌水评论甚至养号后批量倒卖。我认识一个社区运营的哥们儿他们的产品就是被垃圾账号混在真实用户里发垃圾内容运营同学手工封号封到崩溃一不小心还误伤了正常用户。后面他们不得不把封号逻辑收紧结果误伤范围更大了客服工单成倍增加那一个月基本什么都没干全在跟用户解释“为什么封你”。1.2 客服、误封、上游成本都会变成隐形账单垃圾注册从来不是一个孤立的纯技术问题。账号池膨胀意味着数据库存储、对象存储、消息推送通道全部要扩容大量来源存疑的账号拉高了整个风控面的复杂度客服渠道时不时收到“我为什么被封了”的投诉而运营为了让系统不那么“残忍”只能被迫放宽策略结果漏洞又回来了。等你需要基于用户数据做产品决策时统计报表里混进一堆“注册当天就永久沉睡”的僵尸号新增用户、次日留存、转化率全是失真的。我把这些账算了一遍之后得出一条结论反垃圾注册不是“要不要做”的问题而是“必须做而且越早做越好”的问题。FckSignups 这个名字就是在我算完账之后一气之下定下来的也顺便用来命名了整套判定逻辑和后续接入的工具链。2. 传统防线的真实底细我踩过之后才明白2.1 图形验证码扛不住脚本却劝退了一堆真人先说最常用的图形验证码。静态数字字母验证码用现成的 OCR 库破解成功率不低哪怕稍微扭曲一下攻击者也可以接打码平台按次付费成本低到离谱。滑动验证码体验稍微好一点但一样可以被模拟轨迹绕过。真正的问题在于验证码把大量成本转嫁给了真实用户。我见过不少转化率数据加验证码的注册页比不加的注册转化率掉 5 到 15 个百分点尤其是移动端输入麻烦、识别困难、验证模块加载慢用户可能在这一步直接流失。验证码不是不能用而是不应该作为拦在第一道防线的“默认关卡”。它更适合作为对部分可疑请求的“二次确认”而不是让每个正常访客都做一遍三六九等的证明题。后来我改成了“分数异常的人才弹验证码”转化率明显回升垃圾注册率也没有反弹。2.2 邮件验证和简单蜜罐能挡新手挡不住会学习的脚本邮件验证的设计意图是好的通过向邮箱发送激活链接确认邮箱真实存在且可接收邮件。但这个方案挡不住什么样的攻击者用临时邮箱服务的人。这类服务几乎零成本脚本里接一个临时邮箱 API 就能收到激活链接整个过程完全自动化。再加上按次计费的邮件服务垃圾注册每来一波你钱包就受一波伤。简单蜜罐字段也是很多人喜欢用的方案在表单里藏一个正常用户看不到的输入框如果被填写了就认为是机器人。但在实践中固定命名的 honeypot 字段很容易被爬虫和注册机学习。攻击者只要分析一次你页面的 DOM 结构把所有可见性为 none 的输入框都跳过你的蜜罐就废了。还有一种更“聪明”的注册机根本不填任何多余字段只填已知的 name 属性列表里的字段。2.3 为什么单点防御天然有漏洞不管是验证码、邮件验证、蜜罐还是 IP 限流单点防御都有同一个底层问题只要攻击者知道你用了哪一种方案他就能针对性地做绕过。图形验证码有打码平台邮件验证有临时邮箱IP 限流可以换代理池蜜罐可以分析 DOM 结构后跳过。所以后来我意识到关键思路不是“找一把锁把门锁死”而是“让攻击者的行为成本高于收益”。如果一套方案能让注册机必须在特定页面模拟出足够长的人类行为轨迹才能跑通注册流程那大多数批量脚本会放弃这个目标转而去捏更软的柿子。这也是 FckSignups 整套逻辑的核心前提。3. 拦住注册机的是行为差不是验证码3.1 人和脚本在注册页面上的五个可观测差异把“人类行为”翻译成可量化信号之前我先列了一下人和脚本在表单页面上的差异。这些差异是整套方案的数据基础也是后来调参时的依据。时间差真人从页面打开到提交至少需要几秒复杂的注册表单甚至会更久。而脚本通常会在页面加载后几百毫秒内完成填写和提交。输入节奏真人的打字速度不是恒定的会有停顿、删除、修改脚本则基本是毫秒级一次性填完所有字段。视觉位置真人借助屏幕操作必然会在页面可见区域产生一些鼠标移动、点击或触摸行为脚本直接在后台请求接口或者通过自动化框架操作往往不会有这些空间轨迹。焦点序列真人通常会从第一个输入框开始按 Tab 或点击顺序逐项填写脚本可能会乱序填充甚至直接跳过部分字段。浏览器上下文自动化工具WebDriver、Puppeteer 等会暴露一些上下文特征比如navigator.webdriver为 true或者浏览器窗口尺寸异常。3.2 把行为差异翻译成一组可量化的信号差异清楚了接下来就是设计成判定因子。我不打算做复杂的机器学习模型因为垃圾注册的样本标签获取不稳定模型可能过拟合。更稳妥的办法是做成一个轻量评分规则引擎每一条行为观测都映射成一个分值最后汇总出一个“疑似注册机分”。分数越高越像脚本。下面是我一开始定的评分项供参考观测信号判定条件分值蜜罐字段填写了隐藏字段直接标记为垃圾提交耗时页面加载到提交少于3秒50分无交互事件没有任何键盘/指针/focus事件40分自动化特征navigator.webdriver为 true40分无头浏览器特征缺少正常浏览器插件/字体特征30分跳序填写第一个聚焦字段不是表单第一个字段20分阈值我暂时定在 60 分达到或超过就认为这次注册高度可疑。注意那一条“蜜罐字段被填写”是直接进入拒绝逻辑的我把它当作最高优先级信号因为正常用户无论如何都不会去填写一个看不见的输入框。这套规则非常粗糙但它最大的好处是可解释、可调整。上线后如果发现误杀太多可以拉日志查看具体是哪个信号贡献了高分再针对性降低权重。4. 前端实现把行为信号收集起来交给后端4.1 表单结构的隐藏字段设计先说前端。这个方案对前端表单结构有两个基本要求一是必须有真正用户不可见的蜜罐字段二是要在提交时把行为观测数据塞进请求体里。蜜罐字段的实现最关键的一点是不能用typehidden。因为很多注册机在扫描页面 DOM 的时候会对所有input元素做一轮遍历遇到typehidden的字段会直接跳过你的蜜罐就失去意义了。比较合理的做法是用 CSS 把字段移到可视区域外同时让正常用户无法通过 Tab 键聚焦到它。命名也要有迷惑性不能用honeypot、website_at这类一下就能识别出来的名字。我习惯把它伪装成业务上的某个可选字段比如company_website然后给一个一看就不是必填的默认值。字段名越接近真实业务字段脚本越容易踩中。下面是 HTML 部分的示例。form idsignup-form action/api/register methodpost input typetext nameusername required placeholder用户名 input typeemail nameemail required placeholder邮箱 input typepassword namepassword required placeholder密码 !-- 蜜罐字段视觉隐藏正常用户不可见不可聚焦 -- div classhp-wrap styleposition:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; input typetext namecompany_website idcompany_website tabindex-1 autocompleteoff /div button typesubmit注册/button /form4.2 收集时间、轨迹和浏览器特征接下来是行为数据采集。我建议不要让后端去算页面加载时间因为前端对时间的感知更准确并且能在同一个时间坐标里对齐交互序列。核心的观测点有三个页面加载完成的时间点performance.now()在导航开始时会被重置可以用它计算相对时间页面上的第一次交互pointerdown、keydown、focus任一事件提交按钮按下瞬间的时间点此外还要把自动化特征一起采集下来。navigator.webdriver是目前最简单也最有效的检测点在 Chrome 和 Firefox 里当页面由自动化工具驱动时这个属性会被置为 true。注意它不能单独作为判定依据因为某些正常浏览器扩展可能会影响它的返回值。收集的代码可以参考下面这段(function () { var startTime performance.now(); var firstInteractionTime null; var interactionCount 0; var trackingEvents [pointerdown, keydown, focus]; var webdriverDetected false; trackingEvents.forEach(function (eventName) { document.addEventListener(eventName, function () { if (firstInteractionTime null) { firstInteractionTime performance.now(); } interactionCount; }, { passive: true }); }); // 自动化工具检测 if (navigator.webdriver) { webdriverDetected true; } var form document.getElementById(signup-form); form.addEventListener(submit, function (e) { var submitTime performance.now(); var timeFromStart Math.round(submitTime - startTime); var behaviorData { timeFromStart: timeFromStart, firstInteractionTime: firstInteractionTime, interactionCount: interactionCount, webdriverDetected: webdriverDetected, userAgent: navigator.userAgent // 后端做UA异常分析时使用 }; // 把行为数据塞进一个 input 里随表单一起提交 var hiddenInput document.createElement(input); hiddenInput.type text; hiddenInput.name fck_signup_behavior; hiddenInput.style.display none; hiddenInput.value JSON.stringify(behaviorData); form.appendChild(hiddenInput); }); })();这段代码看起来很简单但对应了评分表里的关键项如果用户从页面加载到提交只用了不到 3 秒且交互次数为 0评分会立刻飙高。真实用户来到页面后通常会有几次鼠标移动或键盘输入交互次数不太可能为 0。脚本则往往不会触发任何前端事件或者只在提交时触发一次 submit。4.3 前端不能直接拒绝它只是采集器必须强调一点前端永远不可以做最终判定。因为所有前端代码都运行在被攻击者控制的浏览器环境里攻击者完全可以把这段采集逻辑删掉或者直接伪造一份看似正常的行为数据。所以我们要做的只是在后端拿到这些数据之后把它作为评分因子之一并且永远不要假设这些数据是真实可信的。换句话说前端采集到的行为数据是“软证据”它提供的是概率层面的信号而不是确凿证据。后端评分逻辑必须能容忍行为数据缺失或明显伪造的情况缺失时给一个中性分而不是直接判定为垃圾。这样一来即使攻击者剥离了前端采集我们还有别的防线比如提交频率、蜜罐字段、IP 信誉可以兜底。5. 后端判定用评分把可疑账号挡在门外5.1 评分模型和判定阈值后端拿到前端传来的fck_signup_behavior字段后结合表单字段本身就能做综合评分。业务代码里我会定义一个calcSignupRiskScore函数统一处理所有规则。前端数据和后端观测到的请求特征都在这个函数里被翻译成分数。这个函数的核心逻辑是先给一个基础分比如 0 分然后逐条命中规则就加分。分数超过阈值时进入风险处理流程。阈值怎么定我一开始定的 60 分看起来合理但上线后要根据日志样本做微调。如果误杀太高就提高阈值如果垃圾注册仍然漏进来就降低阈值。这不丢人风控本来就是调出来的不是拍脑袋定出来的。5.2 可疑账号的三种处理策略判定为可疑账号后不一定非要当场拒绝。我整理过三种处理策略按业务容忍度选择。直接拒绝适合注册成本高、资源消耗大的业务。响应里提示“注册过于频繁”或“请稍后再试”不告诉对方具体原因。这个策略最省事但误杀也最难挽回。进入人工复核池账号先标记为“待审核”不授予任何正常用户权益也不发送欢迎邮件。等运营或自动任务做二次确认后再决定是否放行。适合社区、论坛这类真实性要求高的场景。延迟放行给这个账号发一封邮件验证只有点击验证链接后才激活。这是折中方案不会完全拒绝但会显著增加攻击者的成本。我自己的项目因为业务比较轻最终选了第二种和第三种结合可疑账号不直接拒绝而是放入审核池同时触发邮件验证。真实用户被误判后可以主动通过邮件完成验证并激活账号垃圾脚本则在这里停下来。5.3 验证码兜底和限流评分之外还需要两道硬防线。第一道是真正的验证码兜底只在“可疑但无法确认”的请求上弹。可以参考 Cloudflare Turnstile 或 hCaptcha 这类无感验证产品它们对正常用户几乎无感知但对脚本是明确的门槛。第二道是接口限流同一 IP 或同一指纹标识短时间内多次提交注册直接拒绝。这个逻辑要独立于评分体系因为在极端流量下评分还没算完接口已经可能被打满了。下面是后端评分逻辑的核心代码用 Node.js 风格写方便对照思路function calcSignupRiskScore(reqData) { let score 0; const behavior reqData.behavior || {}; const honeypotValue reqData.formFields.company_website || ; // 规则1蜜罐字段被填写最高优先级 if (honeypotValue honeypotValue.trim() ! ) { return { score: 100, risk: direct-block }; } // 规则2从页面加载到提交的时间 const timeFromStart Number(behavior.timeFromStart); if (timeFromStart timeFromStart 3000) { score 50; } else if (timeFromStart timeFromStart 30000) { // 特别快是脚本特别慢也可能是脚本挂机但这里只给少量分 score 5; } // 规则3没有任何交互事件 if (typeof behavior.interactionCount number) { if (behavior.interactionCount 0) { score 40; } else if (behavior.interactionCount 3) { score 15; } } // 规则4自动化工具特征 if (behavior.webdriverDetected) { score 40; } // 规则5UA 疑似无头浏览器简化示例 const ua reqData.userAgent || ; if (/headless|phantom/i.test(ua)) { score 30; } // 规则6注册频率指纹同一IP或指纹计数器超过阈值 const recentCount reqData.frequency.recentCount || 0; if (recentCount 10) { score 50; } let risk pass; if (score 60) risk review; if (score 80) risk block; return { score: score, risk: risk }; }这里需要提醒一点评分函数返回的risk有pass、review、block三档。业务侧要根据这个结果继续处理而不是直接在评分函数里写死后续动作。把“判定”和“处置”解耦后续调整策略时就不用改判定逻辑了。5.4 关于阈值和分数的两个实践说明我第一次上线时把阈值定得比较激进score 60就进复核、score 80直接拒绝。结果发现很多用了密码管理器的真实用户会被打高分因为密码管理器会自动填充表单看起来就是“瞬间完成”且“没有个人交互”。后来我在评分里加了一条人性化规则如果表单包含自动填充特征比如input元素的值在极短时间内被写入但随后用户做了编辑或点击行为就适当减分。另外分数本身不要只给一个总分最好把每个规则的贡献分项也存下来。比如用 JSON 字段记录[time:50, interaction:40]这样出问题的时候可以快速定位到底是哪条规则在误杀。我当时没有这么做事后翻日志非常痛苦别踩这个坑。6. 上线后的真实数据与差点误杀真人的三个坑6.1 上线一周后的数据变化FckSignups 上线第一天效果非常明显。我们产品的注册入口是公开的上线前每天会被注册机灌入 400 到 500 个垃圾账号高峰期能突破 1000。上线后当天就降到 20 个以内而且这 20 个基本是来考察的小型扫描流量。一周后垃圾注册量稳定在每天 2 到 3 个没有出现大规模反弹。但更值得记录的是上线前两天一连串的误杀事故。如果不是当时盯着日志看我可能就把这套方案废弃了。下面这几个坑是真实发生过、也极具代表性的。6.2 坑一蜜罐字段被密码管理器填了我一开始给蜜罐字段起的名字是company_website它看起来像一个正经的业务字段。结果有部分用户使用了 1Password 这类密码管理器这些工具会自动分析表单结构把看起来像“网址”的字段自动填充成用户资料里的主页地址。于是这批真实用户全部命中“蜜罐字段非空”规则被直接拒绝注册。排查的方式很简单看日志中发现被拒用户的行为数据里interactionCount并不为 0时间也正常只有蜜罐字段有值。解决办法有两个第一蜜罐字段名尽量避开密码管理器常见的断言规则比如不要叫website、url、company第二将“蜜罐被填”从直接拒绝降级为“加 90 分进入复核流程”而不是一棍子打死。后续我把蜜罐命中改成了强制走邮件验证真实用户仍然可以正常注册垃圾脚本则大概率在临时邮箱这一环被拦住。6.3 坑二移动端滑动操作没有触发任何“鼠标事件”最早的采集代码只监听了mousemove和click这在桌面端没问题但移动端用户的手指滑动触发的是touchstart和touchend我就漏算了。结果上线当天有相当一部分移动端真实用户被打上“无交互”标签直接进了复核池。用户体验上就是注册完成后收不到欢迎邮件或者账号迟迟不激活。修复方案是把事件监听改成pointerdown、pointermove这类事件在现代浏览器里同时兼容鼠标和触摸。代码也要记得在页面层级监听而不是只监听表单本身因为有些用户会在表单外点击一下再进入输入框。对于不是特别敏感的业务还可以在评分规则里放宽移动端的交互要求只要有触摸事件且总耗时超过 5 秒就不算可疑。6.4 坑三自动化测试工具把自己人给拦了这个坑最尴尬。我们的回归测试是用 Puppeteer 写的测试用例会在注册页面自动填表、自动提交。FckSignups 上线后测试账号全部被拒CI 直接变红。因为在navigator.webdriver检测下Puppeteer 打开的浏览器肯定是暴露的。后来在测试环境里给webdriverDetected规则加了白名单只有固定 UA 的请求才跳过这条规则。需要注意的是千万不要在生产环境也把 UA 白名单放开否则攻击者只需伪造 UA 就能绕过最有效的自动化检测信号。除此之外还有一个小问题是无障碍用户。依赖屏幕阅读器的用户很少会操作视觉焦点也不一定有明显的鼠标轨迹但他们会通过键盘导航触发focus事件。所以我在采集逻辑里把focus也记入交互事件并且把“焦点从未落在表单内”作为可疑信号之一。在兼顾无障碍的同时保证判定逻辑不会直接把这类真实用户挡在外面。7. 让它更省心的后续优化7.1 把确定的垃圾特征整理成动态黑名单评分系统跑起来之后我开始从日志里沉淀“确认无疑”的垃圾特征比如被拒请求使用的临时邮箱域名、惯用 UA 片段、请求来源的 IP 段。FckSignups 里单独维护了一份黑名单配置每次请求先查黑名单命中就直接拒绝不进入评分逻辑。这个优化最大的价值是节省计算成本因为黑名单命中的请求往往是最低质量的扫描流量。黑名单会自己“长身体”。随着拦截次数增加新的攻击者会不断换邮箱域名和 IP但很多临时邮箱服务的域名池有限拒绝几个域名就能挡掉一小撮攻击。为了不让黑名单无限膨胀我设置了一个时效机制IP 段黑名单 24 小时后自动过期邮箱域名黑名单保留 7 天过了有效期重新评估。这样既能应对短期攻击又不会因为误加了某个正常服务商域名而长期误杀。7.2 给“低风险垃圾用户”留一个人工复核通道完全自动化的反垃圾系统一定会出现误判。与其追求 100% 准确率不如在最开始就设计一条人工复核通道。我在后台管理列表里增加了一个“可疑账号”筛选条件运营同学点进去能看到每个账号的评分分项和注册时的行为数据。确认是真人就一键放行确认是垃圾就直接删除并把特征加入黑名单。这条通道在前两天的效果特别好因为评分规则刚上线时误判率相对高运营手动处理几十条可疑账号后我就积累了一堆真实误判样本再回来调参。人工复核既是对系统的补充也是一个持续给它喂数据的闭环。没有这条通道我可能还在用静态规则碰运气。7.3 轻量接入第三方人机验证只对可疑用户展示最后我给系统接上了 Cloudflare Turnstile但并不是默认展示在注册表单里的。使用方式很简单评分结果如果是review但不到block级别后端在注册响应里返回一个标识前端收到这个标识后动态加载 Turnstile 的脚本并插入验证区域。这样所有正常用户完全感知不到额外的验证步骤只有可疑请求才会面临二次人机验证。这一步对转化率几乎没有影响因为 90% 以上的正常用户永远走不到验证那一步。而对于真正的垃圾脚本绝大多数会在 Turnstile 验证环节直接超时或被识别为自动化流量。这套“轻量判定优先重量验证保底”的思路后来我用到其他项目里也同样成立。我自己在这个项目上最深的体会是反垃圾注册不是装上某个验证码插件就能一劳永逸的它更接近一个持续调优的过程。命名成 FckSignups 虽然带着一股脾气但落地到代码和规则之后反而逼着我把用户行为和业务成本放在一起认真想清楚。如果这篇文章能帮你少踩几个坑也就不枉我那个凌晨对着注册提醒发呆的时刻了。
返回列表