如何正确对用户密码进行加密?

本文转载自 如何正确对用户密码进行加密?。由刘志勇翻译自Defuse Security团队的文章Salted Password Hashing - Doing it Right

作为一名Web开发人员,我们经常需要与用户的帐号系统打交道,而这其中最大的挑战就是如何保护用户的密码。经常会看到用户账户数据库频繁被黑,所以我们必须采取一些措施来保护用户密码,以免导致不必要的数据泄露。保护密码的最好办法是使用加盐密码哈希( salted password hashing)。

在对密码进行哈希加密的问题上,人们有很多争论和误解,可能是由于网络上有大量错误信息的原因吧。对密码哈希加密是一件很简单的事,但很多人都犯了错。本文将会重点分享如何进行正确加密用户密码。

重要警告:请放弃编写自己的密码哈希加密代码的念头!因为这件事太容易搞砸了。就算你在大学学过密码学的知识,也应该遵循这个警告。所有人都要谨记这点:不要自己写哈希加密算法! 存储密码的相关问题已经有了成熟的解决方案,就是使用 phpass,或者在 defuse/password-hashinglibsodium 上的 PHP 、 C# 、 Java 和 Ruby 的实现。

密码哈希是什么?

1
2
3
hash("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
hash("hbllo") = 58756879c05c68dfac9866712fad6a93f8146f337a69afe7dd238f3364946366
hash("waltz") = c0e81794384491161f1777c232bc6bd9ec38f616560b120fda8e90f383853542

哈希算法是一种单向函数。它把任意数量的数据转换为固定长度的“指纹”,而且这个过程无法逆转。它们有这样的特性:如果输入发生了一点改变,由此产生的哈希值会完全不同(参见上面的例子)。这个特性很适合用来存储密码。因为我们需要一种不可逆的算法来加密存储的密码,同时保证我们也能够验证用户登陆的密码是否正确。

在基于哈希加密的帐号系统中,用户注册和认证的大致流程如下。

  1. 用户创建自己的帐号。
  2. 密码经过哈希加密后存储在数据库中。密码一旦写入到磁盘,任何时候都不允许是明文形式。
  3. 当用户试图登录时,系统从数据库取出已经加密的密码,和经过哈希加密的用户输入的密码进行对比。
  4. 如果哈希值相同,用户将被授予访问权限。否则,告知用户他们输入的登陆凭据无效。
  5. 每当有人试图尝试登陆,就重复步骤3和4。

在步骤4中,永远不要告诉用户输错的究竟是用户名还是密码。就像通用的提示那样,始终显示:“无效的用户名或密码。”就行了。这样可以防止攻击者在不知道密码的情况下枚举出有效的用户名。

应当注意的是,用来保护密码的哈希函数,和数据结构课学到的哈希函数是不同的。例如,实现哈希表的哈希函数设计目的是快速查找,而非安全性。只有加密哈希函数( cryptographic hash function)才可以用来进行密码哈希加密。像 SHA256 、 SHA512 、 RIPEMD 和 WHIRLPOOL 都是加密哈希函数。

人们很容易认为,Web开发人员所做的就是:只需通过执行加密哈希函数就可以让用户密码得以安全。然而并不是这样。有很多方法可以从简单的哈希值中快速恢复出明文的密码。有几种易于实施的技术,使这些“破解”的效率大为降低。网上有这种专门破解MD5的网站,只需提交一个哈希值,不到一秒钟就能得到破解的结果。显然,单纯的对密码进行哈希加密远远达不到我们的安全要求。下一节将讨论一些用来破解简单密码哈希常用的手段。

如何破解哈希?

字典攻击和暴力攻击( Dictionary and Brute Force Attacks)

字典攻击和暴力破解

破解哈希加密最简单的方法是尝试猜测密码,哈希每个猜测的密码,并对比猜测密码的哈希值是否等于被破解的哈希值。如果相等,则猜中。猜测密码攻击的两种最常见的方法是 字典攻击暴力攻击

字典攻击使用包含单词、短语、常用密码和其他可能用做密码的字符串的字典文件。对文件中的每个词都进行哈希加密,将这些哈希值和要破解的密码哈希值比较。如果它们相同,这个词就是密码。字典文件是通过大段文本中提取的单词构成,甚至还包括一些数据库中真实的密码。还可以对字典文件进一步处理以使其更为有效:如单词 “hello” 按网络用语写法转成 “h3110” 。

暴力攻击是对于给定的密码长度,尝试每一种可能的字符组合。这种方式会消耗大量的计算,也是破解哈希加密效率最低的办法,但最终会找出正确的密码。因此密码应该足够长,以至于遍历所有可能的字符组合,耗费的时间太长令人无法承受,从而放弃破解。

目前没有办法来阻止字典攻击或暴力攻击。只能想办法让它们变得低效。如果密码哈希系统设计是安全的,破解哈希的唯一方法就是进行字典攻击或暴力攻击遍历每一个哈希值了。

查表法( Lookup Tables)

查表法

对于破解相同类型的哈希值,查表法是一种非常高效的方式。主要理念是预先计算( pre-compute)出密码字典中的每个密码的哈希值,然后把他们相应的密码存储到一个表里。一个设计良好的查询表结构,即使包含了数十亿个哈希值,仍然可以实现每秒钟查询数百次哈希。

如果你想感受查表法的速度有多快,尝试一下用 CrackStation 的 free hash cracker 来破解下面的 SHA256。

1
2
3
4
c11083b4b0a7743af748c85d343dfee9fbb8b2576c05f3a7f0d632b0926aadfc
08eac03b80adc33dc7d8fbe44b7c7b05d3a2c511166bdb43fcb710b03ba919e7
e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904
5206b8b8a996cf5320cb12ca91c7b790fba9f030408efe83ebb83548dc3007bd

反向查表法( Reverse Lookup Tables)

反向查表法

这种攻击允许攻击者无需预先计算好查询表的情况下同时对多个哈希值发起字典攻击或暴力攻击。

首先,攻击者从被黑的用户帐号数据库创建一个用户名和对应的密码哈希表,然后,攻击者猜测一系列哈希值并使用该查询表来查找使用此密码的用户。通常许多用户都会使用相同的密码,因此这种攻击方式特别有效。

彩虹表( Rainbow Tables)

彩虹表是一种以空间换时间的技术。与查表法相似,只是它为了使查询表更小,牺牲了破解速度。因为彩虹表更小,所以在单位空间可以存储更多的哈希值,从而使攻击更有效。能够破解任何最多8位长度的 MD5 值的彩虹表已经出现

接下来,我们来看一种谓之“加盐( salting)”的技术,能够让查表法和彩虹表都失效。

加盐( Adding Salt)

加盐

查表法和彩虹表只有在所有密码都以完全相同的方式进行哈希加密才有效。如果两个用户有相同的密码,他们将有相同的密码哈希值。我们可以通过“随机化”哈希,当同一个密码哈希两次后,得到的哈希值是不一样的,从而避免了这种攻击。

我们可以通过在密码中加入一段随机字符串再进行哈希加密,这个被加的字符串称之为盐值。如上例所示,这使得相同的密码每次都被加密为完全不同的字符串。我们需要盐值来校验密码是否正确。通常和密码哈希值一同存储在帐号数据库中,或者作为哈希字符串的一部分。

盐值无需加密。由于随机化了哈希值,查表法、反向查表法和彩虹表都会失效。因为攻击者无法事先知道盐值,所以他们就没有办法预先计算查询表或彩虹表。如果每个用户的密码用不同的盐再进行哈希加密,那么反向查表法攻击也将不能奏效。

接下来,我们看看加盐哈希通常会有哪些不正确的措施。

错误的方法:短盐值和盐值复用

最常见的错误,是多次哈希加密使用相同的盐值,或者盐值太短。

盐值复用( Salt Reuse)

一个常见的错误是每次都使用相同的盐值进行哈希加密,这个盐值要么被硬编码到程序里,要么只在第一次使用时随机获得。这样的做法是无效的,因为如果两个用户有相同的密码,他们仍然会有相同的哈希值。攻击者仍然可以使用反向查表法对每个哈希值进行字典攻击。他们只是在哈希密码之前,将固定的盐值应用到每个猜测的密码就可以了。如果盐值被硬编码到一个流行的软件里,那么查询表和彩虹表可以内置该盐值,以使其更容易破解它产生的哈希值。

用户创建帐号或者更改密码时,都应该用新的随机盐值进行加密。

短盐值( Short Slat)

如果盐值太短,攻击者可以预先制作针对所有可能的盐值的查询表。例如,如果盐值只有三个 ASCII 字符,那么只有 95x95x95=857,375种可能性。这看起来很多,但如果每个查询表包含常见的密码只有 1MB,857,375个盐值总共只需 837GB,一块时下不到100美元的 1TB硬盘就能解决问题了。

出于同样的原因,不应该将用户名用作盐值。对每一个服务来说,用户名是唯一的,但它们是可预测的,并且经常重复应用于其他服务。攻击者可以用常见用户名作为盐值来建立查询表和彩虹表来破解密码哈希。

为使攻击者无法构造包含所有可能盐值的查询表,盐值必须足够长。一个好的经验是使用和哈希函数输出的字符串等长的盐值。例如, SHA256 的输出为256位(32字节),所以该盐也应该是32个随机字节。

错误的方法:双重哈希和古怪的哈希函数

本节将介绍另一种常见的密码哈希的误解:古怪哈希的算法组合。人们很容易冲昏头脑,尝试不同的哈希函数相结合一起使用,希望让数据会更安全。但在实践中,这样做并没有什么好处。它带来了函数之间互通性的问题,而且甚至可能会使哈希变得更不安全。永远不要试图去创造你自己的哈希加密算法,要使用专家设计好的标准算法。有人会说,使用多个哈希函数会降低计算速度,从而增加破解的难度。但是使破解过程变慢还有更好的办法,我们将在后面讲到。

下面是在网上见过的古怪的哈希函数组合的一些例子。

  • 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))))

不要使用其中任何一种。

注意:此部分是有争议的。我收到了一些电子邮件,他们认为古怪的哈希函数是有意义的,理由是,如果攻击者不知道系统使用哪个哈希函数,那么攻击者就不太可能预先计算出这种古怪的哈希函数彩虹表,于是破解起来要花更多的时间。
当攻击者不知道哈希加密算法的时候,是无法发起攻击的。但是要考虑到柯克霍夫原则,攻击者通常会获得源代码(尤其是免费或者开源软件)。通过系统中找出密码-哈希值对应关系,很容易反向推导出加密算法。使用一个很难被并行计算结果的迭代算法(下面将予以讨论),然后增加适当的盐值防止彩虹表攻击。

如果你真的想用一个标准的“古怪”的哈希函数,如 HMAC ,亦无不可。但是,如果你目的是想降低哈希计算速度,那么可以阅读下面有关密钥扩展的部分。

如果创造新的哈希函数,可能会带来风险,构造希函数的组合又会导致函数互通性的问题。它们带来一点的好处和这些比起来微不足道。很显然,最好的办法是,使用标准、经过完整测试的算法。

哈希碰撞( Hash Collisions)

由于哈希函数将任意大小的数据转化为定长的字符串,因此,必定有一些不同的输入经过哈希计算后得到了相同的字符串的情况。加密哈希函数( Cryptographic hash function)的设计初衷就是使这些碰撞尽量难以被找到。现在,密码学家发现攻击哈希函数越来越容易找到碰撞了。最近的例子是MD5算法,它的碰撞已经实现了。

碰撞攻击是指存在一个和用户密码不同的字符串,却有相同的哈希值。然而,即使是像MD5这样的脆弱的哈希函数找到碰撞也需要大量的专门算力( dedicated computing power),所以在实际中“意外地”出现哈希碰撞的情况不太可能。对于实用性而言,加盐 MD5 和加盐 SHA256 的安全性一样。尽管如此,可能的话,要使用更安全的哈希函数,比如 SHA256 、 SHA512 、 RipeMD 或 WHIRLPOOL 。

如何正确进行哈希加密

本节介绍了究竟应该如何对密码进行哈希加密。第一部分介绍基础知识,这部分是必须的。后面阐述如何在这个基础上增强安全性,使哈希加密变得更难破解。

基础知识:加盐哈希( Hashing with Salt)

我们已经知道,恶意攻击者使用查询表和彩虹表,破解普通哈希加密有多么快。我们也已经了解到,使用随机加盐哈希可以解决这个问题。但是,我们使用什么样的盐值,又如何将其混入密码中?

盐值应该使用加密的 安全伪随机数生成器( Cryptographically Secure Pseudo-Random Number Generator,CSPRNG ) 产生。CSPRNG和普通的伪随机数生成器有很大不同,如“ C ”语言的rand()函数。顾名思义, CSPRNG 被设计成用于加密安全,这意味着它能提供高度随机、完全不可预测的随机数。我们不希望盐值能够被预测到,所以必须使用 CSPRNG 。下表列出了一些当前主流编程平台的 CSPRNG 方法。

