找回密码逻辑漏洞全解析
"忘记密码"功能的安全水平,决定了攻击者能否在不知道密码的情况下接管你的账号。现实中,这条链路经常漏洞百出。
一、标准找回密码流程
用户点击"忘记密码"
│
▼
输入手机号/邮箱/用户名
│
▼
系统发送验证码/重置链接
│
▼
用户输入验证码/点击链接
│
▼
验证通过 → 设置新密码
二、漏洞全景图
① 身份确认漏洞 ──┐
② 验证码/链接发送 ──┤
③ 验证环节绕过 ────┤
④ 新密码设置环节 ──┘
三、身份确认漏洞
3.1 漏洞:通过用户名枚举手机号
// VULNERABLE: 找回密码接口泄露手机号
app.post('/api/password/forgot', async (req, res) => {
const { username } = req.body;
const user = await db.User.findOne({ where: { username } });
if (!user) {
// ❌ 不同的错误消息——泄露了用户名是否存在
return res.status(404).json({ error: '用户不存在' });
}
// 更过分:直接把脱敏手机号返回给前端
res.json({
success: true,
phone: user.phone.replace(/(d{3})d{4}(d{4})/, '$1****$2'), // 138****8000
});
});
3.2 修复
// FIXED: 统一响应,不泄露任何信息
app.post('/api/password/forgot', async (req, res) => {
const { identifier } = req.body; // 可以是用户名、手机号、邮箱
// ✅ 统一返回,不管用户存不存在
await rateLimitCheck(req.ip); // 仍然需要频率限制
const user = await db.User.findOne({
where: { $or: [{ username: identifier }, { phone: identifier }, { email: identifier }] },
});
if (user) {
// 只在用户存在时发送,防止攻击者用不存在的账号刷短信
await sendResetCode(user);
}
// ✅ 不管存不存在都返回相同消息
res.json({
success: true,
message: '如果该账号存在,重置信息已发送',
});
});
四、重置链接劫持
4.1 场景一:邮箱重置链接被中间人截获
// VULNERABLE: 重置链接用 http 而非 https
const resetUrl = `http://example.com/reset?token=${token}`;
await sendEmail(user.email, resetUrl);
// ❌ HTTP 明文传输,中间人可以截获
4.2 场景二:Referer 泄露
// VULNERABLE: 重置页面有外链,Referer 会带上 token
app.get('/reset', async (req, res) => {
const { token } = req.query;
// 用户点击页面上的外链,Referer 会是 https://example.com/reset?token=xxx
res.send(`
<form>...</form>
<a href="https://evil.com">帮助中心</a> <!-- ❌ Referer 会带 token -->
`);
});
// FIXED
app.get('/reset', async (req, res) => {
res.setHeader('Referrer-Policy', 'no-referrer'); // ✅ 不发送 Referer
res.send(`...`);
});
4.3 场景三:重置链接存 URL 历史
浏览器会把访问过的 URL 存到历史记录里,如果重置链接出现在:
- 浏览器历史
- 浏览器扩展同步
- 共享电脑的历史记录
- 第三方统计脚本
都会泄露 token。
修复:重置链接用 POST + 表单,不用 GET 参数。
// FIXED: 用 POST 提交 token,不暴露在 URL 中
app.post('/api/password/reset', async (req, res) => {
const { token, newPassword } = req.body; // ✅ token 在 body 里,不在 URL
// ...
});
// 前端:把 token 藏在隐藏表单字段
<form action="/api/password/reset" method="POST">
<input type="hidden" name="token" value="{{token}}">
<input type="password" name="newPassword">
<button type="submit">重置</button>
</form>
五、重置链接与密码重置接口脱节
5.1 漏洞描述
用户通过合法流程拿到了重置链接 A,但攻击者用自己的验证码申请了重置链接 B——然后用 A 的 token + B 的接口请求重置密码。如果后端没有严格绑定,就会发生混乱。
5.2 经典漏洞:重置接口不验证 token 归属
// VULNERABLE
app.post('/api/password/reset', async (req, res) => {
const { token, newPassword } = req.body;
const resetRecord = await db.PasswordReset.findOne({ where: { token } });
if (!resetRecord) {
return res.status(400).json({ error: '链接无效' });
}
// ❌ 没有把 token 和 userId 绑定检查
// 攻击者可以用 A 的 token + 任意新密码重置 A 的账号
// 但更可怕的是——如果 token 不验证归属,甚至可以跨账号
await db.User.update(
{ password: hash(newPassword) },
{ where: { id: resetRecord.userId } } // 只看 token 里存的 userId
);
await resetRecord.update({ used: true });
res.json({ success: true });
});
5.3 修复:token 必须包含完整信息 + 签名
// FIXED: token 是一个带签名的 JWT,包含 userId + 过期时间 + 操作类型
async function generateResetToken(userId) {
const tokenId = crypto.randomUUID();
const token = jwt.sign(
{
type: 'password_reset',
userId,
tokenId,
exp: Math.floor(Date.now() / 1000) + 600, // 10 分钟有效
jti: tokenId, // 唯一标识,用于一次性校验
},
JWT_SECRET
);
await db.PasswordReset.create({ tokenId, userId, expireAt: new Date(Date.now() + 600000) });
return token;
}
app.post('/api/password/reset', async (req, res) => {
const { token, newPassword } = req.body;
// 1. 验证 JWT 签名和过期
let payload;
try {
payload = jwt.verify(token, JWT_SECRET);
} catch (err) {
return res.status(400).json({ error: '链接无效或已过期' });
}
if (payload.type !== 'password_reset') {
return res.status(400).json({ error: '链接类型错误' });
}
// 2. 一次性使用校验
const record = await db.PasswordReset.findOne({
where: { tokenId: payload.jti, used: false },
});
if (!record) {
return res.status(400).json({ error: '链接已使用或无效' });
}
// 3. 密码强度校验
if (!isStrongPassword(newPassword)) {
return res.status(400).json({ error: '密码强度不足' });
}
// 4. 原子更新
await db.sequelize.transaction(async (t) => {
await record.update({ used: true, usedAt: new Date() }, { transaction: t });
await db.User.update(
{
password: hash(newPassword),
passwordChangedAt: new Date(),
},
{ where: { id: payload.userId }, transaction: t }
);
});
// 5. 踢出所有已登录会话
await db.Session.destroy({ where: { userId: payload.userId } });
res.json({ success: true, message: '密码已重置,请重新登录' });
});
六、安全问题绕过
6.1 漏洞描述
使用安全问题(母亲娘家姓、初恋名字)作为找回密码的第二步。但这类信息很容易通过社工/社交媒体猜到。
6.2 攻击方式
Facebook 安全问题:你在哪个城市认识了你最好的朋友?
→ 答案就在用户的 Facebook 资料里
What was your first car?
→ 可能在 Twitter/朋友圈提过
母亲的 maiden name(娘家姓)
→ 某些国家/地区的公共记录可以查到
6.3 修复
- 降低权重:安全问题只能作为辅助验证,不能单独使用
- 增加因子:必须配合短信/邮箱验证码才能重置
- 行为验证:从常用设备/IP 发起找回请求才允许
七、多租户场景的找回漏洞
7.1 漏洞:用户信息跨租户泄露
// VULNERABLE
app.post('/api/password/forgot', async (req, res) => {
const { email } = req.body;
// ❌ 没有加 tenantId 过滤
const users = await db.User.findAll({ where: { email } });
// 如果不同租户有相同邮箱,会返回多个结果
res.json({ success: true, matches: users.map(u => u.username) });
});
// FIXED: 必须限定租户 + 统一响应
app.post('/api/password/forgot', async (req, res) => {
const { email } = req.body;
const tenantId = req.headers['x-tenant-id'];
if (tenantId) {
const user = await db.User.findOne({ where: { email, tenantId } });
if (user) await sendResetToken(user);
}
res.json({ success: true, message: '如果账号存在,重置信息已发送' });
});
八、登录邮箱劫持:钓鱼链接 + CSRF
8.1 攻击链
- 攻击者先在目标网站注册一个账号
- 修改邮箱时,把目标邮箱填进去,触发"验证新邮箱"邮件
- 邮件里的链接有 CSRF 漏洞——点击后直接生效
- 攻击者把这个链接伪装在钓鱼邮件里发给目标
- 目标点击 → 邮箱被篡改为攻击者控制的邮箱
- 然后走"找回密码"流程接管账号
8.2 漏洞代码
// VULNERABLE: 邮箱验证链接没有二次确认
app.get('/verify-email', async (req, res) => {
const { token } = req.query;
const record = await db.EmailVerify.findOne({ where: { token } });
if (!record) return res.status(400).json({ error: '链接无效' });
// ❌ 直接生效,不需要登录态确认
await db.User.update(
{ email: record.newEmail, emailVerified: true },
{ where: { id: record.userId } }
);
await record.destroy();
res.send('邮箱验证成功');
});
8.3 修复
// FIXED: 邮箱验证需要登录态 + CSRF token
app.get('/verify-email', authMiddleware, async (req, res) => {
const { token } = req.query;
const record = await db.EmailVerify.findOne({ where: { token } });
if (!record) return res.status(400).json({ error: '链接无效' });
// ✅ 验证当前登录用户和 token 归属一致
if (record.userId !== req.user.id) {
return res.status(403).json({ error: '链接不属于当前用户' });
}
// ✅ 二次密码确认
const { password } = req.body;
if (!verifyPassword(password, req.user.password)) {
return res.status(400).json({ error: '密码错误' });
}
await db.User.update(
{ email: record.newEmail, emailVerified: true },
{ where: { id: record.userId } }
);
await record.destroy();
res.json({ success: true });
});
九、找回密码安全清单
| 环节 | 检查项 |
|---|---|
| 身份确认 | 统一响应,不泄露用户是否存在 |
| 验证码发送 | 只对已存在用户发送;频率限制;防止短信轰炸 |
| Token 设计 | JWT 签名;包含 userId;10 分钟过期;一次性使用 |
| Token 传输 | 不用 GET;HTTPS;禁用 Referer;不记录日志 |
| 密码设置 | 强度校验;不能与历史密码相同 |
| 生效后处理 | 踢出所有会话;通知原绑定邮箱/手机 |
| 审计 | 记录找回操作日志;异常频率告警 |
十、总结
找回密码功能的核心矛盾是:它既是"忘记了密码"时的救命稻草,也是攻击者最常用的账号接管通道。
设计原则:
- 多重验证:至少两个独立因子(如手机验证码 + 邮箱确认)
- 最小权限:重置链接只能做一件事——设置新密码
- 可审计:每一步操作都要留痕
- 快速失效:链接短期有效;使用后立即作废