首页 > 代码库 > 设计安全的账号系统的正确姿势

设计安全的账号系统的正确姿势

引子

最近有个虚拟练习项目,涉及到系统安全保障的设计,于是对安全保障这块做了一些更深入的了解。发现了很多有趣的东西,开阔了眼界。中间查了一些资料,于是我打算重新整理,用更加循序渐进,大家都能懂的方式,说一说如何设计一个安全的系统。

著名的安全事件

首先来看看最近几年比较著名的拖库撞库后密码泄露的事件:

2011年12月,国内最大的程序员社区 CSDN 遭拖库,600万个账户信息泄露。

2014年3月,携程旅行网的系统存技术漏洞,漏洞可能导致用户的姓名、身份证号码、银行卡类别、银行卡卡号、银行卡CVV码以及银行卡6位Bin泄露。

2014年5月,小米论坛涉及800万用户信息遭泄露,信息包括用户名、密码、注册IP、邮箱等。

2014年12月,12306遭撞库攻击,13万用户信息泄露,包括用户账号、明文密码、身份证、邮箱等敏感信息。

2015年10月,网易邮箱遭攻击,近5亿条用户信息被泄露,包括用户名、密码、密码保护信息、登陆IP以及用户生日等多个原始信息。

除了密码泄露事件,数据被物理删除的事件也是发生:

2015年5月,携程网及APP陷入瘫痪,数据库遭物理删除疑似离职员工报复。

这么多大公司大网站的系统都遭到攻击,泄露用户信息,更别说其他小网站了。这些攻击都可以从技术上来进行防范的,但是我们看到即使是大公司,安全方面也是那么的薄弱。

防范方法

防范的方法简单来说数据从用户键盘敲出的那一刻,到服务器后台存储都要保持正确的姿势。比如:

  • 用正确的姿势保存密码。

  • 用正确的姿势传输数据。

  • 用正确的姿势加密敏感信息。

  • 用正确的姿势对数据进行备份和监控。

  • 用正确的姿势保存密码

这一步非常重要,也比较复杂。用户在浏览器里输入密码,传输到服务器端进行验证,服务端将之前保存的密码信息和用户的输入进行比对。

1. 低级错误:明文保存密码

安全性最低的是在服务端明文保存用户的密码,一旦服务器被入侵,数据被拖走(拖库),所有用户的密码都直接的暴露在外面。这是最初级的做法,毫无安全性可言。假如你在一个网站或论坛注册了一个账号,该网站自动发了一封邮件告诉你注册成功,里面明文写了你的密码,赶紧把密码改了然后再也不要访问这个网站。

2. 低级错误:可逆加密密码

既然不能明文保存密码,那当然是加密保存了。耍个小聪明,比如把密码的字母倒着存,或者每个字母存后一个字母,或者进行异或混淆处理,表面上密码看上去已经看不出来原始的密码是什么了,但实际上这个和明文保存密码并没有本质区别,因为黑客既然可以入侵你的服务器,自然可以拿到你的加密代码,只要按你的算法进行简单的解密就可以得到原始密码。

3. 错误方法:md5 加密密码

在我还是一个初学者的时候,我已被告知不能用前两种方式保存密码,当时的主流方法是使用 md5 加密密码。(年代久远,现在已绝非主流了。) md5 是一种不可逆的加密方法,即密码被 md5 加密后是无法解密出原始密码的,验证密码是否正确的方法是将用户输入的密码 md5 加密后于数据库里保存的 md5 机密后的结果进行比对。这样,服务器端在不知道真实用户密码的情况下也能对用户密码进行验证了。

这是早期比较主流的做法,然而,这依然是非常不安全的。因为只要枚举所有短密码进行 md5 加密,做成一个索引表,就能轻易的逆推出原始密码。这种预先计算好的用于逆推加密散列函数的表就是“彩虹表”。随着“彩虹表”不断变大,md5 的加密已经变得非常的不安全。2015年10月网易邮箱的用户密码泄露也被怀疑只对密码进行了 md5 加密。

4. 正确方法:加盐 hash 保存密码

