作为一名开发者,开发一个用户账户系统很可能就是你工作内容的一部分,其中比较重要的事情就是如何保护用户的密码,当系统被攻破、数据库被拖库时如何降低泄露用户原始密码的风险是一件值得深思的问题(还记得当年CSDN被拖库后爆出系统存储了用户的明文密码吗?)。比较行之有效的办法是对用户密码进行加盐哈希。

为什么要对密码进行哈希

首先我们希望即使被拖库后用户的明文密码也不会遭到泄露,以免使得坏蛋们可以拿着这些明文密码再去撞别家的库。
哈希算法是一个单向函数,它可以将任何大小的数据转化为定长的“指纹”,并且无法被反向计算。而且只要数据源改动了一点,哈希的结果也会完全不同。这样的特性使得它非常适合用于保存密码,因为我们期望加密后的密码无法被解密,但同时也能保证正确校验每个用户的密码。(这里的哈希算法跟数据结构里hashmap中的哈希算法可不是同一回事哦)

单纯的哈希安全吗?

在用户的密码上做一次加密哈希函数实际上并不是十分的安全,因为目前已经有办法可以快速的把密码从简单的哈希值里恢复出来。例如:字典攻击、暴力攻击等。

  • 字典攻击

    在一个字典文件里包含了常见的短语、单词和常用密码等字符串以及其对应的哈希值,用它们和密码的哈希值做对比,如果相同则对应字典里的值就是密码。例如用查表的方式,将预先计算好的哈希值和对应的密码存放到一个用于快速查询的数据结构中,厉害一点的即使存储了上亿个哈希值每秒也能进行数百次查询。

  • 暴力攻击

    暴力攻击会尝试每一个在给定长度下各种字符的组合。这种方式需要进行大量的计算,也通常是破解哈希加密中效率最低的办法,但是它最终会找到正确的密码。但只要密码需要足够长,以至于遍历所有可能的字符串组合将耗费太长时间,使得攻击者放弃这种暴力的方法。

  • 彩虹表

    彩虹表是一种在时间和空间的消耗上找寻平衡的破解技术。它和查表法很类似,但是为了使查询表占用的空间更小而牺牲了破解速度。因为它更小,于是我们可以在一定的空间内存储更多的哈希值,从而使攻击更加有效。

加盐

加盐是目前常用的一种让查表法和彩虹表都失效的计数。即在密码中混入一段“随机”的字符串再进行哈希加密,这个被字符串被称作盐值。由于随机化了哈希值,攻击者不能预先生成彩虹表,增加了攻击成本。另外每个用户都是用一个独一无二的随机盐使得反向查表也变得很难。

加盐哈希的误区

  • 盐值重复

    如果盐值被硬编码到代码里或者用一个固定的随机值,那攻击者只要把盐值用到每个猜测密码上仍然可以用反向查表法或者针对这个系统生成一份彩虹表。

  • 盐值太短

    如果盐值太短,攻击者可以构造一个查询表包含所有可能的盐值。为了使攻击者无法构造包含所有可能盐值的查询表,盐值必须足够长。一个好的做法是使用和哈希函数输出的字符串等长的盐值。

  • 盐值随机性不够

    有人喜欢使用用户名、注册时间作为盐值,这些都不具有符合密码学要求的随机性。盐值应该使用基于加密的伪随机数生成器,例如:java中的java.security.SecureRandom。其他语言:PHP、C/C++、Python都有对应的实现。

关于组合哈希

经常看到有人使用组合哈希函数,人们认为将哈希函数组合起来结果会更加安全,使用多种哈希函数会使得计算更慢,从而破解变慢。网上多见的组合有:

  • md5(sha1(password))
  • md5(md5(salt) + md5(password))
  • sha1(sha1(password))
  • sha1(str_rot13(password + salt))
  • md5(sha1(md5(md5(password) + sha1(password)) + md5(password)))

但有人对此提出质疑:他们认为实际上这样做几乎没有好处,仅仅造成了函数之间互相影响的问题,甚至有时候会变得更加不安全。

使用组合哈希函数是考虑到如果攻击者不知道系统用的哪种哈希函数就不会预先生成彩虹表,但是如密码学中著名的Kerckhoff准则所说,攻击者还是比较容易拿到源码的。

Kerckhoff准则:系统的保密性不依赖于加密体质或者算法的保密,而依赖于密钥。

关于慢哈希

使用随机盐可以让攻击者无法使用查表和彩虹表的方式对大量hash进行破解,但是仍然不能阻止攻击者针对单个密码进行暴力和字典攻击,高端的显卡(GPUs)和一些定制的硬件每秒可以计算数十亿的hash。

为了避免针对单个密码的查表和暴力攻击,可以采用一种称为key扩展(key stretching)的技术。就是让hash的过程便得非常缓慢,即使使用高速GPU和特定的硬件,字典和暴力破解的速度也慢到没有实用价值,通过减慢hash的过程来防御攻击。

key扩展的实现是使用一种大量消耗cpu资源的hash函数。不要去使用自己创造的迭代hash函数,如果迭代不够多,是可以被高效的硬件快速并行计算出来的。要使用标准算法的hash函数,比如PBKDF2或者bcrypt。这类算法使用一个安全因子或迭代次数作为参数,这个值决定了哈希函数会有多慢。

同时应注意如果在web应用的服务器中做这类hash计算,密钥扩展也使得网站更容易遭受拒绝服务攻击(DoS)。当然也可以考虑在用户的浏览器运行hash函数,这样将计算量都分摊到了客户端,可以减轻服务器的压力,客户端的密钥扩展并不能免除服务端进行哈希加密的职责,服务器必须对客户端传来的哈希值再次进行哈希加密,就像看待一个普通密码一样,如果客户端不支持JS的时候,服务端应该接手计算。

慢比较是怎么做的

『慢比较』代码如下:

1
2
3
4
5
6
7
8
 
private static boolean slowEquals(byte[] a, byte[] b){
int diff = a.length ^ b.length;
for(int i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}

代码里使用异或来比较两个数字,将其应用到整数中每一位,当且仅当字节两个整数各位都相等,结果才是0。所以循环结束后diff是0的话只有一种可能,那就是循环前两个数组长度相等(a.length == b.length),并且数组中每一个字节都相同(每次异或的结果都非0)。

使用异或而不是”==”来作比较是因为”==”通常会被编译成带有分支的语句,例如C语言中的“diff &= a == b”可能会被编译为如下汇编语言:

1
2
3
4
5
6
7
8
MOV EAX, [A]
CMP [B], EAX
JZ equal
JMP done
equal:
AND [VALID], 1
done:
AND [VALID], 0

其中的分支导致代码运行的时间不固定,决定于两个整数相等的程度和CPU内部的跳转预测机制。
而C语言代码“diff |=a ^ b”会被编译为下面的样子,它执行的时间和两个整数是什么样的情况无关。

1
2
3
MOV EAX, [A]
XOR EAX, [B]
OR [DIFF], EAX

最后

对密码进行哈希加密的手段,不是为了保护网站免受入侵,而是在入侵已经发生时保护数据库中的用户密码,以免使得危害继续扩散(如果用户在多个网站使用相同的密码,就容易被撞库),至少我们要对用户负责。