密码存储安全:哈希加盐的原理、常见误区与工程迁移实践
发布时间:2026/8/31 17:32:36 作者:尧图编辑部 阅读量:1,286

前一阵参加一次内部代码评审刚打开项目仓库群里就有人发了一张截图同一张用户表里同一个用户连续三次修改密码三行记录的哈希值居然完全相同。页面安静了几秒然后有人打了一行字“盐呢”这一下点醒了所有人。这个功能不是什么新功能就是一个普通的登录注册模块但“盐 Salt”这个环节缺失导致的后果远比表面看起来严重。我当时还在一支配合起来不那么“正规”的小团队里项目代号叫“阿津”团队内部半开玩笑地自称“巴嘎海贼团”。既然自嘲是笨蛋海贼团那自然就得出点海贼才会犯的事故。问题排查到最后密码算法从 MD5 到 SHA256 都试过数据库里存的就是“哈希值”看起来也不是明文但攻击者只要拿到这张表几乎就等于拿到了所有密码。原因就一个哈希没有加盐。那天晚上我们补的不只是几行代码而是把整个密码存储的逻辑重新理了一遍。回过头看这是很多项目都会踩的坑。与其把错误记成一段小插曲不如把整条链路、排查方法和日常评审标准写清楚。这也是“阿津”这个项目第三轮安全整改里我印象最深的一课。1. 为什么哈希不能脱掉“盐”就出场1.1 哈希函数不等于加密先说一个很容易混淆的点。很多新手会把“对密码做哈希”理解成“加密”以为数据到数据库之前转了一圈别人就看不懂了。这个理解方向没错但机制完全不同。哈希函数是一个单向映射把任意长度的输入变成一个固定长度的输出。问题在于同一个输入永远得到同一个输出。这不叫加密因为正常加密还需要密钥和解密过程哈希则没有“解开”这一步。所以密码校验时系统不是去解密而是重新计算一次哈希再比对结果。这个设计本身没问题。问题出在“同一个输入永远得到同一个输出”这个特性上。如果没有盐密码123456在任何一个系统里用同一种哈希算法算出来结果都是一样的。这意味着攻击者不需要针对每个用户单独破解只要预先把常见密码算一遍然后逐个对比数据库里的哈希值。1.2 彩虹表让无盐哈希的处境更危险这里就要说到“预计算攻击”了。攻击者可以提前准备一张很大的表把几十亿个常见密码和它们的哈希值一一对应存下来。拿到数据库后直接在表里查几个毫秒就能倒推出一批弱密码。这种表有一个专门的名称叫彩虹表。它的可怕之处不在于单次计算有多快而在于“一次计算反复使用”。同一个算法同一个无盐哈希所有使用该算法的项目都能被同一张表覆盖。网上有不少公开的彩虹表有些甚至已经覆盖了数十亿条记录。如果你项目里用的还是无盐 MD5 或无盐 SHA1攻击者拿到数据库文件后几乎不需要做真正的“破解”更像是在做“查字典”。1.3 同密码同哈希会放大泄露范围再想象一个更常见的场景一个公司内部系统很多员工为了方便都把密码设成了类似Password2024这样的组合。如果系统没有加盐那么这些的人哈希值会一模一样。攻击者一旦猜出其中一个密码就可以拿同一个哈希去匹配所有类似记录相当于一次性识别出所有用相同密码的人。盐的作用就是打破这种“同密码同哈希”的对应关系。每一行用户数据都使用一个独立生成的随机字符串密码拼接盐之后再做哈希。这样即便是同一个密码不同用户得到的哈希值也完全不同。攻击者不能再通过预计算表批量命中而必须针对每一个用户单独爆破。从工程实践看盐的价值并不在于让单个密码变得更强而在于让攻击者“捞一笔”的成本大幅度提高。一个密码是弱密码加盐后它本质上还是弱密码但加盐让攻击者无法一次验证一堆弱密码必须一个一个试。注意盐不是秘密它通常和哈希值一起明文存储在数据库里。真正重要的不是隐藏盐而是让每个用户的盐各不相同、随机生成。2. 盐到底是怎么作用的2.1 盐的生成方式在很多实现里盐就是一个随机字符串由密码学安全的随机数生成器产生。它不需要是人类可读的文本可以是字节序列也可以是 Base64 或十六进制字符串。常见做法是在用户注册时生成一段 16 字节或更长的随机数据然后拼接到密码后面再进行哈希计算。对不同的用户盐应该完全不同对同一个用户每次修改密码时也应该重新生成一个新盐。系统只要把盐和哈希值一起存下来登录校验时取出对应的盐再做一次同样的计算比对即可。为什么这里要强调“密码学安全的随机数生成器”因为普通语言的random()函数通常基于时间种子可预测性比较强。攻击者如果拿到生成日志或重置时间可能推导出盐的规律。安全场景里盐的随机性不能省。Python 里可以用secrets.token_bytesJava 里可以用SecureRandomNode.js 里可以用crypto.randomBytes。这都属于标准实践不涉及额外依赖。2.2 一个最小可运行的注册与登录校验流程下面用一个简化示例展示加盐流程的骨架。这个示例只为了说清逻辑不一定适用于生产环境。生产环境更建议直接使用 bcrypt、argon2 这类完整方案。import hashlib import secrets # 注册流程生成随机盐计算哈希存储盐和哈希 def register(username, password): salt secrets.token_hex(16) # 每次注册都生成新盐 hash_value hashlib.sha256((salt password).encode(utf-8)).hexdigest() save_user(username, salt, hash_value) # 登录校验取出盐重新计算并比对 def login(username, password): salt, hash_value get_user_salt_and_hash(username) new_hash hashlib.sha256((salt password).encode(utf-8)).hexdigest() return new_hash hash_value这是最基础的加盐逻辑比完全没有盐已经前进了一大步。但只到这里还不够。用 SHA256 这类快速算法加盐虽然挡住了彩虹表却没挡住离线暴力破解因为现代 GPU 可以在极短时间内尝试海量组合。因此真实工程里通常要选用慢哈希算法比如 PBKDF2、bcrypt、scrypt 或 Argon2而不是只在普通 SHA 前面拼一个盐。2.3 数据库里该存什么加盐后的用户表至少需要三个关键字段用户名、盐值、哈希值。有些实现会把盐和哈希合并成一段格式化字符串例如 bcrypt 库的输出就是类似$2b$12$...的格式里面已经包含了盐、迭代次数和哈希结果。这种格式的好处是方便存储和迁移但也有团队更喜欢拆成独立字段便于审计和排查。下面是一个典型拆分展示字段示例说明usernameakira用户标识password_salta3f1b9c2d8e44f61...随机生成每个用户不同password_hashf7d9c1a5...盐加密码后的哈希结果从安全角度看盐值没必要加密存储。因为它本来就会被攻击者拿到真正拉高攻击成本的是每个用户盐值不同攻击者没法复用预先算好的结果。3. 加盐时的常见错误以及一条排查链路3.1 四个高频错误加盐看起来不复杂但实际落地时容易犯的错却不少。我见过最多的大概是下面四种。第一全站共用同一个固定盐。有些项目写了一个配置文件里面写死了一个字符串所有用户都用它。这样做确实让哈希值不再是“纯密码哈希”但本质上等于给所有用户只加了一层固定前缀。攻击者只要知道这个固定盐依然可以重新制作适用的彩虹表事后再慢慢跑。全局固定盐并没有解决“同密码同哈希”的问题。第二用用户名当盐。用户名确实每个用户不同但它不是随机值而且一旦用户名不变盐就不变。攻击者可以把用户名直接纳入暴力破解范围。更麻烦的是用户改名时如果盐跟着变密码哈希就会失效导致额外处理成本。用户名当盐不是一个好的安全设计。第三盐太短或生成方式可预测。随机盐如果只有 4 个字符穷举空间太小如果基于时间戳生成攻击者又能找到规律。网上的一些实现里甚至直接用Math.random()这种非密码学随机数来生成盐这在安全场景里是不够的。第四改密码时没有重新生成盐。有个系统注册时生成了盐之后每次密码重置都沿用旧盐。这样做不一定立刻出问题但从安全习惯上看重置密码就是一次重新建立身份可信度的机会应该重新生成盐。旧盐一旦存在泄露风险继续沿用等于把老问题带到新密码上。3.2 拿到一份可疑代码从哪里开始查如果你接手一个历史项目怀疑密码存储环节没有正确加盐可以按这条链路排查。先看现象数据库里的哈希值是不是出现过大量重复同一个用户修改密码前后哈希是否几乎不变如果不同用户之间的哈希高度一致基本可以判断没有加盐或者盐是全局固定的。再看注册逻辑每次注册时盐字段是独立生成的还是从一个常量里取出来的是否用到密码学安全随机数生成器盐有没有落入日志如果日志里把明文密码和盐同时打印出来那加盐就形同虚设。再看哈希算法项目里用的是普通 MD5、SHA1、SHA256还是带迭代次数的慢哈希很多老项目用的是md5(password salt)这种写法看起来加盐了实际上因为 MD5 计算速度太快现代显卡可以在极短时间内完成海量尝试安全性仍然不够。最后看校验路径登录时是否取了正确的盐密码重置、第三方登录绑定、管理员手动改密这些旁路是否也走了加盐哈希流程很多漏洞不是出现在主流程而是出现在一些不常维护的分支逻辑里。排查时不要只看某一处代码。把“注册、登录、改密、找回密码”四条路径全部拉出来逐一确认盐的生成、存储、取值和哈希算法是否一致。3.3 一个实用的自查动作如果你不确定线上数据库里的历史数据有没有问题可以做一次统计。写一个脚本把用户表中的哈希值按内容分组统计重复数量。如果大量非空哈希值重复说明系统很可能没有加盐或使用了全局固定盐。这个动作不需要访问明文密码只需要数据库查询权限短时间内就能给出结论。另一个自查方式是把注册接口测试两遍同一个密码注册两个不同用户然后比较两个哈希值。如果完全相同基本可以确定没加盐或盐是固定的如果不同只能说明“有盐”还不能说明盐的质量足够高。要判断盐质量还得看随机数来源和长度。4. 从加盐走向正确的密码哈希方案4.1 加盐只是第一道栏杆很多人以为给密码哈希加盐就万事大吉但加盐解决的是“批量破解”的问题并没有解决“单个目标被暴力破解”的问题。只要攻击者掌握了数据库又锁定了某个特定账号他依然可以针对这个账号的盐和哈希去做离线猜测。这时候哈希算法的计算速度就成了关键。MD5 和 SHA 族都是为速度而生的计算极快。攻击者的硬件越强单位时间内能尝试的密码就越多。慢哈希算法的设计目标恰恰是让单次计算故意变慢从而压制暴力破解速度。bcrypt、scrypt、PBKDF2、Argon2 都属于这一类。它们背后有迭代或内存占用等机制攻击者要破解一个密码必须付出远高于 MD5 的计算成本。代价是服务器登录时也会慢一点点但现代硬件上这个开销通常是可以接受的安全收益远大于性能损耗。4.2 常见算法怎么选这里需要明确一个问题我没有办法替你确认某个算法在你的环境里一定最合适因为选型要看语言生态、运行环境、合规要求和团队维护能力。但从通用实践来看可以给出一个大致判断。算法特点常见使用场景PBKDF2标准成熟基于哈希函数可调整迭代次数兼容性要求高的系统bcrypt内置盐自带迭代设计使用广泛大多数 Web 应用scrypt同时占用较多内存抗硬件加速能力强对内存型攻击有更高要求的场景Argon2后起之秀在众多设计里表现较好可调内存、迭代和并行度新系统或安全要求高的场景选型时有一条很实用优先选择语言生态里已有成熟库的方案不要自己实现。自己拼随机数、自己设计拼接规则、自己写比较逻辑都容易埋雷。成熟的密码库已经处理好了格式、比较时防止时序攻击等细节。4.3 新旧系统切换时怎么迁移现实项目里很少能一次把所有老数据全部刷新。更常见的是存量用户还在用旧哈希新用户和改密用户开始用新算法。这时候最简单的方案是二进制兼容登录时先用新算法计算如果匹配则通过如果匹配不上再退回旧算法验证。一旦用旧算法验证通过立刻用新算法重新生成哈希和盐然后更新数据库。这个过程可以让系统自然地完成迁移活跃用户会在登录时逐渐被更新不活跃用户暂时保持不变。等一段时间后再统计还有多少老哈希未迁移判断是否要强制重置剩余密码。整个迁移里最怕的是设计一个“只写入新算法不验证旧密码”的逻辑结果造成大量老用户无法登录。稳妥做法是先让新旧算法并存验证确认大部分用户已经自然迁移再逐步移除旧分支。4.4 别忘了其他边界密码加盐和慢哈希解决的是“数据库泄露后密码被逆向”的风险但它不是安全体系的全部。一个系统里还可能有其他问题例如用户仍然使用弱密码比如123456、admin。盐再强也救不了 6 位纯数字密码在暴力破解下的低复杂度。密码重置链接有效期太长或者重置令牌没有随机化。日志系统把明文密码、盐和哈希一起打进了日志。注册接口没有限制频率攻击者可以不断尝试拖垮认证服务。加盐属于“必不可少但不充分”的一层。它必须和其他措施配合才能形成完整的登录安全方案。5. 把“盐”写进日常工程流程5.1 代码评审时把加盐当作硬条件在我参与过的很多项目里密码存储问题其实不是在攻击发生后才被发现的而是在代码评审阶段就被忽略的。评审的人看代码时更关注业务逻辑很少有人会追问“这个哈希用的什么算法”“盐是怎么生成的”“有没有统一入口”。这里更建议的做法是把密码存储规则写成一个检查清单。每次涉及用户认证、密码变更、导入导出用户数据的改动都必须确认盐的生成方式、算法选择、数据库字段、日志脱敏这几个点。评审记录里直接勾选不完整就打回。这看似繁琐实际能省掉后面很多返工。5.2 用自动化测试验证加盐效果工程上可以加两条很简单的自动化测试防止返工。第一条注册两个不同用户使用同一个密码断言两个哈希值不相等。这条测试能直接验证盐是否保证了“同密不同哈希”。第二条对同一个用户连续两次修改为同一个密码断言两次生成的哈希值不相同。这能验证修改密码时是否重新生成了盐。这两条测试写起来不难却能让后续改动不悄悄把盐弄丢。尤其是项目中新增一套认证逻辑时如果测试没有覆盖到很容易出现新老流程行为不一致。5.3 给团队的真实建议先查最低标准再做替换如果团队规模很小也没有专门的安全人员我建议不要一开始就追求最复杂的安全框架。先把最低标准补齐再逐步升级。第一步确认数据库里所有密码都不是明文。第二步确认现有哈希已经加了随机盐且不同用户盐不同。第三步把算法从高速哈希切换成慢哈希库。第四步加上登录频率限制、日志脱敏和异常告警。这套顺序适合大多数历史项目。先跑通再优化最后工程化。如果你一上来就要求所有人用 Argon2 并自定义参数反而容易因为推行成本太高而搁浅。5.4 安全不是一次上线就结束回到“阿津”这个项目的第三轮整改。那天我们修完代码后并没有立刻宣布“安全了”。我们做了一份哈希迁移统计每天观察存量用户的刷新进度同时把评审清单加到仓库的提交规范里。整个整改过程持续了两周不是一次简单的改 bug。盐这个东西在代码里看起来只有几行。但它背后代表的是整个团队对“密码存储本质”的理解哈希不是加密加盐不是为了隐藏盐而是为了打乱预计算的可能性慢哈希不是为了折磨服务器而是为了抬高攻击者的单次试错成本。真正的生产级密码存储从来不是“用哪个函数拼一下”那么简单而是一整套流程随机盐、慢哈希、统一入口、自动化验证、存量迁移、日志脱敏缺一不可。现在回头看那天凌晨的截图它反而成了团队的一份教材。后来的新人入职时我们会把这段代码历史拿出来讲一遍。不是为了批评谁而是为了说明一个很简单的道理安全设计和业务逻辑一样都需要被看见、被测试、被持续维护。如果你手上也有一份历史代码不妨先做一个小动作打开用户表统计一下哈希重复率。如果发现异常不要慌这不是一个很难补的问题。给它加盐选一个合适的慢哈希库把验证测试写进去再分批迁移旧数据。一步一步走比追求一次完美的架构要实际得多。