自建反垃圾注册中间件:从风险评分到 Express 接入实践
2026/9/17 1:27:22 网站建设 项目流程

FckSignups这名字,是某个凌晨我盯着后台那四千多个乱码账号时起的。当时我已经上了图形验证码,结果注册机跟没事人一样继续灌数据,群里还有朋友截图问我是不是在搞什么拉新活动。气得我直接开干,写了一个专门拦垃圾注册的中间件,并给它起了个带情绪的名字:FckSignups。

它不是验证码库,也不是完整的风控系统,而是在“用户提交注册请求”到“账号落库”之间加一道过滤闸。它会从请求上下文、表单行为、注册后信息三个层面给每个注册请求打分,再按双阈值决定放行、进入人工/邮件验证,还是直接拒绝。这篇文章把它的设计思路、过滤链路、评分实现、接入方式,以及一次真实的误杀复盘全部展开。适合自建 Web 应用、小程序后端、对反垃圾注册和反机器人有需求的个人开发者或小团队参考。

1. 垃圾注册为什么值得你专门写一个中间件

很多人觉得垃圾注册无非是数据库里多点脏数据,定期清理就行。可真等业务跑起来,你会发现它造成的损失比想象中隐蔽得多,也贵得多。

1.1 三类最常见的垃圾注册来源

我把实际接触到的垃圾注册分成三类:批量脚本注册、垃圾内容账号、以及有组织羊毛党。

批量脚本注册最典型,特征是无差别的随机用户名、乱码密码、无意义邮箱,目的可能是占资源、测接口、给论坛刷数据量。垃圾内容账号则为了发广告、钓鱼或推广,注册完立刻开始发帖、私信,这类账号往往带有明显文案特征。有组织羊毛党注册是为了抢优惠券、抽奖、拉新奖励,它们会用真手机号、真邮箱,看起来和正常用户几乎一模一样,但行为模式高度集中,比如同一设备短时间注册多个账号,或者同一 IP 段扎堆出现。

从技术角度看,这三类来源未必都用同一种攻击方式。有的直接调后端 API 接口,绕过前端页面;有的用无头浏览器模拟操作;有的甚至半人工辅助批量填写。这也意味着,只靠单一手段很难覆盖全,必须在请求到达业务逻辑之前就做一个通用过滤器。

1.2 垃圾注册烧掉的隐形成本

首先是真金白银的通路成本。很多产品注册后会触发短信验证码或邮件验证,每一条都有成本。垃圾脚本提交一万个注册请求,你就要付一万次短信或邮件通道费用,哪怕它们全被拦截。

其次是数据指标污染。注册转化率、次日留存、激活率这些指标,只要混入批量注册,立刻失真。运营团队拿着被污染的数据去做判断,轻则误判渠道效果,重则影响投放预算分配。我见过某个活动页面因为没做注册防护,活动期间后台涌现三分之一的机器账号,结果数据分析师拿着这些数据写了一份“新用户画像”,越想越离谱。

然后是下游安全成本。垃圾账号一旦注册成功,后续还可能被用来发帖、刷评论、参与抽奖、消耗客服资源。处理这些衍生问题的成本,往往比处理注册本身高出一个数量级。

1.3 验证码和前端校验为什么不够

图形验证码确实能挡住最原始的脚本,但现在的注册机已经能识别绝大多数点选和字符类验证码,成本很低。滑块验证码、行为验证码稍微难一点,也无非是模拟拖拽轨迹的问题,已经不是不可逾越的障碍。

前端隐藏字段校验更是形同虚设。攻击者只需要用浏览器开发者工具看一眼页面结构,就能把你前端藏的 honeypot 字段找出来,或者在脚本里模拟同样字段的内容。至于通过 JS 收集环境信息、判断是不是无头浏览器,这些手段有用,但都会被成熟的爬虫框架针对性地规避。

问题在于,验证码和前端校验面对的同一批攻击者已经“进化”了。要做拦截,必须有一个不受前端控制的第二道防线,也就是服务端的注册风险过滤层。FckSignups 就是干这个的。

