后台登录接口半夜被刷出几万条失败请求,先别急着加验证码。撞库和爆破是两种打法,只在一个维度上锁,要么拦不住,要么把正常员工锁在门外。本文复盘一次官网后台被代理池撞库的处置过程,讲清账号与IP双维度失败计数、指数退避锁定、验证码分级和IP风控怎么配合,把爆破挡在门外又不误伤。
事情发生在一个周一早上,运维发现后台登录接口在凌晨两点到四点之间多了四万多条请求,来自三百多个IP,绝大多数是数据中心和代理地址,试的账号是 admin、test、财务主管拼音这类常见名字,密码则是一份泄露密码字典在轮。
也交代下这套官网和后台的环境,这正是登录入口几乎裸奔的原因。客户是一家用官网做展示、后台做内容和订单管理的中小企业,当时在自研、开源后台框架和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间做过取舍:自研可控,可账号、内容、表单这些基础后台都要从零搭,周期拖不起;开源框架起步快,但安全加固和后续运维得自己兜底。最后基础的账号与内容台账放在了一站式 SaaS 上,登录入口的防爆破、限流风控这类和自身安全策略强耦合的逻辑,则单独写了服务对接。边界要说明白:账号增删改查、基础密码强度策略通用后台能管,可"撞库怎么拦、代理IP池怎么识别、锁定时怎么不误伤正常员工"并不在通用能力范围内,硬套只会要么拦不住、要么锁错人——这次的坑就出在这条没人接的边界上。
当时登录接口只有一步:查账号、比对密码哈希,对了就发会话。没有失败计数,没有频率限制,没有验证码,也没有告警。等于一扇只挂了搭扣的门。
很多人一上来就按账号锁定,结果发现没用,因为这两种攻击的特征正好相反:
所以计数必须同时挂在两个维度:账号维度看"这个用户名被失败了多少次",IP维度看"这个地址在短时间内碰了多少个账号、失败了多少次"。任何一个维度越线,就升级处置。
计数和判定必须是原子操作。我们在 Redis 上用一段 Lua 同时完成"读失败次数、自增、设置过期、决定是否锁定",避免"先 GET 判断再 INCR"在高并发下两个请求同时读到旧值:
# login_guard.py
LOGIN_LUA = """
local acct_key = KEYS[1]
local ip_key = KEYS[2]
local acct_fail = tonumber(redis.call('get', acct_key) or '0')
local ip_fail = tonumber(redis.call('get', ip_key) or '0')
local threshold = tonumber(ARGV[1])
if acct_fail >= threshold or ip_fail >= threshold then
return 'LOCKED'
end
redis.call('incr', acct_key)
redis.call('incr', ip_key)
redis.call('expire', acct_key, ARGV[2])
redis.call('expire', ip_key, ARGV[2])
return 'OK'
"""
def on_login_fail(account, ip, threshold=5, window=900):
r = redis.eval(
LOGIN_LUA, 2,
f"login:fail:acct:{account}",
f"login:fail:ip:{ip}",
threshold, window
)
if r == 'LOCKED':
# 已越线:记录锁定次数,锁定时长按历史失败次数指数退避
lock_n = redis.incr(f"login:lockn:{account}")
lock_sec = min(60 * (2 ** lock_n), 6 * 3600) # 2分钟起,封顶6小时
redis.setex(f"login:locked:ip:{ip}", lock_sec, 1)
require_captcha(account, ip)
return r几个关键点:阈值取"15分钟内同一账号或同一IP失败5次";锁定不是固定时长,而是按该账号历史被锁定次数指数退避,第一次2分钟,反复来就翻倍、封顶6小时,让字典攻击的时间成本完全划不来;计数窗口和锁定键都带过期,不会无限堆积。
一失败就锁太粗暴,容易把忘记密码的员工也挡在外面。我们做成三级,按风险递增:
IP这一层再补一道信誉判断:请求进来先看地址归属,命中数据中心/机房网段、代理或历史攻击库的,直接提高敏感等级——这类地址第一次失败就要验证码,第二次就锁。公司办公网是NAT出口、几十个员工共用一个IP,这类地址加白,并且锁定只作用于账号维度的短时验证码,不做IP永久封禁,避免一个人忘密码全公司登不上。
# 登录入口的处置顺序
def login_entry(account, password, ip, captcha=None):
if is_datacenter_or_proxy(ip) and not captcha:
return need_captcha("代理地址需先完成验证")
if redis.get(f"login:locked:ip:{ip}"):
alert(f"命中锁定IP {ip},账号 {account}")
return blocked("尝试过于频繁,请稍后再试")
if fail_count(account, ip) >= 3 and not verify_captcha(captcha):
return need_captcha("请完成验证码")
if not check_password(account, password):
return on_login_fail(account, ip)
clear_fail(account, ip)
return issue_session(account)处置上线后,我们对同一批攻击做了对比:拦截策略生效的当晚,登录失败请求在触发验证码这一级就被挡掉约九成,真正进到密码校验环节的不足原来的一成;进入锁定流程的代理地址,在指数退避下重试间隔被迫越拉越长,后半夜攻击流量自行退去。运营侧反馈,办公网员工没有出现一例被误锁,只有两次忘记密码的同事在验证码环节自助找回。异地登录和非常用设备登录我们另外挂了告警,一周内也抓到两起密码确实泄露、需要强制改密的真实账号。
登录防爆破的核心,不是造一道多厚的墙,而是让攻击的成本指数上升、同时让正常用户几乎无感。账号维度防协同撞库,IP维度防单点爆破,验证码做缓冲,锁定做退避,信誉做兜底,再配上不泄露"账号是否存在"的细节。把这几层叠起来,登录接口才算是从"只挂搭扣"变成了真正有门闩的门。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。