凌晨两点多,我被手机通知震醒。后台的注册提醒一条接一条,打开管理界面一看,一分钟内涌进了四十多个新账号,用户名全是“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为 true | 40分 |
| 无头浏览器特征 | 缺少正常浏览器插件/字体特征 | 30分 |
| 跳序填写 | 第一个聚焦字段不是表单第一个字段 | 20分 |
阈值我暂时定在 60 分:达到或超过,就认为这次注册高度可疑。注意那一条“蜜罐字段被填写”,是直接进入拒绝逻辑的,我把它当作最高优先级信号,因为正常用户无论如何都不会去填写一个看不见的输入框。
这套规则非常粗糙,但它最大的好处是可解释、可调整。上线后如果发现误杀太多,可以拉日志查看具体是哪个信号贡献了高分,再针对性降低权重。
4. 前端实现:把行为信号收集起来交给后端
4.1 表单结构的隐藏字段设计
先说前端。这个方案对前端表单结构有两个基本要求:一是必须有真正用户不可见的蜜罐字段,二是要在提交时把行为观测数据塞进请求体里。
蜜罐字段的实现,最关键的一点是不能用type="hidden"。因为很多注册机在扫描页面 DOM 的时候,会对所有input元素做一轮遍历,遇到type="hidden"的字段会直接跳过,你的蜜罐就失去意义了。
比较合理的做法是用 CSS 把字段移到可视区域外,同时让正常用户无法通过 Tab 键聚焦到它。命名也要有迷惑性,不能用honeypot、website_at这类一下就能识别出来的名字。我习惯把它伪装成业务上的某个可选字段,比如company_website,然后给一个一看就不是必填的默认值。字段名越接近真实业务字段,脚本越容易踩中。
下面是 HTML 部分的示例。
<form id="signup-form" action="/api/register" method="post"> <input type="text" name="username" required placeholder="用户名"> <input type="email" name="email" required placeholder="邮箱"> <input type="password" name="password" required placeholder="密码"> <!-- 蜜罐字段:视觉隐藏,正常用户不可见不可聚焦 --> <div class="hp-wrap" style="position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden;"> <input type="text" name="company_website" id="company_website" tabindex="-1" autocomplete="off"> </div> <button type="submit">注册</button> </form>4.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; } // 规则5:UA 疑似无头浏览器(简化示例) 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 虽然带着一股脾气,但落地到代码和规则之后,反而逼着我把用户行为和业务成本放在一起认真想清楚。如果这篇文章能帮你少踩几个坑,也就不枉我那个凌晨对着注册提醒发呆的时刻了。