一、密码找回的重要性

1.1 为什么密码找回是高危入口

密码找回流程的独特性:

1. 用户不记得密码 → 无法使用原有认证方式
2. 服务端必须提供某种"替代验证"手段
3. 替代验证的安全性往往低于主登录流程
4. 一旦找回链接被滥用 = 直接接管账号

密码找回 = 没有密码就能登录的"后门"

1.2 攻击链路示例

常见密码找回攻击链:

信息收集 → 身份冒充 → Token 窃取 → 重置密码 → 账户接管
    │            │           │
    ▼            ▼           ▼
 泄露数据      修改邮箱    钓鱼邮件
 社工          短信劫持    Referer 泄露
               SIM Swap    日志泄露

二、漏洞一:重置链接可预测 / 可枚举

2.1 问题

重置链接中的 token 使用可预测的值(如时间戳 + user_id、自增 ID、弱随机数等)。

2.2 脆弱实现

import time
import hashlib

def generate_reset_token(user_id: int) -> str:
    # 🔴 可预测!只有 user_id + 时间戳,盐值固定
    data = f"{user_id}:{int(time.time())}"
    return hashlib.md5(data.encode()).hexdigest()

# 生成的 token 可被枚举
# 攻击者知道 user_id 和大致时间就能算出

2.3 枚举脚本

import time
import hashlib
import itertools

def generate_weak_token(user_id: int, timestamp: int) -> str:
    data = f"{user_id}:{timestamp}"
    return hashlib.md5(data.encode()).hexdigest()

def brute_force_token(user_id: int, target_hash: str, time_window: int = 3600):
    """在时间窗口内暴力破解 token"""
    now = int(time.time())

    for ts in range(now - time_window, now + time_window):
        candidate = generate_weak_token(user_id, ts)
        if candidate == target_hash:
            print(f"[+] Found! user_id={user_id}, timestamp={ts}")
            return True

    print(f"[-] Not found in {time_window * 2}s window")
    return False

2.4 安全实现

import secrets
from datetime import datetime, timedelta, timezone

def generate_reset_token(user_id: int) -> tuple[str, str, int]:
    """生成安全的重置 token,返回 (token, token_id, expires_at)"""
    token_id = secrets.token_hex(16)  # 128 位随机 ID
    token = secrets.token_urlsafe(32)  # 256 位随机 token
    expires_at = int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())

    # 服务端只存 token 的 hash,不存明文
    # 这样即使数据库泄露也无法直接使用
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    db.execute("""
        INSERT INTO password_reset_tokens
            (token_id, user_id, token_hash, expires_at, used)
        VALUES (?, ?, ?, ?, false)
    """, (token_id, user_id, token_hash, expires_at))

    return token, token_id, expires_at

# 链接格式:/reset-password?token={token}&rid={token_id}
# token 作为凭据,token_id 用于在数据库中查找

三、漏洞二:重置链接不过期

3.1 问题

重置链接永久有效,或有效期长达几天/几周。

3.2 攻击场景

攻击者通过钓鱼邮件让受害者点击重置链接,然后:

  • 受害者还没来得及使用,链接被攻击者的浏览器插件自动记录
  • 1 周后攻击者用这个"永久有效"的链接重置密码
  • 受害者密码被改掉,账户被接管

3.3 修复

def validate_reset_token(token: str) -> tuple[bool, str, dict | None]:
    """校验重置 token 的有效性"""
    # 1. 查找 token 记录
    token_hash = hashlib.sha256(token.encode()).hexdigest()
    row = db.execute("""
        SELECT * FROM password_reset_tokens
        WHERE token_hash = ?
    """, (token_hash,)).fetchone()

    if not row:
        return False, 'invalid token', None

    # 2. 检查是否过期
    now = int(time.time())
    if row['expires_at'] < now:
        return False, 'token expired', None

    # 3. 检查是否已使用
    if row['used']:
        return False, 'token already used', None

    # 4. 检查是否被撤销(如用户主动取消)
    if row['revoked']:
        return False, 'token revoked', None

    return True, 'valid', row

@app.route('/api/reset-password', methods=['POST'])
def reset_password():
    token = request.json['token']
    new_password = request.json['new_password']

    valid, reason, data = validate_reset_token(token)
    if not valid:
        return jsonify({'error': reason}), 400

    # 重置密码
    user = db.get_user_by_id(data['user_id'])
    user.password_hash = argon2.hash_password(new_password)
    user.password_reset_at = datetime.now()
    db.save(user)

    # 🔑 关键:立即标记 token 为已使用
    db.execute("""
        UPDATE password_reset_tokens
        SET used = true
        WHERE token_id = ?
    """, (data['token_id'],))

    # 🔑 使该用户所有其他重置 token 失效
    db.execute("""
        UPDATE password_reset_tokens
        SET revoked = true
        WHERE user_id = ? AND used = false
    """, (data['user_id'],))

    # 🔑 使所有现有会话失效
    session_manager.invalidate_all_sessions(data['user_id'])

    return jsonify({'status': 'ok', 'message': 'password reset successful'})

