加盐密码散列的正确方法(Salted Password Hashing - Doing it Right)简体中文版

立即阅读

原文作者:Defuse Security

原文地址:CrackStation

原文仓库地址:GitHub

译文对应版本:61d0fe7 2021-09-27 21:14:40

构建

  • 安装 mdbook

    cargo install mdbook
    
  • 运行 mdbook serve

    mdbook serve
    

前言

如果您是一名 Web 开发者,您很可能需要建立一个用户账户系统。对用户账户系统来说,最重要的部分就是如何保护用户的密码。用户账户数据库经常被入侵,因此,如果您的网站被攻破,您绝对需要采取措施来保护用户的密码。保护密码的最佳方法是使用加盐密码散列(Salted Password Hashing)。这篇文章将会解释原因,并展示加盐密码散列的正确方法。

关于如何正确进行密码散列有很多相互矛盾的想法以及误解,可能是由于网络上的错误信息太多。其实,密码散列属于非常简单的事情,但有很多人都弄错了。在这篇文章里,我希望不仅可以解释正确的方法,还能解释为什么应该这样做。

重要警告: 如果您正在考虑自己编写密码散列代码,请不要这样做! 这太容易搞砸了。不,您在大学上的密码学课程不能让您免于这个警告。这适用于所有人:不要自己编写密码学算法! 毕竟存储密码的问题已经被解决了。您可以使用 phpass,位于 defuse/password-hashing 的 PHP、C#、Java 和 Ruby 实现,或者 libsodium

如果您由于某种原因错过了上面的大红色警告提示,请立即阅读它。真的,本指南并不是要引导您完成自己编写存储系统的过程,而是要解释为什么密码应该以某种特定的方式存储。

您可以使用以下链接跳转到文章的不同部分:

  1. 什么是密码散列?
  2. 散列是如何被破解的
  3. 加盐
  4. 无效的散列方法
  5. 如何正确散列
  6. 常见问题

什么是密码散列?

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

散列算法是单向函数。它们将任意长度的数据转换为固定长度「指纹」,且无法逆转。它们还具有一种特性,即使输入发生一点点变化,生成的散列值也完全不同(参见上面的示例)。这对于保护密码来说非常有用,因为我们希望以一种即使密码文件本身被泄露也能保护密码的形式来存储密码,但同时,我们需要能够验证用户的密码是否正确。

基于散列的账户系统中账户注册和认证的一般工作流程如下:

  1. 用户创建账户。
  2. 他们的密码被散列并存储在数据库中。这一过程的任何时候都不会将纯文本(未加密)密码写入硬盘。
  3. 当用户尝试登录时,他们输入密码的散列值会与他们真实密码的散列值(从数据库中检索)进行对比。
  4. 如果散列匹配,则授予用户访问权限。否则,用户会被告知他们输入了无效的登录凭据。
  5. 每次有人尝试登录其账户时,都会重复第 3 步和第 4 步。

在第 4 步中,永远都不要告诉用户他们是弄错了用户名还是密码。应该始终显示诸如「无效的用户名或密码」之类的通用消息。这样可以防止攻击者在不知道密码的情况下枚举有效的用户名。

应该注意的是,用于保护密码的散列函数与您可能在数据结构课程中看到过的散列函数不同。用于实现散列表等数据结构的散列函数为快速而设计,而不是为安全而设计。只能使用 加密散列函数(Cryptographic Hash Function) 来实现密码散列。SHA256、SHA512、RipeMD 和 WHIRLPOOL 等散列函数属于加密散列函数。

很容易认为,您所要做的就是把密码丢进加密散列函数,您的用户密码就安全了。这与事实相去甚远。有很多方法可以非常快速地从普通散列中恢复密码。但是,有几种易于实现的技术可以使这些「攻击」的效果大打折扣。为了激发对这种技术的需求,请考虑这个网站(译者注:指原文所在的 CrackStation)。在首页,您可以提交需要破解的一组散列,并在不到一秒钟的时间内收到结果。很显然,简单地对密码进行散列并不能满足我们的安全需求。

下一节将讨论一些用于破解普通密码散列的常见攻击。

散列是如何被破解的

  • 字典攻击和暴力破解(Dictionary and Brute Force Attack)

    字典攻击(Dictionary Attack)
    
    正在尝试 apple        :失败
    正在尝试 blueberry    :失败
    正在尝试 justinbeiber :失败
    ...
    正在尝试 letmein      :失败
    正在尝试 s3cr3t       :成功!
    
    暴力破解(Brute Force Attack)
    
    正在尝试 aaaa :失败
    正在尝试 aaab :失败
    正在尝试 aaac :失败
    ...
    正在尝试 acdb :失败
    正在尝试 acdc :成功!
    

    破解散列的最简单方法是试图猜出密码,对每个猜测进行散列,并检查猜测的散列是否与需要破解的散列相同。如果散列相同,那么猜测的值就是密码。猜测密码的两种最常见的方式是字典攻击暴力破解

    字典攻击使用一些文件,其中包含单词、短语、常用密码和其他可能用作密码的字符串。文件中的每个单词都经过散列处理,并将得到的散列与密码散列进行比较。如果匹配,则这个单词就是密码。这些字典文件是从大量文本中提取出的单词,甚至是使用真实的密码数据库来构建的。字典文件通常会被进一步处理,使其变得更有效,比如使用「leet speak」进行替换(「hello」替换为「h3110」)。

    暴力破解会尝试各种可能的字符组合,直到给定长度为止。这种攻击在计算上非常昂贵,并且通常在给定处理器时间内破解散列的效率最低,但最终总能找到密码。因此密码应该足够长,使得搜索所有可能的字符串来找到密码会因为花费太长时间而变得不值得。

    没有办法防止字典攻击或暴力破解。虽然可以让它们变得不那么有效,但没有办法完全阻止它们。如果您的密码散列系统是安全的,则对每个散列实施字典攻击或暴力破解是破解散列的唯一方法。

  • 查找表(Lookup Table)

    正在搜索:5f4dcc3b5aa765d61d8327deb882cf99:已发现:password5
    正在搜索:6cbe615c106f422d23669b610b564800:不在数据库中
    正在搜索:630bf032efe4507f2c57b280995925a9:已发现:letMEin12
    正在搜索:386f43fab5d096a7a66d67c8f213e5ec:已发现:mcd0nalds
    正在搜索:d5ec75d5fe70d428685510fae36492d9:已发现:p@ssw0rd!
    

    查找表是一种非常有效的方法,可以快速地破解许多相同类型的散列。总体思路是在密码字典中预先计算密码的散列值,并将散列值及其对应的密码存储在查找表数据结构中。良好实现的查找表可以在包含数十亿个散列的情况下每秒处理数百个散列查找。

    如果您想更好地了解查找表的速度,请尝试使用 CrackStation 的免费散列破解器破解以下 SHA256 散列。

    c11083b4b0a7743af748c85d343dfee9fbb8b2576c05f3a7f0d632b0926aadfc
    08eac03b80adc33dc7d8fbe44b7c7b05d3a2c511166bdb43fcb710b03ba919e7
    e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904
    5206b8b8a996cf5320cb12ca91c7b790fba9f030408efe83ebb83548dc3007bd
    
  • 反向查找表(Reverse Lookup Table)

    正在用户散列列表中搜索 hash(apple)...     :匹配 [alice3, 0bob0, charles8]
    正在用户散列列表中搜索 hash(blueberry)... :匹配 [usr10101, timmy, john91]
    正在用户散列列表中搜索 hash(letmein)...   :匹配 [wilson10, dragonslayerX, joe1984]
    正在用户散列列表中搜索 hash(s3cr3t)...    :匹配 [bruce19, knuth1337, john87]
    正在用户散列列表中搜索 hash(z@29hjja)...  :没有用户使用此密码
    

    这种攻击允许攻击者同时对许多散列应用字典攻击或暴力破解,无需预先计算查找表。

    首先,攻击者创建一个查找表,并将泄漏的用户账户数据库中的每个密码散列映射到具有该散列的用户列表。然后攻击者对每个密码猜测进行散列处理,并使用查找表来获取其密码与攻击者猜测相同的用户列表。这种攻击特别有效,因为多个用户拥有相同的密码是很常见的。

  • 彩虹表(Rainbow Table)

    彩虹表是一种使用时间换取空间的技术。它与查找表类似,只是牺牲了散列破解速度来使查找表更小。由于彩虹表更小,因此可以将更多散列的解存储在相同的空间中,使其更有效。可以破解任何不超过 8 个字符的密码的 md5 散列的彩虹表是存在的

接下来,我们将研究一种称为加盐的技术,它使得无法用查找表和彩虹表来破解散列。

加盐

hash("hello")                    = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
hash("hello" + "QxLUF1bgIAdeQX") = 9e209040c863f84a31e719795b2577523954739fe5ed3b58a75cff2127075ed1
hash("hello" + "bv5PehSMfV11Cd") = d1d3ec2e6f20fd420d50e2642992841d8338a314b8ea157c9e18477aaef226ab
hash("hello" + "YYLmfY6IehjZMQ") = a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2c4a8544305df1b60f007

查找表和彩虹表之所以有效,是因为每个密码都是以完全相同的方式进行散列的。如果两个用户拥有相同的密码,则他们也将拥有相同的密码散列值。我们可以通过随机化每个散列值来防止这种攻击,这样,当相同的密码被散列两次时,散列值会是不一样的。

我们可以在运行散列前向密码之后或之前添加一个称为 盐(Salt) 的随机字符串来随机化散列值。如上例所示,这使得相同的密码每次散列后都变成完全不同的字符串。为了检查密码是否正确,我们需要盐,因此它通常与散列值一起存储在用户账户数据库中,或者作为散列值字符串本身的一部分。

盐不需要保密。仅仅通过随机化散列值,查找表、反向查找表和彩虹表就会变得无效。攻击者不会预先知道盐是什么,因此他们无法预先计算查找表或彩虹表。如果每个用户的密码都使用不同的盐进行散列,那么反向查找表攻击也将不起作用。

在下一节中,我们将展示盐通常是如何被错误地实现的。

无效的散列方法

错误的方法:过短盐和重用盐

实现盐的最常见错误是在多个散列中重复使用相同的盐,或者使用过短的盐。

重用盐

一种常见的错误是在每个散列中使用相同的盐。盐要么被硬编码到程序中,要么仅随机生成一次。这种做法是无效的,因为如果两个用户有相同的密码,仍会生成相同的散列值。攻击者仍然可以使用反向查找表同时对每个散列进行字典攻击。攻击者只需要在对密码猜测进行散列处理之前附加上这个唯一的盐即可。如果盐被硬编码到了某个流行的产品中,则可以针对该盐构建查找表和彩虹表,以便更容易地破解该产品生成的散列。

每当用户创建账户或更改密码时,都必须生成一个新的随机盐。

过短盐

如果盐过短,攻击者可以为每个可能的盐都建立一个查找表。例如,如果盐只有三个 ASCII 字符,则只有 95 x 95 x 95 = 857,375 种可能的盐。虽然看起来好像很多,但如果每个查找表只包含 1MB 的最常见密码,那么所有查找表总共只有 837GB,考虑到现在 1000GB 硬盘的价格只有不到 100 美元,所以这并不算很多。

出于同样的原因,用户名不应该用作盐。用户名对于单个服务来说可能是唯一的,但它们是可预测的,而且经常被其他服务上的账户重复使用。攻击者可以为常见用户名构建查找表,用于破解用户名作为盐的散列。

为了使攻击者无法为每一种可能的盐创建查找表,盐必须很长。一个不错的经验法则是使用与散列函数输出长度相同长度的盐。例如,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)))

不要使用上面的任何一种组合。

注意:这一部分已经被证明是有争议的。我收到了许多电子邮件,认为使用古怪的散列函数是有益的,因为如果攻击者不知道正在使用哪个散列函数,就不太可能为这种古怪的散列函数预先计算彩虹表,使得计算散列函数需要花费更长的时间。

攻击者在不知道算法的情况下的确无法攻击散列,但请留意柯克霍夫原则(Kerckhoffs's Principle),即攻击者通常可以访问源代码(特别是自由或开源软件),并且,给定来自目标系统的一些密码-散列对,对算法进行逆向工程并不困难。计算古怪的散列函数确实需要更长的时间,但只是一个小的常数因子而已。最好使用为极难并行化而设计的迭代算法(将在下面讨论)。此外,适当地对散列加盐就可以解决彩虹表问题了。

如果您真的想使用像 HMAC 这样的标准化「古怪」散列函数,那么也没问题。但是,如果您这样做的原因是为了降低散列计算速度,请先阅读下面有关密钥延伸的部分。

与无意实现了完全不安全的散列函数的风险以及古怪散列产生的互操作性问题相比,好处微不足道。显然,最好使用标准且经过良好测试的算法。

散列碰撞

由于散列函数将任意长度的数据映射成了固定长度的字符串,所以一定会有一些输入散列成了相同的字符串。加密散列函数被设计为极难发现这种碰撞。密码学家有时会发现散列函数的「攻击」,这会使找到碰撞变得更容易。一个最近的例子是 MD5 散列函数,实际上已经发现了碰撞。(译者注:SHA-1 的碰撞于 2017 年 2 月也已经被发现。)

发现碰撞攻击表明,除了用户密码以外的字符串也可能拥有相同的散列值。然而,即使是 MD5 这样的弱散列函数,发现碰撞也需要大量的专用计算能力,因此这些碰撞在实践中「偶然」发生的可能性很小。就所有实际目的而言,使用 MD5 和盐进行散列的密码与使用 SHA256 和盐进行散列的密码一样安全。(译者注:由于计算能力的进步和选择前缀碰撞攻击等技术的发展,MD5 已经不适合继续使用。)尽管如此,如果可能,最好使用更安全的散列函数,例如 SHA256、SHA512、RipeMD 或 WHIRLPOOL。

如何正确散列

正确的方法:如何正确散列

本节准确地描述了密码应该如何被散列。第一部分涵盖了基础方法——所有技术都是绝对有必要被应用的。接下来的部分解释了在基础方法之上如何增强,使散列更难被破解。

基础方法:加盐散列

警告:不要只阅读本节。您绝对必须实现下一节中的内容:「使密码破解更难:慢速散列函数」。

我们已经看到了恶意黑客如何使用查找表和彩虹表非常快速地破解普通散列。我们已经了解到使用盐来随机化散列是解决这个问题的方法。但是我们如何生成盐,又如何将盐应用于密码呢?

盐应该使用 密码学安全伪随机数生成器(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG) 生成。CSPRNG 与 C 语言 rand() 函数那样的普通的伪随机数生成器完全不同。顾名思义,CSPRNG 专为密码学安全而设计,这意味着它们可以提供高度随机性且完全不可预测。我们不希望我们的盐是可预测的,因此我们必须使用 CSPRNG。下表列出了一些流行编程平台提供的 CSPRNG。

平台CSPRNG
PHPmcrypt_create_ivopenssl_random_pseudo_bytes
Javajava.security.SecureRandom
Dot NET(C#,VB)System.Security.Cryptography.RNGCryptoServiceProvider
RubySecureRandom
Pythonsecrets
PerlMath::Random::Secure
C/C++(Windows API)CryptGenRandom
GNU/Linux 或 Unix 上的任何语言/dev/random 或 /dev/urandom 读取

每个用户每个密码的盐都应该是独一无二的。用户每次创建账户或更改密码时,都应该使用新的随机盐对密码进行散列。永远不要重复使用盐。盐需要足够长,这样可用的盐会足够多。根据经验,盐至少应该与散列函数的输出长度一样长。盐应该与散列一起存储在用户账户表中。

存储密码时

  1. 使用 CSPRNG 生成长随机盐。
  2. 在密码前附加盐并使用标准密码散列函数(如 Argon2、bcrypt、scrypt 或 PBKDF2)对其进行散列。
  3. 将盐和散列保存在用户数据库记录中。

验证密码时

  1. 从数据库中检索用户的盐和散列。
  2. 将盐附加到给定的密码前并使用相同的散列函数对其进行散列。
  3. 将给定密码的散列值与数据库中的散列值进行比较。如果匹配,则密码是正确的。否则,密码不正确。

在 Web 应用程序中,始终在服务器上散列

如果您正在编写 Web 应用程序,您可能想知道在哪里运行散列。密码应该在用户的浏览器中使用 JavaScript 散列,还是应该「以明文形式」发送到服务器并在服务器中散列?

即使您在 JavaScript 中散列了用户的密码,您仍然必须在服务器上对散列值再次进行散列。考虑一个网站,它在用户的浏览器中散列了用户的密码,却没有散列服务器上的散列值。为了对用户进行身份验证,该网站将接受来自浏览器的散列值并检查该散列值是否与数据库中的散列值完全匹配。这似乎比仅在服务器上散列更安全,因为用户的密码永远不会被发送到服务器,但事实并非如此。

问题在于,客户端的散列在逻辑上成为了用户的密码。用户进行身份验证所要做的就是告诉服务器他们密码的散列值。如果坏人获得了用户密码的散列值,他们就可以使用散列值用于服务器的身份验证,根本不需要知道用户的密码!因此,如果坏人以某种方式从这个假设的网站窃取了散列数据库,将可以立即访问每个人的账户,而无需猜测任何密码。

这并不是说您不应该在浏览器中散列,但如果您这样做了,也绝对必须在服务器上再次散列。在浏览器中进行散列当然是一个好主意,但在您的实现中,需要注意以下几点:

  • 客户端密码散列并不能替代 HTTPS(SSL/TLS)。如果浏览器和服务器之间的连接不安全,中间人可以在浏览器下载 JavaScript 代码时修改它,删除散列功能并获取用户密码。

  • 有些网络浏览器不支持 JavaScript,有些用户会在他们的浏览器中禁用 JavaScript。因此,为了获得最大的兼容性,您的应用程序应该检测浏览器是否支持 JavaScript,如果不支持,则在服务器上模拟客户端散列。

  • 您还需要对客户端散列进行加盐。容易想到的解决方案是让客户端脚本向服务器请求用户的盐。不要这样做,因为这会让坏人在不知道密码的情况下检查用户名是否有效。由于您同样需要在服务器上进行散列和加盐(可以使用更好的盐),因此可以将用户名(或电子邮件)与特定于站点的字符串(例如域名)拼接在一起作为客户端的盐。

使密码破解更难:慢速散列函数

盐可以确保攻击者无法使用查找表和彩虹表等特定攻击来快速破解大量散列集合,但并不能阻止他们分别对每个散列实施字典攻击或暴力破解。高端显卡(GPU)和定制硬件每秒可以计算数十亿个散列值,因此这种攻击仍然非常有效。为了降低这种攻击的效率,我们可以使用一种称为 密钥延伸(Key Stretching) 的技术。

密钥延伸的想法是让散列函数变得非常慢,这样即使使用高速 GPU 或定制硬件,字典攻击和暴力破解也因太慢而变得不值得。目标是,使散列函数足够慢以阻止攻击,但仍然快到不会让用户感知到明显的延迟。

密钥延伸是使用一种特殊类型的 CPU 密集型散列函数实现的。不要试图发明您自己的版本——对密码的散列简单地进行迭代散列是不够的,因为这可以在硬件中并行化,和正常散列执行得一样快。应该使用 PBKDF2bcrypt 这样的标准算法。您可以在此处找到 PBKDF2 的 PHP 实现。

这些算法将安全系数或迭代次数作为参数。这个值决定了散列函数的速度有多慢。对于桌面软件或智能手机应用程序来说,选择此参数的最佳方法是在设备上运行一个简短的基准测试,找到使散列花费大约半秒时间的值。这样,您的程序可以在不影响用户体验的同时尽可能安全。

如果您在 Web 应用程序中使用密钥延伸散列,请注意您将需要额外的计算资源来处理大量身份验证请求,而且密钥延伸可能会让您的网站更容易遭到拒绝服务(Denial of Service,DoS)攻击。我仍然建议使用密钥延伸,但应该设置较小的迭代次数。您应该根据您的计算资源和预期的最大身份验证请求率来计算迭代次数。可以让用户在每次登录时输入验证码来消除拒绝服务威胁。总是应该将您的系统设计为可以方便地增加或减少迭代次数,以便将来进行调节。

如果您担心计算负担,但仍想在 Web 应用程序中使用密钥延伸,请考虑使用 JavaScript 在用户浏览器中运行密钥延伸算法。斯坦福 JavaScript 加密库中包含了 PBKDF2。迭代次数应该设置得足够低,以便系统可以用于移动设备等速度较慢的客户端,如果用户的浏览器不支持 JavaScript,系统应该回退到服务器端进行计算。在客户端进行了密钥延伸并不意味着不需要在服务器端进行散列。您必须像散列正常密码一样散列客户端生成的散列值。

不可能破解的散列:密钥散列和密码散列硬件

只要攻击者可以使用散列来检查密码猜测是否正确,他们就可以对散列进行字典攻击或暴力破解。让散列更安全的下一步是向散列添加一个私钥,只有知道密钥的人才可以使用散列来验证密码。这可以通过两种方式实现。可以使用 AES 等加密算法对散列进行加密,也可以使用 HMAC 等密钥散列算法将私钥包含到散列中。

这并不像听起来那么容易。即使在系统被攻破的情况下,密钥也必须对攻击者保密。如果攻击者获得了系统的完全访问权限,无论密钥存储在哪里,攻击者都能窃取它。密钥必须存储在外部系统中,例如专门用于密码验证的物理隔离服务器,或者连接到服务器的特殊硬件设备,例如 YubiHSM

我强烈推荐任何大规模(超过 100,000 个用户)的服务采用这种做法。我认为对于任何托管超过 1,000,000 个用户账户的服务来说,这种做法都是不可或缺的。

如果您负担不起多台专用服务器或特殊硬件设备,您仍然可以在标准 Web 服务器上享受到密钥散列的好处。大多数数据库都是被 SQL 注入攻击攻破的,在许多情况下,攻击者无法访问本地文件系统(如果您的 SQL 服务器具有本地文件系统访问的功能,请禁用它)。如果您生成了一个随机密钥,将其存储在一个无法从 Web 访问的文件,并将其包含在加盐散列中,那么当您的数据库被简单的 SQL 注入攻击攻破时,散列就不会遭到攻击。不要将密钥硬编码到源代码中,应该在安装应用程序时随机生成它。这并不如使用隔离的系统进行密码散列那样安全,因为如果 Web 应用程序中存在 SQL 注入漏洞,那么也可能有其他类型的漏洞(例如本地文件包含)可以让攻击者读取私钥文件。但是,这总比什么都不做更好。

请注意,使用密钥散列并不意味着不需要加盐。聪明的攻击者最终总会找到读取密钥的方法,因此保证散列值此时仍然受到盐和密钥延伸的保护是很重要的。

其他安全措施

密码散列仅可以在安全漏洞被利用时保护密码。它不会使应用程序整体更加安全。必须做更多的工作来防止密码散列(和其他用户数据)在第一时间被盗。

为了编写安全的应用程序,即使是有经验的开发人员也必须接受安全方面的培训。了解 Web 应用程序漏洞的一个不错的资源是开放 Web 应用程序安全项目(The Open Web Application Security Project,OWASP)。一份很好的介绍是 OWASP 十大漏洞列表。除非您已经理解了列表中的所有漏洞,否则不要尝试编写处理敏感数据的 Web 应用程序。雇主有责任确保所有开发人员都接受过安全应用程序开发方面的充分培训。

让第三方对您的应用程序进行「渗透测试」是个好主意。即使是最优秀的程序员也会犯错,因此让安全专家检查代码是否存在潜在漏洞总是有意义的。应该找一个值得信赖的组织(或雇佣员工)定期检查您的代码。安全审查过程应该在应用程序生命周期的早期开始,并贯穿整个开发过程。

如果确实发生了攻击,监控您的网站来检测到攻击行为也很重要。我建议至少雇用一名全职人员来检测和响应安全漏洞。如果攻击未被检测到,那么攻击者就可以利用您的网站让访问者感染恶意软件,因此检测漏洞并迅速做出响应非常重要。

常见问题

我应该使用什么散列算法?

可以使用:

不要使用:

  • MD5、SHA1、SHA256、SHA512、RipeMD、WHIRLPOOL、SHA3 等快速加密散列函数。
  • crypt 的不安全版本($1$,$2$,$2x$,$3$)。
  • 您自己设计的任何算法。应该只使用公共领域,且经过有经验的密码学家们充分测试的技术。

尽管还没有对 MD5 或 SHA1 的密码学攻击来让它们的散列更容易被破解,它们也已经过时并且被广泛认为(好像还不够广泛)强度不足以存储密码。所以我不建议使用它们。此规则的一个例外是 PBKDF2,它的实现经常把 SHA1 作为底层散列函数。

当用户忘记密码时,我应该如何让用户重置密码?

我个人认为,当今广泛使用的所有密码重置机制都是不安全的。如果您有很高的安全要求,例如提供加密服务,请不要让用户重置他们的密码。

大多数网站使用电子邮件循环来对忘记密码的用户进行身份验证。为此,需要生成一个与账户紧密相关的随机一次性令牌。将令牌包含在发送到用户电子邮件地址的密码重置链接中。当用户点击包含有效令牌的密码重置链接时,提示他们输入新密码。需要确保令牌与用户账户紧密相关,使攻击者无法用发送到自己电子邮件地址的令牌来重置其他用户的密码。

令牌必须设置为在 15 分钟后或使用后到期,以先到者为准。当用户登录(想起了密码)或请求另一个重置令牌时,让任何现有的密码令牌过期也是一个不错的主意。如果令牌永不过期,就可以永远被用于闯入用户的账户中。电子邮件(SMTP)是一种纯文本协议,互联网上可能存在记录电子邮件流量的恶意路由器。此外,用户的电子邮件账户(包括重置链接)可能会在用户密码更改很长时间之后被盗用。使令牌尽快到期可以减少用户遭受这些攻击的风险。

攻击者将会有能力修改令牌,因此不要在令牌中存储用户账户信息或超时信息。令牌应该是一个不可预测的随机二进制大型对象(BLOB),仅用于标识数据库表中的记录。

永远不要通过电子邮件向用户发送新密码。不要忘记在用户重置密码时选择一个新的随机盐。不要重复使用用于散列旧密码的盐。

如果我的用户账户数据库被泄露/入侵,我应该做什么?

您的首要任务是确定系统是如何被入侵的,并修补攻击者用来入侵的漏洞。如果您没有应对入侵行为的经验,我强烈建议您聘请第三方安全公司。

选择掩盖入侵行为并且希望没有人注意到入侵的发生可能十分诱人。但是,试图掩盖入侵行为会让您看起来更不负责任,因为您没有通知用户他们的密码和其他个人信息可能已经被泄露,从而使他们面临更大的风险。您必须尽快通知您的用户——即使您还没有完全理解发生了什么。请在您网站的首页放上一条通知,链接到包含更详细信息的页面,并尽可能通过电子邮件向每个用户发送通知。

向您的用户准确地解释他们的密码是如何被保护的——希望您使用了加盐散列——即使密码受到了加盐散列的保护,恶意黑客仍然可以对散列实施字典攻击和暴力破解。恶意黑客将使用他们找到的任何密码尝试登录不同网站上的用户账户,期望用户在两个网站上使用了相同的密码。将这种风险告知您的用户,并建议他们在使用了类似密码的任何网站或服务上更改密码。强制他们在下次登录您的服务时更改密码。大多数用户会尝试将他们的密码「更改」为与原来相同的密码来快速绕过强制更改。请使用当前密码的散列值来确保他们不能这样做。

即使使用了加盐的慢速散列,攻击者也很可能会很快破解出一些弱密码。为了减小攻击者使用这些密码的机会窗口,除了当前密码之外,您还应该要求用户使用电子邮件循环来进行身份验证,直到用户更改密码为止。请阅读上一个问题「当用户忘记密码时,我应该如何让用户重置密码?」中关于实现电子邮件循环身份验证的提示。

还应该将网站上存储了哪些类型的个人信息告知您的用户。如果您的数据库包含信用卡号,您应该指导您的用户仔细查看他们最近和未来的账单并注销他们的信用卡。

我的密码策略应该如何设置?我应该强制用户使用强密码吗?

如果您的服务没有严格的安全要求,就不要限制您的用户。我建议在用户输入密码时向他们展示有关密码强度的信息,让他们自己决定密码的安全性。如果您有特殊的安全需求,请强制要求密码的长度至少为 12 个字符,且至少有两个字母、两个数字和两个符号。

不要强迫您的用户以六个月一次更高的频率更改密码,因为这样做会造成「用户疲劳」,让用户不太可能选择好的密码。相反,应该培训用户在他们觉得密码已经被泄露时更改密码,并且永远不要将他们的密码告诉任何人。如果是商业环境,应该鼓励员工利用带薪时间来记住和练习他们的密码。

如果攻击者可以访问我的数据库,他们难道不能用自己的散列值替换掉我的密码散列值再登录吗?

的确可以,但是如果有人可以访问您的数据库,他们可能已经能够访问您服务器上的所有内容,因此他们无需登录您的账户就能获得他们想要的任何内容。在网站中使用的密码散列技术目的不在于保护网站不被攻破,而是在网站已经被攻破时保护密码。

您可以使用具有不同权限的两个用户连接到数据库来防止散列值在 SQL 注入攻击期间被替换。一个用户用于「创建账户」部分的语句,另一个用户用于「登录」部分的语句。「创建账户」部分的语句应该能读取和写入用户表,但「登录」部分的语句应该只能读取。

为什么我必须使用像 HMAC 这样的特殊算法?为什么我不能直接将密码附加到私钥后面?

MD5、SHA1 和 SHA2 等散列函数使用了 Merkle–Damgård 结构,会让它们容易受到所谓的长度扩展攻击。这意味着给定一个散列 H(X),攻击者可以在不知道 X 的情况下找到任何其他字符串 Y 的 H(pad(X) + Y) 的值。pad(X) 是散列使用的填充函数。

这意味着给定散列 H(密钥 + 消息),攻击者可以在不知道密钥的情况下计算出 H(pad(密钥 + 消息) + 扩展)。如果散列被用作消息验证码,使用密钥来防止攻击者修改消息并将消息替换为不同的有效散列,则系统会失败,因为现在攻击者已经拥有了有效的消息 + 扩展的散列。

目前尚不清楚攻击者如何使用这种攻击来更快地破解密码散列。但是,由于这种攻击的存在,使用普通散列函数进行密钥散列被认为是不好的做法。一位聪明的密码学家有可能会在某天想出一种聪明的方法来利用这些攻击加快破解速度,因此请使用 HMAC。

盐应该在密码之前还是之后?

这不重要,但为了互操作性,请选择一种并坚持下去。在密码之前加盐似乎更常见。

为什么此页面上的散列代码会在「长度恒定」的时间内比较散列值?

在「长度恒定」的时间内比较散列值可以确保攻击者无法使用定时攻击从在线系统中提取到密码的散列值,然后离线破解它。

检查两个字节序列(字符串)是否相同的标准方法是先比较第一个字节,然后比较第二个,接着比较第三个,依此类推。一旦您发现两个字符串的某个字节不同,就会得知字符串是不同的,可以立即返回否定响应。如果您比较完了两个字符串却没有发现任何不同的字节,就可以确信字符串是相同的并返回肯定结果。这意味着比较两个字符串可能需要不同的时间,具体取决于字符串匹配的程度。

例如,用标准比较方法比较字符串「xyzabc」和「abcxyz」会立即发现第一个字符就是不同的,并不会检查字符串的其余部分。另一方面,当比较字符串「aaaaaaaaaaB」和「aaaaaaaaaaZ」时,比较算法在确定字符串不相等之前会扫描所有的「a」。

假设攻击者想要闯入一个将身份验证尝试速率限制为每秒一次的在线系统。还假设攻击者知道密码散列的所有参数(盐、散列类型等),除了散列本身和(显然不知道的)密码。如果攻击者能够精确测量在线系统将真实密码的散列值与攻击者提供密码的散列值进行比较所需的时间,就可以使用定时攻击来提取部分散列并使用离线攻击破解它,从而绕过系统的速率限制。

首先,攻击者找到 256 个字符串,其散列值以每个可能的字节开头。先将每个字符串发送到在线系统,并记录系统响应所需的时间。花费时间最长的字符串就是散列的第一个字节与真实散列的第一个字节相匹配的字符串。攻击者得知了第一个字节后,可以用类似的方法继续攻击第二个字节、第三个字节,依此类推。一旦攻击者对散列值已经了解得足够多,就可以使用自己的硬件来破解它,不受系统的速率限制。

在网络上实施定时攻击看上去似乎是不可能的。然而,这种攻击已经被完成过,并且已被证明是实用的。这就是为什么此页面上的代码以一种无论字符串匹配程度有多高都花费相同时间进行比较的原因。

SlowEquals 代码是如何工作的?

上一个问题解释了为什么 SlowEquals 是必要的,而这个问题解释了代码实际的工作方式。

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;
}

代码使用异或(XOR)的「^」运算符来比较整数是否相等,而不是「==」运算符。原因将在下面解释。当且仅当两个整数完全相同时,异或这两个整数的结果才为零。这是因为 0 XOR 0 = 0,1 XOR 1 = 0,0 XOR 1 = 1,1 XOR 0 = 1。如果我们将异或应用于两个整数中的所有位,那么仅当所有位都匹配时,结果才会为零。

因此,在第一行中,如果 a.length 等于 b.length,则 diff 变量将会是零,如果不相等,diff 变量将会是非零值。接下来,我们使用 XOR 比较字节,并将结果 OR 到 diff 中。如果字节不同,就会将 diff 设置为非零值。由于 OR 永远不会取消设置位,所以当循环结束时 diff 为零的唯一情况是,它在循环开始之前就为零(a.length == b.length),且两个数组中的所有字节都匹配(没有任何一个 XOR 得到了非零值)。

我们需要使用 XOR 而不是「==」运算符来比较整数的原因是「==」通常会被翻译/编译/解释为一个分支。例如,C 代码「diff &= a == b」可能会被编译成以下 x86 汇编:

MOV EAX, [A]
CMP [B], EAX
JZ equal
JMP done
equal:
AND [VALID], 1
done:
AND [VALID], 0

根据整数是否相等和 CPU 内部的分支预测状态,分支会使代码在不同的时间内被执行。

C 代码「diff |= a ^ b」应该会被编译成以下内容,其执行时间不依赖于整数是否相等:

MOV EAX, [A]
XOR EAX, [B]
OR [DIFF], EAX

为什么要用散列这么麻烦的东西?

您的用户正在您的网站中输入他们的密码。他们信任您并将他们的安全交给了您。如果您的数据库遭到入侵,而且用户的密码未受任何保护,则恶意黑客就可以使用这些密码来破坏您的用户在其他网站和服务上的账户(大多数人在所有地方都使用相同的密码)。面临风险的不仅是您的安全,还有您用户的安全。您应该对用户的安全负责。