
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-signupsnpm 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 小时内限制敏感操作。过滤中间件本身只是一个起点真正要保护业务需要的是“注册时拦截、注册后治理、异常时惩罚”的完整闭环。我只能说经过这几轮折腾后我现在看到后台稳定增长的注册数据已经不再心慌了。偶尔有申诉进来我也能靠着审计日志很快判断到底是误杀还是漏网。对一个被垃圾注册折磨过的开发者来说这种“心里有底”的感觉可能比任何华丽的风控报告都更实在。