四、漏洞三:重置流程跳过验证

4.1 场景一:直接用 user_id 重置

# 攻击者直接请求重置,绕过发送验证邮件的步骤
POST /api/reset-password
Content-Type: application/json

{
  "user_id": 42,
  "new_password": "attacker123"
}
# 直接重置成功!没有要求 token

4.2 场景二:修改邮箱字段

POST /api/forgot-password
Content-Type: application/json

{
  "email": "victim@example.com",
  "target_email": "attacker@evil.com"  # 🔴 攻击者修改了目标邮箱
}
# 重置链接被发到了攻击者邮箱

4.3 场景三:邮箱参数篡改

# 🔴 脆弱实现
@app.route('/api/forgot-password', methods=['POST'])
def forgot_password():
    email = request.json['email']
    target_email = request.json.get('target_email', email)  # 允许覆盖!

    user = db.get_user_by_email(email)
    if user:
        token, _, _ = generate_reset_token(user.id)
        send_email(target_email, token)  # 发送到攻击者指定的地址

    return jsonify({'status': 'ok'})

# ✅ 修复
@app.route('/api/forgot-password', methods=['POST'])
def forgot_password():
    email = request.json['email']

    # 🔑 忽略客户端传入的任何 target_email
    user = db.get_user_by_email(email)

    if user:
        # 🔑 始终发送到用户数据库中登记的邮箱
        token, _, _ = generate_reset_token(user.id)
        send_email(user.email, token)

    # 统一响应,不暴露邮箱是否存在
    return jsonify({'status': 'ok'})

五、漏洞四:重置链接可重放

5.1 问题

重置密码后 token 没有失效,攻击者可以重复使用同一个 token 重置密码。

5.2 攻击场景

1. 攻击者通过钓鱼让受害者点击重置链接并成功设置新密码
2. 攻击者用同一个链接再次重置密码
3. 受害者设置的新密码也被攻击者改掉
4. 因为密码频繁变化,受害者可能怀疑自己记错了密码
5. 攻击者持续通过重放控制账户

5.3 修复

上面的代码已经展示了正确做法:密码重置成功后立即标记 token 为 used = true

此外还应:

  • 每次登录成功后撤销所有未使用的重置 token
  • 发送新重置 token 时撤销该用户之前所有未使用的 token

六、漏洞五:重置流程无速率限制

6.1 问题

攻击者可以无限请求重置链接,导致:

  • 邮箱被淹没(DoS)
  • 尝试枚举注册邮箱

6.2 防护

@app.route('/api/forgot-password', methods=['POST'])
def forgot_password():
    email = request.json['email']
    ip = request.remote_addr

    # 速率限制:每邮箱 3 次/小时
    email_key = f'forgot_rate:email:{email}'
    email_count = redis.incr(email_key)
    redis.expire(email_key, 3600)

    if email_count > 3:
        return jsonify({'error': 'too many requests, try again later'}), 429

    # 速率限制:每 IP 10 次/小时
    ip_key = f'forgot_rate:ip:{ip}'
    ip_count = redis.incr(ip_key)
    redis.expire(ip_key, 3600)

    if ip_count > 10:
        # 触发图形验证码
        captcha_token = request.json.get('captcha_token')
        if not captcha_token or not verify_captcha(captcha_token):
            return jsonify({'error': 'captcha required'}), 400

    # 发送重置邮件...

七、漏洞六:重置链接通过 HTTP 传输

7.1 攻击演示

攻击者在同一网络(咖啡厅 Wi-Fi)中监听受害者的流量:

1. 受害者点击了重置链接
2. HTTP 请求经过时被攻击者拦截
3. 攻击者从 URL 中提取 token
4. 用 token 重置密码 → 接管账号

7.2 修复

# 强制 HTTPS
from flask_talisman import Talisman

Talisman(app, force_https=True)

# 或使用 HSTS
@app.after_request
def add_hsts(response):
    response.headers['Strict-Transport-Security'] = (
        'max-age=31536000; includeSubDomains; preload'
    )
    return response

# 重定向 HTTP → HTTPS
@app.before_request
def force_https():
    if not request.is_secure:
        url = request.url.replace('http://', 'https://')
        return redirect(url, code=301)

八、漏洞七:Referer / URL 参数泄露 token

