结论先行:登录接口防爆破,绝大多数团队只做了"按 IP 限流"这一层,结果被"分布式撞库"轻松绕过——攻击者用 1 万个 IP 各试 1 次,单 IP 远没到阈值,但账号层面已经被磨穿了。真正扛得住的防线是"账号维度限流 + 验证码 + 指数退避锁定"三级叠加,且每一级都要有可量化的阈值。下面给一套可复现的落地清单。
一、现象:按 IP 限流跑了一年,一次撞库还是把 2000 个账号撞穿
去年给一家电商做渗透评估。他们的登录接口 /api/v1/login 已经做了"单 IP 每分钟 10 次"的限流,自认为防住了暴力破解。
我用 1 万个代理 IP,对一批已知手机号(用户提交过的注册手机号)做撞库:每个 IP 只试 1 个账号、1 次,循环换 IP。单 IP 维度远低于阈值,限流一次都没触发。结果 40 分钟内,约 2000 个账号用"手机号 + 常见弱密码(123456、a123456、生日)"被成功登录,其中 37 个是含支付权限的高价值账号。
更扎心的是:整个过程中,login_failed 日志里全是"密码错误",没有任何单点异常——因为攻击把流量摊平了。这就是"只看 IP"的盲区:IP 是攻击者最容易稀释的维度,账号才是攻击者最想磨的目标。
二、根因:限流维度选错 + 失败响应泄露信息 + 锁定策略是"一刀切"
三层问题:
- 限流维度错。按 IP 限流防的是"单点爆破",但撞库/分布式暴力破解天然是"多 IP、低频",IP 维度天然失效。必须叠加账号维度(按用户名/手机号)限流。
- 失败响应不一致。很多接口区分"用户名不存在"和"密码错误"两种返回。这本身就是一个"账号有效性"探测信道——攻击者先扫出哪些账号存在,再集中爆破。这和我之前写的一篇时序侧信道文章是同一类坑:校验逻辑只要对不同输入产生可区分的差异(耗时、文案、状态码),就等于在泄露信息。登录失败应统一返回"账号或密码错误",且用恒定时间比较处理凭据(别用
==逐字节短路)。 - 锁定是"一刀切"而不是"指数退避"。常见做法是"5 次失败锁 15 分钟"。问题有二:要么锁太松(撞库者换个 IP 继续),要么锁太紧(把真实用户一起锁了,客服被淹没)。正确姿势是按失败次数指数退避 + 验证码前置,让"猜一次"的成本随失败次数指数上升。
三、具体做法:三级防御 + 可复现配置
第一级:账号维度滑动窗口限流(Redis)
用 Redis 做账号维度限流,比 IP 维度更抗稀释。用 Lua 脚本保证"判断 + 计数"原子性,避免并发下的竞态(INCR 和 EXPIRE 分两步会被攻击者利用):
-- rate_limit.lua 滑动窗口:key = rl:{username}, 窗口 60s,阈值 5
local key = KEYS[1]
local now = tonumber(ARGV[1]) -- 服务端毫秒时间戳(别用客户端时间)
local window = tonumber(ARGV[2]) -- 60000
local limit = tonumber(ARGV[3]) -- 5
redis.call('zremrangebyscore', key, 0, now - window)
local cnt = redis.call('zcard', key)
if cnt >= limit then
return 0 -- 拒绝
end
redis.call('zadd', key, now, now .. '-' .. math.random(1000000))
redis.call('pexpire', key, window)
return 1 -- 放行
调用:EVALSHA <sha> 1 rl:{username} <now_ms> 60000 5。
关键点:时间戳必须用服务端时钟(System.currentTimeMillis()),绝不能用客户端传来的时间——否则攻击者直接伪造时间戳绕过窗口。
第二级:验证码前置(失败 2 次后触发)
不要"第 1 次就弹验证码"(伤体验),也不要"第 10 次才弹"(伤安全)。建议:同一账号在 10 分钟窗口内失败 ≥ 2 次,下一次必须带一次性验证码。验证码本身要有 TTL(建议 5 分钟)且一次性(用即失效,防重放)。
第三级:指数退避锁定
失败次数 → 锁定时长,指数增长:
| 失败次数 | 锁定时长 | |---|---| | 5 | 30 秒 | | 10 | 5 分钟 | | 20 | 30 分钟 | | 50 | 24 小时 + 触发告警 |
锁定 key 用 lock:{username},SETEX lock:{username} {ttl} 1。解锁不要靠"用户等够了自动放行",而要结合"验证身份(改密/短信)"——否则锁定只是延迟,不提高成本。
配套:失败响应一致化
// 统一返回,不区分用户名/密码
String stored = userRepo.getOrNull(username); // 不存在时用一个固定假哈希占位
boolean ok = stored != null
? constantTimeEquals(stored.getHash(), sha256(pw + stored.getSalt()))
: constantTimeEquals(FIXED_DUMMY_HASH, sha256(pw + DUMMY_SALT)); // 恒定时间,防时序
return Result.error(400, "账号或密码错误");
FIXED_DUMMY_HASH 让"用户名不存在"和"密码错误"走完全相同的计算路径与耗时——这正是恒定时间比较思想在登录场景的延伸。
四、踩过的坑(反直觉处)
- 限流计数放应用内存,重启就清零。攻击者发现"打满再等重启"就能重置计数。计数必须放共享存储(Redis),且多实例共享同一份。
- 用
INCR+EXPIRE两步,不原子。极端并发下INCR成功但EXPIRE还没执行就宕机,key 永不过期,等于给自己上了把永久锁——或者反过来被攻击者利用。必须用 Lua 单脚本原子化。 - 锁定只看 IP,真实用户被"同 IP 撞库"连坐。公司出口 IP、NAT 下,攻击者用你的公网 IP 爆破别的账号,把你整个公司的用户全锁了。所以锁定要账号维度,IP 维度只做辅助。
- 验证码可重放。如果验证码没有"用即删",攻击者抓到一个有效验证码可以反复用。务必
GETDEL/ 原子删除。 - 时间戳用客户端传来的。我见过一个实现把
X-Timestamp头直接当窗口基准,攻击者伪造一个"过去"的时间戳,窗口就永远不滑动——限流形同虚设。
五、结论与可复用清单
登录防爆破的核心认知:IP 是攻击者最容易稀释的维度,账号才是他要磨的目标,所有限流/锁定都要以账号为主键。
可复用清单(可直接抄):
- [ ] 账号维度滑动窗口限流(Redis + Lua 原子化,窗口 60s / 阈值 5,时间戳用服务端)
- [ ] 失败响应统一为"账号或密码错误",凭据比较用恒定时间 + 假哈希占位
- [ ] 失败 ≥ 2 次(10 分钟窗口)触发一次性验证码,TTL 5 分钟、用即删
- [ ] 指数退避锁定(5→30s / 10→5min / 20→30min / 50→24h+告警),解锁需身份验证
- [ ] 计数与锁定状态存 Redis(多实例共享),禁用应用内存计数
- [ ] 监控:单账号失败率、验证码触发率、锁定命中数,任一超阈值告警
一句话记忆:限流管"快慢",验证码管"人机",锁定管"成本"——三者都挂在账号上,才真正抗得住分布式撞库。