前言
上一篇我们靠"响应内容不同"枚举用户名。但如果开发把错误信息统一了呢?是不是就没辙了?这一篇打两个更隐蔽的信号信道 :响应时间 和账户锁定行为------服务器嘴上不说话,但"想多久"和"什么时候发火"会把它卖了。读者可以自行去打靶场,把我的博客当游戏攻略;虽然靶场自带解法,但不直观,而且我对底层逻辑也有一些自己的思考,如果对你有用,希望可以点一个赞。
本篇靶场
| 靶场 | 难度 | 攻击手法 |
|---|---|---|
| Username enumeration via response timing | PRACTITIONER 从业者 | 响应时间侧信道 |
| Username enumeration via account lock | PRACTITIONER 从业者 | 账户锁定行为差异 |
靶场链接:https://portswigger.net/web-security/authentication/password-based
建议先读系列第一篇《用户名枚举入门》,Intruder 的基础操作(四种攻击模式、Positions / Payloads 配置)在那里讲过,这一篇不再重复。
前置知识:X-Forwarded-For 是什么
这两个靶场都会撞上 IP 封锁,绕过要靠伪造 IP,先把原理讲清楚:
X-Forwarded-For(XFF):代理追加的请求头,用来告诉后端原始访问者 IP,可伪造。
用户 → 代理服务器 → 后端网站
网站直接看到的 IP 是代理的 IP ,看不到用户真实 IP。于是代理会额外加一个 HTTP 头 X-Forwarded-For,写上用户真实 IP,传给后端。
⚠️ 重点 :这个头不是 HTTP 原生自带 。如果没有代理,用户自己就能随便构造这个头来造假。所以不能直接信这个头里的 IP,必须由可信代理来填充它。
这个"过度信任代理头"的问题,正是本系列后面密码重置投毒 (X-Forwarded-Host)的同一个病根。
实战一:Username enumeration via response timing
实验目标:在错误信息完全统一的情况下,通过响应时间差异枚举出有效用户名,并爆破其密码登录。
原理:时间差从哪来
登录校验一般是这样的:
java
// 伪代码
User u = db.findByUsername(username);
if (u == null) {
return "Invalid username or password."; // 分支 1:查不到,直接返回
}
boolean ok = hash(password) == u.passwordHash; // 分支 2:算哈希!耗时操作
return ok ? "success" : "Invalid username or password.";
用户名存在时,服务器才会去做密码哈希比对(bcrypt / PBKDF2 这类慢哈希动辄几十到几百毫秒)。用户名不存在就直接返回了。
所以:响应时间 ≈ 用户名是否存在的信号。
怎么放大这个信号?用一个超长密码。密码越长,哈希运算越久,时间差越明显(从几十毫秒放大到几百毫秒甚至秒级)。
第一步:手工验证时间差
在登录界面分别输入正确和错误的用户名,Burp 的 HTTP history 里能看到每个请求的响应时间。
错误的(有点随机,但总体在 700ms 到 200ms 之间注意观察右下角):



正确的(用已知账号 wiener,明显有个递增的趋势):


这里没截全,他到达了2000millis

坑一:撞上防护机制,时间差不准确
⚠️ 踩坑:反复发包后撞上了防护机制,导致响应时间数据失真。

解决:在请求里加上 X-Forwarded-For 头,每次换一个 IP,绕过 IP 封锁。
第二步:用 Pitchfork 模式并发枚举
攻击类型选择 Pitchfork(草叉)------两个位置一一对应:
- payload 1 =
X-Forwarded-For的 IP(每次不同,绕过封锁) - payload 2 = 候选用户名(每次重复同一批,配合超长密码)
替换位置如下:

第一个 payload(IP 列表):

第二个 payload(用户名列表):

⚠️ 踩坑 :我一开始把 IP 范围设成 105~200,结果不够用。set 1 的数量必须大于等于 set 2 的数量,否则后面的用户名取不到对应的 IP,又会被封。
坑二:并行发包把时间差"抹平"了
⚠️ 踩坑 :跑完后发现并没有明显高出的一行------因为线程池是并行的,请求互相抢资源、排队,响应时间就不再反映服务器真实的计算耗时。

解决:在 Resource pool 里把 Maximum concurrent requests 设为 1 ,也就是单线程串行发包 ;同时把已知有效账号 wiener 加进列表当参考系(对照组)。

📌 方法论 :做时序攻击一定要设对照组 。没有
wiener这个已知有效账号做基准,你根本不知道"多少毫秒算高"。
第三步:排除网络抖动,逐个验证
由于是海外靶场,网络波动导致出现好几个都上 1000ms 的,无法直接判定。于是决定把 1000ms 以上的候选逐个手工验证:

Alaska 排除(确认是网络抖动,不是有效账号):