2. FckSignups 的过滤链路:从请求入站到注册落库的每一道闸

FckSignups 不是单一规则,而是一条过滤器链。它在注册请求进入业务处理逻辑之前和之后分别设置检查点,把每一层能拿到的信号汇总成一个可解释的风险分数。

2.1 第一道闸:静态上下文画像

第一道闸在注册请求刚到达时触发,核心是三个静态维度:IP 信誉、设备指纹、短时频率。

IP 信誉包含 IP 是否属于机房 ASN、是否被公开黑名单收录、是否出现过大量异常注册行为。普通家庭宽带用户和机房 ASN 的访问习惯明显不同,如果大量注册请求来自云主机 IDC 网段,这个信号本身就很可疑。

设备指纹让服务端判断“发起注册的浏览器环境是否真实”。客户端脚本会采集用户的 User-Agent、屏幕分辨率、时区、Canvas 指纹、WebGL 渲染信息等,生成一个指纹哈希。真实浏览器和脚本运行环境产出的指纹特征有明显差异,但这里有一个重要的设计原则:指纹缺失不等于风险,指纹并非在所有情况下都可靠。隐私模式、浏览器插件拦截、老旧的嵌入式 WebView 都可能影响指纹采集。所以指纹在这个阶段只作为加分项,不作为一票否决项。

短时频率则是滑动窗口计数,例如“同一 IP 一小时内的注册请求数”。这个信号对突发型脚本特别有效,因为脚本一跑起来往往就是几百次请求瞬间涌过来。

伪代码长这样:

async function staticContextScore(ctx, next) { const ipRisk = scoreIpReputation(ctx.ip); const fpRisk = ctx.fingerprint ? 0 : 12; const rateRisk = await slidingWindowRate(ctx.ip, 3600, 5); const score = Math.min(60, ipRisk + fpRisk + rateRisk); ctx.state.risk = score; return next(); }

设计 IP 频率上限时要注意,手机用户经常挂在同一个运营商出口 IP 下,一个出口 IP 背后可能站着几十上百个真实用户。因此频率计数不能太激进,我更推荐企业 5 分钟最多 20 次注册帧,而不是按小时严格的 3 次。

2.2 第二道闸:表单行为时间线和环境证据

静态画像只能说明“这次请求长得好不好看”,第二道闸进一步观察“这次请求是怎么从页面走到接口的”。

这里用到了三类信号:蜜罐字段、表单时间线、交互事件痕迹。

蜜罐字段是对正常用户不可见、但对填表脚本可见的隐藏字段。普通用户根本看不到这些字段,自然不会填写;自动填表脚本往往会把自己能抓到的 input 都填一遍。为了防脚本适应,蜜罐字段名要随机生成,不能每次都出现在相同位置。

表单时间线记录的是“从页面渲染到注册提交之间的时间差”。真实用户通常会停留十几秒到几分钟,人工阅读和填表总会需要时间。脚本则可能在毫秒级完成从加载到提交的全过程。这个信号很有用,但要注意自动填充密码管理器的用户也可能秒提交,所以要设置一个较短的高危判断阈值,比如低于 1.5 秒且没有任何交互事件才计分。

交互事件痕迹是客户端在页面收集的 mousemove、keydown、touchstart 事件数量,并把事件摘要签名放进提交请求。如果客户端的 JavaScript 完全被禁用,或者攻击者直接调用后端 API,这个签名就会缺失。判断逻辑是:签名缺失但指纹正常,可能是隐私保护;签名缺失加上其他高风险信号,那就需要标记。

行为层整体逻辑可以这样表达:

const timingRisk = (now - renderTime) < 1500 ? 10 : 0; const honeyRisk = lookupHiddenFields(body) ? 25 : 0; const eventRisk = body.interactionCount > 0 ? 0 : 8;

2.3 第三道闸:注册后信息复核

第一、二道闸都在注册请求处理中完成,但有些信息只有在拿到完整注册参数后才知道分值,比如邮箱域名、手机号归属、邀请码配置等。我把它做成一个复核器,在业务代码真正创建账号之前再跑一轮。

