跳转至

Password reset flow attacks

重置凭证泄漏

案例1

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

任意用户密码重置(一):重置凭证泄漏

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

任意用户密码重置(一):重置凭证泄漏

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

任意用户密码重置(一):重置凭证泄漏

发现两者一致,那么,几乎可以确认服务端将密码找回的校验码泄漏至客户端,可导致任意账号密码重置问题。

尝试找回普通账号的密码。密码找回首页输入邮箱后,系统将立即校验该邮箱是否注册:

任意用户密码重置(一):重置凭证泄漏

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

任意用户密码重置(一):重置凭证泄漏

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

任意用户密码重置(一):重置凭证泄漏

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

任意用户密码重置(一):重置凭证泄漏

漏洞根本原理:验证码不是发送到对应的手机号而是直接在响应中返回,也就是说输入任意手机号都可以在响应中看到验证码

总结利用流程如下:

  1. 爆破出合法的邮箱,手机号任意
  2. 抓包获取相应中的验证码
  3. 输入验证码,进入密码重置流程

案例2

用邮件找回密码时,带凭证的重置链接泄漏至客户端,抓捕可获取。用攻击者账号走一次密码找回流程。在找回密码页面输入攻击者账号及其邮箱(yangyangwithgnu、yangyangwithgnu@yeah.net)后提交:

任意用户密码重置(一):重置凭证泄漏

拦截如下应答:

任意用户密码重置(一):重置凭证泄漏

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

任意用户密码重置(一):重置凭证泄漏

这个带 token 的重置链接似曾相识,对,就是前面抓包获取的 token 信息,比对看下:

Text Only
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:

Text Only
isVerify=Y2hlbmNodWFufGNoZW5jaHVhbkBxcS5jb218MTE2MDIzNw==&PassPhrase=cbf0160662358808f3586868f041cbaa 

拼装为重置链接 http://www.xxxx.com/user/forgotPwdEc.php?isVerify=Y2hlbmNodWFufGNoZW5jaHVhbkBxcS5jb218MTE2MDIzNw==&PassPhrase=cbf0160662358808f3586868f041cbaa ,访问之,即可进入密码重置页面:

任意用户密码重置(一):重置凭证泄漏

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

任意用户密码重置(一):重置凭证泄漏


上述存在漏洞的本质在于重置凭证泄漏,而不是通过安全渠道(手机短信、邮箱)直接发送给对应的用户,当然必须要对手机短信和邮箱进行白名单校验,并且要验证用户名与手机号/邮箱是否匹配,若不是合法的手机号/短信,不发送

重置凭证接收端可篡改

案例1

密码重置页面,输入任意普通账号,选择手机方式找回密码。在身份验证页面点击获取短信验证码:

image.png

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

image.png

直接篡改为攻击者的手机号,成功接收短信验证码,提交验证码后,正常执行 3、4 步即可成功重置该账号的密码。

该漏洞的本质在于未将手机号与收到的验证码进行绑定,如果验证了绑定关系,那么即使攻击者可以收到验证码,也无法利用成功后

案例2

在密码找回页面,用攻击账号 test0141,尝试重置目标账号 2803870097 的密码(对滴,你没看错,这两个长得完全不像的账号的确是同个网站的)。

在第一个首页中输入 test0141 和图片验证码完成“01 安全认证”:

image.png

请求为:

image.png

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

image.png

请求为:

image.png

后续的 03、04 步不涉及用户名信息,忽略。

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

第一步,用普通账号 2803870097 进行安全认证:

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

用户混淆

案例1

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

image.png

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

image.png

重定向至登录页面。用普通账号 zhangzhiqiang/PenTest1024 登录成功。

查看个人信息:

image.png

泄漏用户手机号、邮箱等敏感信息。

另外,密码找回流程第三步的请求中的 vcode 参数为短信验证码,单次有效,不可复用,如何实现自动批量密码重置?经测试,将该参数置空,或者完整删除该参数,服务端不再校验短信验证码。

综上,几个问题结合,可导致任意用户密码重置。

案例2

http://xx.xxxx.com/echannel/forgetPassword/ 为重置密码页面。攻击者用户 ID 为 42558。

输入攻击者账号绑定的邮箱后点击“确认”,收到如下带 token 的密码重置链接:

image.png

该重置链接有两个地方引起我的注意:一是,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 中分析。