8.1 问题

重置链接中的 token 是 URL 参数形式:https://example.com/reset?token=abc123

当用户在重置密码页面点击外部链接时,token 会泄露到 Referer 头中。

8.2 攻击场景

受害者在重置密码页面:https://example.com/reset?token=LEAKME

页面底部有个外链:
  <a href="https://evil.com/ads">赞助广告</a>

受害者不小心点击 → 浏览器发送:
  GET https://evil.com/ads
  Referer: https://example.com/reset?token=LEAKME  ← token 泄露!

攻击者从 evil.com 的访问日志中拿到 token

8.3 修复一:POST 表单提交 token

<!-- 不把 token 放在 URL 里 -->
<form method="POST" action="/reset-password">
  <input type="hidden" name="token" value="{{ token }}" />
  <!-- token 在 request body 中,不会出现在 Referer -->
  <input type="password" name="new_password" />
  <button type="submit">Reset</button>
</form>

8.4 修复二:将 token 存入 session / Cookie

@app.route('/reset', methods=['GET'])
def reset_page():
    # token 只在 URL 中出现一次(用于验证身份)
    token = request.args.get('token')
    valid, reason, data = validate_reset_token(token)

    if not valid:
        return render_template('error.html', error=reason)

    # 把 token 存入 session(HttpOnly Cookie),然后 302 到无 token 的页面
    session['reset_token'] = token
    return redirect('/reset-password', code=302)

@app.route('/reset-password', methods=['GET', 'POST'])
def reset_password_page():
    # 从 session 中取 token(不在 URL 中)
    token = session.get('reset_token')
    if not token:
        return redirect('/forgot-password')

    if request.method == 'POST':
        new_password = request.form.get('new_password')
        # ... 重置逻辑
        session.pop('reset_token', None)
        return redirect('/login')

    return render_template('reset-form.html')

8.5 修复三:设置 Referrer-Policy

# 即使 token 在 URL 中,也让浏览器不发送 Referer
response.headers['Referrer-Policy'] = 'no-referrer'
# 或 'same-origin' —— 只在同源时发送 Referer

九、漏洞八:邮箱可被篡改 → 钓鱼陷阱

9.1 场景

攻击者在密码重置流程中,通过某个漏洞修改了用户登记的邮箱,于是重置邮件被发送到攻击者控制的邮箱。

9.2 防护

# 1. 修改邮箱需要二次验证
@app.route('/api/account/email', methods=['PUT'])
def change_email():
    current_password = request.json.get('current_password')
    new_email = request.json.get('new_email')

    user = get_current_user()

    # 必须验证当前密码
    if not argon2.verify(current_password, user.password_hash):
        return jsonify({'error': 'invalid password'}), 401

    # 不能改成已注册的邮箱
    if db.get_user_by_email(new_email):
        return jsonify({'error': 'email already registered'}), 400

    # 发送验证邮件到新邮箱(用户必须点击确认链接才生效)
    verification_token = secrets.token_urlsafe(32)
    redis.setex(f'email_change:{user.id}', 86400, verification_token)
    send_email(new_email, f'Confirm email change: https://example.com/verify-email?token={verification_token}')

    return jsonify({'status': 'ok', 'message': 'verification email sent'})

# 2. 密码重置后撤销邮箱修改请求
@app.route('/api/reset-password', methods=['POST'])
def reset_password():
    # ... 重置密码成功后:
    redis.delete(f'email_change:{user_id}')
    # 撤销所有活跃的邮箱修改请求

十、漏洞九:CSRF 攻击重置密码

10.1 攻击场景

如果重置密码接口只依赖 Cookie 认证:

<!-- evil.com 上的页面 -->
<form action="https://example.com/api/reset-password" method="POST">
  <input type="hidden" name="new_password" value="hacked123" />
</form>
<script>document.forms[0].submit();</script>

<!-- 受害者已登录 example.com,浏览器自动带 Cookie -->
<!-- 攻击者不需要知道旧密码就能修改 -->

10.2 修复

# 重置密码(使用旧密码)需要 CSRF Token
@app.route('/api/account/password', methods=['PUT'])
@require_csrf_token  # 强制 CSRF 校验
def change_password():
    # ...

# 密码找回(使用重置 token)是一次性流程,天然有 token 防护
# 但也要确保 token 不能被跨站请求利用

# 使用 SameSite Cookie
response.set_cookie('session', value, samesite='Strict', secure=True)

十一、漏洞十:社会工程学 → 安全问题绕过

11.1 经典社工问题

忘记密码页上的安全问题:
- 你母亲的娘家姓? → 公开信息
- 你高中就读的学校? → 公开信息
- 你第一辆车的品牌? → 公开信息
- 你出生的城市? → 公开信息
- 你最喜欢的宠物名字? → 可能在社交网络找到