邮箱是最重要的复核对象。免费邮箱协议千差万别但仍是主流;临时邮箱协议制造对应。我们需要维护一张临时邮箱 Photoshop 网域表,再加一个 MX 记录查询,确认邮箱域名真实存在。注意这里容易误杀:很多新生小众邮箱服务商或企业自建邮箱的 MX 记录解析不稳定,所以 MX 不存在只加少量分数,不直接拒绝。

另外,把注册邮箱哈希后放入 Redis 做一个“同一指纹或同一 IP 的邮箱去重”。正常用户极少在五分钟内用相同设备频繁更换邮箱注册,这往往是刷小号的标准动作。我把这一逻辑称为“注册频谱检测”。

完成三层评分后,总分决定动作。整体链路可以看作由前端初始化开始,经过静态画像、行为时间线、注册信息复核、落地动作,最终将请求写入日志。

3. 双阈值评分与可解释审计:实现时最容易踩的两个坑

实现评分时我需要反复提到两个关键设计:双阈值区间和可解释审计。只给一个“阈值判断”太过粗暴,而“神秘黑盒”则会让误杀、漏放都无法追溯。

3.1 用双阈值切三个区间,而不是用单阈值一刀切

我采用的设计是:分数低于 lowRisk 直接放行,介于 lowRisk 和 highRisk 之间进入“挑战模式”,高于 highRisk 则直接拒绝并记录。挑战模式可以是一次更严谨的邮箱二次验证、手机短信验证,或者将账号置为“待激活”状态。

风险分建议的处理动作对这一档的考虑
0-30直接创建账号正常用户第一次注册,不要因为某个特征就给糟糕的体验
31-60开启邮件/短信确认,设定 24 小时观察期可能是疑似脚本,也可能是隐私敏感的真实用户,用一个不打断主流程的验证手段过滤
61-100拒绝创建,记录风险明细分数来源多元且明确,综合特征显著指向自动化行为

这种设计比单一阈值更有弹性。如果只设一个“高于 50 拒绝”,那么 51 分的用户就会直接挂掉,而攻击者也会反复试探你的阈值然后绕过。双阈值让中间地带存在一个成本更高的二次校验,真实用户多付一次验证的代价很低,批量机器人付不起这个数量级的代价。

3.2 权重表怎么设计才不容易误杀

每种信号不应永远固定分值,要针对业务特征动态修正。下面是我在最初版本里的权重表,以及它的误杀预警:

特征权重参考误杀预警
IP 属于机房 ASN20很多公司远程办公出口就是机房 IP,不能独立定罪
IP 上小时注册数 > 530学校、商场、会展中心出口通常由大量用户共享,频率特征不可靠
指纹缺失12隐私模式、浏览器插件、WebView 都可能指纹缺失
交互事件空8无障碍辅助工具、钱包内网页可能无法收集事件
蜜罐字段被填入25几乎只来自脚本填表,误杀率极低
邮箱为一次性域名20需要维护较新的一次性域名库
同一指纹多邮箱35可能是企业里多设备相同软件环境,谨慎使用

看权重表可以得出一个重要结论:低分未必是真人,但高分基本是机器。所以双阈值中的低阈值和高阈值不能同时均等对待,低阈值需要更宽容,高阈值则需要多个高风险信号同时命中才判死。

实际调参时,我从日志里找出真实用户通过率的基线,再用历史垃圾注册样本去反向逐步调整。总是先拿样本跑一遍分数,看分布是否明显分裂;如果两个群体的分数高度重合,说明特征选错了,要回到信号层去修。

3.3 可解释的审计日志比拦截记录更有价值

FckSignups 每决定一个动作,都会记录一份结构化的审计日志。不要只记录“拒绝/通过”和总分,一定要把每一条规则的命中和得分都写进去。我通常用 JSON 保存:

{ "request_id": "a1b2c3", "ip": "203.0.113.5", "action": "reject", "total_score": 78, "rules": [ {"rule": "ip_asn", "score": 20, "reason": "ASN belongs to IDC range"}, {"rule": "honeypot", "score": 25, "reason": "hidden field filled"}, {"rule": "rate_limit", "score": 25, "reason": "12 signups in 10 minutes"}, {"rule": "email_domain", "score": 8, "reason": "unrecognized provider, low evidence"} ] }

这个日志有几个用途。运营人员申诉时,可以直接看到这条请求为什么被拦截,而不是给一个冷冰冰的错误码。做策略调整时,可以根据日志反推是哪个特征导致了误杀。给后端同学排查时,可以马上定位是网关、脚本还是真正的黑产行为。

我当时用 MongoDB 存这些日志,按时间字段建索引,再做了个简单的管理页面。每次有人申诉,我先复查日志再决定是否放白名单,这样逐步把规则从“宁可错杀”调成“精准拦截”。

4. 黑产绕过思路与反制取舍:不要迷信单个指纹特征

作为开发者,你必须知道攻击者的大致打法,否则你做的过滤器就是自我安慰。这一节我尽量白盒化地讲讲常见绕过思路,以及我选择的反制策略。

4.1 攻击者的常用绕过手段

第一种是直接调 API 接口,完全不经过页面。页面上的 JS 指纹、交互采集、蜜罐字段统统失效,攻击者只要抓到注册接口的请求格式,就能用脚本灌数据。这时候服务端只能靠静态画像和注册后复核兜底,这也是我坚持要做第三道闸的原因。

第二种是用无头浏览器模拟真实页面。无头浏览器能执行 JS,能产出 Canvas 指纹,能模拟一部分事件。但它最大的弱点是“行为时间线”和“事件语义”,只是程序化地触达浏览器,并非真的“填写”。如果脚本只是简单 sleep 之后提交,时间线特征会与原用户妥协;如果刻意模拟随机停顿,又会与“人类慢操作”类似。所以这类手法越来越难从单一维度拦,需要综合利用多个弱信号。

第三种是换 IP 池、换邮箱域名、换设备指纹。换 IP 只需要轮换出口节点,不能直接把注册拉黑,只能靠频率上大维度做关联。但设备指纹可以被工具批量伪装,邮箱域名可以自建域名池。这些手段叠加后,把成本提高的同时,会大大增加系统性识别的复杂度。

4.2 反制思路:把“识别精准”转向“攻击成本”

既然拼识别很难有绝对优势,那就要把重心转向“让攻击者批量注册的边际成本上升”。这个思路做好后,很多看上去挡不住的攻击,会因为成本收益不划算而自行停手。

具体落地是三招。第一,注册后加一道异步验证,得分中危的用户进入延迟激活状态,第二天再判断是否需要人工审核。批量脚本大多想立刻得到可用账号,延迟激活直接打乱它们的周转链路。第二,对低分账号也做定期回扫,用登录环境、发布内容、行为序列做二次体检。很多垃圾账号注册时很干净,但发布广告时行为特征明显。第三,规则定期更换参数,包括蜜罐字段名、时间阈值、权重配置,避免被对方探测后拟合。

有一点我特别想强调:如果你在公开项目里透露了所有的规则权重和绕过方法,那你就是在给攻击者递刀。FckSignups 这种开源库只能公开框架和接入方式,具体的内部权重、名单、专门特征,上线时一定要本地化修改。这也是为什么我不建议直接把网上现成的风控插件原封不动部署到生产环境。

4.3 黑产也在观察你的业务文案和日志

有一点容易被忽略,攻击者会通过错误提示和日志界面的反馈来反推你的规则。比如你返回“注册失败:请求过于频繁”,对方马上知道你在做频率限制,然后换策略绕行;如果你返回“系统开小差了,请稍后再试”,对方看不出太多信息。所以在 FckSignups 的接入配置里,我默认把拒绝提示伪装成通用系统错误,同时避免把分数、规则名直接暴露给前端。日志管理端必须走内网访问,在公网部署时要做额外的鉴权保护。

