把一个需要人工审批的表单换成自动化的反滥用闸门,核心决定不在算法上,而在一句话上:分数只定价,不判决。没有任何一个信誉分会导致拒绝,它只决定你要算多久的哈希。本地信号必然误判,而误判的代价应该是慢几秒,不是办不成事。
一、先看清那一步人工操作到底在决定什么
原来的流程是:读者提交邮箱申请,管理员看一眼,点批准。看起来这是一个动作,实际上管理员在那一下同时做了两个决定:
- 建不建这个邮箱——防的是自动化批量注册;
- 要不要给对外发信权限(
send_allowed,默认0)。
这两件事的风险完全不是一个量级。第一件的坏情况是数据库里多了一堆垃圾账号;第二件的坏情况是域名信誉受损——而域名信誉是与该域下所有既有邮箱共享的。一次滥用发信会连坐整个邮件系统。
所以自动化只覆盖第一件:PoW 通过后自动置为已批准、分配默认配额,而 send_allowed 仍然保持 0,继续由后台单独开。
这不是没做完,是刻意的。把一个包含多个决定的人工步骤自动化时,先把它拆开,逐个问”这一个的坏情况是什么”。把不该自动化的那部分一起自动化掉,是这类改造最常见的事故。
二、手写 SHA-256 出 bug 不会报错,只会永远验不过
PoW 要在浏览器里算大量哈希。第一反应是用 crypto.subtle.digest,但它每次调用返回一个 Promise——每个哈希一次微任务调度,实测只有几万次/秒,跑不动一道需要百万次尝试的题。所以 Worker 里必须放一份同步的 SHA-256 实现。
而手写 SHA-256 有一个特别讨厌的失败模式:它一旦有 bug,客户端和服务端算出的哈希对不上,验证永远失败,并且不报任何错。用户看到的是”提交后一直不通过”,服务端日志里是一次正常的验证失败。没有任何东西指向哈希实现本身。
所以这份实现不能靠”跑起来了”来验收,必须拿向量对拍。做法是把 Worker 源码从 JS 里抽出来,用 node 单独跑:
- 21 个哈希向量,由 Python 的
hashlib生成。重点是覆盖分块边界:55、56、63、64、65 字节。SHA-256 按 64 字节分块,长度域占 8 字节,所以 55/56 这一对正好卡在”填充还塞得下”和”必须多开一块”的分界上——绝大多数手写实现的 bug 都在这里。 - 400 个前导零比特用例,验证难度判定函数(数前导零位这件事,跨字节边界时特别容易写错)。
- 端到端:node 按 Worker 的逻辑挖出一个解,再用 node 的 crypto 按服务端 PHP 的逻辑验一遍。两边独立实现,能对上才算数。
这三层缺一不可。前两层保证哈希是对的,第三层保证两端对”要哈希什么”的理解是一致的——拼接顺序、分隔符、编码,任何一处不一致,哈希再正确也验不过。
三、难度必须实测,拍脑袋会拍出一个折磨人的数
PoW 的难度用前导零比特数表示,期望尝试次数是 2bits。要把它换算成”用户要等几秒”,需要一个实测的哈希速率:
node 单线程 ~72 万次/秒
浏览器 Worker 约为它的 40–60%(~35 万次/秒)
照这个速率算:
| 难度 | 期望尝试 | 浏览器耗时 | 体感 |
|---|---|---|---|
| 17 bit | 13 万 | 约 0.4 秒 | 察觉不到 |
| 21 bit | 210 万 | 约 6 秒 | 明显在等,但可接受 |
| 840 万 | 约 24 秒 | 我最初设的上限,太长了 |
23 bit 是我一开始拍的数。24 秒对一个被信号误判的真实读者是折磨——他没做错任何事,只是恰好从某个 IP 段过来。上限最后定在 21 bit。
这里的关键是:难度不是安全参数,是用户体验参数。它的上限应该由”最坏情况下一个无辜用户要等多久”决定,而不是由”要让攻击者付出多大代价”决定——后者算出来的数总是更大。
四、IP 评分:只用自己能观察到的信号
难度由一个本地信誉分决定。这个分只用本站自己能观察到的东西:
| 信号 | 加分 |
|---|---|
| 反向 DNS 命中主机商关键词 | +30 |
| 没有 PTR 记录 | +5 |
| 近期失败 | +10/次(上限 30) |
| 短时重复通过 | +8/次(上限 25) |
| 新账号 / 账号很年轻 | +15 / +8 |
| UA 缺失或明显是自动化工具 | +20 |
刻意没做的三件事,比做了的更能说明设计意图:
- 不调外部信誉 API。那等于把每个访客的 IP 发给第三方,为了防滥用而制造一个更大的隐私问题。
- 不用第三方黑名单。它们的误判你既看不见也改不了,而代价由你的读者承担。
- 不按国家加权。这一条最值得展开:国家与”这个人会不会滥用”的相关性,远弱于它与”这个人住在哪”的相关性。按国家加权实际上是在惩罚地理位置,而不是在识别行为。
IP 不存原文,计数器的键是 HMAC(IP, salt)——需要”同一个 IP 最近失败过几次”这个事实,不需要知道那个 IP 是什么。
五、分数只定价,不判决
这是整套设计里唯一一条不能妥协的:没有任何分数会导致直接拒绝。分数高只意味着题更难。
理由很简单:上面那些信号必然会误判。用共享出口的公司网络、用运营商 CGNAT、刚注册的账号、某些浏览器的 UA——这些都是完全正常的读者,也都会加分。
一个会拒绝的系统,误判的代价是”这个人办不成事,而且不知道为什么”。一个只定价的系统,误判的代价是”这个人多等了几秒”。前者你必须把误判率压到极低才敢上线,后者可以带着已知的误判率工作。
这条原则的适用面远不止 PoW。任何基于启发式信号的风控,只要你的信号是本地的、不完备的,就应该考虑把输出从”通过/拒绝”改成”成本”。
六、两种题不能互换
站内有两个入口都用这套闸门:邮箱申请,和一个公开的 IP 信誉查询工具。它们的题互不通用——挑战的载荷里带一个 purpose 字段,验证时逐字比对。
不这么做的后果是:攻击者可以在最便宜的入口(公开工具,难度低)批量刷题,拿去换最敏感的入口(邮箱申请)的通过。最便宜的入口会给最敏感的入口定价。
挑战本身是一个 HMAC 签名的无状态字符串,不落库——签发端点是公开的,落库等于给了一个免费的写入接口。单次使用的约束只在验证成功那一刻写一条短期记录。
七、那个公开工具自己也要先解题
站上的 IP 信誉查询工具会告诉你:本站给你当前 IP 打了多少分,以及每一项信号的权重。这是刻意公开的——闸门的安全性不依赖于信号保密。
但这个工具本身也必须先做一次 PoW。否则它就是一个免费的 IP 信誉查询接口:拿一个代理池挨个查,就能枚举出本站认为”干净”的地址段,然后专挑那些地址来用。
这是个容易漏掉的点:一个把你的风控判据暴露出来的诊断工具,本身就是风控的一部分。
八、实测与没测的
测过的:
curl 的 UA 得分 20
浏览器 UA 得分 0
取题 17bit → 解题 0.05s → 提交返回 200
重放同一道题 403 already_used
没测的也要写出来:登录态下的邮箱申请全流程没有端到端测过,因为手头没有测试账号。代码路径是确定的(验证失败则带错误码重定向),但”确定的代码路径”和”跑过一遍”是两回事。写下来是为了下次有账号时补上,而不是让它悄悄变成”应该没问题”。
References
- RFC 6234:SHA-256 规范与测试向量
- Back (2002), Hashcash — A Denial of Service Counter-Measure
- MDN:SubtleCrypto.digest(为什么它是异步的)
相关阅读:IP 信誉评分器(本文说的那个工具)、AI 系统的威胁建模、鉴权闸门覆盖的是路径,不是数据(另一个”以为挡住了其实没挡住”的例子)。