加盐哈希

每个用户的每一个密码都要使用独一无二的盐值。用户每次创建帐号或更改密码时,密码应采用一个新的随机盐值。永远不要重复使用某个盐值。这个盐值也应该足够长,以使有足够多的盐值能用于哈希加密。一个经验规则是,盐值至少要跟哈希函数的输出一样长该盐应和密码哈希一起存储在用户帐号表中

存储密码的步骤:

  • 使用 CSPRNG 生成足够长的随机盐值。
  • 将盐值混入密码,并使用标准的密码哈希函数进行加密,如Argon2、 bcrypt 、 scrypt 或 PBKDF2 。
  • 将盐值和对应的哈希值一起存入用户数据库。

校验密码的步骤:

  • 从数据库检索出用户的盐值和对应的哈希值。
  • 将盐值混入用户输入的密码,并且使用通用的哈希函数进行加密。
  • 比较上一步的结果,是否和数据库存储的哈希值相同。如果它们相同,则表明密码是正确的;否则,该密码错误。

在 Web 应用中,永远在服务端上进行哈希加密

如果您正在编写一个 Web 应用,你可能会疑惑究竟在哪里进行哈希加密,是在用户的浏览器上使用 JavaScript 对密码进行哈希加密呢,还是将明文发送到服务端上再进行哈希加密呢?

就算浏览器上已经用JavaScript哈希加密了,但你你还是要在服务端上将得到的密码哈希值再进行一次哈希加密。试想一个网站,将用户在浏览器输入的密码经过哈希加密,而不是在传送到服务端再进行哈希。为了验证用户,这个网站将接受来自浏览器的哈希值,并和数据库中的哈希值进行匹配即可。因为用户的密码从未明文传输到服务端,这样子看上去更安全,但事实并非如此。

问题是,从客户端的角度来看,经过哈希的密码,从逻辑上成为用户的密码了。所有用户需要做的认证就是将它们的密码哈希值告诉服务端。如果一个攻击者得到了用户的哈希值,他们可以用它来通过认证,而不必知道用户的明文密码!所以,如果攻击者使用某种手段拖了网站的数据库,他们就可以随意使用每个人的帐号直接访问,而无需猜测任何密码。

这并不是说你不应该在浏览器进行哈希加密,但是如果你这样做了,你一定要在服务端上再进行一次哈希加密。在浏览器中进行哈希加密无疑是一个好主意,但实现的时候要考虑以下几点:

  • 客户端密码哈希加密不是 HTTPS(SSL/TLS)的替代品。如果浏览器和服务端之间的连接是不安全的,那么中间人攻击可以修改 JavaScript 代码,删除加密函数,从而获取用户的密码。
  • 某些浏览器不支持 JavaScript ,还有一些用户在浏览器中禁用 JavaScript 功能。因此,为了更好的兼容性,您的应用应该检测浏览器是否支持 JavaScript ,如果不支持,就需要在服务端模拟客户端进行哈希加密。
  • 客户端的哈希加密同样需要加盐。显而易见的解决方案是使客户端脚本向服务端请求用户的盐值。但是不提倡这样做,因为它可以让攻击者能够在不知道密码的情况下检测用户名是否有效。既然你已经在服务端上对密码进行了加盐哈希(使用合格的盐值),那么在客户端,将用户名(或邮箱)加上网站特有的字符串(如域名)作为客户端的盐值也是可行的。

使密码更难破解:慢哈希函数( Slow Hash Function)

加盐可以确保攻击者无法使用像查询表和彩虹表攻击那样对大量哈希值进行破解,但依然不能阻止他们使用字典攻击或暴力攻击。高端显卡( GPU )和定制的硬件每秒可以进行十亿次哈希计算,所以这些攻击还是很有效的。为了降低使这些攻击的效率,我们可以使用一个叫做密钥扩展( key stretching)的技术。

这样做的初衷是为了将哈希函数变得非常慢,即使有一块快速的 GPU 或定制的硬件,字典攻击和暴力攻击也会慢得令人失去耐心。终极目标是使哈希函数的速度慢到足以令攻击者放弃,但由此造成的延迟又不至于引起用户的注意。

密钥扩展的实现使用了一种 CPU 密集型哈希函数( CPU-intensive hash function)。不要试图去创造你自己的迭代哈希加密函数。迭代不够多的话,它可以被高效的硬件快速并行计算出来,就跟普通的哈希一样。要使用标准的算法,比如 PBKDF2 或 bcrypt 。你可以在这里找到 PBKDF2 在 PHP 上的实现。

这类算法采取安全因子或迭代次数作为参数。此值决定哈希函数将会如何缓慢。对于桌面软件或智能手机应用,确定这个参数的最佳方式是在设备上运行很短的性能基准测试,找到使哈希大约花费半秒的值。通过这种方式,程序可以尽可能保证安全而又不影响用户体验。

如果您想在一个 Web 应用使用密钥扩展,须知你需要额外的计算资源来处理大量的身份认证请求,并且密钥扩展也容易让服务端遭受拒绝服务攻击( DoS )。尽管如此,我还是建议使用密钥扩展,只不过要设定较低一些的迭代次数。这个次数需要根据自己服务器的计算能力和预计每秒需要处理的认证请求次数来设置。消除拒绝服务的威胁可以通过要求用户每次登陆时输入验证码( CAPTCHA )来做到。系统设计时要将迭代次数可随时方便调整。

如果你担心计算带来负担,但又想在 Web 应用中使用密钥扩展,可以考虑在浏览器中使用 JavaScript 完成。斯坦福大学的 JavaScript 加密库就包含了 PBKDF2 的实现。迭代次数应设置足够低,以适应速度较慢的客户端,如移动设备。同时,如果用户的浏览器不支持 JavaScript ,服务端应该接手进行计算客户端密钥扩展并不能免除服务端端进行哈希加密的需要。你必须对客户端生成的哈希值再次进行哈希加密,就跟普通口令的处理一样。

不可能破解的哈希加密:密钥哈希和密码哈希设备

只要攻击者可以使用哈希来检查密码的猜测是对还是错,那么他们可以进行字典攻击或暴力攻击。下一步是将密钥( secret key)添加到哈希加密,这样只有知道密钥的人才可以验证密码。有两种实现的方式,使用ASE算法对哈希值加密;或者使用密钥哈希算法 HMAC 将密钥包含到哈希字符串中。

实现起来并没那么容易。这个密钥必须在任何情况下,即使系统因为漏洞被攻陷,也不能被攻击者获取。如果攻击者完全进入系统,密钥不管存储在何处,总能被找到。因此,密钥必须密钥必须被存储在外部系统,例如专用于密码验证一个物理上隔离的服务端,或者连接到服务端,例如一个特殊的硬件设备,如 YubiHSM

我强烈建议所有大型服务(超过10万用户)使用这种方式。我认为对于任何超过100万用户的服务托管是非常有必要的。

如果您难以负担多个服务端或专用硬件的费用,依然有办法在标准的Web服务端上使用密钥哈希技术。大多数数据库被拖库是由于 SQL 注入攻击,因此,不要给攻击者进入本地文件系统的权限(禁止数据库服务访问本地文件系统,如果有此功能的话)。如果您生成一个随机密钥并将其存储在一个通过 Web 无法访问的文件上,然后进行加盐哈希加密,那么得到的哈希值就不会那么容易被破解了,就算数据库已经遭受注入攻击,也是安全的。不要将密钥硬编码到代码中,应该在安装应用时随机生成。这么做并不像使用一个独立的系统那样安全,因为如果 Web 应用存在 SQL 注入点,那么有可能存在其他一些问题,如本地文件包含漏洞( Local File Inclusion ),攻击者可以利用它读取本地密钥文件。无论如何,这个措施总比没有好。

请注意,密钥哈希并不意味着无需进行加盐。高明的攻击者最终会想方设法找到密钥,因此,对密码哈希仍然需要进行加盐和密钥扩展,这一点非常重要。

其他安全措施

密码哈希仅仅在安全受到破坏时保护密码。它并不能使整个应用更加安全。首先有很多事必须完成,来保证密码哈希值(和其他用户数据)不被窃取。

即使是经验丰富的开发人员也必须学习安全知识,才能编写安全的应用。此处有关于Web应用漏洞的重要资源: The Open Web Application Security Project (OWASP)。还有一个很好的介绍: OWASP Top Ten Vulnerability List 。除非你理解了列表中的所有漏洞,否则不要去尝试编写一个处理敏感数据的Web应用程序。雇主也有责任确保所有开发人员在安全应用开发方面经过充分的培训。

对您的应用进行第三方“渗透测试”是一个很好的主意。即使最好的程序员也可能会犯错,所以,让安全专家审计代码寻找潜在的漏洞是有意义的。找一个值得信赖的机构(或招聘人员)来定期审计代码。安全审计应该从开发初期就着手进行,并贯穿整个开发过程。

监控您的网站来发现入侵行为也很重要。我建议至少雇用一名全职人员负责监测和处理安全漏洞。如果某个漏洞没被发现,攻击者可能通过网站利用恶意软件感染访问者,因此,检测漏洞并及时处理是极为重要的。

常见疑问

我应该使用什么样的哈希算法?

可以使用:

  • 精心设计的密钥扩展算法如 PBKDF2 、bcrypt 和 scrypt 。
  • OpenWall的的 Portable PHP password hashing framework。
  • PBKDF2在PHP、C#、Java和Ruby的实现。
  • crypt 的安全版本。

不可使用:

  • 快速加密哈希函数,如 MD5 、SHA1、SHA256、SHA512、RipeMD、WHIRLPOOL、SHA3等。
  • crypt()的不安全版本。
  • 任何自己设计的加密算法。只应该使用那些在公开领域中的、由经验丰富的密码学家完整测试过的技术。

尽管目前还没有一种针对MD5或SHA1非常高效的攻击手段,但它们过于古老以至于被广泛认为不足以用来存储密码(可能有些不恰当)。所以我不推荐使用它们。但是也有例外,PBKDF2中经常使用SHA1作为它底层的哈希函数。

当用户忘记密码时如何重置密码?

这是我个人的观点:当下所有广泛使用的密码重置机制都是不安全的。如果你对高安全性有要求,如加密服务,那么就不要让用户重设密码。

大多数网站向那些忘记密码的用户发送电子邮件来进行身份认证。要做到这一点,需要随机生成一个一次性使用的令牌( token ),直接关联到用户的帐号。然后将这个令牌混入一个重置密码的链接中,发送到用户的电子邮箱。当用户点击包含有效令牌的密码重置链接,就提示他们输入新密码。确保令牌只对一个帐号有效,以防攻击者从邮箱获取到令牌后用来重置其他用户的密码。

令牌必须在15分钟内使用,且一旦使用后就立即作废。当用户登录成功时(表明还记得自己的密码), 或者重新请求令牌时,使原令牌失效是一个好做法。如果令牌永不过期,那么它就可以一直用于入侵用户的账号。电子邮件(SMTP)是一个纯文本协议,网络上有很多恶意路由在截取邮件信息。在用户修改密码后,那些包含重置密码链接的邮件在很长时间内缺乏保护,因此,尽早使令牌尽快过期,来降低用户信息暴露给攻击者的风险。

攻击者能够篡改令牌,因此不要把帐号信息和失效时间存储在其中。它们应该以不可猜测的二进制形式存在,并且只用来识别数据库中某条用户的记录。

千万不要通过电子邮件向用户发送新密码。记得在用户重置密码时随机生成一个新的盐值用来加密,不要重复使用已用于密码哈希加密的旧盐值。

如果帐号数据库被泄漏或入侵,应该怎么做?

你的首要任务是,确定系统被暴露到什么程度,然后修复攻击者利用的的漏洞。如果你没有应对入侵的经验,我强烈建议聘请第三方安全公司来做这件事。

捂住一个漏洞并期待没人知道,是不是很省事,又诱人?但是这样做只会让你的处境变得更糟糕,因为你在用户不知情的情况下,将它们的密码和个人信息置于暴露风险之中。就算你还没有完全发生什么事情时,你也应该尽快通知用户。例如在首页放置一个链接,指向对此问题更为详细的说明;如果可能的话通过电子邮件发送通知给每个用户告知目前的情况。

向用户说明他们的密码究竟是如何被保护的:最好是使用了加盐哈希。但是,即使用了加盐哈希,恶意黑客仍然可以使用字典攻击和暴力攻击。如果用户在很多服务使用相同的密码,恶意黑客会利用他们找到的密码去尝试登陆其他网站。告知用户这个风险,建议他们修改所有类似的密码,不论密码用在哪个服务上。强制他们下次登录你的网站时更改密码。大多数用户会尝试“修改”自己的密码为原始密码,以便记忆。您应该使用当前密码哈希值以确保用户无法做到这一点。

就算有加盐哈希的保护,也存在攻击者快速破解其中一些弱口令密码的可能性。为了减少攻击者使用这些密码的机会,应该对这些密码的帐号发送认证电子邮件,直到用户修改了密码。可参考前面提到的问题:当用户忘记密码时如何重置密码?这其中有一些实现电子邮件认证的要点。

另外告诉你的用户,网站存储了哪些个人信息。如果您的数据库包括信用卡号码,您应该通知用户仔细检查近期账单并销掉这张信用卡。

应该使用什么样的密码策略?是否应该使用强密码?

如果您的服务没有严格的安全要求,那么不要对用户进行限制。我建议在用户输入密码时,页面显示出密码强度,由他们自己决定需要多安全的密码。如果你有特殊的安全需求,那就应该实施长度至少为12个字符的密码,并且至少需要两个字母、两个数字和两个符号。

不要过于频繁地强制你的用户更改密码,最多每半年一次,超过这个次数,用户就会感到疲劳。相反,更好的做法是教育用户,当他们感觉密码可能泄露时主动修改,并且提示用户不要把密码告诉任何人。如果这是一个商业环境,鼓励员工利用工作时间熟记并使用他们的密码。

如果攻击者入侵了数据库,他不能直接替换哈希值登陆任意帐号么?

是的,但如果有人入侵您的数据库,他们很可能已经能够访问您的服务端上的所有内容,这样他们就不需要登录到您的帐号,就可以获得他们想要的东西。密码哈希(对网站而言)的目的不是为了保护被入侵的网站,而是在入侵已经发生时保护数据库中的密码

你可以通过给数据库连接设置两种权限,防止密码哈希在遭遇注入攻击时被篡改。一种权限用于创建用户,一种权限用于用户登陆。“创建用户”的代码应该能够读写用户表;但“用户登陆”的代码应该只能够读取用户表而不能写入。

为什么要使用一种像HMAC的特殊算法,而不是只将密钥混入密码?

如 MD5、SHA1、SHA2 和 Hash 函数使用 Merkle–Damg?rd ,这使得它们很容易受到所谓的长度扩展攻击( length extension attack)。意思是给定的哈希值 H(X),对于任意的字符串 Y,攻击者可以计算出 H(pad(X)+Y) 的值,而无需知道 X 的值。其中, pad(X) 是哈希函数的填充函数。

这意味着,攻击者不知道密钥的情况下,仍然可以根据给定的哈希值 H(key+message) 计算出 H(pad(key+message)+extension) 。如果该哈希值用于身份认证,并依靠其中的密钥来防止攻击者篡改消息,这方法已经行不通。因为攻击者无需知道密钥也能构造出包含 message+extension 的一个有效的哈希值。

目前尚不清楚攻击者如何利用这种攻击来快速破解密码哈希。然而,由于这种攻击的出现,不建议使用普通的哈希函数对密钥进行哈希加密。将来也许某个高明的密码学家有一天发现利用长度扩展攻击的新思路,从而更快的破解密码,所以还是使用 HMAC 为好。

盐值应该加到密码之前还是之后?

无所谓,选择一个并保持风格一致即可,以免出现互操作方面的问题。盐值加到密码之前较为普遍。

为何本文的哈希代码都以固定时间比较哈希值?

使用固定的时间来比较哈希值可以防止攻击者在在线系统使用基于时间差的攻击,以此获取密码的哈希值,然后进行本地破解。

比较两个字节序列(字符串)是否相同的标准做法是,从第一个字节开始,每个字节逐一顺序比较。只要发现某个字节不同,就可以知道它们是不同的,立即返回false。如果遍历整个字符串没有找到不同的字节,可以确认两个字符串就是相同的,可以返回true。这意味着比较两个字符串,如果它们相同的长度不一样,花费的时间不一样。开始部分相同的长度越长,花费的时间也就越长。

例如,字符串 “XYZABC” 和 “abcxyz” 的标准比较,会立即看到,第一个字符是不同的,就不需要检查字符串的其余部分。相反,当字符串 “aaaaaaaaaaB” 和 “aaaaaaaaaaZ” 进行比较时,比较算法就需要遍历最后一位前所有的 “a” ,然后才能知道他们是不同的。

假设攻击者试图入侵一个在线系统,这个系统限制了每秒只能尝试一次用户认证。还假设攻击者已经知道密码哈希所有的参数(盐值、哈希函数的类型等),除了密码的哈希值和密码本身。如果攻击者能精确测量在线系统耗时多久去比较他猜测的密码和真实密码,那么他就能使用时序攻击获取密码的哈希值,然后进行离线破解,从而绕过系统对认证频率的限制。

首先攻击者准备256个字符串,它们的哈希值的第一字节包含了所有可能的情况。他将每个字符串发送给在线系统尝试登陆,并记录系统响应所消耗的时间。耗时最长的字符串就是第一字节相匹配的。攻击者知道第一字节后,并可以用同样的方式继续猜测第二字节、第三字节等等。一旦攻击者获得足够长的哈希值片段,他就可以在自己的机器上来破解,不受在线系统的限制。

在网络上进行这种攻击似乎不可能。然而,有人已经实现了,并已证明是实用的。这就是为什么本文提到的代码,它利用固定时间去比较字符串,而不管有多大的字符串。

“慢比较( slowequals)”函数如何工作?

前一个问题解释了为什么“慢比较”是必要的,现在来解释代码如何工作。

1
2
3
4
5
6
7
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 XOR 0 = 0,1 XOR 1 = 0,0 XOR 1 = 1,1 XOR 0 = 1

如果我们将其应用到整数中每一位,当且仅当字节两个整数各位都相等,结果才是0。

所以,在代码的第一行中,如果a.length等于b.length ,相同的话得到0,否者得到非零值。然后使用异或比较数组中各字节,并且将结果和diff求或。如果有任何一个字节不相同,diff就会变成非零值。因为或运算没有“置0”的功能,所以循环结束后diff是0的话只有一种可能,那就是循环前两个数组长度相等(a.length == b.length),并且数组中每一个字节都相同(每次异或的结果都非0)。

我们需要使用XOR,而不是“==”运算符比较整数的原因是,“==”通常是编译成一个分支的语句。例如,C语言代码中“ diff &= a == b”可能编译以下x86汇编:

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内部的跳转预测机制(branch prediction)。

而C语言代码“diff |= a ^ b”会被编译为下面的样子,它执行的时间和两个变量是否相等无关。

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

为何要进行哈希?

用户在你的网站上输入密码,是因为他们相信你能保证密码的安全。如果你的数据库遭到黑客攻击,而用户的密码又不受保护,那么恶意黑客可以利用这些密码尝试登陆其他网站和服务(大多数用户会在所有地方使用相同的密码)。这不仅仅关乎你网站的安全,更关系到用户的安全。你有责任负责用户的安全。