密码加盐哈希,拖库也不怕

任何系统通常都有用户,数据库因而少不了用户表,少不了要保存用户密码。那么,密码能明文保存吗?肯定不能!

如果用户密码用明文保存,一旦数据库被拖库,用户密码就全泄露了呀!用户密码泄露可不是件小事,影响的也不仅仅是泄露站点,因为用户通常用同一个密码走天下!

写到这不禁想起多年前发生的 CSDN 拖库事件,泄露了大量用户的密码,笔者的账号密码也在其中。顺便吐槽一下,CSDN 烂不是一天两天了,一个程序员交流网站竟然明文保存密码!

当年我还年轻,好多网站的账号密码都是一样的。黑客拖库得到我的账号密码后,可以用它来登录我的 QQ ,我的邮箱,甚至是网银!想想就觉得可怕!

从此以后,我养成了一个习惯——每个网站都用随机生成的独立的密码。为此,我还自己写了个密码管理器,支持随机生成密码、加密保存密码、按域名等关键字搜索密码。不过,后来懒得自己折腾,直接入了 1Password

话说回来,您可能会觉得:只要数据库保护得足够好,不被拖库,明文保存密码似乎也问题不大。其实拖库是无法百分之百避免的,因为系统总有漏洞,比如 SQL注入 漏洞就可以用来拖库。

因此,拖库肯定要防,如何将拖库损失降到最低也必须考虑。况且就算没有拖库,明文保存的密码也有由内部渠道泄露的风险。那么,密码如何加密保存呢?

哈希

您可能会想到,先选择一个加密密钥,然后对密码进行加密后再保存到数据库;查询时再用密钥对密码进行解密。这看上去是个不错的方案,但事实并非如此,原因有二:

  • 如果数据和密钥同时被泄露,密码就可以被解密出来;
  • 密码仍可能由内部渠道泄露,因为系统管理员可能同时拥有加密密钥和数据库权限;

因此,最理想的做法是:让密码保存后,无法再被复原出来。换句话讲,加密必须是 不可逆 的。这时,您应该会想到 md5sha128sha256 等哈希算法。是的,用户密码通常都是用哈希算法加密后再保存到数据库的。

保存

用户注册或修改密码时,系统用哈希算法将用户的明文密码转换成一个 哈希值 ,再保存到数据库。以密码 123456 为例,用 md5 将其转换成一个哈希值:

1
2
3
>>> import hashlib
>>> hashlib.md5(b'123456').hexdigest()
'e10adc3949ba59abbe56e057f20f883e'

哈希值作为密码的替代品,保存到数据库。这样一来,就算数据库被拖,黑客也无法根据哈希值还原出密码来。

验证

数据库不保存密码,那登录时如何验证用户的密码是否正确呢?

哈希算法有一个特点,相同的输入产生的输出一定也是相同的。换句话讲,同一个密码算出来的哈希值一定是一样的;不同密码算出来的哈希值通常是不一样的。

由于哈希冲突的存在,两个不同的密码算出来的哈希值也有可能是一样的。但只要哈希值空间足够大,哈希冲突的概率就可以降到极低。以 md5 算法为例,哈希冲突的概率几乎为零。因此,可以认为不同密码算出来的 md5 值是不一样的。

利用这个特性,登录验证时可以用同样的哈希算法将用户提供的密码转换成一个哈希值,再跟数据库中的进行比较。如果两个哈希值相同,说明用户提供的登录密码正确。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 数据库中的哈希值(原密码是:123456)
>>> hash_in_db = 'e10adc3949ba59abbe56e057f20f883e'
# 用户输入的密码
>>> password_input = b'123456'
# 对密码求哈希再跟数据库中的比较,相等表示密码正确!
>>> hashlib.md5(password_input).hexdigest() == hash_in_db
True

# 用户输入错误密码
>>> password_input = b'12345678'
# 对密码求哈希再跟数据库中的比较,不相等表示密码错误!
>>> hashlib.md5(password_input).hexdigest() == hash_in_db
False

彩虹表

黑客拖库也无法通过哈希值还原密码,是不是就可以高枕无忧了呢?很不幸,并不是的!因为黑客还有一个大招—— 彩虹表rainbow table )。

