行为验证码实战:把刷注册的脚本成本抬上去

一、为什么你的图片验证码只防住了自己人

做站长时间久了,登录、注册、留言、投稿这些入口早晚都会被刷。第一反应通常是加一个图形验证码,找一段 PHP 的 GD 代码贴上,看到页面上出现四位歪歪扭扭的数字,心里就踏实了。结果第二天打开后台,垃圾评论一条没少,注册用户多了三百个,全是乱码昵称。再看验证码接口的日志,请求数是页面访问量的二十倍。

问题不在于「验证码有没有用」,而在于你把一个一次性的人机判定做成了一次性的静态资源。我统计过自己站上被刷得最惨的一个留言接口,抓包看请求序列,攻击脚本的行为是:GET 验证码图片,把图片丢给打码平台,0.3 秒后拿到四位数字,POST 提交留言,循环。整个过程不需要打开浏览器,不需要执行 JavaScript,session 也维护得好好的。图片在他眼里只是一个 OCR 题目,难度约等于零。

这篇文章不讨论怎么调 GD 参数、怎么加干扰线——那条路已经被证明走不通了。我写的是这两年在一台小机器上把行为验证做起来的完整过程:判定放在哪里、什么信号真的有用、怎么控制误伤,以及那些做了却毫无收益的部分。

二、先把三类验证分清楚,别用错

「验证码」这个词被混用得很厉害,实际上至少有三种东西,各自解决的问题完全不同。

第一类是静态挑战:图形字符、算术题、滑动拼图。它的特点是答案存在服务端(或者存在客户端但可被反解),一次挑战对应一次作答。它防的其实是「不会写代码的人」,对写脚本的人无效。图形码之所以还能见到,是因为它有成本——每次都要打码平台扣一分钱。如果你只是想让刷的量下降九成,它依然有用,但它不叫安全。

第二类是行为判定:不给题目,只观察客户端在提交前后产生的信号——有没有真的动鼠标、键盘、页面停留了多久、请求头顺序对不对、有没有执行过 JS、浏览器指纹是否自洽。判定结果是「像不像人」,不是「答对没答对」。这类方案体验最好,但对信号的采集质量和阈值调校要求最高,误伤率往往是你最头疼的地方。

第三类是令牌式服务(reCAPTCHA、hCaptcha、Turnstile)。把判定交给第三方,你只负责验证返回的 token。省事,但引入了外部依赖:用户在境内可能加载不出,第三方脚本本身也增加了首屏负担。老站如果只需要挡掉底层脚本,这一类是性价比最高的一档。

我做的是第二类和第一类的组合:一个极轻的行为采分脚本给所有请求打分,分低的静默放行,分处于中间地带的才出一道挑战,分极低的直接拒绝并记录。这个结构的好处是九成正常用户永远不会看到任何题目,而脚本必须同时骗过行为判定和挑战,成本被抬高到不划算。

三、判定放在服务端,且不要和 session 绑死

第一个架构决策:采分和判分必须分离。前端只负责上报信号,绝不做「通过/不通过」的决定。很多开源方案把校验写在前端 JS 里,改一行返回值就全过了。这不是技术问题,是设计问题。

第二个决策:判定状态不要只存在 PHP session 里。原因很实际——session 是跟着 cookie 走的,脚本可以清空 cookie 重新开始,而 session 文件在 /tmp 里堆积还得配 GC。我改成一张轻量的判定表,用「IP + 浏览器指纹哈希 + 小时窗口」做键,存分数、判定结果、挑战次数。这样做还有两个附带好处:一是同一 IP 换 session 反复挑战会被识别出来,二是可以按 IP 维度做速率限制,而不只是按会话。

表结构大概是这样,字段不多,关键在索引和过期清理:

