如何正确对用户密码进行加密?
如何正确对用户密码进行加密?
作为一名 Web 开发人员,我们经常需要与用户的帐号系统打交道,而这其中最大的挑战
就是如何保护用户的密码。经常会看到用户账户数据库频繁被黑,所以我们必须采取一些
措施来保护用户密码,以免导致不必要的数据泄露。保护密码的最好办法是使用加盐密码
哈希( salted password hashing)。
在对密码进行哈希加密的问题上,人们有很多争论和误解,可能是由于网络上有大量错误
信息的原因吧。对密码哈希加密是一件很简单的事,但很多人都犯了错。
密码哈希是什么?
哈希算法是一种单向函数。它把任意数量的数据转换为固定长度的“指纹”,而且这个过程
无法逆转。它们有这样的特性:如果输入发生了一点改变,由此产生的哈希值会完全不
同。
hash("hello") =
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
hash("hbllo") =
58756879c05c68dfac9866712fad6a93f8146f337a69afe7dd238f3364946366
hash("waltz") =
c0e81794384491161f1777c232bc6bd9ec38f616560b120fda8e90f383853542
这个特性很适合用来存储密码。因为我们需要一种不可逆的算法来加密存储的密码,同时
保证我们也能够验证用户登陆的密码是否正确。
在基于哈希加密的帐号系统中,用户注册和认证的大致流程如下。
1. 用户创建自己的帐号。
2. 密码经过哈希加密后存储在数据库中。密码一旦写入到磁盘,任何时候都不允许是明
文形式。
3. 当用户试图登录时,系统从数据库取出已经加密的密码,和经过哈希加密的用户输入
的密码进行对比。
4. 如果哈希值相同,用户将被授予访问权限。否则,告知用户他们输入的登陆凭据无
效。
5. 每当有人试图尝试登陆,就重复步骤 3 和 4。
在步骤 4 中,永远不要告诉用户输错的究竟是用户名还是密码。就像通用的提示那样,始
终显示:“无效的用户名或密码。”就行了。这样可以防止攻击者在不知道密码的情况下枚
举出有效的用户名。
应当注意的是,用来保护密码的哈希函数,和数据结构课学到的哈希函数是不同的,比如
HashMap 中的哈希函数:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap 中的 hash 函数是为了快速查找,它追求的是把键值均匀的散列在数组中,而非
安全性。只有加密哈希算法才可以用来进行密码哈希加密,像 SHA256、SHA512、
RIPEMD 和 WHIRLPOOL 都是加密哈希函数。
技术派中的加密算法用的是 Spring 的 DigestUtils.md5DigestAsHex() 方法:
c5e29b26-9f9f-4bac-9a5d-951a7bcef272.png
MD5 由密码学家 Ronald Linn Rivest 设计,于 1992 年公开,但现在被证实无法防止碰撞
攻击,因此更推荐使用 BCrypt 算法(后面会讲)。
如何破解哈希?
字典攻击和暴力攻击( Dictionary and Brute Force Attacks)
Secure_Salted_Password_Hashing_-_How_to_do_it_Properly1.png
破解哈希加密最简单的方法是尝试猜测密码,哈希每个猜测的密码,并对比猜测密码的哈
希值是否等于被破解的哈希值。如果相等,则猜中。猜测密码攻击的两种最常见的方法是
字典攻击 和 暴力攻击 。
字典攻击使用包含单词、短语、常用密码和其他可能用做密码的字符串的字典文件。对文
件中的每个词都进行哈希加密,将这些哈希值和要破解的密码哈希值比较。如果它们相
同,这个词就是密码。字典文件是通过大段文本中提取的单词构成,甚至还包括一些数据
库中真实的密码。还可以对字典文件进一步处理以使其更为有效:如单词 “hello” 按网络
用语写法转成 “h3110” 。
暴力攻击是对于给定的密码长度,尝试每一种可能的字符组合。这种方式会消耗大量的计
算,也是破解哈希加密效率最低的办法,但最终会找出正确的密码。因此密码应该足够
长,以至于遍历所有可能的字符组合,耗费的时间太长令人无法承受,从而放弃破解。
目前没有办法来阻止字典攻击或暴力攻击。只能想办法让它们变得低效。如果密码哈希系
统设计是安全的,破解哈希的唯一方法就是进行字典攻击或暴力攻击遍历每一个哈希值
了。
查表法( Lookup Tables)
Secure_Salted_Password_Hashing_-_How_to_do_it_Properly2.png
对于破解相同类型的哈希值,查表法是一种非常高效的方式。主要理念是预先计算(
pre-compute)出密码字典中的每个密码的哈希值,然后把他们相应的密码存储到一个表
里。一个设计良好的查询表结构,即使包含了数十亿个哈希值,仍然可以实现每秒钟查询
数百次哈希。
如果你想感受查表法的速度有多快,尝试一下用 CrackStation 的 free hash cracker 来破解
下面的 SHA256。
c11083b4b0a7743af748c85d343dfee9fbb8b2576c05f3a7f0d632b0926aadfc
08eac03b80adc33dc7d8fbe44b7c7b05d3a2c511166bdb43fcb710b03ba919e7
e4ba5cbd251c98e6cd1c23f126a3b81d8d8328abc95387229850952b3ef9f904
5206b8b8a996cf5320cb12ca91c7b790fba9f030408efe83ebb83548dc3007bd
反向查表法( Reverse Lookup Tables)
Secure_Salted_Password_Hashing_-_How_to_do_it_Properly3.png
这种攻击允许攻击者无需预先计算好查询表的情况下同时对多个哈希值发起字典攻击或暴
力攻击。
首先,攻击者从被黑的用户帐号数据库创建一个用户名和对应的密码哈希表,然后,攻击
者猜测一系列哈希值并使用该查询表来查找使用此密码的用户。通常许多用户都会使用相
同的密码,因此这种攻击方式特别有效。
彩虹表( Rainbow Tables)
彩虹表是一种以空间换时间的技术。与查表法相似,只是它为了使查询表更小,牺牲了破
解速度。因为彩虹表更小,所以在单位空间可以存储更多的哈希值,从而使攻击更有效。
能够破解任何最多 8 位长度的 MD5 值的彩虹表已经出现。
cf849e25-b9f0-4398-b4bf-51b7b3740553.png
加盐( Adding Salt)
接下来,我们来看一种谓之“加盐( salting)”的技术,能够让查表法和彩虹表都失效。
hash("hello") =
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
hash("hello" + "QxLUF1bgIAdeQX") =
9e209040c863f84a31e719795b2577523954739fe5ed3b58a75cff2127075ed1
hash("hello" + "bv5PehSMfV11Cd") =
d1d3ec2e6f20fd420d50e2642992841d8338a314b8ea157c9e18477aaef226ab
hash("hello" + "YYLmfY6IehjZMQ") =
a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2c4a8544305df1b60f007
查表法和彩虹表只有在所有密码都以完全相同的方式进行哈希加密才有效。如果两个用户
有相同的密码,他们将有相同的密码哈希值。我们可以通过“随机化”哈希,当同一个密码
哈希两次后,得到的哈希值是不一样的,从而避免了这种攻击。
我们可以通过在密码中加入一段随机字符串再进行哈希加密,这个被加的字符串称之为盐
值。如上例所示,这使得相同的密码每次都被加密为完全不同的字符串。我们需要盐值来
校验密码是否正确。通常和密码哈希值一同存储在帐号数据库中,或者作为哈希字符串的
一部分。
盐值无需加密。由于随机化了哈希值,查表法、反向查表法和彩虹表都会失效。因为攻击
者无法事先知道盐值,所以他们就没有办法预先计算查询表或彩虹表。如果每个用户的密
码用不同的盐再进行哈希加密,那么反向查表法攻击也将不能奏效。
错误的方法:短盐值和盐值复用
最常见的错误,是多次哈希加密使用相同的盐值,或者盐值太短。
盐值复用( 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 方法。
Secure_Salted_Password_Hashing_-_How_to_do_it_Properly_hash.png
每个用户的每一个密码都要使用独一无二的盐值。用户每次创建帐号或更改密码时,密码
应采用一个新的随机盐值。永远不要重复使用某个盐值。这个盐值也应该足够长,以使有
足够多的盐值能用于哈希加密。一个经验规则是,盐值至少要跟哈希函数的输出一样长。
该盐应和密码哈希一起存储在用户帐号表中。
如果系统已经集成 Spring Security 的话,可以直接使用其提供的 BCryptPasswordEncoder
工具类来完成密码的哈希算法。
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
看过该类源码的小伙伴应该了解它内部其实使用了一种加盐技术:通过在密码中加入一段
随机字符串再进行哈希加密,这个被加的字符串称之为盐值。
public String encode(CharSequence rawPassword) {
String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
技术派中的加密其实也用到了加盐技术。当然了,盐值最好每次都是随机的,这样就会让
相同的密码每次加密都会生成完全不同的密文字符串。
e1119ee4-9041-409a-9076-9f2507d74774.png
因为攻击者无法事先知道盐值,所以也就没办法预先计算查询表和彩虹表,从而让反向查
表法攻击无法奏效。
存储密码的步骤:
• 使用 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)”函数如何工作?
前一个问题解释了为什么“慢比较”是必要的,现在来解释代码如何工作。
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 汇编:
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”会被编译为下面的样子,它执行的时间和两个变量是否相等无
关。
MOV EAX,[A]
XOR EAX,[B]
OR [DIFF],EAX
为何要进行哈希?
用户在你的网站上输入密码,是因为他们相信你能保证密码的安全。如果你的数据库遭到
黑客攻击,而用户的密码又不受保护,那么恶意黑客可以利用这些密码尝试登陆其他网站
和服务(大多数用户会在所有地方使用相同的密码)。这不仅仅关乎你网站的安全,更关
系到用户的安全。你有责任负责用户的安全。
面试模拟
密码加密其实也是面试中经常被问到的题目,之前一位球友在面试拼多多服务端暑期实习
的时候就被问到了。
d0c92742-aefe-4d10-a397-8b3b717ba1fd.png
我们来模拟一下。
面试官老王问:“用户名密码登录时是如何加密的,和验证的?”
你(小二):
在技术派中,我使用了 Spring Security 的 BCrypt 算法对密码进行了加盐哈希,盐值是随
机的,因此即使两个用户使用了相同的密码,生成的哈希结果也是不同的。
然后将加密后的哈希值(包含盐值)存储到 MySQL 数据库中。
b6d3fde4-869c-4db8-b44d-090af2cb0025.png
当登录的时候,直接调用 BCryptPasswordEncoder 的 matches 方法进行比对,该方法会
自动从存储的加密密码中提取盐值,再对用户输入的密码进行相同的哈希计算。源码主要
在 BCrypt 类中。
72358ae3-b120-47f1-9f38-5251e80485c5.png
代码示例如下所示:
// 创建一个 BCryptPasswordEncoder 实例
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 注册时加密密码
String rawPassword = "沉默王二是条狗";
String encodedPassword = passwordEncoder.encode(rawPassword);
// 打印加密后的密码(每次加密结果都不同)
System.out.println("加密后的密码: " + encodedPassword);
// 登录时验证密码
boolean isPasswordMatch = passwordEncoder.matches(rawPassword,
encodedPassword);
System.out.println("密码验证结果: " + isPasswordMatch); // 输出为 true
面试官老王问:“加盐有什么好处?哈希有什么好处?”
你(小二):如果密码未加盐,常用的密码(如 “123456”)的哈希值是已知的,攻击者
可以直接匹配彩虹表中的哈希值来获取密码。而通过加盐,即使是相同的密码,生成的哈
希值也不同,无法通过彩虹表轻松破解。
哈希函数是单向的,意味着从哈希值无法轻易推导出原始密码。密码哈希后,即使数据库
泄露,攻击者也不能直接从哈希值中恢复原始密码。
爬虫攻防之前端策略简析
公钥加密算法的基石:大素数