Password reset flow attacks¶
重置凭证泄漏¶
案例1¶
用邮件找回密码时,作为重置凭证的验证码在 HTTP 应答中下发客户端,抓包后可轻易获取。先用攻击者账号走一次密码找回流程,测试账号 yangyangwithgnu@yeah.net 选用邮箱找回密码:

点击获取校验码后抓取如下应答:

其中,VFCode 从字面理解很可能是校验码。登录邮箱查看网站发过来的密码找回邮件:

发现两者一致,那么,几乎可以确认服务端将密码找回的校验码泄漏至客户端,可导致任意账号密码重置问题。
尝试找回普通账号的密码。密码找回首页输入邮箱后,系统将立即校验该邮箱是否注册:

将 UName 参数定义为枚举变量,以常见 qq 邮箱作为字典,可枚举出多个有效邮箱:

以 chenwei@qq.com 为例,在应答包中找到校验码,成功将其密码重置为 PenTest1024,验证可登录:

尝试找回管理员账号的密码。从该网站的域名注册信息中找到联系人的邮箱为 fishliu@xxxx.cn,可推测后台用户的邮箱后缀为 @xxxx.cn,所以,用常见后台用户名简单调整可构造出后台用户邮箱字典,枚举出大量后台用户:

漏洞根本原理:验证码不是发送到对应的手机号而是直接在响应中返回,也就是说输入任意手机号都可以在响应中看到验证码
总结利用流程如下:
- 爆破出合法的邮箱,手机号任意
- 抓包获取相应中的验证码
- 输入验证码,进入密码重置流程
案例2¶
用邮件找回密码时,带凭证的重置链接泄漏至客户端,抓捕可获取。用攻击者账号走一次密码找回流程。在找回密码页面输入攻击者账号及其邮箱(yangyangwithgnu、yangyangwithgnu@yeah.net)后提交:

拦截如下应答:

显然是个重定向,isVerify、PassPhrase 这两个参数很可疑,后续交互中应留意,先放包,进入发送重置邮件的页面,输入验证码后提交。登录攻击者邮箱查看重置邮件:

这个带 token 的重置链接似曾相识,对,就是前面抓包获取的 token 信息,比对看下:
forgotPwdEa.php?isVerify=eWFuZ3lhbmd3aXRoZ251fHlhbmd5YW5nd2l0aGdudUB5ZWFoLm5ldHw2MzQyNDkw&PassPhrase=01e4f6d4ede81b2604dc320bc4e3a6e8
forgotPwdEc.php?isVerify=eWFuZ3lhbmd3aXRoZ251fHlhbmd5YW5nd2l0aGdudUB5ZWFoLm5ldHw2MzQyNDkw&PassPhrase=01e4f6d4ede81b2604dc320bc4e3a6e8
唯一区别 forgotPwdEa 和 forgotPwdEc 两个文件名。
接下来验证通过服务端泄漏的 token 能否重置普通用户的账号密码。从重置流程可知,要重置密码必须提供用户名及其邮箱(或手机号)。
获取有效用户名。注册页面中,输入用户名后立即校验该用户名是否被占用:

对应请求、应答如下:

用户名已存在返回 failed,不存在返回 ok。以此特征,用常见国人姓名字典,可枚举出大量有效用户名(如 chenchuan、chenanqi、chenanxiu、zhangfeng 等等),存为 username.txt。
获取有效用户名对应邮箱。密码找回首页提交的请求中,user_name 与 email 参数匹配情况下,HTTP 应答代码为 302,交互包如下:

可以此特征枚举有效用户名及其邮箱。现在考虑如何制作邮箱字典?很多用户喜欢用用户名注册 qq 邮箱,换言之,用户名 yangyangwithgnu 可能对应邮箱 yangyangwithgnu@qq.com。所以,用前面已经获取有效用户名字典 username.txt 快速制作了邮箱字典 qq-email.txt,其中,username.txt 与 qq-email.txt 逐行对应。
例如,前者第一行为 yangyangwithgnu、后者第一行为 yangyangwithgnu@qq.com。将上面的数据包放入 burp 的 intrduer 中,攻击类型选 pitchfork、user_name 的参数值定义为枚举变量 1 并加载字典 username.txt、email 的参数值定义为枚举变量 2 并加载字典 qq-email.txt,可枚举出大量有效用户名/邮箱信息,如,zhangfeng/zhangfeng@qq.com、chenchuan/chenchuan@qq.com 等等。
用普通账号 chenchuan/chenchuan@qq.com 演示密码重置漏洞。输入用户名、密码提交,正常完成密码找回逻辑,从交互包中获取服务端下发的重置 token:
isVerify=Y2hlbmNodWFufGNoZW5jaHVhbkBxcS5jb218MTE2MDIzNw==&PassPhrase=cbf0160662358808f3586868f041cbaa
拼装为重置链接 http://www.xxxx.com/user/forgotPwdEc.php?isVerify=Y2hlbmNodWFufGNoZW5jaHVhbkBxcS5jb218MTE2MDIzNw==&PassPhrase=cbf0160662358808f3586868f041cbaa ,访问之,即可进入密码重置页面:

输入新密码 PenTest1024 后系统提示修改成功。用 chenchuan/PenTest1024 成功登录:

上述存在漏洞的本质在于重置凭证泄漏,而不是通过安全渠道(手机短信、邮箱)直接发送给对应的用户,当然必须要对手机短信和邮箱进行白名单校验,并且要验证用户名与手机号/邮箱是否匹配,若不是合法的手机号/短信,不发送
重置凭证接收端可篡改¶
案例1¶
密码重置页面,输入任意普通账号,选择手机方式找回密码。在身份验证页面点击获取短信验证码:

拦截请求,发现接收验证码的手机号为请求包中的参数:

直接篡改为攻击者的手机号,成功接收短信验证码,提交验证码后,正常执行 3、4 步即可成功重置该账号的密码。
该漏洞的本质在于未将手机号与收到的验证码进行绑定,如果验证了绑定关系,那么即使攻击者可以收到验证码,也无法利用成功后
案例2¶
在密码找回页面,用攻击账号 test0141,尝试重置目标账号 2803870097 的密码(对滴,你没看错,这两个长得完全不像的账号的确是同个网站的)。
在第一个首页中输入 test0141 和图片验证码完成“01 安全认证”:

请求为:

输入图片验证码获取短信验证码完成“02 身份验证”:

请求为:

后续的 03、04 步不涉及用户名信息,忽略。
全流程下来,客户端并未直接提交接收短信验证码的手机号,多次尝试可知,02 中出现的 user_name 用于查询下发短信的手机号,用它可以间接指定接收端,那么,它是否仅此作用而不用于指定重置密码的账号?如下思路验证,先将 userName 置为 2803870097 完成 01 以告诉服务端重置的账号,再将 user_name 置为 test0141 完成 02 以欺骗服务端将短信验证码发至攻击者手机,顺序完成 03、04 或许能实现重置 2803870097 的密码。具体如下。
第一步,用普通账号 2803870097 进行安全认证:

第二步,对普通账号 2803870097 进行身份验证:

拦截发送短信验证码的请求:

将 user_name 从 2803870097 篡改为 test0141,控制服务端将验证码发至 test0141 绑定的手机号:

test0141 的手机号成功接收到验证码 872502,将该验证码填入重置 2803870097 的身份校验页面后提交:

第三步,输入新密码 PenTest1024 后提交,系统提示重置成功:

第四步,用 2803870097/PenTest1024 登录,验证成功:

用户混淆¶
案例1¶
密码找回页面 http://www.xxxx.cn/getpass.html,用攻击者账号走完密码找回全流程,涉及三步请求,依次为:验证用户名是否存在、获取短信验证码、提交短信验证码和新密码。第三步的请求拦截如下:

各参数作用从其命名可了解。尝试将 accountname 参数值篡改为普通账号 zhangzhiqiang 后放行,应答为:

重定向至登录页面。用普通账号 zhangzhiqiang/PenTest1024 登录成功。
查看个人信息:

泄漏用户手机号、邮箱等敏感信息。
另外,密码找回流程第三步的请求中的 vcode 参数为短信验证码,单次有效,不可复用,如何实现自动批量密码重置?经测试,将该参数置空,或者完整删除该参数,服务端不再校验短信验证码。
综上,几个问题结合,可导致任意用户密码重置。
案例2¶
http://xx.xxxx.com/echannel/forgetPassword/ 为重置密码页面。攻击者用户 ID 为 42558。
输入攻击者账号绑定的邮箱后点击“确认”,收到如下带 token 的密码重置链接:

该重置链接有两个地方引起我的注意:一是,NDI1NTg= 为 base64 编码,解码为 42558,正是攻击者账号的用户 ID;二是,这是个 REST 风格的请求。所以,尝试用普通账号的用户 ID 的 base64 编码替换 NDI1NTg= 后,成功重置该普通用户的密码。
特别注意,参数部份出现 / 应想到这是 rest 风格的参数和参数值。
重置凭证未校验¶
密码找回需要鉴别用户的合法身份,证明你就是你,通常有两种做法,一是网站将重置验证码发至用户绑定的邮箱或手机号,用户持重置验证码证明你就是你,二是用户输入密码保护问题对应的答案。其中,验证码、密保答案就是重置密码的重要凭证。
在日常对密码找回功能的攻击中,我的大部份精力聚焦在是否可以暴破验证码、是否可以劫持接收验证码的手机号或邮箱、是否可以混淆重置其他账号、是否可以绕过验证步骤、甚至是猜测重置 token 的生成规律等攻击方式上,反而忽略了最容易、最低技术含量的一种方式——服务端未校验重置凭证。换言之,不论你输入的重置验证码或密保答案是否正确,只要请求格式无误,均可成功重置任意账号密码。
案例1¶
密码找回页面 http://www.omegatravel.net/users/retrievePassword/ 用攻击者账号 yangyangwithgnu@yeah.net 进入密码找回全流程,输入图片验证码后提交:

随后收到带 token 的密码重置链接的邮件:

其中,key:FqvICT 和 userEmail:yangyangwithgnu@yeah.net 引起了我的注意。正常来说,提交该 URL 后,服务端会校验 key 与 userEmail 是否匹配,若匹配则进入提交新密码页面,若不匹配则报错。现在,我尝试将 key 从 FqvICT 改为 xxxxxx 后再访问,本来心理预期将看到报错页面,没想到进入了新密码提交页面,难倒所谓的重置 token 仅仅是个摆设?
赶紧找个账号试试,就拿信息收集时找到的 travel24@omegatravel.net 为例(更多后台账号见后文)。参照前面收到的重置链接格式,简单拼装为 http://www.omegatravel.net/users/retrievePasswordReset/key:xxxxxx/userEmail:travel24@omegatravel.net,是滴,key 的值我随便写的,访问看看,哇,居然真的进入了新密码提交页面:

输入新密码 PenTest1024 后提交,网站提示“修改密码成功”。尝试用 travel24@omegatravel.net/PenTest1024 登录,成功进入系统:

如何获取其他账号?从注册页面可知,该网站只能用邮箱注册,邮箱即账号。我关心普通用户和内部员工用户两类账号(即邮箱)。普通用户的邮箱字典方面,把国人常见姓名拼音 top500 结合常见邮箱后缀(@qq.com、@163.com 等等)快速生成个简单邮箱字典;内部员工的邮箱方面,我从该网站域名注册信息查询到联系人为 omegait@omegauk.net,说明该公司使用 @omegauk.net 的邮箱后缀,同上,把国人常见姓名拼音 top500、常见后台账号,结合 @omegauk.net 邮箱后缀,快速生邮箱字典;另外,从“联系我们”、“诚聘英才”、“公司简介”等页面找到大量内部员工邮箱和合作伙伴邮箱,如 info@jadetravel.co.uk、info@ukchinese.com 等等:

将以上几类邮箱字典存为 mail.txt 也就是用户名。
这样,我不仅可以重置普通账号的密码,还能劫持大量内部员工、合作伙伴的账号,为避免影响业务,不再实际操作。
案例2¶
在密码找回页面 http://www.hzpzs.net/u_findPassword.asp 输入有效用户名 yangyangwithgnu 后拦截请求包:

猜测该数据包用于用户名有效性校验,放至 repeater 中分析。
由于没用图片验证码,导致可枚举有效用户名,发现三类情况。一是,用户名存在且设置过密保问题,应答类似:

二是,用户名存在但未设置密保问题,应答类似:

三是,无效用户名,则应答类似:

用常见用户名和中国人姓名拼音作为字典进行枚举,在所有结果中过滤显示含有关键字
按正常流程,对 chenxin 进行密码重置,输入任意密保答案均可重置密码:

该案例本质在于服务端并未从多个因素判断用户,只从用户名来判断,从而通过找到了未设置密保问题的用户来重置密码,当然仅根据密保问题来重置密码这个机制本身就是开发问题,如果要这样设计,必须保证密保问题的唯一性和非空性
重置凭证可暴破¶
密码找回需要鉴别用户的合法身份,证明你就是你,通常有两种做法,一是网站将重置验证码发至用户绑定的邮箱或手机号,用户持重置验证码证明你就是你,二是用户输入密码保护问题对应的答案。其中,验证码、密保答案就是重置密码的重要凭证。有些网站生成四位数字的重置验证码,复杂度较低,[0000, 9999] 也就一万种组合,在如今的计算能力和网络带宽条件下,顺手的工具三五分钟的功夫就能枚举完。如果服务端又未设置验证码的存活有效期、未限制高频访问,那么极易暴破。
案例1¶
密码找回页面 http://www.xxxx.com/find-pw.html 用攻击者账号 13908081024 进入密码找回全流程,输入图片验证码、选择手机找回、获取短信验证码,发现短信验证码为 4 位数字且短信内容上未看到有效期信息,所以,可暴破短信验证码,进行后续的重置流程。
用账号枚举漏洞遍历得到的普通手机号 13908093346 为例,进入密码找回流程,提交短信验证码:

其中,1234 是我随便输入的错误的短信验证码,需要对 code 参数值进行暴破以找出正确的短信验证码。
具体而言,我将该请求中的 code 参数值定义为枚举变量、以 [0000, 9999] 为字典,设定 32 个线程进行暴破:

很快暴出短信验证码为 6909:

提交短信验证码后顺利进入新密码设置页面,重置密码设为 PenTest1024 后提交,服务端返回“修改成功”:

尝试用 13908093346/PenTest1024 登录,验证成功
应答中存在影响后续逻辑的状态参数¶
案例1¶
在密码找回页面 http://www.xx.cn/yy/action/forgot 用攻击者手机号 13908081024 进入密码找回全流程,获取短信验证码 033128、输入图片验证码、输入短信验证码并提交:

服务端校验通过后,系统应答如下:

简单分析发现,校验通过时服务端并未向客户端 set-cookie,猜测服务端并未记录校验状态,是否进入设置新密码页面完全是由前端 js 基于应答状态决定的,那么,即便我没有短信验证码,通过将服务端下发给客户端的校验状态从“失败”改为“成功”,也能成功重置找回账号密码。
具体而言,以信息搜集时找到的客服手机号 13980808888 为例。输入手机号、获取短信验证码、输入图片验证码、输入错误的短信验证码 123123 后提交:

由于短信验证码错误,系统校验肯定失败,系统应答如下:

拦截该应答,用前面抓取校验成功的应答包替换之:

放行至客户端,顺利进入新密码设置页面:

输入新密码 PenTest1024 后提交,页面提示密码修改成功:

尝试用 13980808888/PenTest1024 登录,验证成功:

该案例能利用的前提是已知正确的响应数据,一般而言需要攻击者先走一遍正确重置的业务流程
predictable tokens¶
通过邮箱找回密码时,邮件中将出现一个含有 token 的重置 URL,该 token 即为重置凭证。从经验来看,开发人员习惯以时间戳、递增序号、关键字段(如邮箱地址)等三类信息之一作为因子,采用某种加密算法或编码生成 token,攻击者可以基于能收集到的关键字段,用常见加密算法计算一遍,以判断是否可以预测出 token。
案例1¶
金蝶云之家密码找回,带凭证的密码找回链接如下:

从参数名猜测,u 可能为 username、t 为 token,为减少复杂性,测试发现,删掉 u 参数及值也可正常重置密码,所以,可忽略 u 参数,重点关注 t 参数。
连续 5 次获取重置链接,从邮件中提取 5 个 t 参数值如下:

仔细观察发现变化点如下:

可以看出,t 参数值的 5~8位、最后 4 位,按 2 的增量呈现递增变化。
分析清楚凭证的变化规律,要重置任意账号就轻松了。比如,要重置普通账号 admin 的密码,可以先触发找回攻击者账号的密码,获取 t 为 52df773f24ac5b651d288d42,然后触发找回 admin 的密码,t 未知,再次触发找回攻击者账号的密码,获取 t 为 52df774d24ac5b651d288d54,根据前面分析得知的变化规律,普通账号的 t 的 5~8 位一定是 [7740, 774c] 间的偶数,后 4 位范围一定在 [8d44, 8d52] 间的偶数。几次枚举便可得有效 t 参数值:

案例2¶
某网站密码找回功能,请求含有三个参数:

username 是邮箱、rvcode 图片验证码、sid 未知,登录邮箱查看重置 URL:

key 参数为重置凭证,尝试分析生成方式。直接将其放入 md5 在线破解网站无果,尝试用 username、rvcode、sid 等三个参数的排列组合进行 md5,当尝试到 md5(username + sid) 时发现生成结果与邮件中的凭证一致:

猜测出其 key 的生成算法,那么,后续将豪无压力重置任意账号的密码了。
类似的还有,带凭证的重置链接为 http://mysite.com/sms.php?k=a18f057d5aF,多次获取重置链接发现,凭证 f198a79b9cF 末尾的 F 恒定不变,前面 10 位字符疑似 md5 加密,尝试对不同参数的排列组合进行 md5 加密,当尝试到 md5(手机号+图片验证码) 时发现生成结果与邮件中的凭证一致:

密码重置中的主机头注入¶
HTTP Host头由客户端发送,格式如Host: portswigger.net,其作用是:标识客户端浏览器要与哪个域通信,通常用在服务器配置了多个虚拟主机或者使用CDN的情景下。例如下面NGINX的配置,单个IP地址(甚至是同一个端口)托管了多个域:
server {
listen 8000;
server_name noa.icu;
location / {
root /var/www/html/public;
index index.html index2.html index3.html;
}
}
# 多个Server可以配置相同的端口,使用不同的主机名区分
server {
listen 8000;
server_name localhost;
location / {
root html;
index index.html index2.html index3.html;
}
}
客户端发送的请求中,HTTP头为localhost,会匹配到NGINX默认页面。
客户端发送的请求中,HTTP头为noa.icu,会匹配到Hugo页面(Web应用页面)。
密码重置的工作流程¶
1、用户输入邮箱,提交重置密码请求
2、应用程序后端检查用户是否存在,并为其生成一个临时的、唯一的、高熵的Token,并将该Token与用户账户关联。
3、应用程序后端为用户生成重置密码的URL,并通过邮件发送至用户邮箱,类似 https://normal-website.com/reset?token=0a1b2c3d4e5f6g7h8i9j
4、用户登录邮箱并点击URL,应用程序验证Token通过后,跳转至重置密码页面,重置密码后销毁Token。
漏洞原理¶
漏洞出现在上述过程中的第三步,用户使用邮箱重置账户密码时,应用程序需要知道当前域名,并根据域名生成重置密码的URL并发送用户邮箱。
当网站开发人员或运维人员未在配置文件中手动指定当前域,后端可能将客户端发送的Host头看作当前应用的域,攻击者通过自定义Host头生成指向受其控制的恶意域的密码重置链接,用户登录邮箱并点击URL后,实际访问的是攻击者恶意域下的网站,由此攻击者窃取重置密码所需Token,并最终破坏其帐户。
- 攻击者获取到目标用户的用户名和注册邮箱(这些可能是重置密码必要的参数),让后以攻击者的名义提交一个重置密码的请求。当表单提交的时候,攻击者拦截这个HTTP请求并且修改对应的Host请求头,将其值改为由攻击者控制的域名。例如
evil-user.net。 - 受害者从该网站受到一封真实的重置密码的邮件。如前所述,邮件里可能包含一个重置密码的链接,链接上带有一个与用户账户相关联的
token。但是,这里的URL里包含的域名是攻击者提交的那个Host头指向的域名,如:https://evil-user.net/reset?token=0a1b2c3d4e5f6g7h8i9j。 - 如果受害者点击了这个链接(或者通过其它方式发起请求,比如被反病毒扫描扫了),那么这个
token就会发送到攻击者控制的服务器。 - 现在攻击者收到了这个
token,可以通过它拼接正确的链接访问,这样就会进入受害者真实的密码重置页面,可以将密码重置成自己知道的,然后以重置密码登录。
案例1¶
用户选择忘记密码,填写用户名后,系统会生成重置密码的Token并发送至用户邮箱。

用户登录个人邮箱接收邮件,访问邮件中的URL,即可输入新密码,完成密码重置。

在上述提交用户名的数据包中,将原始的Host头内容替换受自己控制的域名后,用户收到的URL是拼接了恶意域名的URL,证明服务器存在密码重置投毒漏洞。


若用户或用户的邮箱安全软件访问/扫描了该URL,则攻击者则可以通过查看访问记录获取到用户的密码重置Token:

后续攻击者可以通过该Token拼接正确的重置密码URL,并对受害者的账号密码进行重置。
如果HOST存在校验,可以尝试
X-Forwarded-Host: attacker.com X-Host: attacker.com
等进行绕过
如何修复¶
使用环境变量/配置文件(最推荐)¶
不要在代码里读 Host 头,而是从安全的配置中心或环境变量中读取 BASE_URL。
# 错误做法:动态读取
host = request.headers.get('Host')
url = f"http://{host}/reset"
# 正确做法:读取环境变量(在部署时由运维/K8s注入)
base_url = os.environ.get('APP_BASE_URL') # 例如: https://www.prod.com
url = f"{base_url}/reset"
优点:彻底切断客户端控制,且配置由运维统一管理,非常安全。
严格的 Host 白名单校验¶
如果系统确实需要动态获取(例如上述的多租户 SaaS 场景),必须在读取 Host 头后,进行**严格的白名单校验**。
host = request.headers.get('Host')
# 必须校验 host 是否在数据库或配置允许的合法域名列表中
if host not in ALLOWED_TENANT_DOMAINS:
abort(400, "Invalid Host")
利用框架的安全机制¶
使用现代框架提供的安全特性。例如 Django 的 ALLOWED_HOSTS 配置,Spring Security 的 Host Header 校验机制。如果请求的 Host 不合法,框架会在到达业务代码前直接拦截并返回 400 错误。
正确的密码重置流程设计¶
设计一个安全的密码重置流程,需要从**整体架构**到**每个细节环节**都进行严格的安全控制。以下是一套经过业界验证的、完整的密码重置安全设计方案。
一、 整体流程概览¶
用户点击"忘记密码"
│
▼
① 提交标识(邮箱/手机号)
│
▼
② 服务端验证 → 生成 Token → 存储 Token
│
▼
③ 通过安全渠道发送重置链接/验证码
│
▼
④ 用户点击链接 / 输入验证码
│
▼
⑤ 服务端验证 Token(时效、次数、来源)
│
▼
⑥ 用户设置新密码(需满足强度要求)
│
▼
⑦ 更新密码 → 销毁 Token → 清除其他会话 → 通知用户
二、 各环节安全设计详解¶
环节 ①:发起重置请求¶
安全要点:
-
统一响应,不泄露用户是否存在 无论邮箱是否注册,都返回相同的提示信息,防止**用户枚举攻击**。
-
频率限制(Rate Limiting) 对同一 IP、同一邮箱进行严格的请求频率限制,防止暴力刷取 Token 或邮件轰炸。
-
人机验证(CAPTCHA) 在触发重置前要求通过图形验证码或行为验证(如 reCAPTCHA、滑块验证),防止自动化脚本批量触发。
环节 ②:Token 生成与存储¶
这是整个流程中最核心的安全环节。
Token 生成规范¶
| 属性 | 要求 | 说明 |
|---|---|---|
| 长度 | ≥ 32 字节(256 bit) | 确保不可被暴力猜测 |
| 随机性 | 必须使用 CSPRNG | 如 secrets.token_urlsafe(32)(Python)、crypto.randomBytes(32)(Node.js) |
| 不可预测 | 禁止使用时间戳、用户ID、自增ID等 | 防止被推算 |
| 编码 | URL-safe Base64 或 Hex | 避免特殊字符导致链接解析错误 |
import secrets
# ✅ 正确:使用密码学安全的随机数生成器
reset_token = secrets.token_urlsafe(32)
# 输出示例: "g7Kp2xR9mQ4vL8nB3wY6tA1cF5hJ0dE..."
# ❌ 错误:使用伪随机或可预测的值
import random
reset_token = str(random.randint(100000, 999999)) # 仅6位数字,极易爆破
reset_token = hashlib.md5(f"{user_id}{time.time()}".encode()).hexdigest() # 可预测
Token 存储规范¶
-
存储 Token 的哈希值,而非明文(与存储密码同理)
这样即使数据库泄露,攻击者也无法利用 Token。 -
数据库表设计示例
SQLCREATE TABLE password_reset_tokens ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, token_hash VARCHAR(64) NOT NULL, -- SHA-256 哈希 expires_at TIMESTAMP NOT NULL, -- 过期时间 used BOOLEAN DEFAULT FALSE, -- 是否已使用 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id (user_id), INDEX idx_token_hash (token_hash) );
环节 ③:邮件/短信发送¶
防御 Host 注入(核心修复点)¶
绝对不要从 HTTP 请求头中读取域名来拼接链接。
# ❌ 危险:从请求头读取
host = request.headers.get('Host')
reset_link = f"https://{host}/reset?token={reset_token}"
# ✅ 安全:从配置文件/环境变量读取
BASE_URL = os.environ.get("APP_BASE_URL") # 如 "https://www.example.com"
reset_link = f"{BASE_URL}/reset?token={reset_token}"
邮件内容安全¶
- 链接使用 HTTPS 协议。
- 邮件中明确告知用户:"如果您未发起此请求,请忽略此邮件。"
- 不要在邮件中直接包含明文密码或敏感信息。
- 考虑在邮件中显示请求发起的 IP 地址和设备信息,帮助用户识别异常请求。
环节 ④ & ⑤:验证 Token¶
当用户点击重置链接或提交验证码时,服务端必须进行**多维度校验**:
def verify_reset_token(raw_token: str, user_id: int) -> bool:
# 1. 计算哈希
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
# 2. 查询数据库
record = db.query(
"SELECT * FROM password_reset_tokens "
"WHERE token_hash = %s AND user_id = %s",
(token_hash, user_id)
)
if not record:
return False # Token 不存在或不匹配用户
# 3. 检查是否已使用(防止重放攻击)
if record.used:
return False
# 4. 检查是否过期
if datetime.now() > record.expires_at:
return False
return True
校验清单:
| 校验项 | 要求 | 防御的攻击 |
|---|---|---|
| Token 存在性 | 哈希匹配 | Token 伪造/猜测 |
| 用户绑定 | Token 与 user_id 绑定 | 跨用户 Token 复用 |
| 时效性 | 通常 15~30 分钟 | 长期暴露的 Token 被利用 |
| 一次性 | 使用后立即标记 used=True |
重放攻击 |
| 新密码不能与旧密码相同 | 校验历史密码 | 无效重置 |
环节 ⑥:设置新密码¶
- 密码强度校验:长度 ≥ 8 位,包含大小写、数字、特殊字符(根据业务需求调整)。
- 禁止使用近期密码:检查新密码是否与最近 3~5 次使用的密码相同。
- 服务端二次校验:不要仅依赖前端 JS 校验,后端必须重新验证。
- 传输安全:确保整个页面通过 HTTPS 传输,防止中间人窃听。
环节 ⑦:重置完成后的清理与通知¶
这一步经常被忽略,但至关重要:
-
立即销毁 Token
-
使该用户的所有现有会话失效 密码被重置意味着账户可能已被入侵,必须强制所有设备重新登录。
-
发送安全通知 向用户的注册邮箱(和/或手机)发送通知:
"您的账户密码已于 2026-09-03 14:30 (UTC+8) 被修改。如果这不是您本人的操作,请立即联系客服。"
-
记录审计日志 记录密码重置的时间、IP、User-Agent 等信息,便于事后追溯。
三、 纵深防御策略总结¶
| 防御层 | 措施 | 防御目标 |
|---|---|---|
| 网络层 | 全站 HTTPS、HSTS | 中间人窃听、降级攻击 |
| 网关层 | WAF 规则、Host 头白名单 | Host 注入、SQL 注入 |
| 应用层 | 频率限制、CAPTCHA | 暴力破解、邮件轰炸 |
| 业务层 | 统一响应、不泄露用户信息 | 用户枚举 |
| 数据层 | Token 哈希存储、短时效、一次性 | Token 泄露、重放 |
| 会话层 | 重置后清除所有会话 | 会话劫持 |
| 通知层 | 密码变更通知、异常登录告警 | 账户接管后的感知 |
四、 常见反模式(应避免的做法)¶
| ❌ 反模式 | 风险 |
|---|---|
| 使用 6 位数字验证码作为 Token | 仅 100 万种组合,可被暴力破解 |
| Token 有效期过长(如 24 小时以上) | 增加 Token 被截获利用的窗口期 |
| Token 可重复使用 | 攻击者截获一次后可反复利用 |
| 通过 URL 参数传递 Token 且未做防护 | Token 可能出现在浏览器历史、Referer 头、服务器日志中 |
| 密码重置后不踢出其他会话 | 攻击者可能仍持有有效 Session |
| 在邮件链接中包含用户 ID 且未签名 | 攻击者可篡改 user_id 重置他人密码 |
| 将 Token 明文存储在数据库中 | 数据库泄露后 Token 直接可用 |
五、 一句话总结¶
安全的密码重置 = 不可预测的 Token + 短时效 + 一次性使用 + 固定域名拼接 + 哈希存储 + 重置后全局踢出 + 安全通知。
每一个环节都是防御链条上的一环,任何一环的缺失都可能被攻击者利用。安全设计需要**纵深防御**的思维,而不是依赖单一措施。