适用场景
用户枚举(User Enumeration)与密码喷洒(Password Spraying)是登录入口最常见的两类探测手段,前者用来确认哪些账号真实存在,后者用极少量常见密码横向尝试大量账号,以规避单账号锁定阈值。两类行为经常组合出现:先用枚举拿到有效用户名清单,再对清单执行喷洒。
本文适用于承载 SSH、网站后台、VPN 门户或统一身份认证的服务器,重点解决三个问题:登录接口响应差异导致的账号泄露、单一 IP 维度的限流被分布式喷洒绕过、以及锁定策略被反向利用造成拒绝服务。
前置条件
- Linux 服务器(Debian/Ubuntu 或 CentOS),已安装 Nginx 1.18 以上版本并掌握站点配置目录;
- OpenSSH 服务端,具备 /etc/ssh/sshd_config 修改权限;
- fail2ban 0.10 以上版本,支持 bantime.increment 递增封禁;
- 若存在 AD 域或云身份平台,需具备策略配置权限(可选)。
原理说明
用户枚举的成因是认证系统对「账号不存在」与「密码错误」返回了可观测的差异。这些差异可能出现在四个地方:错误文案(”用户不存在” 与 “密码错误”)、HTTP 状态码(404 与 401)、响应体长度、以及响应耗时。其中耗时差异最隐蔽也最容易被忽略:若系统在账号不存在时直接返回,而账号存在时需要执行一次 bcrypt 校验(约 100 毫秒),攻击者只要统计响应时间分布就能区分。
密码喷洒的成因是限流与锁定策略的维度单一。常见防御只按来源 IP 计数,于是攻击者用代理池或肉鸡分散来源;或者只按密码尝试次数锁定,于是攻击者对每个账号只尝试 1 至 2 个高频密码(如 Password123、公司名+年份),恰好落在锁定阈值之下。有效防御必须把限流维度从「IP」扩展到「IP + 用户名」,并对全局喷洒特征做检测。
操作步骤
1. 在应用层消除响应差异
账号不存在时,仍然执行一次哈希校验,保证耗时一致。以下为 Python 示例,关键在于使用预生成的固定哈希作为占位:
import bcrypt
# 预生成的固定 bcrypt 哈希,用于不存在账号的等时校验
DUMMY_HASH = b"$2b$12$eImiTXuWVxfM37uY4JANjQK1U9p1yv0XyF8dG0kQ2m3n4o5p6q7r8"
def verify_login(username: str, password: str) -> bool:
row = db.get_user(username) # 查不到返回 None
stored = row["pw_hash"] if row else DUMMY_HASH
ok = bcrypt.checkpw(password.encode(), stored)
return row is not None and ok # 统一对外返回「用户名或密码错误」
随后统一错误文案与状态码,无论何种失败原因,一律返回 401 与同一段提示文字,不区分账号是否存在。
2. Nginx 层按「IP + 用户名」双维度限流
默认的 limit_req_zone 只能用单一变量做键,需要通过 map 把 IP 与用户名拼成复合键。同时对用户名做格式白名单校验,避免攻击者用随机超长用户名制造海量键值撑爆共享内存:
http {
# 维度一:单 IP 登录频率
limit_req_zone $binary_remote_addr zone=login_ip:10m rate=5r/m;
# 维度二:IP + 用户名 复合键,用户名只允许常规字符且长度受限
map $arg_username $login_user_key {
default $binary_remote_addr;
"~^[A-Za-z0-9_.-]{1,32}$" "$binary_remote_addr:$arg_username";
}
limit_req_zone $login_user_key zone=login_user:10m rate=3r/m;
server {
location = /login {
limit_req zone=login_ip burst=3 nodelay;
limit_req zone=login_user burst=2 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8080;
}
}
}
3. 加固 OpenSSH,收窄认证面
编辑 /etc/ssh/sshd_config,减少尝试次数并关闭可能泄露账号信息的认证方式:
MaxAuthTries 3
LoginGraceTime 20
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
GSSAPIAuthentication no
UseDNS no
修改后执行 sshd -t 校验语法,再 systemctl reload sshd 生效。关闭 keyboard-interactive 认证尤其重要,因为部分系统在 PAM 预认证阶段对存在与不存在的账号会返回不同的交互提示,直接暴露账号是否有效。
4. fail2ban 增加喷洒检测规则
内置 sshd 规则只统计单 IP 的失败次数,对「一个 IP 尝试大量不同用户名」的喷洒行为不敏感。新建过滤器针对性匹配:
# /etc/fail2ban/filter.d/sshd-spray.conf
[Definition]
failregex = ^%(__prefix_line)sFailed password for (invalid user )?\S+ from <HOST>
^%(__prefix_line)sInvalid user \S+ from <HOST>
ignoreregex =
对应的 jail 配置启用递增封禁,让反复触发的来源被封禁时间成倍延长:
# /etc/fail2ban/jail.d/sshd-spray.local
[sshd]
enabled = true
port = ssh
filter = sshd-spray
maxretry = 5
findtime = 600
bantime = 3600
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 604800
关键参数说明:maxretry 5 配合 findtime 600 表示 10 分钟内失败 5 次即封禁;bantime.increment 开启递增,bantime.factor 2 表示每次再犯翻倍,bantime.maxtime 604800 上限为 7 天。
5. 从日志中识别喷洒特征
喷洒的典型特征是「同一来源 IP、大量不同用户名、失败时间密集」。用一条 awk 命令统计攻击源冲击的用户名数量:
grep "Failed password" /var/log/auth.log \
| awk '{src=""; usr="";
for (i=1; i<=NF; i++) {
if ($i == "from") src = $(i+1);
if ($i == "for") usr = $(i+1);
}
if (src != "") print src, usr}' \
| sort -u | awk '{c[$1]++} END {for (ip in c) print c[ip], ip}' \
| sort -rn | head -20
若某个 IP 冲击的去重用户名数量超过 10 而失败次数居高,基本可判定为喷洒而非误输密码,应直接加入防火墙黑名单或封禁段。
6. 锁定策略与业务告警联动
Web 侧在用户名维度限流触发后,不应简单返回「账号已锁定」——那会反过来泄露账号存在。推荐做法是:进入慢速通道(响应延迟递增)并要求二次验证,同时异步发送告警给账号本人。AD 域环境可配置细粒度密码策略,把锁定阈值设在大于喷洒窗口的次数之上:
New-ADFineGrainedPasswordPolicy -Name "SprayGuard" `
-Precedence 10 -MinPasswordLength 14 `
-LockoutThreshold 10 -LockoutDuration 00:15:00 `
-LockoutObservationWindow 00:15:00
配置验证
- 响应一致性:对存在与不存在的账号各请求 3 次,比较耗时与响应体长度:
for u in admin notexistuser; do
for i in 1 2 3; do
curl -s -o /dev/null -w "$u tc=%{time_total} size=%{size_download}\n" \
-d "username=$u&password=wrongpw" https://www.example.com/login
done
done
- 两组结果的 time_total 应处于同一量级,size_download 应完全一致,否则仍可被枚举。
- 限流生效:连续请求登录接口,第 4 次起应返回 429,检查 error.log 中是否出现 “limiting requests”,以确认命中的是预期 zone。
- SSH 统一响应:执行 ssh -o PreferredAuthentications=none 用户名@IP,存在与不存在的账号都应返回相同的 “Permission denied (publickey)”,不出现 “invalid user” 差异。
- fail2ban 状态:fail2ban-client status sshd 查看当前封禁列表与累计触发次数,确认 jail 的 filter 名称与配置一致。
常见问题
FAQ 1:账号锁定策略会不会被攻击者反向利用,造成拒绝服务?
会。任何「按失败次数锁定账号」的策略理论上都能被用来锁死正常用户。缓解方式有三条:一是把锁定阈值设得高于喷洒窗口内的典型尝试次数(例如 10 次),让喷洒在触发锁定前就被限流或验证码拦住;二是采用递增延迟而不是硬锁定,攻击者收益下降而正常用户几乎无感;三是对登录请求做来源信誉判断,把已知代理池与云主机网段的请求单独降级处理,不要让其消耗正常用户的失败计数。
FAQ 2:统一了错误文案后,为什么还能被枚举出账号?
文案只是最表层的一维。还需要检查:HTTP 状态码是否一致(部分框架对不存在用户返回 404)、响应体长度是否一致(页面渲染出不同的错误插槽)、响应耗时是否一致(存在账号会触发一次哈希计算)、以及注册与找回密码接口是否独立泄露账号状态(”该手机号未注册” 同样是枚举点)。以上任意一维存在差异,枚举就仍然可行。
FAQ 3:密码喷洒和暴力破解的区别是什么?
暴力破解是「单账号 × 大量密码」,特征明显、容易触发锁定;密码喷洒是「少量密码 × 大量账号」,每个账号只尝试一两次,专门避开锁定阈值。因此防御重点不同:破解靠的是次数阈值与验证码,喷洒靠的是来源维度聚合与全局用户名去重统计。只配置按账号锁定的系统,对喷洒几乎没有防护效果。
总结
防御用户枚举与密码喷洒的要点可以归纳为四句:认证响应必须做到文案、状态码、长度、耗时四个维度完全一致;限流键要从单一 IP 升级为「IP + 用户名」复合键,同时约束用户名格式;封禁策略要用递增退避,避免硬锁定被反向利用;检测侧必须以「来源 IP 冲击的去重用户名数量」作为喷洒判定指标,而不是只看失败总次数。