做站这些年,最烦的事情不是服务器宕机,也不是被攻击,而是后台注册列表里一夜之间多出几百个用户名像乱码一样的账号,邮箱全是临时邮箱,昵称全是广告词。你说删吧,删到手软;不删吧,数据库越来越脏,严重的时候还会被塞进垃圾邮件内容,直接拖垮站内信和评论区的体验。这个困扰我很多年的问题,最后逼着我写了一个专门治垃圾注册的小工具,名字起得也很直白:FckSignups。这篇文章就把这个工具的完整思路、实现细节和踩坑记录都摊开讲清楚,希望对同样被垃圾注册折磨的站长有点帮助。
FckSignups 说白了就是一套专门拦截 WordPress 垃圾注册的轻量级防护方案,不依赖任何第三方验证码服务,不修改核心文件,通过隐蔽表单、时间陷阱、签名库和频率控制这几层机制,把纯机器注册和半自动注册挡在门外,同时尽量不误伤真人用户。适合用过各种防垃圾插件但效果一般的建站者,也适合想自己动手写一套防护逻辑、愿意折腾代码的技术型站长参考。
1. 垃圾注册为什么杀不完——先看清对手的路数
1.1 机器人注册的三种典型攻击模式
想治垃圾注册,先得知道垃圾注册是怎么来的。我根据后台日志和拦截记录的统计,把目前遇到的注册攻击分成了三类:
第一类是纯脚本批量注册。这类攻击用的是最简单的 POST 请求脚本,循环调用wp-login.php?action=register接口,提交预设好的用户名、邮箱、密码。脚本根本不解析页面,只在乎 HTTP 响应码是不是 200。这类攻击占比最高,大概在七八成以上,特点是频率高、速度快,一分钟能打几百次请求,用户名有明显的规律性,比如一串随机字符加数字后缀。
第二类是浏览器自动化注册。攻击者用 Selenium、Puppeteer 这类自动化工具,模拟真实的浏览器操作,会加载页面、等待元素出现、点击按钮、填写表单。这类攻击比纯脚本稍微难挡一点,因为它会执行 JavaScript,也会带上正常浏览器的 Cookie 和请求头。它的特点是频率相对低一些,但行为特征上有迹可循,比如鼠标轨迹异常、页面停留时间太短、提交表单的时间远远小于人工输入所需时间。
第三类是人工半自动注册。这类最恶心,由真人操作,但配合打码平台、临时邮箱、代理 IP 来批量养号。真人注册的请求行为和正常用户几乎一模一样,很难用技术手段百分百识别,只能靠注册后的行为分析、邮箱信誉库这些外部数据源来辅助判断。FckSignups 对这类攻击能做的有限,但可以通过提高时间成本和增加注册门槛来劝退大部分低端操作者。
1.2 单点拦截的失效原因:为什么验证码和插件都不够
很多站长第一个想到的方案是装验证码插件。我早期也是这么干的,从图像验证码到算术验证码到滑块验证码都试过,但效果并没有想象中那么好。
图形验证码现在基本被 OCR 识别和打码平台废掉了,复杂的图形验证码识别率能到百分之八九十;算术验证码更是脚本直接解析文本就能算出答案,毫无难度。滑块验证码体验好一点,但如果用的是开源的第三方服务,对方可以直接拿到前端 JS 代码,反推验证逻辑甚至直接跳过验证接口。用 Google reCAPTCHA 这种大厂服务效果是好,但对国内用户体验不友好,加载慢,而且免费额度有限,注册量大的站点还得考虑成本问题。
通用的防垃圾注册插件我也用过不少,问题在于它们太重。动辄加载几十个 JS 文件、引入外部字体和样式,有些还强制要求用户进行邮箱验证,把注册流程搞得特别复杂。结果就是机器人没怎么拦住,转化率反而掉了一截。我印象很深刻的一次,一个同学跟我说他注册我们社区账号的时候怎么都点不过去验证码,换了 Chrome 和 Edge 都不行,最后放弃了。那次之后我就决定不再依赖大而全的插件,开始琢磨一套轻量的自研方案。
1.3 FckSignups 的定位:轻量、分层、可观测
FckSignups 的思想说穿了也不复杂:不在单点上面死磕,而是在注册链路的多处埋点,把可疑信号加权汇总,再决定放行、拦截还是人工审核。每一层拦截只负责挡掉一部分攻击,层层叠加之后,漏网之鱼就很少了。整个工具只包含一个核心 PHP 文件加一个可选的配置页面,不引入任何外部库,不额外加载 JS 和 CSS 资源,对站点的性能影响可以忽略不计。
还有一个关键点是可观测性。很多插件拦了垃圾注册之后什么都不记录,你根本不知道它拦的是谁、为什么拦、有没有误杀。FckSignups 把每一次拦截都写入独立的日志表,记录触发原因、IP、时间、浏览器指纹和提交内容摘要。这样既能回收攻击特征,也能在收到用户申诉时快速定位问题,做到有的放矢。
2. FckSignups 的核心拦截机制与配置说明
2.1 第一层:隐藏字段蜜罐,筛掉最笨的机器人
FckSignups 的第一道防线是一个不用任何 JavaScript 就能生效的蜜罐字段。原理非常简单:在注册表单里额外输出一个input字段,然后用 CSS 把它隐藏掉,同时加上autocomplete="off"和tabindex="-1"属性,确保真人用户既看不到也摸不着。
正常的真人填写注册表单时,永远不会在这个字段里输入内容,因为看不见。而大部分批量注册脚本是直接把整个表单的所有字段都取出来逐一填写,或者干脆模拟浏览器自动填充,蜜罐字段就会带上值。一旦检测到这个字段非空,脚本就直接判定为机器人,不进入后续逻辑,直接返回注册失败,同时写入日志。我实测下来,第一层蜜罐能挡住大约三成到四成的纯脚本攻击。
这里有几个调试细节值得注意。第一,字段的name不能太直白,我用的是u_website或者account_extra这类看起来像正常字段的名字,如果你直接写个honeypot,稍微智能一点的脚本就会绕过。第二,早期版本我给蜜罐字段加过display:none的样式,后来发现有些浏览器插件会自动填充隐藏表单,导致误杀,现在改成了position:absolute;left:-9999px这种方式,既隐藏又不触发自动填充。第三,HTML 里要加required属性也没必要,反正在服务端判空就够了。
2.2 第二层:时间陷阱,让脚本自动暴露
第二层防线是时间戳校验,专业一点叫时间陷阱。核心逻辑是:记录注册表单从生成到提交的时间差,真人用户再快也不可能在两秒内完成填写并提交,而脚本通常在页面加载后的零点几秒内就会发出请求。
具体实现上,我在输出注册表单时植入一个加密的时间戳隐藏字段。用户提交注册请求后,服务端解密这个时间戳,和当前时间做差值。如果差值小于预设阈值(默认为 3 秒),直接判定为机器人。反过来,如果用户在页面上停留过久才提交,也有问题,后面我会说。
这里要特别小心一个坑:如果服务器和用户之间的时钟有偏差,或者用户提交时网络抖动,时间戳校验可能会出现误判。所以我做了一层容错,在加密字段里附加一个nonce随机串,同一字段只允许使用一次,防止重放攻击,同时允许 5 秒的时间偏移。这个设计让正常用户在移动端这种输入较慢的场景下基本不会触发拦截,实验跑下来误杀率低于千分之一。
有人可能会问:为什么不用现成的非交互式 reCAPTCHA 的时间统计功能?原因很简单,FckSignups 的目标是无外部依赖,不向第三方发送用户数据,也不依赖外部服务的可用性。这在注册环节对用户体验至关重要——很多用户会开着广告拦截插件,第三方验证码脚本被拦掉之后直接白屏,导致无法注册,而 FckSignups 完全不受影响。
2.3 第三层:签名库与频率控制,挡住“智能”刷手
蜜罐和时间陷阱解决的是笨机器人,但绕过这两层的攻击者会手动识别表单结构、去除隐藏字段、修改提交参数。这个时候就要靠签名库和频率控制了。
签名库的思路是:对于通过前两层校验的注册请求,提取一组浏览器的行为特征作为指纹,包括但不限于:
- User-Agent 字符串
- Accept-Language 头
- 提交表单的字段名和字段顺序
- 是否存在 Ajax 请求特征头
- Cookie 数量和名称
提取完之后做一次哈希,得到一个签名。如果同一个签名在短期内出现的次数超过阈值,说明可能是同一个脚本在批量提交,直接拦截。我最初的版本只提取 User-Agent,后来发现攻击者把 UA 改成 Chrome 的很常见,加上了字段顺序之后准确性提升了一大截。原因很好理解:浏览器表单提交是有固定 HTML 结构顺序的,而脚本往往按照自己的参数构造逻辑拼接,顺序会和正常表单不一致。
频率控制相对直白:基于 IP 维度的滑动窗口限流。同一个 IP 在一小时内最多允许 3 次注册尝试,超过则临时封禁 2 小时。这个阈值我调了很久,正常站点几乎不会有一个 IP 在短时间内注册多个账号的场景,机构网络或者校园网出口 IP 可能会误伤,所以特意把注册失败后的重试也计入频率,但允许通过邮箱确认链接来解锁,给误杀留了后路。
2.4 异常行为加权评分与阈值处置
三层拦截是各自独立的,但攻击者的行为往往是多信号叠加的,所以 FckSignups 在最后一层做了个加权评分机制。每个拦截点不是简单地返回“拦/放”,而是输出一个可疑分值,比如:
| 检测项 | 评分 |
|---|---|
| 表单提交时间小于 3 秒 | +40 |
| 表单提交时间大于 24 小时(防挂机) | +10 |
| 蜜罐字段非空 | +100(直接拦截) |
| User-Agent 为空或包含常见脚本标识 | +30 |
| 签名在 1 小时内出现 3 次以上 | +30 |
| IP 段命中已知 IDC 机房的默认网段 | +20 |
| 邮箱域名命中临时邮箱列表 | +50 |
当总分达到 60 分时,触发人工审核队列;达到 100 分时,直接丢弃注册请求。这个评分表我用了很长时间,效果最明显的地方在于,它把很多模棱两可的边缘情况兜住了。比如一个用户的输入速度很快、填写时间只有 2.5 秒,但他输入的密码强度挺高、用户名也正常,总分够不到拦截线,就正常放行;而一个脚本即使每条特征都只扣一点分,累加起来也会被拦掉。
3. 从原理到代码:FckSignups 的落地实现
3.1 插件主体结构与挂载点
FckSignups 的实现基于 WordPress,因为它是我最常用的建站系统,而且 WordPress 的钩子机制非常适合做这类拦截。整个功能拆成三个文件:
fcksignups.php:主文件,负责插件初始化、钩子注册、管理界面fcksignups-core.php:核心拦截逻辑,包含所有检测和评分方法fcksignups-log.php:日志记录和查询接口
主文件里注册了两个关键的 WordPress 钩子。一个挂在register_form上,用来在注册表单里输出隐藏字段和时间戳;另一个挂在registration_errors上,这是 WordPress 提供的注册校验过滤器,在创建用户之前执行,我们在这个钩子里检查所有检测项并返回错误或放行。
// 主插件文件的关键挂载代码 add_action('register_form', 'fcksignups_inject_hidden_fields'); add_filter('registration_errors', 'fcksignups_check_registration', 10, 3); add_action('user_register', 'fcksignups_post_registration_log', 10, 1);第一个钩子负责在表单里注入蜜罐和时间戳,第二个钩子负责拦截,第三个钩子负责记录最终注册结果。三个钩子合起来覆盖了注册链路的完整周期:生成表单、校验提交、记录结果。为什么不在user_register里做拦截?因为在 WordPress 的执行顺序里,registration_errors过滤器的结果决定是否继续创建用户,如果在user_register里才处理,用户已经被写入数据库了,再删就麻烦得多。
3.2 核心拦截逻辑代码讲解
核心拦截逻辑是fcksignups_check_registration这个方法,完整代码大概两百行,核心部分可以分成四段来看。
第一段是蜜罐检查,这是最简单也是最粗暴的拦截段:
$honeypot_field = isset($_POST['account_extra']) ? sanitize_text_field($_POST['account_extra']) : ''; if (!empty($honeypot_field)) { fcksignups_log('honeypot_filled', get_ip_address()); return new WP_Error('fck_blocked', '注册失败,请重试。'); }注意这里返回的错误信息是通用的“注册失败,请重试”,不会告诉访问者是因为蜜罐字段被填了。跟机器人没什么好沟通的,省得它们根据错误提示去改进脚本。
第二段是时间戳校验:
$timestamp_field = isset($_POST['fck_ts']) ? $_POST['fck_ts'] : ''; $data = fcksignups_decrypt($timestamp_field); if ($data === false) { // 时间戳字段缺失或解密失败,同样视为可疑 return new WP_Error('fck_blocked', '注册失败,请重试。'); } $elapsed = time() - intval($data['time']); if ($elapsed < 3 || $elapsed > 86400) { fcksignups_log('time_anomaly', get_ip_address() . '|elapsed=' . $elapsed); return new WP_Error('fck_blocked', '注册失败,请重试。'); }时间戳加密我用的是 OpenSSL 的AES-128-CBC,密钥存在wp-config.php里而不是数据库里,避免数据泄露后密钥一起暴露。解密失败说明字段是伪造的或者被篡改了,直接拦截。这里有一个很实用的经验:日志里记录的是时间戳差值而不是用户输入,因为差值才是特征数据,能帮你判断攻击脚本是修改了什么逻辑才绕过这层检查的。
第三段是签名和频率检查。签名提取用$_SERVER里的字段拼接后做md5,代码很糙但效率高:
$signature_source = ( ($_SERVER['HTTP_USER_AGENT'] ?? '') . '|' . ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '') . '|' . implode(',', array_keys($_POST)) ); $signature = md5($signature_source); $recent_count = fcksignups_count_recent('signature', $signature, 3600); if ($recent_count >= 3) { fcksignups_log('signature_repeat', $signature . '|count=' . $recent_count); return new WP_Error('fck_blocked', '注册失败,请重试。'); }频率检查同理,用 IP 加操作类型作为键,在自定义表里快速 count。这里我没用 WordPress 的transient做频率记录,是因为transient是面向所有操作的,和注册频率混在一起容易互相干扰,而且transient的过期清理不够及时。自定义表更可控,也可以随时查询统计数据。
第四段是评分汇总和阈值判断,把各检测项的分值累加之后做最终决定。如果分数在 60 到 99 之间,我会把该注册请求标记为“待人工审核”而不是直接拒绝。实现方式是给registration_errors返回一个自定义错误对象,然后设置一个标志位,让后续的邮件通知发送给管理员而不是立刻创建用户。有人会说这个逻辑复杂,但实际用下来非常值,因为它避免了很多误杀情况——边缘用户进入了人工审核队列,站长可以手动处理,而不是直接被拒之门外。
3.3 与现有系统的集成方式
FckSignups 不只是拦截注册,还可以和站点的其他系统集成。我做了三个比较顺手的扩展点:
第一是邮件通知联动。正常情况下 WordPress 会在新用户注册后发送通知给管理员,被 FckSignups 拦截的用户不会触发这个通知;进入人工审核队列的用户则会单独发一封摘要邮件,包含可疑点,方便管理员快速判断。
第二是IP 黑名单同步。如果同一个 IP 在一天内被拦截超过 10 次,自动把该 IP 写入站点的.htaccess拒绝列表。这个功能默认是关闭的,需要显式配置才启用,因为自动修改服务器配置文件有风险,如果 .htaccess 语法写错了,整站会 500。
第三是邮箱域名黑名单扩展。FckSignups 内置了一个简易的临时邮箱域名列表,但有条件的话可以对接 QQ 邮箱、网易邮箱、Gmail 这些大厂的邮箱域名白名单,只允许特定域名注册,对很多垂直社区来说这是最干净的做法。缺点是可能误伤使用小众邮箱的正常用户,所以只建议在目标用户群体明确的情况下开启。
4. 真用户误杀、漏杀与性能——上线后必须盯的三件事
4.1 误杀排查:正常用户为什么会被拦
再精细的规则也会误杀,FckSignups 上线之后我遇到的最典型误杀场景有三类。
第一类是在校园网或者公司 NAT 出口注册的用户。整个学校上千人共用一个公网 IP,如果这个 IP 之前被脚本用来注册过几次,后面的真人用户会一直被频率控制拦截。排查方法是查看日志里同一 IP 的拦截记录,如果全是时间戳差值很小的,大概率是脚本;如果时间差值正常但 IP 计数器已经封顶,那基本是 NAT 误杀。处理方案是把该 IP 加入白名单,或者缩短封禁时间,从 2 小时改成 30 分钟。
第二类是浏览器自动填充导致的假蜜罐。现在很多浏览器的密码管理器会在页面加载后自动填充所有表单字段,包括蜜罐字段。虽然我已经改成了绝对定位隐藏的方式,但部分浏览器(尤其是移动端的自带浏览器)还是会把蜜罐字段填上。后来我改用aria-hidden="true"加readonly属性,同时在提交前由 JS 把蜜罐字段值清空,才基本解决。
第三类是用户长时间停留在注册页导致的超时拦截。有些人先打开注册页面放着,过了几个小时再填表提交,这时候时间戳值超过了 86400 秒,会被当成异常行为。解决方案是把超时上限放宽到 7 天,代价是一张注册表单的有效期变长了,理论上有重放攻击风险,但配合一次性 nonce 使用,实际影响非常小。
4.2 漏杀分析与策略调优
比误杀更让人头疼的是漏杀。有些垃圾账号成功注册进来了,这时候要做的事情不是懊恼,而是回到日志里找规律。
我的分析方法是:每两周拉一次最近 14 天的拦截日志和注册日志,标记出那些成功注册但后来被判定为垃圾账号的用户,反查它们的拦截评分。如果一批垃圾账号的评分普遍在 40 到 55 分之间,说明阈值设得太高了,调整到 45 分,效果立竿见影。如果垃圾账号的评分普遍很低,说明有某个检测项没有覆盖到,需要补充新的特征。
最近一次调优是在去年年底。我发现大量垃圾注册来自移动端 UA,而且它们的Accept-Language头写得特别全,成功绕过了语言头检查。于是加了一条规则:如果 UA 是移动端但存在桌面端特有的表单提交特征,加分;如果 UA 是移动端且没有 Touch 事件相关的请求头,加分。改完之后垃圾注册量又降了一截。
这里还有个小技巧:在评分系统里给每个检测项设置一个“置信度”权重。蜜罐非空是 100% 置信,直接拦住;时间过短是 80% 置信,设为高分值但不直接拦截;UA 有异常是 30% 置信,只能作为辅助信号。权重不同,防止单点误杀。
4.3 性能开销与数据库清理
FckSignups 的性能开销很小,因为每个检测都是内存操作或者走索引查询,测试环境下单次注册请求的额外耗时在 10 到 20 毫秒左右。唯一需要注意的是日志表会持续增长,时间久了会占用不小的空间。
我这边大概每 3000 次拦截产生 1MB 日志,如果站点每天被攻击几千次,一个月就是近百 MB 的日志量。所以我在日志表里加了一个定时清理任务,保留最近 30 天的数据,更早的自动清除。如果想留作长期分析的样本,可以在清理前先导出 CSV 存档。
另外,评分查询里用到的fcksignups_count_recent函数的 SQL 必须走索引,我在created_at和check_type字段上都建了联合索引,否则日志量上来之后,每次注册请求都要全表扫一遍,页面会明显变慢。这个是上线后才发现的坑,早期没建索引的时候注册接口偶尔会超时,加了索引之后问题消失。
5. 常见问题与踩坑记录速查表
5.1 高频问题自查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 注册页面加载不出来了 | 自定义时间戳加密函数报错 | 检查openssl扩展是否启用,改为wp_salt做密钥来源 |
| 自己测试时被拦截 | 浏览器自动填充了蜜罐字段 | 表单提交前用 JS 清空蜜罐字段,或用aria-hidden替代display:none |
| 同一个 IP 的真人用户被拦 | 频率计数器没有区分场景 | 检查 IP 是否 NAT 出口,加入白名单或缩短封禁时间 |
| 小号邮箱注册全部失败 | 临时邮箱域名列表过旧 | 更新列表或改为大厂邮箱白名单模式 |
| 日志表增长太快 | 攻击流量大,清理任务没有运行 | 确认wp_schedule_event定时任务存在,或者换用外部日志服务 |
| 注册成功的垃圾账号明显增加 | 攻击者改进了脚本绕过蜜罐 | 查看评分日志,看是哪个检测项失效,针对性地加新特征 |
这张表是我在文档里给别人排查用的,几乎每个问题都在生产环境真实出现过。值得多提一句的是:如果注册接口突然变慢,先查日志表索引,再查服务器带宽,最后才怀疑插件逻辑,顺序别搞反。
5.2 几个实测有效的避坑技巧
第一个技巧是不要只用一个传输层验证手段。很多防垃圾方案死磕请求头或者验证码,但其实真正聪明的做法是组合使用隐蔽性强的信号,让攻击者不知道你在检测什么,它们就没办法对症下药地绕过。比如蜜罐加时间戳的组合,你在 HTML 里输出的字段名称和值域都可以随机化,每次请求都不一样,脚本就没法写死过滤规则。
第二个技巧是拦截信息要模糊。无论什么原因拦截,返回给用户的信息都写成“注册失败,请重试”,不要具体提示“你操作太频繁”或者“你的 IP 被封禁”。很多攻击者会利用这些提示来优化脚本,减少暴露,保持接口反馈的神秘感是最安全的。
第三个技巧是分级处理,不要一刀切。高分值直接拦截,低分值的加入人工审核队列,这个机制彻底改变了我的注册管理体验。被误杀的玩家可以申诉,管理员在后台一键通过,不用重新走注册流程,对用户体验的影响降到了最低。如果站点本身有客服系统,这一步能省下大量沟通成本。
写在最后
其实 FckSignups 从想法到落地,前前后后花了我大概两周的业余时间,中间调参又折腾了一个多月。现在回头看,最值得分享的不是某一段代码或者某一个检测项,而是这套分层拦截、加权评分、日志回溯的思路。安全攻防本来就是一场猫鼠游戏,没有任何一个方案能一劳永逸,但只要留好日志、攒好特征、持续调优,就能用很低的成本守住自己的站点。如果你也在被垃圾注册困扰,不妨先别急着装一堆插件,试着从拦截一层一层的攻击者开始,你会发现自己写的东西比任何黑盒方案都好用。