11.2 脆弱实现

@app.route('/forgot/security-question', methods=['POST'])
def answer_security_question():
    username = request.json['username']
    answer = request.json['answer']

    user = db.get_user_by_username(username)

    # 🔴 明文比较,且答案可能被猜到
    if user.security_question_answer == answer.lower():
        # 直接发送重置链接
        token = generate_reset_token(user.id)
        send_reset_link(user.email, token)
        return jsonify({'status': 'ok'})

    return jsonify({'error': 'wrong answer'}), 400

11.3 修复

# 1. 尽量不用安全问题,或只作为辅助验证
# 2. 安全问题的答案也要哈希存储
import argon2

# 存储答案(哈希)
user.security_answer_hash = argon2.hash_password(answer.lower())

# 验证时
if argon2.verify(answer.lower(), user.security_answer_hash):
    # ...
    pass

# 3. 强制多因素:安全问题 + 短信
@app.route('/forgot/multi-step', methods=['POST'])
def multi_step_forgot():
    step = request.json['step']  # 'security_question' or 'sms_code'

    if step == 'security_question':
        # 验证安全问题 → 发送短信到已验证手机
        # 不能独立完成重置

    if step == 'sms_code':
        # 验证短信 → 最后发送重置链接
        # 必须两步都通过

十二、完整的密码找回安全流程

┌─────────────────────────────────────────────────────────┐
│                 Password Reset Flow                     │
├─────────────────────────────────────────────────────────┤
│  1. 用户请求重置                                         │
│     POST /api/forgot-password                           │
│     - 速率限制(邮箱/IP 维度)                           │
│     - 第 N 次触发图形验证码                              │
│     - 统一响应(不暴露邮箱是否存在)                     │
│     - 生成强随机 token(256 位)                         │
│     - token SHA256 哈希存入 DB                           │
│     - 发送到用户登记的邮箱(无法被覆盖)                 │
├─────────────────────────────────────────────────────────┤
│  2. 用户点击邮件链接                                     │
│     GET /reset?token=xxx                                │
│     - 校验 token 是否有效 / 过期 / 已使用               │
│     - 校验通过后把 token 存入 session Cookie            │
│     - 302 重定向到 /reset-form(URL 中无 token)        │
├─────────────────────────────────────────────────────────┤
│  3. 用户提交新密码                                       │
│     POST /api/reset-password                            │
│     - 从 session 取 token(不从 URL 取)                │
│     - 校验 token 有效性                                 │
│     - 校验新密码强度 + HaveIBeenPwned                   │
│     - 密码哈希存入 DB(Argon2id)                        │
│     - 🔑 标记 token 为已使用                             │
│     - 🔑 撤销该用户所有未使用的重置 token                │
│     - 🔑 使该用户所有现有会话失效                        │
│     - 🔑 撤销所有活跃的邮箱修改请求                      │
│     - 从 session 中清除 token                            │
│     - 发送密码变更通知邮件                               │
└─────────────────────────────────────────────────────────┘

十三、密码找回审计清单

  • 重置 token 是否为强随机(>=256 位)
  • token 是否有过期时间(建议 <=1 小时)
  • token 是否一次性使用
  • 密码重置后是否撤销所有重置 token
  • 密码重置后是否使所有会话失效
  • 重置流程是否有速率限制(邮箱/IP 维度)
  • 是否统一响应(不暴露邮箱是否存在)
  • token 是否不通过 URL 传输完整流程(存入 session/Cookie)
  • Referrer-Policy 是否设置为防止 token 泄露
  • 是否强制 HTTPS
  • 是否有邮箱修改的二次验证
  • 安全问题答案是否哈希存储
  • 是否提供了更安全的找回方式(SMS + 设备验证)

十四、总结

密码找回流程是"无密码的身份验证"——一旦这个环节出问题,等于主认证直接被绕过。核心防御原则:

  1. token 必须强随机 + 短期有效 + 一次性使用(三重保障)
  2. 客户端不可信——忽略所有 target_email / user_id / target 等客户端传入的定位参数
  3. 重置成功后立即清理——撤销 token + 失效会话 + 通知用户
  4. 安全问题尽量废弃——改用短信 + 设备指纹 + 风险评分

十五、参考资料

  • OWASP Password Recovery Cheat Sheet
  • PortSwigger Password Reset Poisoning
  • NIST SP 800-63B Digital Identity Guidelines
  • Krebs on Security: Twitter Password Reset Hack
  • GitHub: Reset Token Best Practices
  • CWE-640: Weak Password Recovery Mechanism