由于没用图片验证码,导致可枚举有效用户名,发现三类情况。一是,用户名存在且设置过密保问题,应答类似:

任意用户密码重置(四):重置凭证未校验

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

任意用户密码重置(四):重置凭证未校验

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

任意用户密码重置(四):重置凭证未校验

用常见用户名和中国人姓名拼音作为字典进行枚举,在所有结果中过滤显示含有关键字 的应答,得到的所有 UserName 参数值即为未设置密保问题的用户名。如:aaron、admin、adolph、alisa、chenchen、esther、jones 等等。

按正常流程,对 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、输入图片验证码、输入短信验证码并提交:

image.png

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

image.png

简单分析发现,校验通过时服务端并未向客户端 set-cookie,猜测服务端并未记录校验状态,是否进入设置新密码页面完全是由前端 js 基于应答状态决定的,那么,即便我没有短信验证码,通过将服务端下发给客户端的校验状态从“失败”改为“成功”,也能成功重置找回账号密码。

具体而言,以信息搜集时找到的客服手机号 13980808888 为例。输入手机号、获取短信验证码、输入图片验证码、输入错误的短信验证码 123123 后提交:

image.png

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

image.png

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

image.png

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

任意用户密码重置(六):应答中存在影响后续逻辑的状态参数

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

任意用户密码重置(六):应答中存在影响后续逻辑的状态参数

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

任意用户密码重置(六):应答中存在影响后续逻辑的状态参数

该案例能利用的前提是已知正确的响应数据,一般而言需要攻击者先走一遍正确重置的业务流程

predictable tokens

通过邮箱找回密码时,邮件中将出现一个含有 token 的重置 URL,该 token 即为重置凭证。从经验来看,开发人员习惯以时间戳、递增序号、关键字段(如邮箱地址)等三类信息之一作为因子,采用某种加密算法或编码生成 token,攻击者可以基于能收集到的关键字段,用常见加密算法计算一遍,以判断是否可以预测出 token。

案例1

金蝶云之家密码找回,带凭证的密码找回链接如下:

image.png

从参数名猜测,u 可能为 username、t 为 token,为减少复杂性,测试发现,删掉 u 参数及值也可正常重置密码,所以,可忽略 u 参数,重点关注 t 参数。

连续 5 次获取重置链接,从邮件中提取 5 个 t 参数值如下:

image.png

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

image.png

可以看出,t 参数值的 5~8位、最后 4 位,按 2 的增量呈现递增变化。

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

image.png

案例2

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

image.png

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

image.png

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

image.png

猜测出其 key 的生成算法,那么,后续将豪无压力重置任意账号的密码了。

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

image.png

密码重置中的主机头注入

HTTP Host头由客户端发送,格式如Host: portswigger.net,其作用是:标识客户端浏览器要与哪个域通信,通常用在服务器配置了多个虚拟主机或者使用CDN的情景下。例如下面NGINX的配置,单个IP地址(甚至是同一个端口)托管了多个域:

Text Only
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,并最终破坏其帐户。

  1. 攻击者获取到目标用户的用户名和注册邮箱(这些可能是重置密码必要的参数),让后以攻击者的名义提交一个重置密码的请求。当表单提交的时候,攻击者拦截这个HTTP请求并且修改对应的Host请求头,将其值改为由攻击者控制的域名。例如evil-user.net。
  2. 受害者从该网站受到一封真实的重置密码的邮件。如前所述,邮件里可能包含一个重置密码的链接,链接上带有一个与用户账户相关联的token。但是,这里的URL里包含的域名是攻击者提交的那个Host头指向的域名,如:https://evil-user.net/reset?token=0a1b2c3d4e5f6g7h8i9j。
  3. 如果受害者点击了这个链接(或者通过其它方式发起请求,比如被反病毒扫描扫了),那么这个token就会发送到攻击者控制的服务器。
  4. 现在攻击者收到了这个token,可以通过它拼接正确的链接访问,这样就会进入受害者真实的密码重置页面,可以将密码重置成自己知道的,然后以重置密码登录。

案例1

用户选择忘记密码,填写用户名后,系统会生成重置密码的Token并发送至用户邮箱。

image-20260903160144796

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

image-20260903160204482

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

image-20260903160225407

image-20260903160237817

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

image-20260903160258419

后续攻击者可以通过该Token拼接正确的重置密码URL,并对受害者的账号密码进行重置。

