密码重置这个功能,几乎是每个网站的标配。但恰恰是这种看起来平平无奇的功能,往往藏着一些让人哭笑不得的安全漏洞。今天咱们就从一个非常典型的场景聊起:验证码被拿到前端去校验,加密传输链路又没做好,再加上动态令牌和会话绑定不严谨,这三件事凑到一起,到底有多危险?修复策略又该怎么落地?咱们不堆术语,就用大白话把这件事掰开揉碎了讲清楚。
一、先聊聊这个漏洞是怎么回事
1.1 验证码放在前端校验的问题
很多开发同学在写密码重置的时候,脑子里想的是“用户要重置密码,得先证明这个手机号或邮箱是他的”。于是他们搞了个验证码发送功能,把验证码发到用户手机或邮箱里,然后让用户填回来。问题出在哪儿呢?有些实现为了图省事,把“验证码对不对”这个判断直接写在了前端页面里。比如后端把验证码返回给了前端,前端自己比较用户输入的验证码是否相等,相等就调重置密码接口,不相等就提示错误。
你想想看,验证码本身是后端生成的,然后通过短信或邮件发给用户,这个过程中后端明明可以自己保存一份,等用户提交的时候再比对。但如果后端把验证码一股脑塞给了前端,那用户只要打开浏览器调试工具,就能看到这个验证码,甚至可以直接绕过前端校验,直接去请求重置密码的接口。这种漏洞的本质,就是把本该由服务器判定的安全逻辑,移交给了客户端。可是客户端是完全不可信的,用户能看到的代码和内存,攻击者同样能看到。
1.2 加密传输链路没做好
第二个问题是传输链路。现在很多网站还是用HTTP,或者虽然用了HTTPS但部分接口没接好,比如验证码接口是HTTP,重置密码接口又是HTTPS。这会带来什么问题?验证码和重置密码的请求在网络上裸奔,中间任何一台路由器、WiFi热点、运营商设备,都可以截获这些数据。攻击者不需要知道你输入的密码是什么,他只需要截获你手机收到的验证码,或者截获你提交的请求,就能冒充你完成密码重置。
可能有人会说,现在HTTP不是会被浏览器警告吗?但很多App或者自定义客户端,或者某些老旧系统的内部调用,并不走浏览器,也就没有警告。而且就算用了HTTPS,如果证书校验不严格,或者存在中间人攻击的情况,同样可能被窃听。加密传输链路的关键在于,从客户端到服务器之间,整条路径上的数据都应该被加密,不能有明文环节。更细节的还有防重放的问题,即使加密了,攻击者如果抓取到一个合法的重置请求,能不能再次发送?这涉及到时间戳、随机数、一次性令牌等机制。
1.3 动态令牌绑定会话的修复策略
再来说说动态令牌。为了增强安全性,很多系统会给密码重置流程生成一个临时的token(令牌),这个token会发到用户邮箱或手机里,用户带着这个token去请求重置密码。这个想法是好的,但如果token跟会话(session)没有绑定,或者token本身是静态的、可预测的,那攻击者只要偷到token就能用。更过分的是,有些系统生成token之后,不管用户是谁,也不管在哪个浏览器上访问,只要是这个token就都能过。这时候token就像一把万能钥匙,谁捡到都能开门。
所谓“绑定会话”,指的是token应该和用户当前的会话ID、IP地址、User-Agent等环境信息绑定在一起。如果攻击者拿着你的token从他的浏览器里访问,因为会话ID不同,服务器就应该拒绝。但现实中很多实现没这么做,导致token被窃取后可以任意使用。那么,修复策略是否到位?咱们来看下面的实际场景和代码示例。
二、真实场景下的攻击路径
2.1 假设一个常见的密码重置流程
为了讲清楚,咱们模拟一个很典型的流程,用的技术栈是Python的Flask框架(当然,其他语言也类似)。假设这个网站有一个密码重置功能,流程是这样的:
- 用户输入注册时的手机号,点击“获取验证码”。
- 后端生成一个6位数字验证码,并调用短信服务发送给用户。
- 后端把这个验证码直接以JSON格式返回给前端(这就是大坑)。
- 用户在页面上输入收到的验证码和新的密码,前端先检查验证码是否匹配,匹配了就向
/reset_password发出请求,把手机号、验证码、新密码一起提交。 - 后端接到请求后,再检查验证码是否正确(因为前端已经检查过了,后端可能会忽略这个检查),然后更新密码。
攻击者只需要注册一个自己的手机号,走到获取验证码那一步,就能从接口响应里看到验证码,根本不需要等短信。然后他随便填一个新密码,就能改掉自己的密码。这看起来没什么,但如果攻击者知道别人的手机号,他可以用别人的手机号发起重置,然后从响应里拿到那个验证码,直接把别人的密码改掉。
2.2 攻击者怎么一步步绕过
攻击者会用工具(比如Burp Suite)直接拦截请求,他不需要打开页面,直接模拟HTTP请求就能完成整个攻击。具体步骤:
第一步,请求/get_code,参数是phone=13900000000(受害者的手机号)。后端响应的JSON里包含code=483920。攻击者拿到了这个验证码。
第二步,攻击者直接构造一个POST请求到/reset_password,带上phone=13900000000、code=483920、new_password=Hacker123。
第三步,后端如果只简单比对了一下这个验证码是不是跟之前发送的一致,没错,一致,那就更新密码。攻击者成功篡改了受害者的密码。
整个过程甚至不需要经过前端页面。这就是验证码回到前端校验之后,攻击者可以完全无视前端逻辑的原因。如果传输链路还是HTTP,攻击者甚至不需要调接口,直接抓包就能看到所有明文数据。更严重的是,有些系统没有对请求做频率限制,攻击者可以暴力枚举验证码,这又是另一个漏洞了。
三、修复方案实例演示
3.1 技术栈说明
我们这次的所有示例统一使用 Python 的 Flask 框架,配合 PyJWT 来生成和校验令牌,使用 Redis 来存储验证码和会话绑定关系。示例代码会带有详细注释,方便你理解每一步在做什么。
# 请先安装依赖:
# pip install flask pyjwt redis requests
from flask import Flask, request, jsonify
import jwt
import redis
import random
import time
import hashlib
import secrets
app = Flask(__name__)
app.secret_key = "这是一个非常重要的密钥,实际请用环境变量"
# 连接Redis,用于存储验证码和令牌信息
r = redis.Redis(host='localhost', port=6379, db=0)
# 假设有一个数据库表:users (phone, password_hash)
# 这里为了演示,用一个dict代替,实际请用数据库
USERS_DB = {
"13900000000": {"password_hash": "old_password_hash"}
}
3.2 修复第一步:验证码必须后端校验
第一个要修复的,就是把验证码的校验彻底挪到后端来做。前端只负责收集用户输入的验证码,然后提交给后端,后端再和Redis里保存的验证码比对。同时,验证码必须是一次性的,用过就删除,而且还要设置过期时间,比如5分钟内有效。
# 发送验证码接口
@app.route('/send_code', methods=['POST'])
def send_code():
"""向后端请求发送验证码,验证码仅存在服务端,绝不返回给前端"""
data = request.get_json()
phone = data.get("phone")
# 检查手机号是否已注册(业务需要)
if phone not in USERS_DB:
return jsonify({"error": "该手机号未注册"}), 404
# 生成6位数字验证码
code = f"{random.randint(0, 999999):06d}"
# 将验证码存入Redis,键名带手机号,5分钟过期
key = f"reset_code:{phone}"
r.setex(key, 300, code)
# 这里调用真实的短信服务接口,把code发给用户手机
# send_sms(phone, f"您的验证码是:{code},5分钟内有效")
# 为了演示,我们打印到控制台
print(f"向 {phone} 发送验证码:{code}")
# 注意:响应体中不包含code!
return jsonify({"message": "验证码已发送"}), 200
# 验证码校验(内部函数)
def verify_code(phone, code):
"""取出Redis中保存的验证码,比对并删除(一次性)"""
key = f"reset_code:{phone}"
saved_code = r.get(key)
if not saved_code:
return False
# 防止时序攻击,使用固定时间比较
if secrets.compare_digest(saved_code.decode(), code):
r.delete(key) # 用掉后立即删除
return True
return False
这里的关键点是:验证码永远不会出现在响应里,攻击者无法从接口响应中直接获取。而且验证码用过即焚,即使被截获了,也来不及重复利用。你可能注意到,我们用了secrets.compare_digest来对比验证码,这是为了避免通过时间差异来猜测验证码,属于安全编码的细节。
3.3 修复第二步:加密传输和防重放
加密传输这个事,第一要务是让整个站点都换上HTTPS,并且强制跳转。然后在代码层面,我们还需要防止重放攻击。什么叫重放?攻击者抓到你提交的合法请求,然后原封不动地再发送一遍,服务器如果不做校验,就相当于答应了两次。解决办法是在请求中加一个nonce(随机数)或者时间戳,服务端判断这个nonce是否已经用过,以及时间戳是否在合理范围内。
我们可以在生成重置令牌的时候,把nonce和过期时间也考虑进去。下面用PyJWT生成一个带过期时间和jti(唯一标识)的令牌,同时将这个jti存到Redis里,标记为已使用。这样即使请求被重放,第二次提交时服务端发现jti已经存在,就会拒绝。
# 生成带防重放的令牌
def generate_reset_token(phone, session_id):
"""生成JWT令牌,并绑定会话ID,同时写入jti用于防重放"""
jti = secrets.token_hex(16) # 随机唯一ID
now = int(time.time())
payload = {
"sub": phone, # 手机号
"jti": jti, # 唯一ID,防重放
"session": session_id, # 绑定会话ID
"iat": now, # 签发时间
"exp": now + 300 # 5分钟后过期
}
token = jwt.encode(payload, app.secret_key, algorithm="HS256")
# 在Redis中记录jti已经使用,有效期和token一致
r.setex(f"used_jti:{jti}", 300, phone)
return token
# 重置密码接口
@app.route('/reset_password', methods=['POST'])
def reset_password():
"""用户提交验证码和新密码,同时携带会话ID"""
data = request.get_json()
phone = data.get("phone")
code = data.get("code")
new_password = data.get("new_password")
session_id = data.get("session_id")
# 1. 先校验验证码
if not verify_code(phone, code):
return jsonify({"error": "验证码错误或已过期"}), 400
# 2. 生成重置令牌(这里演示,实际应该在验证码通过后,再让用户点击重置链接等)
# 我们这里简化,验证码通过后直接生成一个token作为后续重置的凭证
token = generate_reset_token(phone, session_id)
# 3. 注意:这里我们只是发了token给前端,更安全的做法是让用户再走一个带token的重置步骤
# 为了完整演示,我们直接把token返回,但真实系统应该导向一个携带token的重置密码页
# 实际应用中,可以把这个token作为响应,后续请求必须带上这个token
return jsonify({"reset_token": token}), 200
# 使用token真正执行修改密码
@app.route('/change_password', methods=['POST'])
def change_password():
"""携带重置令牌和新密码,真正修改密码"""
data = request.get_json()
token = data.get("token")
new_password = data.get("new_password")
session_id = data.get("session_id")
try:
# 校验JWT签名和过期时间
payload = jwt.decode(token, app.secret_key, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
return jsonify({"error": "令牌已过期"}), 400
except jwt.InvalidTokenError:
return jsonify({"error": "无效令牌"}), 400
# 防重放检查:jti是否已经被用过了
jti = payload.get("jti")
if r.exists(f"used_jti:{jti}"):
# 如果jti还在,说明这个令牌还没被使用过;但我们要标记它。
# 注意:更安全的做法是,在第一次使用后就删除这个key。
r.delete(f"used_jti:{jti}")
else:
return jsonify({"error": "令牌已被使用"}), 400
# 检查令牌绑定的会话是否一致
if payload.get("session") != session_id:
return jsonify({"error": "会话不一致"}), 403
phone = payload.get("sub")
# 更新密码,实际要加密存储(比如用pbkdf2)
# password_hash = hashlib.pbkdf2_hmac('sha256', new_password.encode(), salt, 100000)
USERS_DB[phone]["password_hash"] = f"new_hash_{new_password}"
return jsonify({"message": "密码已更新"}), 200
在上面的示例中,我们实际上把流程拆成了两步:第一步验证验证码,拿到一个重置令牌;第二步带着令牌去修改密码。令牌里绑定了会话ID,而且带有一个唯一的jti,每次使用后我们就从Redis删除这个jti,如果再次收到同样的令牌,就会因为used_jti不存在而被拒绝。这样就防止了重放攻击。同时,令牌有过期时间,就算被偷了,也只能在短时间内在绑定的会话下使用。
关于HTTPS,代码层面其实不需要做什么,但部署时一定要配置好。你可以在Nginx层强制跳转HTTPS,或者在Flask里用装饰器强制所有请求走HTTPS。不过更需要关注的是你的API是否全部通过HTTPS暴露,以及证书是否有效。另外,如果你的前端是App,那么你需要采用证书锁定或至少做严格证书校验,防止中间人。
3.4 修复第三步:动态令牌与会话绑定
我们已经在上面的generate_reset_token里加入了session字段。但什么是“会话”?怎么拿到会话ID?通常用户在登录后会有一个会话cookie,我们把这个会话ID传到后端。在密码重置场景中,如果用户并没有登录,那么我们可以生成一个临时的会话ID,让用户带着这个ID走完后续流程。比如,用户在请求验证码时,前端可以先向/get_session接口申请一个临时会话ID,这个ID也存储在Redis里,并设置有效期。然后在提交验证码、重置密码时都带上这个ID。这样攻击者即使拿到了token,只要他没有这个会话ID(因为会话ID在用户的浏览器里),就无法使用token。
我们还可以进一步绑定用户的环境信息,比如User-Agent或者IP,不过这会带来误伤,比如用户从手机切到电脑时IP变化。所以实际应用中,绑定一个高熵的临时会话ID是最常见的做法。
下面展示如何生成临时会话ID:
# 生成临时会话ID
@app.route('/init_reset_session', methods=['POST'])
def init_reset_session():
"""为密码重置流程创建一次性会话"""
session_id = secrets.token_urlsafe(32)
# 设置10分钟过期
r.setex(f"reset_sid:{session_id}", 600, "active")
return jsonify({"session_id": session_id}), 200
然后,在前端每次调用后续接口时,必须带上这个session_id。当然,更稳妥的方式是把session_id放在HttpOnly Cookie中,这样JavaScript也读不到,但为了API调用的灵活性,很多系统还是选择放在请求头里。我们这里演示一下逻辑。
另外,还有一个重要细节:生成重置令牌的接口/reset_password也应该校验这个临时会话是否存在且有效。上面示例中generate_reset_token直接用了传入的session_id,但没有校验这个session_id是不是我们之前签发的。所以我们需要在reset_password接口里加上校验:
# 在reset_password接口中增加会话有效性校验
def is_valid_reset_session(session_id):
"""检查临时会话是否存在且有效"""
return r.exists(f"reset_sid:{session_id}") == 1
# 修改后的reset_password开头加上:
# if not is_valid_reset_session(session_id):
# return jsonify({"error": "会话无效"}), 403
这样,攻击者如果没有这个session_id,即使拿到验证码和token也白搭。而session_id本身又是高熵随机数,无法猜测。所有依赖验证码的环节都转移到了服务端,传输层又有HTTPS保护,重放攻击也被jti机制拦截。这套方案基本算是把主要的洞都堵上了。
四、这些修复策略到底到位没有
4.1 优点分析
从上面的示例可以看到,修复后的流程有几个明显的优点:
- 验证码完全由后端校验,前端就算逆向也拿不到真正的验证码,因为验证码只存在于服务端。
- 一次性机制起到了作用:验证码用过即删,令牌的jti用过即删,就算被截获,你也没法用第二次。
- 令牌绑定了临时会话ID,攻击者无法跨会话使用令牌,即使会话ID随着响应被返回(比如放在JSON里),但Token和会话ID同时被截获的概率会低很多,而且会话ID也可以放在HttpOnly Cookie里,进一步提高了安全性。
- 防重放设计使得攻击者重复发送请求不会得逞。
- 通过HTTPS加密传输,流量中的敏感信息不再裸奔。
4.2 缺点与潜在问题
但是,这套方案也不是完美的,有几个缺点和潜在问题值得注意:
- 增加了流程复杂度:用户要先申请临时会话,再获取验证码,再提交验证码拿令牌,最后换密码。对于用户来说,操作步骤变多了,可能导致转化率下降。
- 依赖Redis或类似存储:如果Redis挂了,整个流程都会瘫痪。你需要考虑缓存的高可用问题。
- 会话绑定的粒度:如果只绑定临时会话ID,而会话ID本身在请求体中传递,那么仍有可能被网络抓包者获取(在HTTPS下抓包难度高,但若存在恶意终端或浏览器扩展,仍有机会)。更安全的方式是将会话ID放在HttpOnly Cookie里,并且设置SameSite=Strict来防止CSRF。
- jti的存储:每次使用令牌都要删除Redis中的key,如果用户没有完成最后一步就关闭页面,jti会一直存在直到过期,这期间如果令牌泄露,仍有可能在过期前使用。所以你可以设计为第一次使用后就永久删除,但需要处理并发情况。
- 验证码的发送频率限制:我们还没有做,比如同一手机号每分钟只能发一次,每天最多发五次。不然攻击者也可以疯狂发送骚扰短信,或者利用这个进行短信轰炸。
- 暴力枚举验证码:如果在校验时不限制尝试次数,攻击者可以反复提交不同的code,直到匹配(因为验证码只有6位数字,100万种可能)。我们需要给验证码校验加一个错误次数上限,比如5次错误后立即作废该验证码。
关于最后一点,我们可以在verify_code函数中增加计数。这里简单展示一个增强版本:
# 增强版验证码校验,带错误次数限制
def verify_code_with_limit(phone, code):
key = f"reset_code:{phone}"
attempts_key = f"reset_code_attempts:{phone}"
saved_code = r.get(key)
if not saved_code:
return False
# 尝试次数超过5次就作废
attempts = r.get(attempts_key)
if attempts and int(attempts) >= 5:
r.delete(key) # 删除验证码
return False
if secrets.compare_digest(saved_code.decode(), code):
r.delete(key)
r.delete(attempts_key)
return True
else:
# 增加尝试次数,并在5分钟内有效
r.incr(attempts_key)
r.expire(attempts_key, 300)
return False
4.3 注意事项
在实际开发中,除了上面的修复,还要注意几个坑:
- 不要把所有逻辑堆在一个接口里。要遵循“验证码校验”和“密码修改”分离的原则。验证码通过后发一个短期有效的令牌,再次请求时带着令牌,避免攻击者利用已泄露的验证码直接改密码。
- 令牌的签名密钥一定要保密,不能硬编码在代码里,要放到环境变量或专门的密钥管理服务中。
- 日志中不要出现验证码、令牌、密码等敏感字段。否则就算代码再安全,日志泄露了也白搭。
- 前端不要显示敏感错误信息。比如后端返回“验证码不存在”和“验证码错误”的提示要统一,以免攻击者枚举手机号是否存在。
- 要设置合理的过期时间。验证码建议5-10分钟,令牌建议5-15分钟,太长会增加风险,太短影响用户体验。
- 所有接口都要限流。尤其是
send_code和reset_password,可以用nginx或者业务中间件实现IP级别的限流。
五、总结
我们这一路分析下来,密码重置流程中的验证码回到前端校验,这属于安全逻辑的位置放错了,攻击者可以直接从响应里拿到验证码,等于把门钥匙放在门垫下面。加密传输链路没做好,又让数据在网络上裸奔,加大了被窃听的风险。动态令牌绑定会话的修复策略,如果只是把令牌发给用户而不绑定任何上下文,那令牌就只是一个拥有者的凭证,偷到就能用。
修复的核心就是:所有关键校验必须发生在服务端;所有敏感数据必须加密且在服务端存储;所有令牌必须短期有效、一次性、并且和会话环境绑定。我们给出的示例代码展示了如何通过Flask、Redis和JWT来落地这些策略。但也要清醒地认识到,安全是一个持续演进的过程,没有一套方案是万无一失的。即使做了这些修复,也要定期做渗透测试、代码审计,关注最新的攻击手法。另外,迁善改过不仅是在写代码时,更是在设计整个业务的时候就该把安全当作第一优先级。希望这篇文章能帮你理清思路,让你在下次做密码重置功能时,直接一步到位,别踩这些坑。
最后,请牢记:用户的密码就是他的数字身份,你保护不好它,用户就会失去对你的信任。
评论
围绕“密码重置流程中的验证码回到前端校验,业务逻辑漏洞绕过加密传输链路,动态令牌绑定会话的修复策略是否到位”参与讨论