加盐 hash 是指在加密密码时,不只是对密码进行 hash ,而是对密码进行调油加醋,放点盐(salt)再加密,一方面,由于你放的这点盐,让密码本身更长强度更高,彩虹表逆推的难度更大,也因你放的这点盐,让黑客进行撞库时运算量更大,破解的难度更高。

如何进行加盐就是一门很重要的学问了。md5 是一种 hash 算法,以下就拿 md5 来举例。假如密码是 123456 ,md5 的结果如下:

技术分享

像 123456 这样的简单密码,是很容易被逆推出来的。但是假如我们往简单密码里加点盐试试:

技术分享技术分享

上面例子里的 #g5Fv;0Dvk 就是我们加的盐。加完之后,密码的强度更高了,彩虹表破解的难度加大了。或者进行加盐两次 md5 :

技术分享技术分享技术分享

到这里,你一定会有疑问,是不是把 md5 多做几次,或者自定义一些组合的方式就更安全了。其实不是的,黑客既然能拿到数据库里的数据,也很有可能拿到你的代码。

一个健壮的、牢不可破的系统应该是:

即使被拿走了数据和所有的代码,也没办法破解里面的数据。

这也是为什么大家不必实现自己的加密算法,而是使用公开的加密算法的原因,比如:RSA、AES、DES 等等。既然无法保证加密代码不被泄露,那就使用公开的加密算法,只要保护好私钥信息,就算你知道我的加密方式也没有任何帮助。

大部分情况下,使用 md5(md5(password) + salt) 方式加密基本上已经可以了:

技术分享

其中,最关键的是 salt 从哪里来? salt 该怎么设置才能安全。有几个重要的点:

  • 不要使用固定不变的 salt。

  • 每个用户的 salt 都需要不同。

  • salt 必须由服务端使用安全的随机函数生成。

  • 客户端运算需要的 salt 需要从服务端动态获取。

  • 客户端加盐 hash 的结果并不是最终服务端存盘的结果。

由于客户端也需要执行加盐 hash ,所以,salt 不能直接写在客户端,而是应该动态从服务端获得。服务端生成随机的 salt 时,必须使用安全的随机函数,防止随机数被预测。

各语言安全的随机函数:

技术分享

就算 salt 值动态从服务端获取,也有可能被中间人拦截获取。同时,客户端的加盐 hash 的过程相当于是完全暴露的。一种更安全的做法是,客户端使用 javascript 进行加盐 hash,把结果传到服务器后,服务器对结果再进行一次 加盐 hash 或者 加密 hash(比如:HMAC) ,然后再和数据库的结果进行比对。

如果需要达到更高的安全等级,可以考虑:

1. 使用更安全的 hash 函数用来抵抗碰撞攻击,比如:SHA256, SHA512, RipeMD, WHIRLPOOL。

两个不同的内容 hash 的结果可能相同,攻击者在不知道真实密码的情况下,使用其他密码进行碰撞攻击从而登录系统。使用更安全的 hash 函数可以减少这种情况的发生。

2. 可以使用一种大量消耗 cpu 的 hash 算法对抗暴力破解,比如PBKDF2 或者 bcrypt。

暴力破解就是枚举所有可能的密码进行尝试验证,使用大量消耗 cpu 的 hash 算法可以极大增加暴力破解的时间。

3. 比较加盐 md5 结果时,使用时间恒定的比较函数。

在比较两个字符串时,通常都一个字符一个字符进行比较,如果某个字符不匹配就会立即返回。攻击者可以根据验证的时间长短来判断前几位字符是否正确,然后逐步修正最终得到正确的结果。

因此,在比较 hash 时,使用时间恒定的比较函数,可以让攻击者摸不着头脑。比如下面这段代码:

1
2
3
4
5
6
7
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;
}

异或(^)操作可以用来判断两个字符是否相等,比如:

1
2
0 XOR 0 = 0    1 XOR 1 = 0
0 XOR 1 = 1    1 XOR 0 = 1

上面的函数枚举每个字符进行异或判断,然后将所有的结果取或运算,得到最终的结果,比较的时间是恒定的。

4. salt 的值不要和最终 hash 的结果存在同一个数据库。

SQL 注入是常见的攻击手段,被注入后数据库里的数据被暴露无遗。所以,应该将 salt 分开存储,存到别的机器的数据库里,让攻击者拿不到 salt ,从而无法轻易破解信息。

5. 最终存储的结果使用基于 key 的 hash 函数,比如 HMAC。 key 从外部安全性极高的专属服务中获得。

有了这层加固,即使数据被拖库,攻击者也无法从 hash 的结果逆推回原始密码。因为使用了加密的 hash 函数。基于 key 的 hash 函数只是进行哈希运算时,除了传入原始内容外,还需要传入一个密钥(key)。攻击者没有 key 几乎不可能对数据进行解密。

key 可以保存在极高安全性的通用的 key 管理系统,使用加密协议传输,对访问者进行验证,只允许特定的机器有权限访问。

用正确的姿势传输数据

使用 HTTP 协议传输数据时,数据都是明文传输的,数据从发出到服务器接收,中间可能被劫持,篡改。比如常见的 DNS 劫持,HTTP 劫持,中间人攻击。

用正确的姿势传输数据,目的就是为了保证传输的数据安全,简单归纳为两点:

需要确保进行通讯的服务端是官方的、正确的服务端,而不是跟一个假的服务端在通信。

确保信息在网络上传输时是加密的,只有客户端和服务端有能力对数据进行解密。

确保信息在传输时不被篡改,或者数据被篡改时能立即发现。

1. 验证服务端的合法性

《改变未来的九大算法》一书中提到了公钥加密和数字签名技术,这是进行安全通信的基础技术保障。这里涉及到了加密技术,先了解两个最基础的概念:

对称加密:加密和解密时使用的是同一个密钥。

非对称加密:需要两个密钥来进行加密和解密:公开密钥(public key,简称公钥)和私有密钥(private key,简称私钥) ,公钥加密的信息只有私钥才能解开,私钥加密的信息只有公钥才能解开。

非对称加密是实现验证服务端合法性的基础,常见的加密算法有 RSA 、 ECC 等 。服务端生成一对公钥和私钥,公钥是公开的所有人都知道,客户端需要和服务端通信时,使用该公钥进行数据加密,由于只有真实合法的服务端才拥有对应的私钥,所有只有真实的服务端才能解密该信息,然后返回数据给客户端时,使用客户端自己生成的公钥进行加密,这样数据只有对应的客户端才能理解。

技术分享

使用 HTTPS 时,数字证书里包含了名称和公钥信息,只要认证该证书是合法的,并且对方能理解用该公钥加密的信息,就能确定是合法的服务端。

2. 确保通信的安全

既然使用非对称加密的方式,可以保证双方安全的通信,那是不是就一直使用非对称加密传输数据就行了?理论上是可以的,但是非对称加密的效率要比对称加密的效率低很多。通常的做法是,通过非对称加密的方法,协商出一个只有双方知道的对称加密密钥。

即使在不安全的通信环境下,也可以协商出一个只有双方才知道的对称加密密钥。在《改变未来的九大算法》一书里,有一个经典的描述如何交互密钥的例子(在所有沟通都是透明的情况下,如何协商出一个只有你和阿诺德才知道的颜料颜色。):

技术分享

ECDH 就是基于上面原理设计的密钥交换算法:

技术分享

密钥协商好后,双方就可以使用该密钥进行加密传输了,比如使用 AES 、 DES。

由于 ECDH 密钥交换协议不验证公钥发送者的身份,因此无法阻止中间人攻击。如果监听者 Mallory 截获了 Alice 的公钥,就可以替换为他自己的公钥,并将其发送给 Bob。Mallory 还可以截获 Bob 的公钥,替换为他自己的公钥,并将其发送给 Alice。这样,Mallory 就可以轻松地对 Alice 与 Bob 之间发送的任何消息进行解密。他可以更改消息,用他自己的密钥对消息重新加密,然后将消息发送给接收者。

