专栏名称: 聊聊架构
在这里煮酒聊架构。
目录
相关文章推荐
架构师之路  ·  为啥DeepSeek爆火之后,中国人想到的是 ... ·  昨天  
美团技术团队  ·  空降复旦!上海首条高校无人机配送航线启航 ·  3 天前  
架构师之路  ·  DeepSeek开源EPLB,世界上从来没有 ... ·  3 天前  
架构师之路  ·  炸裂官宣!大佬亲自站台,AWS全力支持Dee ... ·  2 天前  
51好读  ›  专栏  ›  聊聊架构

如何安全的存储用户密码?

聊聊架构  · 公众号  · 架构  · 2017-03-13 12:32

正文

作者|Defuse Security团队
编辑|刘志勇

本文介绍了对密码哈希加密的基础知识,以及什么是正确的加密方式。还介绍了常见的密码破解方法,给出了如何避免密码被破解的思路。相信读者阅读本文后,就会对密码的加密有一个正确的认识,并对密码正确进行加密措施。

本文由 Defuse Security 安全团队 撰写 ,作者采用了 CC协议 ,InfoQ翻译并分享,以飨读者。

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

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

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

密码哈希是什么?

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

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

  1. 用户创建自己的帐号。

  2. 密码经过哈希加密后存储在数据库中。密码一旦写入到磁盘,任何时候都不允许是明文形式。

  3. 当用户试图登录时,系统从数据库取出已经加密的密码,和经过哈希加密的用户输入的密码进行对比。

  4. 如果哈希值相同,用户将被授予访问权限。否则,告知用户他们输入的登陆凭据无效。

  5. 每当有人试图尝试登陆,就重复步骤3和4。

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

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

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

如何破解哈希?
字典攻击和暴力攻击( Dictionary and Brute Force Attacks)
字典攻击 暴力攻击
Trying apple        : failed Trying aaaa : failed
Trying blueberry    : failed Trying aaab : failed
Trying justinbeiber : failed Trying aaac : failed
Trying letmein      : failed Trying acdb : failed
Trying s3cr3t       : success! Trying acdc : success!

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

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

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

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

查表法( Lookup Tables)
Searching: 5f4dcc3b5aa765d61d8327deb882cf99: FOUND: password5
Searching: 6cbe615c106f422d23669b610b564800:  not in database
Searching: 630bf032efe4507f2c57b280995925a9: FOUND: letMEin12 
Searching: 386f43fab5d096a7a66d67c8f213e5ec: FOUND: mcd0nalds
Searching: d5ec75d5fe70d428685510fae36492d9: FOUND: p@ssw0rd!

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

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

反向查表法( Reverse Lookup Tabs)

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

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

彩虹表( 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 方法。

Platform CSPRNG
PHP mcrypt_create_iv, openssl_random_pseudo_bytes
Java java.security.SecureRandom
Dot NET (C#, VB) System.Security.Cryptography.RNGCryptoServiceProvider
Ruby SecureRandom
Python os.urandom
Perl Math::Random::Secure
C/C++ (Windows API) CryptGenRandom
Any language on GNU/Linux or Unix Read from /dev/random or /dev/urandom

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

存储密码的步骤:

  1. 使用 CSPRNG 生成足够长的随机盐值。

  2. 将盐值混入密码,并使用标准的密码哈希函数进行加密,如Argon2、 bcrypt 、 scrypt 或 PBKDF2 。

  3. 将盐值和对应的哈希值一起存入用户数据库。

校验密码的步骤:

  1. 从数据库检索出用户的盐值和对应的哈希值。

  2. 将盐值混入用户输入的密码,并且使用通用的哈希函数进行加密。

  3. 比较上一步的结果,是否和数据库存储的哈希值相同。如果它们相同,则表明密码是正确的;否则,该密码错误。

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

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

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







请到「今天看啥」查看全文