找回密码逻辑漏洞全解析

"忘记密码"功能的安全水平,决定了攻击者能否在不知道密码的情况下接管你的账号。现实中,这条链路经常漏洞百出。

一、标准找回密码流程

用户点击"忘记密码"
    │
    ▼
输入手机号/邮箱/用户名
    │
    ▼
系统发送验证码/重置链接
    │
    ▼
用户输入验证码/点击链接
    │
    ▼
验证通过 → 设置新密码

二、漏洞全景图

① 身份确认漏洞 ──┐
② 验证码/链接发送 ──┤
③ 验证环节绕过 ────┤
④ 新密码设置环节 ──┘

三、身份确认漏洞

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 攻击链

  1. 攻击者先在目标网站注册一个账号
  2. 修改邮箱时,把目标邮箱填进去,触发"验证新邮箱"邮件
  3. 邮件里的链接有 CSRF 漏洞——点击后直接生效
  4. 攻击者把这个链接伪装在钓鱼邮件里发给目标
  5. 目标点击 → 邮箱被篡改为攻击者控制的邮箱
  6. 然后走"找回密码"流程接管账号

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;不记录日志
密码设置 强度校验;不能与历史密码相同
生效后处理 踢出所有会话;通知原绑定邮箱/手机
审计 记录找回操作日志;异常频率告警

十、总结

找回密码功能的核心矛盾是:它既是"忘记了密码"时的救命稻草,也是攻击者最常用的账号接管通道。

设计原则:

  1. 多重验证:至少两个独立因子(如手机验证码 + 邮箱确认)
  2. 最小权限:重置链接只能做一件事——设置新密码
  3. 可审计:每一步操作都要留痕
  4. 快速失效:链接短期有效;使用后立即作废