解决方法是,Alice 和 Bob 可以在交换公钥之前使用数字签名对公钥进行签名。

即使攻击者不能解密传输的内容,但仍可以使用重放攻击尝试身份验证或用于欺骗系统。重放攻击是指攻击者将数据包截取后,向目标主机重新发送一遍数据包。

防御重放攻击的方法主要有:

  • 使用时间戳。数据包在一定时间范围内才是有效的。

  • 使用递增的序号。收到重复的数据包时可以轻易的发现。

  • 使用提问应答方式。收到数据包时可以判断出来是否应答过。

HTTPS 正是使用了上述的原理,保证了通信的安全。所以,任何对安全有需求的系统都应该使用 HTTPS。如果是使用自有协议开发,比如 APP 或游戏,应该使用上述的方法保障通信的安全。

用正确的姿势加密敏感信息

我们都知道,用户的密码不能明文保存,而且要使用不可逆的加密算法,只保存最终的 hash 结果用来验证是否正确。那用户其他的敏感信息呢?比如身份证、银行卡、信用卡等信息,该如何加密保存而不被泄露呢?

对于身份证信息,可以像密码一样只保存 hash 的结果,可以用于用户输入身份证号后进行验证。假如需要给用户显示身份证信息,只需要保存抹掉了几位数字的身份证号。

假如你的系统涉及到支付,需要用户的银行卡,信用卡(卡号,CVV码)等信息时,必须遵循 PCI DSS (第三方支付行业数据安全标准)标准。PCI DSS 是由 PCI 安全标准委员会的创始成员(visa、mastercard、American Express、Discover Financial Services、JCB等)制定,力在使国际上采用一致的数据安全措施,包括安全管理、策略、过程、网络体系结构、软件设计的要求的列表等,全面保障交易安全。

如果只是银行卡,还需要遵循 ADSS (银联卡收单机构账户信息安全管理标准) 标准。

2014年3月携程泄露用户银行卡信息就是因为没有遵循 PCI DSS 标准。

用正确的姿势对数据进行备份和监控

2015年5月的携程数据被删事件,就是数据备份没有做好的例子。数据备份是为了防止由于硬盘损坏或人为破坏导致的数据丢失。主要措施有:磁盘 raid,物理备份(磁带库),异地的逻辑备份。同时做好权限控制,并对访问记录做好监控,及时发现问题,保留现场证据。

总结

本文总结了设计一个安全系统的基本原理和方法,并没有举出一个特定具体的方案,因为不同的系统对安全性的要求各有不同,设计者应该根据自身系统的特点进行具体设计。比如加盐 hash 的具体实施方法,salt 值如何构成等等。

本文所述内容如有不实之处或者有争议的部分,欢迎交流指出。

附录

常用的加密算法:

  • 对称加密:DES、3DES、TDEA、Blowfish、RC2、RC4、RC5、IDEA、SKIPJACK、AES。

  • 非对称加密:RSA、ECC(椭圆曲线加密算法)、Diffie-Hellman、El Gamal、DSA(数字签名用)

  • Hash 算法:MD2、MD4、MD5、HAVAL、SHA-1、SHA256、SHA512、RipeMD、WHIRLPOOL、SHA3、HMAC

DES、3DES、AES 区别:

  • DES:1976年由美国联邦政府的国家标准局颁布,密钥为 56 位。

  • 3DES:DES加密算法的一种模式,它使用3条56位的密钥对数据进行三次加密。

  • AES:高级加密标准,是下一代的加密算法标准,速度快,安全级别高,用来替代原先的DES。密钥长度可以是128,192或256比特。

参考文章

  • Salted Password Hashing - Doing it Right

  • ECDH 算法概述

  • Rainbow table

  • 《改变未来的九大算法》第四章:公钥加密

 

 

 

 

 

即使在数据被拖库,代码被泄露,请求被劫持的情况下,也能保障用户的密码不被泄露。

说具体一些,我们理想中的绝对安全的系统大概是这样的:

  1. 首先保障数据很难被拖库。
  2. 即使数据被拖库,攻击者也无法从中破解出用户的密码。
  3. 即使数据被拖库,攻击者也无法伪造登录请求通过验证。
  4. 即使数据被拖库,攻击者劫持了用户的请求数据,也无法破解出用户的密码。

如何保障数据不被拖库,这里就不展开讲了。首先我们来说说密码加密。现在应该很少系统会直接保存用户的密码了吧,至少也是会计算密码的 md5 后保存。md5 这种不可逆的加密方法理论上已经很安全了,但是随着彩虹表的出现,使得大量长度不够的密码可以直接从彩虹表里反推出来。

所以,只对密码进行 md5 加密是肯定不够的。聪明的程序员想出了个办法,即使用户的密码很短,只要我在他的短密码后面加上一段很长的字符,再计算 md5 ,那反推出原始密码就变得非常困难了。加上的这段长字符,我们称为盐(Salt),通过这种方式加密的结果,我们称为 加盐 Hash 。比如:

技术分享
salt

上一篇我们讲过,常用的哈希函数中,SHA-256、SHA-512 会比 md5 更安全,更难破解,出于更高安全性的考虑,我的这个方案中,会使用 SHA-512 代替 md5 。

技术分享
salt

通过上面的加盐哈希运算,即使攻击者拿到了最终结果,也很难反推出原始的密码。不能反推,但可以正着推,假设攻击者将 salt 值也拿到了,那么他可以枚举遍历所有 6 位数的简单密码,加盐哈希,计算出一个结果对照表,从而破解出简单的密码。这就是通常所说的暴力破解。

为了应对暴力破解,我使用了加盐的慢哈希。慢哈希是指执行这个哈希函数非常慢,这样暴力破解需要枚举遍历所有可能结果时,就需要花上非常非常长的时间。比如:bcrypt 就是这样一个慢哈希函数:

技术分享
bcrypt

通过调整 cost 参数,可以调整该函数慢到什么程度。假设让 bcrypt 计算一次需要 0.5 秒,遍历 6 位的简单密码,需要的时间为:((26 * 2 + 10)^6) / 2 秒,约 900 年。

好了,有了上面的基础,来看看我的最终解决方案:

技术分享
password_secutity

上图里有很多细节,我分阶段来讲:

1. 协商密钥

基于非对称加密的密钥协商算法,可以在通信内容完全被公开的情况下,双方协商出一个只有双方才知道的密钥,然后使用该密钥进行对称加密传输数据。比如图中所用的 ECDH 密钥协商。

2. 请求 Salt

双方协商出一个密钥 SharedKey 之后,就可以使用 SharedKey 作为 AES 对称加密的密钥进行通信,客户端传给服务端自己的公钥 A ,以及加密了的用户ID(uid)。服务端从数据库中查找到该 uid 对于的 Salt1 和 Salt2 ,然后再加密返回给客户端。

注意,服务端保存的 Salt1 和 Salt2 最好和用户数据分开存储,存到其他服务器的数据库里,这样即使被 SQL 注入,想要获得 Salt1 和 Salt2 也会非常困难。

3. 验证密码

这是最重要的一步了。客户端拿到 Salt1 和 Salt2 之后,可以计算出两个加盐哈希:

SaltHash1 = bcrypt(SHA512(password), uid + salt1, 10)
SaltHash2 = SHA512(SaltHash1 + uid + salt2)

使用 SaltHash2 做为 AES 密钥,加密包括 uid,time,SaltHash1,RandKey 等内容传输给服务端:

Ticket = AES(SaltHash2, uid + time + SaltHash1 + RandKey)
AES(SharedKey, Ticket)

服务端使用 SharedKey 解密出 Ticket 之后,再从数据库中找到该 uid 对应的 SaltHash2 ,解密 Ticket ,得到 SaltHash1 ,使用 SaltHash1 重新计算 SaltHash2 看是否和数据库中的 SaltHash2 一致,从而验证密码是否正确。

校验两个哈希值是否相等时,使用时间恒定的比较函数,防止试探性攻击。

time 用于记录数据包发送的时间,用来防止录制回放攻击。

4. 加密传输

密码验证通过后,服务端生成一个随机的临时密钥 TempKey(使用安全的随机函数),并使用 RandKey 做为密钥,传输给客户端。之后双方的数据交互都通过 TempKey 作为 AES 密钥进行加密。

假设被拖库了

以上就是整个加密传输、存储的全过程。我们来假设几种攻击场景:

  1. 假设数据被拖库了,密码会泄露吗?

    数据库中的 Salt1 ,Salt2 , SaltHash2 暴露了,想从 SaltHash2 直接反解出原始密码几乎是不可能的事情。

  2. 假设数据被拖库了,攻击者能不能伪造登录请求通过验证?

    攻击者在生成 Ticket 时,需要 SaltHash1 ,但由于并不知道密码,所以无法计算出 SaltHash1 ,又无法从 SaltHash2 反推 SaltHash1 ,所以无法伪造登录请求通过验证。

  3. 假设数据被拖库了,攻击者使用中间人攻击,劫持了用户的请求,密码会被泄露吗?

    中间人拥有真实服务器所有的数据,仿冒了真实的 Server ,因此,他可以解密出 Ticket 中的 SaltHash1 ,但是 SaltHash1 是无法解密出原始密码的。所以,密码也不会被泄露。

    但是,中间人攻击可以获取到最后的 TempKey ,从而能监听后续的所有通信过程。这是很难解决的问题,因为在服务端所有东西都暴露的情况下,中间人假设可以劫持用户数据,仿冒真实 Server , 是很难和真实的 Server 区分开的。解决的方法也许只有防止被中间人攻击,保证 Server 的公钥在客户端不被篡改。

    假设攻击已经进展到了这样的程度,还有办法补救吗?有。由于攻击者只能监听用户的登录过程,并不知道真实的密码。所以,只需要在服务端对 Salt2 进行升级,即可生成新的 SaltHash2 ,从而让攻击者所有攻击失效。

    具体是这样的:用户正常的登录,服务端验证通过后,生成新的 Salt2 ,然后根据传过来的 SaltHash1 重新计算了 SaltHash2 存入数据库。下次用户再次登录时,获取到的是新的 Salt2 ,密码没有变,同样能登录,攻击者之前拖库的那份数据也失效了。

Q & A

  1. 使用 bcrypt 慢哈希函数,服务端应对大量的用户登录请求,性能承受的了吗?

    该方案中,细心一点会注意到, bcrypt 只是在客户端进行运算的,服务端是直接拿到客户端运算好的结果( SaltHash1 )后 SHA-512 计算结果进行验证的。所以,把性能压力分摊到了各个客户端。 

  2. 为什么要使用两个 Salt 值?

    使用两个 Salt 值,是为了防止拖库后,劫持了用户请求后将密码破解出来。只有拥有密码的用户,才能用第一个 Salt 值计算出 SaltHash1 ,并且不能反推回原始密码。第二个 Salt 值可以加大被拖库后直接解密出 SaltHash1 的难度。

  3. 为什么要动态请求 Salt1 和 Salt2 ?

    Salt 值直接写在客户端肯定不好,而且写死了要修改还得升级客户端。动态请求 Salt 值,还可以实现不升级客户端的情况下,对密码进行动态升级:服务端可定期更换 Salt2 ,重新计算 SaltHash2 ,让攻击者即使拖了一次数据也很快处于失效状态。

  4. 数据库都已经全被拖走了,密码不泄露还有什么意义呢?

    其实是有意义的,正如刚刚提到的升级 Salt2 的补救方案,用户可以在完全不知情的情况下,不需要修改密码就升级了账号体系。同时,保护好用户的密码,不被攻击者拿去撞别家网站的库,也是一份责任。

欢迎大家针对本文的方案进行讨论,如有不实或者考虑不周的地方,请尽情指出。或者有更好的建议或意见,欢迎交流!

 

转载:http://www.jianshu.com/u/adeb17d1204b

设计安全的账号系统的正确姿势