5. Express 接入实战:最小可运行配置和默认值

理论说太多了,下面直接看接入。这段以一个 Express 项目为例,说明怎么把过滤中间件接进注册路由。这里的示例代码已经按可抽取包的形式整理,你们接入时可以根据自己框架调整。

5.1 最小可运行代码

先安装依赖,假设包名叫fck-signups

npm install fck-signups ioredis

用 ioredis 存储频率计数和临时黑名单,因为它需要持久化和分布式共享。如果你只有一个单点服务,也可以用一个内存 Map 代替,但进程重启会丢数据。

下面是注册路由最简接入:

const express = require('express'); const Redis = require('ioredis'); const { createSignupGuard, applySignupRule } = require('fck-signups'); const app = express(); const redis = new Redis(process.env.REDIS_URL); app.use(express.json()); const guard = createSignupGuard({ redis, lowRisk: 30, highRisk: 60, signupPerIp: { windowMs: 3600 * 1000, max: 8 }, honeypotFields: ['website_url', 'callback_phone'], verifyEmailDm: true, disposableDomainList: './data/disposable_domains.txt', fingerprint: true, logSink: (entry) => console.log(JSON.stringify(entry)) }); app.post('/api/signup', guard(), async (req, res) => { const { email, password } = req.body; // ...创建用户的业务逻辑 res.json({ ok: true }); }); app.listen(3000);

这套配置下,中间件会在注册路由处理函数之前运行。它把风险判断包装成一个ctx.state.signupScore,如果分数低于 lowRisk 直接 next,如果中危则置为“待验证”状态,并返回提示给前端;高危则直接返回 200 但带有“业务失败”的错误信息。

5.2 核心配置项与默认值

我整理了一份常用的配置表,用默认值的含义帮助理解。建议刚开始不要追求严苛,先宽松运行几天,看日志里的分步分布再收紧。

配置项默认值作用和建议
lowRisk30低于该值直接创建账号,开始建议偏低
highRisk60高于该值直接拒绝,初期建议偏高
signupPerIp.max8滑动窗口内同一 IP 的注册上限,入口 IP 不稳时加大
signupPerIp.windowMs3600000窗口长度,建议至少覆盖一轮活动投放周期
honeypotFieldswebsite_url 等需要随机化字段名,不可硬编码固定名称
verifyEmailDmtrue解析邮箱域名 MX 记录,注意设置短超时
fingerprinttrue需要前端引入指纹采集脚本,不传时该特征不参与评分
logSinkconsole建议接入日志系统,至少保留 30 天

初始配置的关键是先避免影响正常用户:低阈值偏低、高阈值偏高,让大部分流量先通过,再靠日志去调整。许多人上线第一天就把 highRisk 设成 40,结果正常注册被砍掉一大片,运营直接炸锅。

5.3 前端需要配合的指纹采集

为了拿到第二道闸的行为证据,前端要在注册页面加载时采集一次环境信息,并把交互事件计数随注册请求传送。采集脚本可以很简单:

window.__signupEnv = { ua: navigator.userAgent, screen: `${window.screen.width}x${window.screen.height}`, lang: navigator.language, fp: createFingerprint(), // 内部生成的稳定哈希 events: 0 }; window.addEventListener('mousemove', function() { window.__signupEnv.events++; }); window.addEventListener('keydown', function() { window.__signupEnv.events++; }); // 提交时把 __signupEnv 拼进 JSON body

注意,不要在前端做任何风险判断,只负责采集。所有判断必须在服务端完成,因为攻击者可以随意伪造前端字段,服务端规则才是底线。

6. 一次凌晨误杀的真实复盘:共享出口 IP 与邮箱风险分的冲突

接入 FckSignups 一周后,我遇到了第一次比较严重的误杀事故,这值得单独复盘一遍。因为它说明了一个很现实的问题:再合理的评分机制,只要没结合真实网络环境,就会误伤正常用户。

6.1 现象与非正常注册投诉

那是一个周六凌晨,我从后台连续收到两条用户投诉,说注册一直不成功,系统提示“服务暂不可用”。当时我第一反应是数据库连接池满了,查了一圈没问题,打开 FckSignups 日志后才发现,这两个请求都被判为高风险的自动注册,action 是 reject,分数分别是 76 和 81。

分数来源的规则组合让我有点意外。第一条命中了 IP 风险、邮箱域名风险、事件空这三项;第二条命中了限频、蜜罐、IP 风险。看起来像机器注册,但用户居然手动来投诉了,说明很可能是真人。

6.2 根因定位过程

这类问题不能只看单个请求,要看请求背后的环境上下文。我把相关的日志按 IP 聚合,发现那段时间同一个出口 IP 下有十几个正常注册请求,分布在一个多小时里,并非瞬间爆发。这个模式更像大量真实用户共用一个出口,可能是某个公司或学校的统一出口网络。

再看邮箱域名,这两个用户用的是同一家小众邮箱服务商,这个域名在我维护的一次性邮箱黑名单里恰好有部分重合,被 verifyEmailDm 打了一个较高的分数。可实际上,这家服务商在最近一季度已经转为收费服务,黑名单数据来不及更新,产生了误判断。

第一个请求的事件空特征更关键:用户是在一个极简版页面内完成注册的,这个页面的交互 JS 因为广告拦截插件被全局注入失败,所以事件计数一直是零。后端逻辑在“事件空 + 邮箱风险 + IP风险”组合下,把它推过了 60 分。

最终定位为两个误杀因素:一次性邮箱名单过期,和“事件缺失”权重在代理环境与插件场景下过高。

6.3 修复与验证

我先修正了邮箱域名名单,对入库的一次性域名列表加上了“最近三个月 MX 仍属于免费域名”的复核条件,将过期域名自动降级。然后改变了事件缺失特征的惩罚逻辑:不再单独计分,只有当同时检测到指纹缺失、交互空、蜜罐字段被填三个信号时,才给事件特征加分。

最后更新了配置,对一个出口 IP 下的注册频率做了更细的操作:如果这些注册请求来自不同 User-Agent 和不同语言环境,将频率风险上限压到 20 分,而不是按原权重直接叠加。验证方式是回放当周的注册日志,正常用户的拦截率从约 3.7% 降到了 0.6%,而垃圾注册样本的拦截率保持稳定。这之后我没有急着继续收紧阈值,而是让新规则先跑两周,再通过申诉率来判断误杀是否还在。

7. 关于 FckSignups 这个名字,以及它不做什么才是价值

最后说点实话。FckSignups 这个名字就是我在看到垃圾注册时的情绪输出,但它在生产环境里并不适合对外宣传。面向用户的一面,官方提示语、报错文案、隐私声明里我全都改成了“安全验证”或者“系统检测到异常操作”,用户看到的永远是一个稳定合规的提示。

这个中间件解决的是批量脚本注册、临时邮箱滥用、撞库式试探注册、以及部分羊毛党批量开号,但它解决不了真人恶意注册,也不会给你提供完整的风控决策。真人用真手机号、真身份信息来注册小号时,用技术手段识别很难,还需要结合业务规则去判断。所以它更适合作为注册链路里的第一道过滤闸,而不是唯一闸门。

在实际使用里,我还会把它和账号生命周期打通进一步优化:低分账号创建后继续观察 7 天,如果出现发布广告、私信骚扰、异常登录,就自动纳入人工审核队列;中危账号在激活后 24 小时内限制敏感操作。过滤中间件本身只是一个起点,真正要保护业务,需要的是“注册时拦截、注册后治理、异常时惩罚”的完整闭环。

我只能说,经过这几轮折腾后,我现在看到后台稳定增长的注册数据已经不再心慌了。偶尔有申诉进来,我也能靠着审计日志很快判断到底是误杀还是漏网。对一个被垃圾注册折磨过的开发者来说,这种“心里有底”的感觉,可能比任何华丽的风控报告都更实在。

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

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

立即咨询