如果HOST存在校验,可以尝试

X-Forwarded-Host: attacker.com X-Host: attacker.com

等进行绕过

如何修复

使用环境变量/配置文件(最推荐)

不要在代码里读 Host 头,而是从安全的配置中心或环境变量中读取 BASE_URL。

Python
# 错误做法:动态读取
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 头后,进行**严格的白名单校验**。

Python
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 错误。

正确的密码重置流程设计

设计一个安全的密码重置流程,需要从**整体架构**到**每个细节环节**都进行严格的安全控制。以下是一套经过业界验证的、完整的密码重置安全设计方案。


一、 整体流程概览

Text Only
用户点击"忘记密码"
       │
       ▼
  ① 提交标识(邮箱/手机号)
       │
       ▼
  ② 服务端验证 → 生成 Token → 存储 Token
       │
       ▼
  ③ 通过安全渠道发送重置链接/验证码
       │
       ▼
  ④ 用户点击链接 / 输入验证码
       │
       ▼
  ⑤ 服务端验证 Token(时效、次数、来源)
       │
       ▼
  ⑥ 用户设置新密码(需满足强度要求)
       │
       ▼
  ⑦ 更新密码 → 销毁 Token → 清除其他会话 → 通知用户

二、 各环节安全设计详解

环节 ①:发起重置请求

安全要点:

  • 统一响应,不泄露用户是否存在 无论邮箱是否注册,都返回相同的提示信息,防止**用户枚举攻击**。

    Text Only
    ✅ "如果该邮箱已注册,我们将发送重置链接。"
    ❌ "该邮箱未注册。"  /  "重置链接已发送至 xxx@xxx.com"
    

  • 频率限制(Rate Limiting) 对同一 IP、同一邮箱进行严格的请求频率限制,防止暴力刷取 Token 或邮件轰炸。

    Text Only
    建议策略:
    - 同一邮箱:每 15 分钟最多 1 次,每天最多 5 次
    - 同一 IP:每分钟最多 10 次
    

  • 人机验证(CAPTCHA) 在触发重置前要求通过图形验证码或行为验证(如 reCAPTCHA、滑块验证),防止自动化脚本批量触发。


环节 ②:Token 生成与存储

这是整个流程中最核心的安全环节。

Token 生成规范
属性 要求 说明
长度 ≥ 32 字节(256 bit) 确保不可被暴力猜测
随机性 必须使用 CSPRNG 如 secrets.token_urlsafe(32)(Python)、crypto.randomBytes(32)(Node.js)
不可预测 禁止使用时间戳、用户ID、自增ID等 防止被推算
编码 URL-safe Base64 或 Hex 避免特殊字符导致链接解析错误
Python
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 的哈希值,而非明文(与存储密码同理)

    Python
    import hashlib
    
    token_hash = hashlib.sha256(reset_token.encode()).hexdigest()
    # 将 token_hash 存入数据库,明文 token 仅通过邮件发送一次
    
    这样即使数据库泄露,攻击者也无法利用 Token。

  • 数据库表设计示例

    SQL
    CREATE 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 请求头中读取域名来拼接链接。

Python
# ❌ 危险:从请求头读取
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

当用户点击重置链接或提交验证码时,服务端必须进行**多维度校验**:

Python
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 传输,防止中间人窃听。

环节 ⑦:重置完成后的清理与通知

这一步经常被忽略,但至关重要:

  1. 立即销毁 Token

    Python
    db.execute("UPDATE password_reset_tokens SET used = TRUE WHERE token_hash = %s", (token_hash,))
    # 或直接删除记录
    

  2. 使该用户的所有现有会话失效 密码被重置意味着账户可能已被入侵,必须强制所有设备重新登录。

    Python
    # 清除该用户的所有 session / JWT 黑名单
    session_store.invalidate_all(user_id)
    

  3. 发送安全通知 向用户的注册邮箱(和/或手机)发送通知:

    "您的账户密码已于 2026-09-03 14:30 (UTC+8) 被修改。如果这不是您本人的操作,请立即联系客服。"

  4. 记录审计日志 记录密码重置的时间、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 + 短时效 + 一次性使用 + 固定域名拼接 + 哈希存储 + 重置后全局踢出 + 安全通知。

每一个环节都是防御链条上的一环,任何一环的缺失都可能被攻击者利用。安全设计需要**纵深防御**的思维,而不是依赖单一措施。