最终锁定 arlington(状态码是302):

登录成功:

实战二:Username enumeration via account lock
实验目标:利用"账户锁定"这一防护机制本身的逻辑缺陷,反向枚举出有效用户名,并爆破其密码。
原理:防护为什么会"泄密"
账户锁定是防护,但它自带信息泄露:
java
// 伪代码
if (!userExists(username)) {
return "Invalid username or password."; // 不存在的账号:永远不锁
}
if (failCount(username) >= 3) {
return "Account locked"; // 存在的账号:错 3 次就锁
}
只有真实存在的账号才有"失败计数器"。所以:
- 提交不存在的账号 → 不管试多少次,永远返回普通错误
- 提交存在的账号 → 试到第 4 次,返回"已锁定"
"会不会被锁"本身就是一道是非题 ,答案就是"这个账号存不存在"。这就是所谓的防护机制的元信息泄露。
第一步:在 Repeater 里摸清触发阈值
在 Repeater 里反复提交同一个用户名,观察第几次触发锁定。
先用假设的账号试了几次都没锁定------说明这个账号不存在;同时可以确认这条路子走得通:

第二步:用 Intruder 找出会"发火"的那个用户名
给每个候选用户名连续发 5 次请求(5 > 阈值,确保触发),观察哪一组的响应 Length 不一样。
结果找到用户名 ansible:

第三步:爆破密码,注意锁定的窗口期
标记 password 位置构造 payload:

⚠️ 踩坑 :这一关我试着加
X-Forwarded-For头,发现好像没什么用 ------换了 IP 照样触发锁定。说明这里锁的是账户维度,不是 IP 维度。但试的过程中发现一个规律:连续失败锁定后,等一会儿又解锁了,可以接着猜。

经过在 Repeater 里的反复实验,确认是连续 3 次失败触发锁定。
坑三:答案其实一直躺在响应长度里
⚠️ 踩坑 :绕了一大圈,最后才发现答案早就出来了------前面我没仔细看 Length:
Length 含义 3266 什么都没显示 → 这就是正确答案 3344 密码错误,且账号未锁定 3396 密码错误,且账号已锁定 所以直接看
Length = 3266的那一行,用户名ansible、密码joshua就出来了。

📌 心得 :先穷举信号,再动工具。状态码、Length、响应时间、报错文案------把每条信道都扫一遍再设计攻击,往往能省掉 80% 的弯路。我这一关就是先入为主认定"要用 XFF 绕过",反而忽略了最明显的 Length 信号。
每人的靶场实例不同,用户名 / 密码 / 域名请替换成你自己的。
原理对比:两种隐藏信道
| 维度 | 响应时间枚举 | 账户锁定枚举 |
|---|---|---|
| 信号来源 | 是否执行密码哈希运算 | 是否存在失败计数器 |
| 观测指标 | Response completed 耗时 | 响应长度 / 文案变化 |
| 放大手段 | 超长密码拉长哈希耗时 | 连续失败触发阈值 |
| 噪声来源 | 网络抖动、并发排队 | 锁定窗口期、解锁时间 |
| 关键配置 | Resource pool 单线程 | 锁定阈值 + 解锁周期 |
| 防御难度 | 高(需要恒定时间比较) | 中(需要统一锁定表现) |
两者的共同点:开发者只统一了"说了什么",没统一"做了什么"。
防守
- 恒定时间比较(Constant-time compare)
- 用户名不存在时,也执行一次等价的密码哈希运算(用一个假哈希比对),抹平时序差异
- 密码比对用
hash_equals()/MessageDigest.isEqual()这类恒定时间函数,避免因字符逐位比较而提前返回
- 锁定行为对无效用户名同样生效
- 对不存在的用户名也维护一个"影子计数器",返回一致的响应,让"会不会被锁"不再是判断题
- 统一错误文案与响应长度
- 文案、标点、长度、状态码全部一致(回看系列第一篇)
- 不要过度信任代理头
X-Forwarded-For必须由可信代理填充,后端不应接受客户端直传(回看本篇前置知识)
- 限速 + 告警
- 登录接口按 IP + 账号双维度限速,短时间大量失败触发安全告警
小结
- 攻击手法一句话 :当响应内容被统一时,改用"行为"做信号------要么看响应时间 (存在才做哈希运算),要么看账户锁定(存在才有失败计数器),两条信道都能反推出有效用户名。
- 这篇学到 :
X-Forwarded-For的原理与绕过用法、Resource pool 单线程对时序攻击的必要性、设对照组 (wiener)的实验方法,以及最重要的一条教训------先穷举信号,再动工具。 - 下篇预告 :防护再升级------如果 IP 会被直接封锁怎么办?下一篇打 Broken brute-force protection, IP block,用"每隔几次插一次正确登录"的手法重置计数器。