众所周知,很多人喜欢用一些简单好记的密码,例如 123456111111 以及生日等等。那么,黑客可以收集弱密码集合,然后计算出每个弱密码的哈希值,形成一张弱密码和哈希值的映射表。

这样一来,黑客拖库后拿用户的密码哈希值到映射表中匹配,就可能破解出原密码。如下图,黑客拿到 alan 的密码哈希值到映射表中匹配,命中密码 123456 的哈希值,这说明 alan 的密码大概率就是 123456

换句话讲,通过预先计算的弱密码哈希值映射表,黑客可以成功破解出用户的弱密码,这也是系统管理员经常明令禁用弱密码的原因。像这样的预先计算好的密码哈希值映射表,在加密领域一般叫做彩虹表。

加盐哈希

那么,彩虹表破解方法又该如何防御呢?——答案是给哈希算法 加盐salted )。

彩虹表破解的关键在于:给定哈希算法,同一个密码算出的哈希值一定是相同。因此,黑客可以预先收集并算好弱密码哈希值,再遍历破解。如果有办法让相同密码算出来的哈希值完全不同,就可以应对彩虹表!

如果我们将一个密码和一个随机字符串合在一起算哈希值,结果肯定千差万别,这个随机字符串通常就叫

密码哈希前先加把盐,我们实现了动态哈希值,但登录又该如何验证密码是否正确呢?很显然,如果我们知道密码加的盐是什么,将待验证密码和盐合起来算哈希值,再跟原来的哈希值对比即可:

如此一来,数据库除了要保存哈希值,还得保存参与哈希的随机字符串,也就是盐。哈希值和盐可以分为两个字段保存,也可以合在一起保存,具体看应用和数据库的设计思路。

哈希值通常以十六进制字符表示,如果随机字符串只包含字母和数字字符,用横杆 - 将它们拼在一起即可。下面用 Python 来演示一把:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. 准备一个字符集用于生成随机字符串,字符集只包含字母和数字
>>> import string
>>> charset = string.ascii_letters + string.digits
>>> charset
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

# 2. 生成一个随机字符串作为盐,长度为32
>>> import random
>>> salt = ''.join(random.sample(charset, 32))
>>> salt
'IF4ti79dY1kvQwNfVS50rg3RTUAnDqsX'

# 3. 将盐和密码拼在一起算哈希值
>>> import hash
>>> password = '123456'
>>> hash = hashlib.md5((salt+password).encode()).hexdigest()
>>> hash
'8d7aa04dbe38f3e91de617237fea7472'

# 4. 将盐和哈希值拼在一起保存到数据库
>>> salted_hash = '{}-{}'.format(salt, hash)
>>> salted_hash
'IF4ti79dY1kvQwNfVS50rg3RTUAnDqsX-8d7aa04dbe38f3e91de617237fea7472'

登录验证密码时,盐和哈希值可以通过横杆分割出来:

1
2
3
4
5
>>> salt, hash = salted_hash.split('-')
>>> salt
'IF4ti79dY1kvQwNfVS50rg3RTUAnDqsX'
>>> hash
'8d7aa04dbe38f3e91de617237fea7472'

之后再按上图所示,将盐和待验证密码拼接起来计算哈希值,比较验证即可。

引入随机字符串作为哈希盐后,黑客就无法拿一张彩虹表就来破解了。相反,黑客只能暴力破解:遍历每个用户,再逐个尝试弱密码。这相当于为每个待破解的用户,都生成一张专门的彩虹表,计算量可想而知!

那我们是不是就可以肆无忌惮地使用弱密码了呢?肯定不是!原因很多:

  • 常见的弱密码比如 123456 ,可能被优先尝试而很快就被破解出来;
  • 如果密码长度不够,密码组合数不多,也容易被遍历破解;
  • 万一您访问的网站很垃圾,哈希密码没加盐,甚至跟 CSDN 那样明文保存呢?

密码选择原则

  • 不同站点密码独立,这样就算泄露了也不会影响其他的站点;
  • 密码长度要够,包含字母、数字、以及符号等各种组合,以防被轻松破解;
  • 密码中最好不要包含跟个人相关的信息,比如生日;

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: