本文起因

昨天面试(实习岗),面试官其中一个问题是这样的:

  • Q:你了解现在一些加密相关的技术吗?比如对称加密或非对称加密?
  • A:了解过一点,比如知道现代计算机的技术是比较容易破解md5、sha1等简单的hash算法,如果想要更高的安全性,更推荐使用bcrypt或者RSA(存在前向安全问题)等加密算法。
  • Q:可以说说非对称加密具体是怎么实现的吗?
  • A:公钥私钥balabala~
  • Q:可以说说具体的应用场景吗?
  • A:balabala~(感觉讲得不是很好,就不贴出来了)

后来想起之前网友推给我的一篇博客,当时简单看了下感觉还不错,于是就收藏了(收藏了就等于学会了.jpg),现在想起来有这么个东西,于是就重新看了一遍,因为搭建了博客,所以就顺便尝试着翻译了一下。

下文开始皆是翻译。

原文地址:https://crackstation.net/hashing-security.htm

注意!作者也许会更改原文也说不定,如果发现本文内容与原文有较大差异,那么说明作者修改原文了,请以原文内容为准!(我查过这篇原文早期的版本,那时候还认为md5是安全的,但现在明显不一样了)

前言

如果你是一个Web开发人员,你可能不得不建立一个用户帐户系统。而用户帐户系统最重要的方面是如何保护用户密码。用户帐户数据库可能经常受到黑客攻击,所以如果你的网站被入侵,你必须采取措施保护你用户的密码。保护密码的最好方法是使用hash+盐。

关于如何正确地进行密码散列,人们有很多相互矛盾的想法和误解,可能是由于网络上大量的错误信息造成的。密码散列是其中一种非常简单的方法,但是很多人都会犯错。在这一章中,我希望不仅解释正确的方法,而且解释为什么应该这样做。

重要警告: 除非很有经验,否则不要尝试自己实现密码散列 . 因为这真的非常容易翻cece。 你在学校里学的简单散列并不能让你免疫这个警告。 这条警告适用于所有非专业人士: 不要自己实现密码散列! 实际上现在已经有很多成熟的解决方案。 比如 phpass, the PHP, C#, Java, 和 Ruby 启动 defuse/password-hashing, 或 libsodium 等均有。

如果你因某些原因错过了上面的警告,请现在重新看一遍。实际上,本篇文章也并不是教你如何实现密码散列,而是告诉你如何正确的使用已有的散列算法.

你可以通过以下目录快速浏览你需要的东西:


哈希加密是什么

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

哈希算法是一种单向函数。它们可以将任意长度的数据,转换成固定长度的"密钥"。它们还具有这样的特性:如果输入发生了微小的变化,那么得到的散列码将会有很大不同(比如上面的例子)。这对于保护密码非常有用,因为我们想用密文的方式存储密码,即便密码文件受到损坏,我们也能验证密码是否正确。

在基于hash加密的系统中,用户注册和认证的流程如下:

  1. 用户创建了账户。
  2. 他们的密码被hash后存储在数据库中。在任何情况下,都不能将明文(未加密的)密码写入硬盘。
  3. 当用户尝试登陆时,系统会将他们输入的密码hash值将与他们的真实密码的hash值进行比对(从数据库查询)。
  4. 如果hash值匹配,则授予用户访问权。如果不匹配,则告知用户输入了无效的登录凭据。
  5. 每当用户尝试登陆时,都会重复上面的3,4步骤。

在步骤4中,永远不要告诉用户到底是用户名错了还是密码错了。应始终显示“用户名或密码错误”这种含糊信息,让攻击者无法暴力破解用户名。

需要注意的是,用于保护密码的hash函数与你在数据结构课程中看到的hash函数并不相同。用户实现哈希表等数据结构的hash函数,它们设计的目的往往是保证速度,而不是安全。只有密文的hash函数才能够用于对密码进行散列。像SHA256、SHA512、RipeMD和WHIRLPOOL这样的hash函数都属于能对密码进行散列的函数(密文)。

人们很容易认为,只需要通过简单hash函数对密码进行加密,用户的密码就是安全的。然而事实却不是这样的。有许多方法可以快速地从普通hash函数加密后的密文中恢复密码。有一些易于实现的技术可以大大提高这些“攻击”的效率。比如你可以使用这个网站(原文的网站),在首页,你可以提交一份由简单hash函数生成的待破解的散列码,然后将在1秒内收到结果。显然,简单的hash函数并不能满足我们的安全需求。

下面一小节,我们将看到一些用于破解简单hash密文的常见攻击。

hash是如何被破解的

字典+暴力攻击

Dictionary Attack

Trying apple        : failed
Trying blueberry    : failed
Trying justinbeiber : failed
...
Trying letmein      : failed
Trying s3cr3t       : success
Brute Force Attack

Trying aaaa : failed
Trying aaab : failed
Trying aaac : failed
...
Trying acdb : failed
Trying acdc : success

破解简单的hash最简单的方式就是直接猜密码,对每个猜测的明文都进行hash,然后跟简单hash生成的密文进行比对。如果两个密文相同,那么恭喜你,猜对了。猜测密码最常用的方法就是字典、暴力比对。

字典攻击使用的是包含单词,短语,常用密码等数据的文件。将文件中的每个单词,都进行hash,然后和盗窃到的密文进行比对,如果匹配成功了,则说明尝试的这个字符串就是密码。

暴力攻击会尝试所有可能的字符组。这种攻击在计算上非常昂贵,并且效率极低(破解每个密文都需要重新穷举一遍),但它们最终总会找到密码。除非你的密码足够长,长到现代计算机也需要花费非常昂费的成本才能破解,但我想这样的密码用户自己可能也记不住,明显不值得。

字典攻击和暴力(穷举举)攻击是无法杜绝的。虽然我们可以让破解的成本变得更高学,但是依旧没法杜绝。如果您的密码hash系统是安全的,破解散列密文的唯一方法是对每个散列进行字典或穷举攻击。

查询表的破解方式

Searching: 5f4dcc3b5aa765d61d8327deb882cf99: FOUND: password5
Searching: 6cbe615c106f422d23669b610b564800:  not in database
Searching: 630bf032efe4507f2c57b280995925a9: FOUND: letMEin12
Searching: 386f43fab5d096a7a66d67c8f213e5ec: FOUND: mcd0nalds
Searching: d5ec75d5fe70d428685510fae36492d9: FOUND: p@ssw0rd!

查找表是一种非常有效的方法,可以非常快速地破解许多相同类型的hash函数。其基本思想是在密码字典表中预先计算密码的hash值,并将它们及其对应的密文存储在表中。一些性能不错的查询方案可以实现每秒可以处理数百个hash值的查找,即使表中包含数十亿个hash值。

如果你想知道查找表能有多快,试试用CrackStation免费提供的hash破解器破解以下sha256哈希的密文。

c11083b4b0a7743af748c85d343dfee9fbb8b2576c05f3a7f0d632b0926aadfc
08eac03b80adc33dc7d8fbe44b7c7b05d3a2c511166bdb43fcb710b03ba919e7
e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904
5206b8b8a996cf5320cb12ca91c7b790fba9f030408efe83ebb83548dc3007bd

反向查询表的破解方式

Searching for hash(apple) in users' hash list...     : Matches [alice3, 0bob0, charles8]
Searching for hash(blueberry) in users' hash list... : Matches [usr10101, timmy, john91]
Searching for hash(letmein) in users' hash list...   : Matches [wilson10, dragonslayerX, joe1984]
Searching for hash(s3cr3t) in users' hash list...    : Matches [bruce19, knuth1337, john87]
Searching for hash(z@29hjja) in users' hash list...  : No users used this password

这种攻击允许攻击者同时对多个hash值进行字典或暴力攻击,而不必预先计算密码的hash密文。

首先,攻击者会创建一个查询表,将盗窃回来的用户帐户数据库进行映射。然后攻击者对每个猜测的密码进行hash,之后攻击者获得的不是单个用户使用的密码,而是使用这个密码的用户列表。这种攻击对于很多用户都用相同密码的场景特别有效。

彩虹表的破解方式

彩虹表是一种时间-空间权衡的技术。它有点类似于上面的查找表,只是它可能会为了让表更小,牺牲了hash的破解速度。因为表的数据变得更小,所以在相同的空间能存储更多的破解方案,从而使破解更有效率。彩虹表可以破解任何md5哈希的密码,最长可达8个字符。

如果用mysql举例子,就相当于减小每条记录占用的空间,那么在固定的buffer pool内存中,就可以存储更多的数据,那么每次从磁盘中加载数据到内存后,就可以做更多的比较。

在下面的小节,我们将介绍一种加盐的hash方法,它们将使查找表、彩虹表的破解方式失效。

加盐

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

查找表和彩虹表的破解方式必须满足一些前提,比如每个密码都是以完全相同的方式散列的。比如两个用户有相同的密码,他们hash出来的密码都是一样的。我们可以通过随机化每个hash来防止这些攻击,这样当相同的密码被hash两次时,hash后的值就不一样了。

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

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

在下面一小节,我们将看到一些错误的“盐”实现。

错误的盐(一):短盐和盐复用

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

盐复用

常见的错误是在每个hash中使用相同的盐。要么将盐硬编码到程序中,要么随机生成一次。这是无效的,因为如果两个用户使用相同的密码,他们密码的hash值仍然是一样的。攻击者仍然可以使用反向查找表攻击来找到密码一样的用户列表。他们只需要在每次猜测密码之前加一点盐就可以了。如果将这种盐硬编码到流行的产品中,可以为这种盐构建查找表和彩虹表,以便更容易地破解该系统生成的hash值。

另外,每个用户每次设置或修改密码都应该使用不同的盐,或者每次都使用随机盐。

短盐

如果盐太短,攻击者可以为每种可能的盐构建一个查找表。例如,盐只有三个ASCII字符,则只有95x95x95 = 857,375个可能的盐。这看起来似乎很多,但如果每个查询表只包含1MB最常见的密码,那么总的来说,它们将只有837GB,考虑到现在300元以下就可以买到1TB的硬盘,也许这并不算多。

类似的,用户名不应该用作盐。用户名对于单个服务可能是唯一的,但是它们是可预测的,并且经常在其他服务的帐户中重用。攻击者可以为常见的用户名构建查找表,并使用它们来破解。

为了不让攻击者为每个可能的盐创建查找表,盐必须很长。一个好的经验法则是使用与哈希函数输出大小相同的盐。例如,SHA256的输出是256位(32字节),所以盐应该至少是32个随机字节。

错误的盐(二):组合hash

本节介绍另一个常见的密码hash误解:组合hash算法。人们很容易忘我地尝试组合不同的哈希函数,妄想得到更安全的结果。然而,在实践中,这样做几乎没有什么好处。它所做的只是让函数之间互相影响而已,有时甚至会降低hash值的安全性。还是开头那句,永远不要试图发明你自己的hash算法,应该根据专业人士定制的标准操作。有些人会认为使用多个hash函数会使哈希的计算过程变慢,从而导致破解变慢,但是其实有一个更好的方法可以让破解过程变慢,我们稍后会看到。

以下是我在网上看到的一些组合hash

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

在生产中最好不要使用以上的任何一种。

注意:这一节的论点其实是有比较大的争议的。我收到很多邮件认为组合的哈希函数是一件好事,因为如果攻击者不知道组合hash中具体使用了哪些算法,那攻击者几乎是不可能通过各种表操作来破解的,就算可以,也需要花更多的时间破解。

当攻击者不知道hash算法时,他无法攻击,但请注意Kerckhoffs的原理,即攻击者通常可以访问源代码(特别是免费或开源软件),并且能获取到hash和密码对,这样攻击者就可能逆推出系统所使用的hash算法。计算组合hash确实会花费更多的时间,但这只是因为很小的常数因子拖慢了而已。最好使用一种难以并行化的迭代算法下面将讨论这些)。而且,适当地对hash进行加盐可以解决彩虹表问题。

如果你真的想使用一个标准的组合哈希函数,比如HMAC,那是没问题的。但是,如果这样做的原因是为了降低hash破解的速度,那么请先阅读下面一小节。

最后再强调一次,不要尝试通过组合hash自创加密(容易造成安全问题),更建议使用标准的、经过实际测试的hash算法。

hash冲突问题

由于散列函数将任意长度的数据转换成固定长度的密文字符串,因此必定会有一些字符串经过hash加密后会得到相同的密文。hash函数的设计就是为了让这些冲突难以被发现。然而随着时间流逝,密码学家发现攻击者越来越容易找到冲突了。最近的一个例子是MD5函数的hash冲突被发现了。

冲突攻击表明除了用户的密码之外,其他字符串有可能具有相同的hash值。然而,即使在像MD5这样的弱hash函数中寻找冲突也需要大量的算力,因此在实践中发生这些冲突的可能性很小。使用MD5和盐进行散列的密码在实际应用中与使用SHA256和盐进行散列的安全性是一样的。不过,如果可以的话,最好使用更安全的hash函数,如SHA256、SHA512、RipeMD或WHIRLPOOL。

正确地使用hash

本小节将会详细描述应如何正确使用hash函数,其中下面的第一部分会介绍最基本的要素,也是在hash加密中必须做到的;后面会讲解怎样在这个基础上进行扩展,使得hash加密更难被破解。

在上面的描述中,我们已经看到恶意的黑客如何使用查找表和彩虹表快速地破解普通哈希表。我们也知道,随机化hash盐是解决问题的方法。但是我们如何生成盐,并将它应用到密码中呢?

实际上,盐值应该使用基于加密的伪随机数生成器(Cryptographically Secure Pseudo-Random Number Generator – CSPRNG)来生成。CSPRNG和普通的随机数生成器有很大不同,如C语言中的rand()函数。顾名思义,CSPRNG专门被设计成用于加密,它能提供高度随机和无法预测的随机数。我们当然不希望自己的盐值被秦松猜测到,所以一定要使用CSPRNG。下面的表格列出了当前主流编程语言中的CSPRNG方法:

语言CSPRNG
PHPmcrypt_create_iv, openssl_random_pseudo_bytes
Javajava.security.SecureRandom
Dot NET (C#, VB)System.Security.Cryptography.RNGCryptoServiceProvider
RubySecureRandom
Pythonos.urandom
PerlMath::Random::Secure
C/C++ (Windows API)CryptGenRandom
Any language on GNU/Linux or UnixRead from /dev/random or /dev/urandom

每个用户密码的盐值都应该是唯一的。每次用户创建帐户或更改密码时,都应该使用新的随机盐对密码进行hash。永远不要复用盐。盐也应该拥有足够的长度,以便于能够产生更多不同的盐。经验表示,盐的长度应该至少等于你hash值的长度。盐应该与密码的hash值一起存储在用户帐户表中。

存储密码的步骤

  1. 使用CSPRNG生成一个长随机盐。
  2. 将盐用于密码hash加密,注意要使用稳定、标准的hash加密算法,比如:Argon2,bcrypt,scrypt,PBKDF2等。
  3. 把盐和hash后的密文都保存在数据库中。

验证密码的步骤

  1. 从数据库中取出盐和hash密文。
  2. 对用户输入的密码,使用对应的hash算法并加上面取出的盐。
  3. 比较第1、2步计算出的hash密文
  4. 如果匹配,则说明密码是正确的,否则,就是错误的。

注意!上面说的是匹配,而不是相等,这是原文没有强调的一点,个人感觉容易踩坑,假如你用的只是普通的md5,那可能真的会相等,但类似于上面推荐的bcrypt等hash算法,对同一个数据进行加密,每次生成的密文都不一样,但却是能匹配上的。

不要在服务端以外的地方加密

如果你在开发一个Web程序,你可能会产生这样一个疑惑:到底是在客户端用js加密密码传送到服务端,还是将密码明文发送到服务器在加密呢?

即便你在客户端使用js对密码进行hash加密了,但这并不代表你就不用在服务端再加密了。或者我们现在就可以来想象一下这么个场景:现在有一个应用,只在客户端进行加密,而没有在服务端进行加密。那么在进行用户认证的时候,用户输入的密码会先经过hash加密,然后再传给服务端做匹配,这看起来似乎会更安全些,然而事与愿违。

问题就在于客户端的hash密文在逻辑上成为用户的密码。用户要进行身份验证,只需将密码的hash值告诉服务器即可。如果被不怀好意的人得到了用户密码的hash值,他们就可以直接使用它来通过服务器的验证,而不需要知道用户的密码!所以,如果坏人从这个网站上窃取了数据库,他们就可以立即访问每个人的账户,而不必花费任何精力去猜测密码。

下面举个例子再说明下上面的问题

比方说黑客盗取了银行的账户数据库信息,这个数据库主要含有:用户名、密码、盐,其中一条记录是下面这个样子的:

| username | password | salt |
| chen | hawf987w7a9w8d7 | 9ga79c797a79w |

如果你的程序是在客户端就进行hash,在服务端直接进行比对的话,那么黑客就可以直接拿数据库得到的密文"hawf987w7a9w8d7"来当做密码直接发送登录请求了,然后黑客顺利获取该身份的token,之后就利用伪造的身份进行一系列操作了,可能转账类的点操作需要额外验证,但你卡里有多少钱、近期消费、个人信息之类的东西都暴露无遗了。

但如果服务端还需要再进行加密,然后再比对,那上面那个例子就完全无法成立了,因为黑客再发送"hawf987w7a9w8d7"的话并没有任何意义,这串密文经过再hash,出来的值肯定是匹配不上的。

虽然上面举了这么个例子,但并不是说在客户端进行加密就是坏事了,相反,这其实是一个好主意,只是不能省下服务端的加密就是了,除此之外你可能还需要注意以下几点:

  • 在客户端对密码进行hash并不能取代HTTPS(SSL/TLS),如果连接是不安全的,黑客可以在下载js代码时修改它,删除hash逻辑并获得用户的密码。
  • 有些浏览器、设备并不支持js或你所想的客户端加密方案,对待这些特殊的案例,你可能要在服务端上进行判断并模拟客户端的hash加密。
  • 客户端的hash算法仍然是要加盐的。一种比较容易想到的方案就是客户端向服务端发送请求获取盐,但很可惜,这样做也是错误的,因为这样做通常就意味着攻击者可以在不知道密码的情况下去穷举用户名,这种情况下可能需要你自己根据不同环境想办法解决盐的生成策略了。

让hash破解变得更难:慢hash函数

盐的存在使得攻击者无法使用查找表和彩虹表之类的方式来快速破解大量hash密文,但是它并不能防止攻击者对每个密文使用字典或穷举攻击。高端显卡(gpu)和一些定制硬件每秒可以计算数十亿次hash,这个速率对我们的安全仍然存在威胁。为了降低这些攻击的效果,我们可以使用另一种技术,它就是密钥扩展

这种技术的核心思想就是让hash算法变得更慢,慢到让一些高端的GPU或者定制硬件无法使用字典法或穷举法破解,或让其破解成本变得攻击者无法接受,并且这种慢hash的方法,不会对用户的体验(响应时间)造成太大影响。

密钥扩展是依靠一种CPU密集型实现的hash函数。再次强调,不要尝试自己发明简单的迭代hash加密,如果迭代次数不够多,那仍然是可以被现在的高级的硬件快速并行计算出来的,就和前面讲到过的错误hash方法一样。应该使用标准的算法,比如PBKDF2或者bcrypt。这里提供PBKDF2在PHP上的一种实现

这类算法会以安全系数或迭代次数作为基准参数,这个基准参数会决定hash方法有多慢。

对于桌面应用或者手机应用,获取这个基准参数的最好方案,就是先在设备上进行一些基准测试,找到一个hash时间和用户体验之间的平衡点(至少不要让用户感到一丢丢难受?)。这样,你就可以保证你的应用具有一定的安全性,而又不会影响到用户的体验。

而对于Web后台或者服务器而言,使用密钥扩展就需要额外注意一些问题了,因为服务端不同于客户端,是要面对大量请求的,若使用不当,则很容易被Dos或者DDos攻击。不过即便是这样,我也仍然推荐你使用密钥扩展,只是对于使用到密钥扩展的接口,比如登录等敏感接口,设计和实现的时候应该完善验证码、ip限流等措施。

如果你担心计算量给服务器带来的一些问题,但又不想Web程序中放弃使用密钥扩展,那么你其实可以考虑在浏览器中用js完成。在Stanford JavaScript Crypto Library里包含了PBKDF2的实现。为了不给用户带来不好体验,可能需要你对hash的迭代次数进行一定的调整。同时不要忘了在客户端不支持js的时候,服务端模拟计算。另外,客户端使用了密钥扩展并不说明服务端就不用再hash了,上面已经解释过这个问题,面对客户端传来的密文,服务端依旧是把它当成普通密码来处理。

不会被破解的hash加密:hash密钥和hash专用设备

只要攻击者能通过hash函数判断自己所猜测的密码是否正确,那就意味着他可以使用字典或穷举法进行攻击。针对这种情况,我们可以考虑在hash函数中增加一个密钥,使得只有知道密钥的人,才能正确匹配密文。有两种方案可以实现这个思路:①可以使用AES算法对原本的密文进行加密;②使用HMAC算法将密钥塞进密文中。

也许上面的方法听起来是挺简单的,但做起来其实可难了,因为你必须保证密钥任何时候都不能被攻击者获取,包括攻击者已经获取当前系统最高权限的情况。为了预防这种情况,你应该将密钥存储在外部独立服务器,或者一些hash专用的独立设备上,比如像YubiHSM这样的。

我墙裂建议拥有10W用户或以上的大型服务使用这种方法,因为面对这样量级的用户群体,许多黑客都会觉得有攻击的价值。

如果你承受不住过多的服务器或定制设备的费用,是不是就没法做到更好的安全性?其实还有别的方法,但安全性相对于上面的会有所下降,不过总比没有好。

过往经验表示,很多针对数据库的攻击,使用的都是SQL注入攻击,而现在常用的SQL服务一般都是没有本地文件的访问权限的(除了SQL系统本身需要的文件以外,如果有读取其它文件的权限,那么应该禁用它)。针对这种情况,只要你做好足够的防注入措施,或者把密钥放到足够安全的地方(至少让Web相关的应用程序无法访问到),那么即便你的数据库或者Web程序防线被攻破了,也能确保将损失降到最低。

请注意,为hash增加了密钥之后,并不代表你就不需要慢hash和盐了,因为攻击者往往会想尽办法获取到你的密钥,为了照顾密钥被获取后的情况,你最好不要放弃盐和慢hash的方案。

其它一些安全方案

对密码进行hash加密等措施能在系统被入侵后,继续保护用户的数据安全,但请注意,这些方式并不能保护你的Web程序本身。为了保护你的Web程序和数据的安全,你应该做更多的防范措施。

即便是一些经验比较丰富的开发人员,他也需要额外地学习更多关于安全的知识才能写出处理敏感数据的程序。这里有一个不错的Web安全学习资源:The Open Web Application Security Project (OWASP),还有一个关于它的十大漏洞介绍:OWASP Top Ten Vulnerability List。除非你非常熟悉并且能处理这些漏洞,否则请不要鲁莽地去写处理敏感数据的程序。

有时候,让那第三方对你的应用程序进行“渗透测试”也许也是一个不错的选择。即使最优秀的程序员也可能会犯错误,所以让安全专家检查代码中的潜在漏洞还是有一定意义的。可以找一个值得信赖的第三方机构(或者雇佣员工)来定期检查你的代码,并且安全审查应该贯穿整个程序开发的生命周期。

除了检查代码中可能潜在的安全漏洞,你还应该时时刻刻的监控你的程序。比如你可以雇佣一些这方面的专家,他们的工作就是负责监控整个线上的应用程序,一旦发现应用程序被黑客攻击了,能在最短的时间内做出应对措施,这样做至少能让攻击者肆更难受一些。

常见问题

应该使用哪些hash算法加密?

应该使用:

不应该使用:

  • 一些快hash算法,比如:MD5,SHA1,SHA256,SHA512,RipeMD,WHIRLPOOL,SHA3等。
  • crypt的废弃版本,如:$1$, $2$, $2x$, $3$
  • 自己创造的hash算法(除非你是密码学家)。

尽管MD5和SHA1不至于被秒破解,但它们是在太过古老,并且在现代已经被认为不适合用于存储密码了,所以我不建议你使用它们。但也有些例外,比如PBKDF2底层就频繁的依赖SHA1作为hash算法。

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

这里举一个邮箱验证的例子(原文作者是认为这种方式不安全的,但他依旧举了这这个例子)。

大多数网站都会使用电子邮件+验证码的方式去验证忘记密码的用户。首先,你会生成一个一次性的令牌,并且这个令牌只对应了唯一一个用户的账号,然后你会将这个令牌混入一个链接并通过邮件发送给用户。当用户点击这个包含有效令牌的链接后,就可以重置自己的密码了。

重置密码的令牌一定要设置一个过期时间,这个时间最好是在15分钟以内,如果用户申请了另一个重置密码的令牌,那么旧的令牌就应该马上失效。如果令牌没有过期时间,或者在一定时间内有多个有效令牌对应一个用户账号,那都会造成一些安全风险。另外,电子邮件协议(SMTP)是一个纯文本协议,网络上有很多恶意路由可以拦截到邮件信息,所以在用户成功修改密码后,应该让令牌立即失效,降低令牌被攻击者重复使用的风险。

令牌中不要含有明文信息,比如用户的一些账号信息或者过期时间,因为攻击者可能会篡改令牌。令牌本身应该是一个密文,且一个有效令牌只能对应一个用户,千万不要让多个有效令牌对应一个用户。

永远不要通过电子邮件的方式给用户发送新密码(STMP是纯文本协议)。用户重置密码时,不要复用原来的盐,而是重新生成一个随机盐去加密用户的密码。

当用户的数据泄露/被入侵后,如何应对?

你要做的第一件事,是尽快查出这个安全漏洞并且修复,如果你自己或者公司解决不了这件事,那么你应该去雇佣一些可信赖的第三方机构来尽快解决这件事情。

然后你必须尽快地通知客户当前发生了什么事,泄露了哪些比较关键的数据,如信用卡帐号密码之类的泄露了则重点提醒用户关注这段时间的转账记录,或冻结信用卡,并尽快的向用户声明补偿方案。

千万不要对用户隐瞒数据被泄露的事实,因为这种事除非你有足够强大的公关,否则是必定会传出去的。当用户知道自己的数据在自己不知情的情况下被暴露出去,或者用户因没有收到通知而在不安全的时间段做了敏感操作,他们只会更加愤怒,最终公司只会赔更多。

为了让泄露后的数据不那么容易被破解,在事前你应该完善上面所讲到的hash扩展方案,这里就不再一一赘述了。

设置密码的策略应该是怎么样的?要强制让用户设置很复杂的密码吗?

如果你的应用数据不是那么的敏感,那你大可不必要求用户设置很强的密码。但你应该让用户在输入密码时,知道密码的安全强度是什么样的,让用户自己决定密码的安全程度。如果您有特殊的安全需求,则强制要求密码最小长度为12个字符,并要求至少有两个字母、两个数字和两个符号的组合。

不要频繁地强制你的用户去修改密码,因为这可能造成用户设置密码的“疲劳”,这种“疲劳”可能会驱使户不经大脑思考就尽快设置更简单、没有安全性的密码,以尽快解决你发出的烦人警告。如果你非要提醒用户修改密码,那么最好是挑一些有泄露风险的,然后再去通知他们。在公司等一些保密比较严谨环境中,则可以硬性要求员工在工作期间记忆密码。

攻击者入侵了数据库,他可以通过更改数据库来直接登录吗?

当然可以,不过不要忘记了hash的目的是什么,它是为了防止数据在泄露之后,以明文的方式呈现在攻击者面前。

为了避免这种情况,你的数据库账户应该划分不同权限,如只是针对用户登录的应用模块,可以设置数据库只读而不允许修改。

为什么我非得使用像HMAC一类hash算法,而不能只把密钥和hash算法简单叠加在一起?

类似MD5,SHA1,还有SHA2这种hash算法使用了Merkle–Damgård结构,这会使得密文十分容易被长度扩展攻击破解。比如说如果已经知道一个哈希值H(X),对于任意的字符串Y,攻击者可以计算出H(pad(X) + Y)的值,而不需要知道X是多少,其中pad(X)是哈希函数的填充函数(padding function,比如某个hash算法将数据按每128bit分为一组,如果最后一组<128bit就会进行填充,前面也说过hash算法生成的密文应该是固定的)。

这也意味着,给定一个哈希值H(key + message),攻击者可以在不知道key的情况下计算出H(pad(key + message) + extension)的值。如果这个hash值是给用户密码加密的,并且依靠key(应该是密钥)来防止外人破解的,那么很遗憾,这个key在攻击者眼里形同虚设,因为他不需要这个key也能计算出message和extension的hash值。

目前还不清楚攻击者能不能使用这种方法更快速的破解,但是由于这种攻击的出现,上文中提到的几个hash算法已经被认为是不好的实现。也许某天会有一些技术高超的密码学家或者黑客会发明出更好的长度扩展攻击,为了保证应用的安全还是使用HMAC吧。

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

这其实并不重要,放前面后面都行,但你最好想办法记住这个位置,免得加密之后无法进行匹配。现在比较常见的方式是放前面,当然这仅供参考。

为什么本文(原文)中的hash方法都是经过固定时间才返回的?

让密码匹配的时间变得固定,可以避免攻击者对在线系统的hash密文进行计时攻击+离线破解。

所谓的计时攻击,就是指我们在比较两个字符串时,通常是从头到尾的一个个字符进行匹配的,如果中间出现了不匹配,那么马上返回false,如果遍历完整个字符串都没有不同,那么立即返回true。那么攻击者就可以根据返回的时间间隔,判断密文的匹配程度到达多少了。

举个例子,攻击者发送了字符串"xyzabc",而你的系统查出了"abcxyz"进行比对,因为首字母都不一样了,所以在第一次比对之后就返回false,这样攻击者就知道第一个字母是错的,排除掉再继续。如果你系统查出来的是"axzaba",由于前五个都花费时间匹配了,所以返回的时间就比较长,这样攻击者就知道他前一段密文撞对了,只需要改后一段默认继续尝试即可。

最终,攻击者只需要拿到足够长、有效的hash密文,就可以到自己的计算机上离线破解了。

这种计时攻击可能在许多人眼里都是不可能实现的,但实际上已经诞生了并被用于工业环境中。所以本文(原文)使用的代码都是按固定时间返回的。

定长时间返回结果是如何做到的?

我们已经知道hash后的密文一般都是定长的,所以如果仅仅是比较两字符串是否相等,那么使用bit比较即可,示例代码如下:

     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];
         // 若完全相同,结果自然是 == 0
         return diff == 0;
     }
  • 如果长度不一样,也可以手动填充固定位数再进行运算。

用这种方法,就可以让匹配结果返回的速度与字符匹配的长度无关。

那为什么不用"=="而是用XOR来比较呢?原因是因为"=="通常会被编译成带有分支的语句,比如C的"diff &= a == b"在x86的系统中可能会被编译成下面这个样子:

MOV EAX, [A]
CMP [B], EAX
JZ equal
JMP done
equal:
AND [VALID], 1
done:
AND [VALID], 0
  • 这会使得返回时间受参数的影响。

而使用XOR,比如C的"diff |= a ^ b"在x86系统中可能会被编译成下面这个样子:

MOV EAX, [A]
XOR EAX, [B]
OR [DIFF], EAX
  • 返回时间完全不会受到参数的影响。

加密真的有必要这么麻烦吗?

当你问这个问题的时候,你可能会被马上开除,用户在你的网站上输入密码,就表示用户信任你的网站。如果你网站的数据被黑客入侵了,而用户的密码又是明文存储的,那就意味着黑客可以拿这些密码到用户常用的其它网站上尝试登录(大部分用户在不同网站都使用同一个密码)。实际上对密码加密不仅仅是对你网站的安全负责,更是对用户的安全负责。


原文地址:https://crackstation.net/hashing-security.htm

注意!作者也许会更改原文也说不定,如果发现本文内容与原文有较大差异,那么说明作者修改原文了,请以原文内容为准!(我查过这篇原文早期的版本,那时候还认为md5是安全的,但现在明显不一样了)