CREATE TABLE verify_score (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  ip_bin VARBINARY(16) NOT NULL,        -- INET6_ATON 存,别存字符串
  fp CHAR(32) NOT NULL,                  -- 浏览器指纹的 md5
  win_hour INT UNSIGNED NOT NULL,        -- unix 时间戳整除 3600
  score TINYINT NOT NULL DEFAULT 0,
  challenges TINYINT UNSIGNED NOT NULL DEFAULT 0,
  passed TINYINT NOT NULL DEFAULT 0,
  ua_hash CHAR(16) NOT NULL,
  created_at INT UNSIGNED NOT NULL,
  UNIQUE KEY uk_ip_fp_hour (ip_bin, fp, win_hour),
  KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

写入用 INSERT ... ON DUPLICATE KEY UPDATE,避免并发下的先查后插。清理不用 cron 也能做:每次写入时以百分之一概率顺手 DELETE FROM verify_score WHERE created_at < UNIX_TIMESTAMP() - 86400,量小到可以忽略,却省了一个定时任务和一次运维记忆负担。

一个容易忽略的细节:IP 要用 INET6_ATON() 存成二进制,别存成 45 字节字符串。一是索引小,二是可以正确按前缀做匹配,三是避免「同一个 IPv6 地址不同写法」造成的统计分裂。取真实 IP 时记得 Nginx 那边要配 real_ip_header 并只信任自己的 CDN 段,否则所有请求的 IP 都是反代地址,整个判定逻辑就废了。

四、哪些行为信号真的有用

网上列的信号清单动辄二三十条,我实测下来,真正稳定、可解释、值得入库的只有下面六类。其余要么噪声太大,要么容易被合法用户触发,都被我删掉了。

第一,鼠标与键盘的事件序列。不是看你有没有鼠标事件(脚本也可以伪造),而是看轨迹的物理性:真实鼠标的位移是变化的,会抖动、会停顿、会有轻微的加速度;伪造的往往是一条匀速直线或者干脆只有两次移动。我取三个量:移动事件数、方向的转折次数、相邻两点的时间间隔方差。三个都为 0 或方差恰为 0 的,基本可以确定是合成的。

第二,表单填写的时间分布。一个真人从看到表单到点击提交,键盘输入的间隔是有分布特征的;脚本一次把四个字段填满是瞬间完成的。我记录「首次 focus 到提交的毫秒数」和「输入事件数」,小于 400 毫秒且没有一次退格、没有一次焦点切换的,扣分。这一条误伤率非常低,因为它只对极端值扣分。

第三,请求头的自洽性。HTTP 头里有几组是天然相关的:Accept-Language 与声称的地区、Sec-CH-UA 与 User-Agent 的主版本号、Sec-Fetch-Site 与实际 Referer 的关系。用 Python requests 直接发的请求,往往是 Accept: */*、没有 Accept-Language、没有 Sec-Fetch-*,UA 却声称自己是 Chrome 120。这种不一致是最廉价的强信号,成本为零。

第四,JS 环境的完整性。不是查 UA,而是查几个「真实浏览器一定有、无头环境经常缺失或不一致」的点:navigator.plugins.length、navigator.languages 是否为数组、window.chrome 是否存在、时区偏移与 IP 归属地是否矛盾、WebGL 渲染器字符串是否落在一个空白的 SwiftShader 上。注意:这一条只能作为加分项,不能作为单独判定,因为隐私插件会改变这些值。

第五,请求速率与路径顺序。这个放在服务端做,不需要前端配合:同一个 IP 在 10 分钟内直接 POST 接口而没有先 GET 页面、单位时间请求数远超人类、同一秒内多个不同账号的操作,都属于结构性异常。这是我用过最有效的一条,因为它是行为学层面的,脚本作者往往只想着骗前端。

第六,历史的信誉。这个 IP 过去一小时有没有通过过挑战、有没有被拒绝过、这个指纹是不是第一次出现却已经在提交。用一张表记录,判定时把历史分加权进来。信誉的好处是「老用户几乎无感」——正常访客第二次来的时候分数已经够了。

有几个信号我试过又删了,理由是收益远低于成本:Canvas 指纹(用户装了防指纹插件后全站都一样,且涉及合规)、字体列表完整枚举(耗时长,用户明显感到卡顿)、鼠标热力图(数据量大,无法实时判定)、键盘键值采集(有隐私风险,且中文输入法的 composition 事件让逻辑复杂到没法维护)。

五、打分与阈值:宁可放过,不要误杀

判定不是二分类,是一条三段线。我把六类信号折算成 0 到 100 的分数,然后按下面三档处理:

score >= 60   → 直接放行,不挑战,记录一次通过
20 <= score < 60 → 出挑战(滑动拼图或算术题),限 3 次
score < 20    → 直接拒绝,返回统一的错误文案,延迟 800ms 响应

三个阈值不是拍脑袋定的。上线第一周我开了「影子模式」:所有请求都算分,但一律放行,把分数和最终是否被人工标记为垃圾的数据一起记下来。跑满一周后用这批数据反推阈值——把正常用户的分数分布画出来,取第 1 百分位作为「低分线」,这样误伤率天然控制在 1% 以内。这一步很关键,跳过它就等于在盲调,上线后你会发现总有用户在群里说「我怎么提交不了」。

另有两个调校经验:

一是拒绝时不要立刻返回。加 500 到 1000 毫秒的随机延迟,并且返回看起来正常的错误(比如「提交失败,请稍后重试」),而不是「检测到机器行为」。前者让脚本不得不重试,拖慢它的整体吞吐;后者等于告诉对方哪条规则命中了,下次直接绕过。

二是给用户留一条人工通道。连续挑战失败三次之后,不要把这个人永久拉黑,而是切换成一个需要人工审核的入口(留言进入待审核队列、注册进入邮件验证)。人是有可能被误判的,特别是用老版本浏览器的用户和开了严格隐私模式的用户。我的做法是:被拒的提交全部落库,但不入库到业务表,管理员在后台能看到「被拦下的提交」列表,误伤可以一键放行。这一条救过我好几次。

六、挑战本身怎么做才不鸡肋

如果判定已经给出了「可疑」,挑战的作用就是抬高成本,而不是最后一锤定音。所以挑战必须具备两个性质:服务端校验、一次性使用。

图形算术题的问题在于答案空间太小,且可以离线穷举。滑动拼图稍好,但缺口位置如果从前端传回来,等于没验证。正确的做法是:

服务端生成挑战并落库。生成时写入挑战 ID、正确答案或缺口坐标、绑定 IP 与指纹、过期时间(我设 3 分钟)。前端只拿到挑战 ID 和图,作答时提交 ID 与答案,服务端比对后立刻把该条置为已用。用过一次就失效,且同一个挑战 ID 不允许出现第二次提交——这一条能拦掉不少「拿一个通过记录反复重放」的脚本。

图片素材要足够多。我见过有站点的拼图背景图一共只有 20 张,脚本把这 20 张图的缺口位置全部离线算好,命中率立刻到 100%。素材库至少上百张、每次随机裁切、加轻度噪声,是不费力就能做到的加固。

挑战失败要计入那个 IP 的挑战次数。单次失败不要紧,一小时里失败十次就该降级处理了——这是我做的最简单也最有效的联动:挑战次数表就存在上面那张 verify_score 里,challenges 字段超过阈值后,即使分数回升也强制出挑战,且挑战难度提高(多一位数、缺口更小)。

别忘了 API 也能被当页面刷。所有的 POST 接口——留言、评论、投稿、找回密码、注册、以及看起来无害的「浏览量上报」——都应该过同一套判定。我最初只保护了注册和留言,结果垃圾内容从「投稿」接口进来了,那个接口当时连验证都没加,因为我觉得「投稿有审核,不急」。

七、性能与实现取舍:为什么不装现成的

到这里一定会有人问,为什么不直接装一个开源的行为验证插件。我评估过几个,最后自己在 PHP 里写了一百多行的版本,理由是三条:

第一,插件把判定逻辑放在前端且写得明明白白。前端代码是公开的,只要被针对过一次,绕过方法就在贴吧里流传。自己写的东西没有公开的攻击面。

第二,第三方脚本是首屏的负担。一个验证库动辄 200KB,为了挡机器人让所有用户多下载 200KB,LCP 直接掉一截,这个账在我这种小站上算不过来。我自己的采集脚本压到 2.6KB 并加了 defer,对性能的影响基本看不出来。

第三,数据留在自己这里。分数、被拦记录、误伤放行都在自己的后台,出了问题能查。用第三方的话,遇到误判只能等回复邮件。

代价是维护成本。我的做法是把整个判定收在一个文件里(约 180 行),一个配置数组控制所有阈值,改动只在一个地方,不引入框架依赖。这套东西跑了八个月,规则改过三次,每次改动都是十分钟的事。

数据库开销也要算。每个请求一次 ON DUPLICATE KEY UPDATE,在最高峰的时候我的小机器上 QPS 不到二百,这张表的写入占不到 3% 的 IO。但如果你的站是几十万 PV,就要考虑把同一 IP 的分数在内存里缓存几秒(APCu 或 Redis 都行),合并写入,别让每个像素级请求都打到 MySQL。我做了一个很土但有效的优化:只对「有风险的请求」写库,分数明显很高的请求(比如带完整 Sec-Fetch 头、有鼠标轨迹、速率正常)直接放行不落库,写入量降了七成。

八、上线顺序与验证方法

验证类功能最怕的是「一上线就全站改行为」,出了问题很难定位。我按下面的顺序推,每一步都能单独回滚:

第一步,影子模式。只采集、只算分、只记录,行为完全不变。跑一周,看日志里的分数分布,确认正常用户的分数确实集中在高位。这一步就能发现采集脚本的 bug,比如某个字段在 Safari 上取不到导致全站低分。

第二步,只保护一个接口。选一个最不痛不痒的(我用的是「浏览量上报」),打开挑战与拒绝,观察两天。看被拦下的样本,人工判断是垃圾还是误伤。这一步是阈值校准的黄金窗口。

第三步,逐步扩大到留言、注册、找回密码。每加一个接口观察一到两天。不要在同一个小时里把所有入口一起打开,否则用户涌入的投诉你根本分不清是哪个环节的问题。

第四步,关掉旧的图形验证码。两套并存期间,用户会被要求做两次验证,体验极差,而且旧的那套还在泄露「这个站有个简单的验证码」的信息。确认新方案稳定之后,把旧的删干净,连验证码图片的接口一起删——留着就是第二个入口。

验证方法很简单,用 curl 模拟一次脚本请求看效果:

curl -s -o /dev/null -w '%{http_code} %{time_total}' \
  -X POST https://www.example.com/comment \
  -d 'name=test&content=hello&url=http://spam.example';

不带 Accept-Language、不带 Sec-Fetch-*、没有 referer 的裸请求,在打开保护后应该得到 403 或者一个看起来正常但实际失败的响应,且耗时明显变长(延迟生效)。再用浏览器正常提交一次,应该秒过。这一正一反两个结果,就是这套东西是否工作的最小验证。

观察指标只看三个:垃圾提交进入业务表的条数(应该断崖式下降)、被拦下的样本里人工判定为误伤的比例(应该低于 2%)、留言接口的 P95 响应时间(增加不应该超过 30 毫秒)。前两个在后台看,第三个在访问日志里按 $request_time 统计就行。

九、几个不该做的事

不要用第三方免费的「反机器人」脚本却把密钥写在前端。见过不少站点这样干,密钥在 HTML 里明文可见,验证结果又只在前端判断,等于给攻击者一个开关。

不要按 UA 黑名单做主要防线。UA 是可以随便改的字符串。我曾花过一周维护一份「可疑 UA 列表」,后来发现攻击者直接把我自己的 Chrome UA 抄了过去,规则立刻全废。UA 只能作为打分里的一小项权重。

不要对正常用户弹出「检测到异常」的提示。这既是安全问题也是体验问题。所有拒绝都伪装成普通的业务失败,理由只写在服务端日志里。

不要忘了清理。判定表、挑战表、被拦提交表,都要有过期策略。我见过一个站这三张表加起来占了数据库八成的空间,而里面全是三个月前的垃圾数据。写库时顺手清理,或者一个每天跑一次的 innoDB 小删除,成本都很低。

最后一句实话:没有任何一种验证能百分之百挡住有预算的攻击者,这个目标本身就不现实。能接受的目标是——把刷一次的成本从「一个脚本五分钟」抬到「一个人半小时还得付打码费」,绝大多数刷子在这一步就会换目标。对个人站来说,这已经够了。

Last modification:October 27th, 2026 at 01:26 pm

Leave a Comment