你的用户密码正在数据库里裸奔 一次安全存储改造实录
原创
紫禁
紫禁
紫禁玄科
2026年10月14日 00:45
四川
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
SECURITY · INTELLIGENCE
你的用户密码正在数据库里裸奔 一次安全存储改造实录
2026年10月04日 · 8 min
网络安全#安全编程#密码存储#代码审计
01
导读
上个月帮一个朋友的创业项目做代码审计,我习惯性地点开用户表结构,在 password 字段后面看到了一行注释:「md5 加密存储」。我问他:「盐呢?」他愣了一下:「md5 不就够了吗?」这篇文章,就是我把当时给他讲的那套东西整理成了一份可以直接抄走的实录——不讲攻防对抗,只讲怎么把密码存储这件事写对。这是安全编程里最基础、也是我做审计时见过错误最多的一环。
02
一、先算一笔账:拖库之后,你的密码值多少钱
写代码的人容易觉得「数据库都拿走了才有事,我操这份心干嘛」。但安全编程的第一课就是建立威胁模型:假设数据库已经丢了,你的密码存储方式决定了用户的损失下限。
密码存储是用户数据的最后一道防线
1. 明文存储:拖库即全员裸奔,用户在你家的密码等于在别处的密码
2. MD5/SHA1 无盐:一张彩虹表几秒钟批量还原大半,剩下的交给显卡
3. MD5 加盐:挡住彩虹表,但挡不住针对性爆破——单个哈希的枚举依然是秒级到分钟级
4. 慢哈希加盐:把单次验证成本抬到几十毫秒,离线枚举成本直接涨几个数量级
实战中最常见的连锁反应是「撞库」:密码被还原后,用户在其他网站的账号跟着遭殃。2011 年国内那波拖库潮之后,很多人才第一次意识到,一个「只是存个密码」的字段写错了,代价是全站用户的信任。所以这笔账很清楚——密码字段的每一次偷懒,都会在数据库泄露那天连本带利还回来。
03
二、三个常见错误写法,问题到底出在哪
我审计过的项目里,密码存储的错误写法高度集中在三种:直接明文、md5(password)、md5(password + 固定盐)。它们的共同问题,用一段 20 行的代码就能看明白:
不加盐的哈希,等于把字典摊开给人查
pwd\_cost.py — 为什么快哈希不能拿来存密码
pwd\_cost.py — 为什么快哈希不能拿来存密码(耗时对比演示)
import hashlib, time, os
def md5\_store(pwd: str) -> str:
\"\"\"错误写法:无盐、太快、可被字典预计算匹配\"\"\"
return hashlib.md5(pwd.encode()).hexdigest()
def scrypt\_store(pwd: str) -> str:
salt = os.urandom(16)
dk = hashlib.scrypt(pwd.encode(), salt=salt, n=2\\14, r=8, p=1)
return dk.hex()
ts = lambda: f\"[{time.strftime('%H:%M:%S')}]\"
问题一:相同密码 -> 相同哈希,攻击者一眼识别「撞脸」账号
print(ts(), \"问题一:哈希撞脸\")
print(\" 用户A 的 md5:\", md5\_store(\"123456\")[:16], \"...\")
print(\" 用户B 的 md5:\", md5\_store(\"123456\")[:16], \"...\")
问题二:md5 计算太快,离线枚举几乎没有成本
runs = 10000
t0 = time.time()
for \_ in range(runs):
md5\_store(\"guess-me\")
t\_md5 = time.time() - t0
t0 = time.time()
for \_ in range(3):
scrypt\_store(\"guess-me\")
t\_scrypt = (time.time() - t0) / 3
print(ts(), f\"问题二:{runs} 次 md5 只花 {t\_md5:.3f} 秒\")
print(f\" 一次 scrypt 要 {t\_scrypt\*1000:.0f} 毫秒,是 md5 的 {t\_scrypt/(t\_md5/runs):.0f} 倍\")
跑完这段代码,两个问题一目了然。第一,哈希撞脸:两个用户用同一个密码,MD5 值一字不差——拿到库的人不用还原密码,只要按哈希分组,就知道哪些账号「共享」了同一个口令,再从最容易撞开的一组下手。第二,算得太快:一万次 MD5 只要几毫秒,而一次 scrypt 要几十毫秒,成本差了四个数量级以上。哈希算法当年是为「校验文件完整性」设计的,追求的就是快——而密码存储恰恰需要慢,这是两种完全相反的需求。
04
三、把存储写对:一份 30 行的标准实现
正确写法只有一条主线:每个用户一个随机盐 + 慢哈希算法 + 把参数存进哈希串。不依赖任何第三方库,Python 标准库的 hashlib.scrypt 就够用(OpenSSL 1.1 以上环境均可运行):
慢哈希刻意抬高单次计算成本,让离线枚举无利可图
pwd\_safe.py — 密码安全存储的标准写法
pwd\_safe.py — 密码安全存储的标准写法(仅用 Python 标准库)
import hashlib, hmac, os, base64, time
N, R, P = 2\\14, 8, 1 # scrypt 参数,n 从 2^14 起步
PEPPER = b\"pepper-from-kms\" # 实战中从环境变量或 KMS 读取,不进代码库
def hash\_password(pwd: str) -> str:
salt = os.urandom(16) # 每个用户、每次改密都换新盐
dk = hashlib.scrypt(PEPPER + pwd.encode(), salt=salt, n=N, r=R, p=P)
b64 = base64.b64encode
return \"scrypt$%d$%d$%d$%s$%s\" % (
N, R, P, b64(salt).decode(), b64(dk).decode())
def verify\_password(pwd: str, stored: str) -> bool:
\_, n, r, p, salt\_b64, dk\_b64 = stored.split(\"$\")
salt, want = base64.b64decode(salt\_b64), base64.b64decode(dk\_b64)
got = hashlib.scrypt(PEPPER + pwd.encode(),
salt=salt, n=int(n), r=int(r), p=int(p))
return hmac.compare\_digest(got, want) # 时间恒定比较,防时序侧信道
if \\_name\\ == \"\\main\\_\":
ts = lambda: f\"[{time.strftime('%H:%M:%S')}]\"
stored = hash\_password(\"MyStr0ng!Pass2026\")
print(ts(), \"存储串:\", stored[:56], \"...\")
print(ts(), \"正确密码 ->\", verify\_password(\"MyStr0ng!Pass2026\", stored))
print(ts(), \"错误密码 ->\", verify\_password(\"password123\", stored))
again = hash\_password(\"MyStr0ng!Pass2026\")
print(ts(), \"再哈希一次:\", again[:56], \"...\")
print(ts(), \"两次存储串不同(盐不同):\", again != stored)
密码安全存储流程
几个关键设计,逐个说清楚:
1. 盐用 os.urandom(16):每个用户、每次改密码都换新盐。盐不需要保密,它的作用是让「相同密码产出不同哈希」,彩虹表和批量比对就此失效
2. 参数跟着哈希串一起存:scrypt$n$r$p$salt$hash 这种自描述格式,将来调高参数后新旧记录可以共存,验证时按各自记录里的参数算
3. 比较用 hmac.compare_digest:普通 == 比较会「提前返回」,理论上能通过耗时差异逐字节猜出正确哈希,时间恒定比较把这条路堵死
4. 参数怎么选:scrypt 的 n 从 2^14 起步、内存允许就上 2^15、2^16;bcrypt 的 cost 建议 10 以上;新项目可以直接上 Argon2id(argon2-cffi 库)。原则是「单次验证 50-100 毫秒」——对正常用户无感,对离线枚举致命
另外强烈建议加一层 pepper:在密码进哈希前先拼一个全局密钥(从环境变量或 KMS 读,不进代码库、不进数据库)。这样即便数据库整个被拖走,没有 pepper 的攻击者拿到的哈希串依然算不出密码——这是「数据库泄露」和「服务器沦陷」之间的最后一道隔离。
05
四、登录环节还有四个坑
存储写对了,验证环节写错照样前功尽弃。这几个坑我在项目里反复遇到:
1. 用户枚举:「用户名不存在」和「密码错误」返回不同的提示和耗时,等于公开账号是否注册。统一返回「账号或密码错误」,且不存在的账号也要走一遍假哈希计算,把耗时拉平
2. 没有限速:登录接口不做频率限制,再强的哈希也挡不住在线爆破。按 IP + 账号双维度限速,连续失败 5 次引入验证码或短暂锁定
3. 改密不吊销会话:改密码后旧 token 依然有效,账号被盗后受害者改了密码也把人请不出去
4. 日志里打密码:调试日志把请求体整个打出来,密码明文进日志文件。日志脱敏是安全编程的另一门必修课,密码字段永远只记「已脱敏」
这四条和存储方案是乘法关系:任何一条为零,整体安全性就是零。
06
五、存量 MD5 数据怎么迁
很多项目不是新起的,用户表里躺着几百万条 MD5。推倒重来会让所有人改密码,不现实。标准做法是渐进式迁移:
-
表里加一列
pwd_secure,新注册、改密码一律写 scrypt 串,旧列不动 -
登录验证时先按新列校验;新列为空则走旧的 MD5 校验,校验通过后当场用该密码生成 scrypt 串写入新列——用户无感完成迁移
-
定期统计新列覆盖率,一个季度后对仍未迁移的休眠账号强制下次登录改密
-
迁移完成后把旧 MD5 列彻底删除,别留着当「历史备份」
我之前在一个百万级用户的社区项目上跑过这套流程,三个月迁移了 92% 的活跃用户,全程零投诉——用户根本感知不到后台换了一套哈希算法,而这已经是他账户安全的一次静默升级。
07
收尾:一张可以贴在墙上的自查清单
-
密码是慢哈希(scrypt / bcrypt / Argon2id)吗?MD5、SHA1、SHA256 无论加不加盐都不合格
-
盐是每个用户独立的随机值吗?硬编码在配置文件里的「固定盐」等于没加
-
哈希参数存在哈希串里吗?将来升级参数要不要全表重算?
-
比较用的是
hmac.compare_digest吗? -
有没有限速、统一错误提示、改密吊销会话、日志脱敏?
-
有条件上 pepper(KMS/环境变量)吗?数据库丢了能不能兜住?
密码存储是那种「写对一次、十年受益」的基础设施。它没有炫技空间,但它是用户把信任交给你时,你唯一能还回去的东西。今天这篇文章里的两段代码都放在文末可以自取,跑一遍、抄进你的项目里,比收藏十篇「安全最佳实践」都管用。
觉得有用?点个「在看」让更多人看到
紫禁玄科
专注网络安全 · 深度分析
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论