密码安全是互联网安全的一个缩影,我们在享受互联网服务的同时,也应当对它投入更多的关注。
1.密码安全的重要性
2011年12月,国内某开发者社区网站被黑客“拖库”,600多万个密码明文存储的用户账号被公开, 大量用户直接面临数据隐私泄露和数据安全的双重威胁。
这次事件为我们敲响了警钟,一旦发生“拖库”,如何尽可能地减少用户的损失,在每一个系统中 都不应被忽略。从开发者的角度而言,可以落实在如何安全存储用户密码上。
为什么要安全存储用户密码呢?当被“拖库”时,当前系统的所有数据就成为既定损失,如果用户 的密码同时被泄露,且未做加密,那么即便及时修复了“拖库”的问题,后续也可能会导致扩散性损 失。因为黑客手里还握着大量的用户密码,安全威胁随时都在,这时能做的只能是紧急升级系统,强 制所有用户验证手机号码或邮箱,并重置密码方能再次进入,这显然很被动。如果在系统开发之初就 有安全存储用户密码的意识,则能有效防止这种情况。
2.密码加密的演进
安全存储用户密码很多时候都是使用加密的方式来进行的。提到加密,诸如 MD5、SHA等摘要算 法通常会第一时间浮现在我们脑海里,尽管它们并非真正的加密(不可逆)。例如,在密码加密这个 场景下,对用户密码进行 MD5运算后再存储就是一种看起来可行的思路,只要在每次登录验证时,对 用户提交的密码进行同样的 MD5运算,然后与数据库中存储的值进行对比即可。
事实上,尽管摘要算法无法通过逆运算获得原文,但由于摘要值有限(MD5算法最多只能表示36 的16次方个摘要值),而原文无限,所以一定存在两个甚至更多个不同数据通过运算得到同一摘要值 的情况,即存在被“碰撞”的可能。
除“碰撞”外,最直接的破解方式是构建反查表。相同串(密码)经过 MD5运算后一定会得到同一个值,于是有些人便制作了这样一张表。
$2a$10$nlvQs9oQOSxc2gBX5xjj..UtjxpcGODTNVYNoftX2roCobzV7nRrK | 123456 |
$2a$10$NVdFHCDHD/QCpri5OouS2OWVsYkxHkrThveL7IQ4oElPUZ//oUKEi | admin |
$2a$10$dGwcC1Ii4QKUYoQMKXmv4ehCoeBj9iPcDTdOkLVvJ1DDwj0ASdbwW | qwerty |
$2a$10$Fp5b4I0t0djgimab.BqJp.k3UAdpuOM1iOn6GyC2V44LGpAokzGrO | 5201314 |
这张表穷举了几乎所有的常用密码,专门用于实现MD5(其他散列算法同理)的快速反查(MD5 分为32位和16位,事实上,除了长度没有其他区别。将32位的前8位和后8位去掉即可得到16位,生成 表时自然会选择位数少的,这样空间占用小)。如果我们的密码恰好被反查表收录,那么黑客几乎不 费吹灰之力就可以直接完成破解。可想而知,这种破解方式的成功率完全取决于反查表的覆盖面是否 足够大,以及数据是否足够精准。熟悉暴力破解的读者可能知道,一个强大的“字典”对破解的成功率 和效率有非常大的帮助,反查表在一定程度上类似于“字典”。如果“字典”被穷举完了还找不到密码, 就意味着破解失败。
反查表是一种初级的破解手段,只能应付一些可以预想到的密码形式。理论上只要我们的密码设 置得足够长,字符组成足够复杂,就很难被收录。反查表的大小受限于储存设备的容量大小(内存或 硬盘),因而通常只列举一些常见的密码。
基于反查表的缺陷,在无法拥有更多内存的情况下,如何增加表的容量是一个值得思考的方向。 瑞士洛桑联邦技术学院的 Philippe Oechslin 提出了一种优化型的时间—内存交换算法,让表不再只是 存储明文与密文的一一对应关系,而是助力于穷举计算的一个查找数据集,从而允许在有限的内存中 以极高的效率破解更多可能的密码。受该思路的启发,一些黑客改进了部分流程,形成了大名鼎鼎的 彩虹表。
下面简单介绍彩虹表的原理
彩虹表的大小通常超过100GB,不同于反查表,彩虹表没有直接存储摘要值与原文的一一对应关 系,而是存储了一个时间—空间平衡的散列链集。可以认为每一条散列链代表了一组相同特征的明 文,但每一条散列链并不需要完整存储,仅仅存储起始节点和末节点即可。例如,在图7-1中,H代表 一次散列计算,R1、R2、R3代表一次递归性质的约减计算,R3中的3指定了一条散列链会经历3次约 减,即代表了这条散列链可以携带3条明文(后面用K指代)。当需要破解一个散列值时,只要对其做 相对应的约减计算,便能推导出其所处的散列链,再由散列链正推就可能直接得到该散列值的明文。 彩虹表相比于反查表,由于K的存在,使得每一条记录都能携带更多的明文,即减少了空间占用。但
也由于K的存在,在进行破解时,需要付出相应的运算量(关于K的等差数列求和)。实际上,在普通 计算机上辅以CUDA技术(显卡厂商NVIDIA推出的基于GPU的运算平台),可以达到每秒千亿次的 破解速度,这就使得散列加密很多时候只是充当了遮羞布的作用。
尽管在彩虹表面前散列加密是脆弱的,但也并非束手无策,因为彩虹表也有弱点,加盐是最简单 有效地防御彩虹表的手段。
所谓加盐加密,是指在计算摘要值之前,为原文附上额外的随机值,以达到扰乱目的的加密方 式。具体实现方法并不固定。例如,以用户名作为盐值。
为了使盐值真正随机,可以将盐值作为用户的一个数据字段存储,并用UUID之类的字符串来表示。
或者将随机盐值直接挂在加密后的摘要值上,省去额外的存储字段。
反查表和彩虹表都是通过对散列值或散列值的加工从而逆向推导出密码的,加盐等同于阻断了散 列值与密码的直接对应关系,使得逆向推导的破解方式不再具有威慑力,但最经典的穷举法依然会带 来威胁。随机盐值通常在实际存储密码时会同步存储,即当发生“拖库”时,盐值也很难幸免。在正向 穷举时,盐值的阻碍非常小。例如,穷举1至8位的密码,只需相应附上盐值即可,不用在乎该盐值有 多长,而且其计算耗时在当前来说是完全可以接受的,尤其在算力强的计算平台上(计算能力随着时 间的推移会越来越强,意味着暴力破解当前的散列密码会越来越容易)。
既然穷举法如此简单粗暴,那么有制约的手段吗?当然有,“蛇打七寸”,穷举破解十分依赖于计 算能力,常规的散列算法因为速度快反而助推了穷举的速度,所以我们需要一种慢的加密手段,慢到暴力破解无法忍受,慢到超级计算机也无可奈何,BCrypt正是这样一种算法。
$2a$10$nlvQs9oQOSxc2gBX5xjj..UtjxpcGODTNVYNoftX2roCobzV7nRrK
其中,2a表明了算法的版本。2a版本加入了对非ASCII字符以及空终止符的处理,类似的版本号还有2x、2y、2b 等,都是在前面版本的基础上修复了某些缺陷或新增了某些特性。12是一个成本参数, 它表明该密文需要迭代的次数。12是指2的12次方,即4096次。BCrypt依靠此参数来限制算法的速 度。成本参数的理想取值是既让暴力破解无法忍受,又不会显著影响用户的实际体验(未来,计算能 力会越来越强,成本参数也应相应调整)。2a$12$之后的前22位是该密文的随机盐值,最后31位为真 正的散列值。
如果我们在数据库中存储用户密码时用的是BCrypt密文,那么在用户登录时,需要同步取得用户 输入的密码以及数据库存储中的BCrypt密文,从密文中提取盐值和成本参数,与用户的密码进行一次 BCrypt加密,最后比较两个密文是否一致。
除使用不可逆的散列算法来加密用户密码外,一些可逆的加密算法是否适合使用呢?例如,非对 称加密算法RSA,或者对称加密算法DES、AES等,它们看起来更加安全。是的,但正因为它们可逆,就又变得不安全了。因为当一个系统遭受“拖库”时,密钥是否还安全无法评估。事实上,我们也并不需要让用户的密码可逆。
3.Spring Security的密码加密机制
Spring Security内置了密码加密机制,只需使用一个PasswordEncoder接口即可。
public interface PasswordEncoder {
/**
* Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
* greater hash combined with an 8-byte or greater randomly generated salt.
*/
String encode(CharSequence rawPassword);
/**
* Verify the encoded password obtained from storage matches the submitted raw
* password after it too is encoded. Returns true if the passwords match, false if
* they do not. The stored password itself is never decoded.
*
* @param rawPassword the raw password to encode and match
* @param encodedPassword the encoded password from storage to compare with
* @return true if the raw password, after encoding, matches the encoded password from
* storage
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
/**
* Returns true if the encoded password should be encoded again for better security,
* else false. The default implementation always returns false.
* @param encodedPassword the encoded password to check
* @return true if the encoded password should be encoded again for better security,
* else false.
*/
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
PasswordEncoder接口定义了encode和matches两个方法,当用数据库存储用户密码时,加密过程用 encode方法,matches方法用于判断用户登录时输入的密码是否正确。
此外,Spring Security 还内置了几种常用的 PasswordEncoder 接口,例如, StandardPasswordEncoder中的常规摘要算法(SHA-256等)、BCryptPasswordEncoder加密,以及类似 BCrypt的慢散列加密Pbkdf2PasswordEncoder等,官方推荐使用BCryptPasswordEncoder。
下面我们就尝试在系统中接入BCrypt这种密码加密方式。由于BCrypt加密后的密文长度超过50, 所以在我们的表结构中,如果用户密码字段过短将会导致出错,因此应首先检查并确保密码字段长度 已经扩充到60以上。接着用一个 BCrypt 密文来实验,可以使用前面示例的BCrypt 密文,它的原文 是“blurooo”,我们可以修改表中的某个用户密码为该密文。
数据方面已经准备完毕,Spring Security方面的配置其实非常简单:
@Bean
public PasswordEncoder passwordEncoder() {
PasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
return bCryptPasswordEncoder;
}
在声明一个PasswordEncoder的bean之后,Spring Security会自动应用。重启服务,发现user这个用 户已经无法通过密码123来登录了。实际上需要什么密码没有人知道,但admin可以使用“blurooo”来登 录,验证了我们的配置是有效的。
到这一步并没有完成密码加密的完整接入,因为新用户“写库”时密码还是明文,需要做一些调整。
@Autowired
private PasswordEncoder passwordEncoder;
@PostMapping("/create")
public Object createUser(String username, String password) {
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
// ...
return User;
}
通常情况下,在新系统中使用BCrypt加密不需要考量太多,但老系统由于存在大量旧数据,草率 接入会导致老用户无法登录,这种情况该怎么解决呢?很简单,我们自己实现一个PasswordEncoder并继承BCryptPasswordEncoder即可。
@Component
public class MyPasswordEncoder implements PasswordEncoder {
private static Pattern BCRYPT_PATTERN = Pattern.compile("\\A\\$2a?\\$\\d\\d\\$[./0-9a-zA-Z]{53}");
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
// 如果密码不是Bcrypt密文
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
// ...
}
return super.matches(rawPassword, encodedPassword);
}
}
在这个PasswordEncoder中,只有当密码不是BCrypt密文时,才启用自定义的匹配逻辑,其余还是沿用原来的方案,即可轻松达到兼容的目的。
再进一步,如果我们不仅想要兼容,还想将不安全的旧密码无缝修改成BCrypt密文,该如何操作 呢?这是个很好的问题。如果旧密码都是未经任何加密的明文,也许“跑库”修改是非常好的一种选择,但并非所有系统都有这么理想的状态。假如旧密码都是被散列加密过的,那么可以用下面两种方法解决这个问题: