保密(Confidentiality)系统如何保证敏感数据无法被包括系统管理员在内的内外部人员所窃取、滥用?
保密是加密和解密的统称,是指以某种特殊的算法改变原有的信息数据,使得未授权的用户即使获得了已加密的信息,但因不知解密的方法,或者知晓解密的算法但缺少解密所需的必要信息,仍然无法了解数据的真实内容。
按照需要保密信息所处的环节不同,可以划分为“信息在客户端时的保密”、“信息在传输时的保密”和“信息在服务端时的保密”三类,或者进一步概括为“端的保密”和“链路的保密”两类。我们把最复杂、最有效,又早有标准解决方案的“传输环节”单独提取出来,放到下一个小节去讨论,本节将结合笔者的一些个人观点,重点讨论密码等敏感信息如何保障安全等级、是否应该从客户端开始加密、应该如何存储及如何验证等常见的安全保密问题。
保密的强度
保密是有成本的,追求越高的安全等级,就要付出越多的工作量与算力消耗。连国家保密法都会把秘密信息划分为秘密、机密、绝密三级来区别对待,可见即使是信息安全,也应该有所取舍。笔者以用户登录为例,列举几种不同强度的保密手段,讨论它们的防御关注点与弱点:
- 以摘要代替明文:如果密码本身比较复杂,那一次简单的哈希摘要至少可以保证即使传输过程中有信息泄漏,也不会被逆推出原信息;即使密码在一个系统中泄漏了,也不至于威胁到其他系统的使用,但这种处理不能防止弱密码被彩虹表攻击所破解。
- 先加盐值再做哈希是应对弱密码的常用方法:盐值可以替弱密码建立一道防御屏障,一定程度上防御已有的彩虹表攻击,但并不能阻止加密结果被监听、窃取后,攻击者直接发送加密结果给服务端进行冒认。
- 将盐值变为动态值能有效防止冒认:如果每次密码向服务端传输时都掺入了动态的盐值,让每次加密的结果都不同,那即使传输给服务端的加密结果被窃取了,也不能冒用来进行另一次调用。尽管在双方通信均可能泄漏的前提下协商出只有通信双方才知道的保密信息是完全可行的(后续介绍“传输安全层”时会提到),但这样协商出盐值的过程将变得极为复杂,而且每次协商只保护一次操作,也难以阻止对其他服务的重放攻击。
- 给服务加入动态令牌,在网关或其他流量公共位置建立校验逻辑,服务端愿意付出在集群中分发令牌信息等代价的前提下,可以做到防止重放攻击,但是依然不能抵御传输过程中被嗅探而泄漏信息的问题。
- 启用 HTTPS 可以防御链路上的恶意嗅探,也能在通信层面解决了重放攻击的问题。但是依然有因客户端被攻破产生伪造根证书风险、有因服务端被攻破产生的证书泄漏而被中间人冒认的风险、有因CRL更新不及时或者OCSP Soft-fail 产生吊销证书被冒用的风险、有因 TLS 的版本过低或密码学套件选用不当产生加密强度不足的风险。
- 为了抵御上述风险,保密强度还要进一步提升,譬如银行会使用独立于客户端的存储证书的物理设备(俗称的 U 盾)来避免根证书被客户端中的恶意程序窃取伪造;大型网站涉及到账号、金钱等操作时,会使用双重验证开辟一条独立于网络的信息通道(如手机验证码、电子邮件)来显著提高冒认的难度;甚至一些关键企业(如国家电网)或机构(如军事机构)会专门建设遍布全国各地的与公网物理隔离的专用内部网络来保障通信安全。
听了上述这些逐步升级的保密措施,你应该能对“更高安全强度同时也意味着更多代价”有更具体的理解,不是任何一个网站、系统、服务都需要无限拔高的安全性。也许这时候你会好奇另一个问题:安全的强度有尽头吗?存不存在某种绝对安全的保密方式?答案可能出乎多数人的意料,确实是有的。信息论之父香农严格证明了一次性密码(One Time Password)的绝对安全性。但是使用一次性密码必须有个前提,就是已经提前安全地把密码或密码列表传达给对方。譬如,给你给朋友人肉送去一本存储了完全随机密码的密码本,然后每次使用其中一条密码来进行加密通信,用完一条丢弃一条,理论上这样可以做到绝对的安全,但显然这种绝对安全对于互联网没有任何的可行性。
客户端加密
客户端在用户登录、注册一类场景里是否需要对密码进行加密,这个问题一直存有争议。笔者的观点很明确:为了保证信息不被黑客窃取而做客户端加密没有太多意义,对绝大多数的信息系统来说,启用 HTTPS 可以说是唯一的实际可行的方案。但是!为了保证密码不在服务端被滥用,在客户端就开始加密是很有意义的。大网站被拖库的事情层出不穷,密码明文被写入数据库、被输出到日志中之类的事情也屡见不鲜,做系统设计时就应该把明文密码这种东西当成是最烫手的山芋来看待,越早消灭掉越好,将一个潜在的炸弹从客户端运到服务端,对绝大多数系统来说都没有必要。
为什么客户端加密对防御泄密会没有意义?原因是网络通信并非由发送方和接收方点对点进行的,客户端无法决定用户送出的信息能不能到达服务端,或者会经过怎样的路径到达服务端,在传输链路必定是不安全的假设前提下,无论客户端做什么防御措施,最终都会沦为“马其诺防线”。之前笔者已经提到多次的中间人攻击,它是通过劫持掉了客户端到服务端之间的某个节点,包括但不限于代理(通过 HTTP 代理返回赝品)、路由器(通过路由导向赝品)、DNS 服务(直接将你机器的 DNS 查询结果替换为赝品地址)等,来给你访问的页面或服务注入恶意的代码,极端情况下,甚至可能把要访问的服务或页面整个给取代掉,此时不论你在页面上设计了多么精巧严密的加密措施,都不会有保护作用。而攻击者只需地劫持路由器,或在局域网内其他机器释放 ARP 病毒便有可能做到这一点。
额外知识:中间人攻击(Man-in-the-Middle Attack,MitM)
在消息发出方和接收方之间拦截双方通信。用日常生活中的写信来类比的话:你给朋友写了一封信,邮递员可以把每一份你寄出去的信都拆开看,甚至把信的内容改掉,然后重新封起来,再寄出去给你的朋友。朋友收到信之后给你回信,邮递员又可以拆开看,看完随便改,改完封好再送到你手上。你全程都不知道自己寄出去的信和收到的信都经过邮递员这个“中间人”转手和处理——换句话说,对于你和你朋友来讲,邮递员这个“中间人”角色是不可见的。
对于“不应把明文传递到服务端”的观点,也是有一些不同意见的。譬如其中一种保存明文密码的理由是为了便于客户端做动态加盐,因为这需要服务端存储了明文,或者某种盐值/密钥固定的加密结果,才能每次用新的盐值重新加密来与客户端传上来的加密结果进行比对。笔者的看法是每次从服务端请求动态盐值,在客户端加盐传输的做法通常都得不偿失,客户端无论是否动态加盐,都不可能代替 HTTPS。真正防御性的密码加密存储确实应该在服务端中进行,但这是为了防御服务端被攻破而批量泄漏密码的风险,并不是为了增加传输过程的安全。
密码存储和验证
这节笔者以 Fenix's Bookstore 中的真实代码为例,介绍对一个普通安全强度的信息系统,密码如何从客户端传输到服务端,然后存储进数据库的全过程。“普通安全强度”是指在具有一定保密安全性的同时,避免消耗过多的运算资源,验证起来也相对便捷。对多数信息系统来说,只要配合一定的密码规则约束,譬如密码要求长度、特殊字符等,再配合 HTTPS 传输,已足防御大多数风险了。即使在用户采用了弱密码、客户端通信被监听、服务端被拖库、泄漏了存储的密文和盐值等问题同时发生,也能够最大限度避免用户明文密码被逆推出来。下面先介绍密码创建的过程:
- 用户在客户端注册,输入明文密码:
123456
。password = 123456
- 客户端对用户密码进行简单哈希摘要,可选的算法有 MD2/4/5、SHA1/256/512、BCrypt、PBKDF1/2,等等。为了突出“简单”的哈希摘要,这里笔者故意没有排除掉 MD 系这些已经有了高效碰撞手段的算法。
client_hash = MD5(password) // e10adc3949ba59abbe56e057f20f883e
- 为了防御彩虹表攻击应加盐处理,客户端加盐只取固定的字符串即可,如实在不安心,最多用伪动态的盐值(“伪动态”是指服务端不需要额外通信可以得到的信息,譬如由日期或用户名等自然变化的内容,加上固定字符串构成)。
client_hash = MD5(MD5(password) + salt) // SALT = $2a$10$o5L.dWYEjZjaejOmN3x4Qu
- 假设攻击者截获了客户端发出的信息,得到了摘要结果和采用的盐值,那攻击者就可以枚举遍历所有 8 位字符以内(“8 位”只是举个例子,反正就是指弱密码,你如果拿 1024 位随机字符当密码用,加不加盐,彩虹表都跟你没什么关系)的弱密码,然后对每个密码再加盐计算,就得到一个针对固定盐值的对照彩虹表。为了应对这种暴力破解,并不提倡在盐值上做动态化,更理想的方式是引入慢哈希函数来解决。
慢哈希函数是指这个函数执行时间是可以调节的哈希函数,通常是以控制调用次数来实现的。BCrypt 算法就是一种典型的慢哈希函数,它做哈希计算时接受盐值 Salt 和执行成本 Cost 两个参数(代码层面 Cost 一般是混入在 Salt 中,譬如上面例子中的 Salt 就是混入了 10 轮运算的盐值,10 轮的意思是 2^10^ 次哈希,Cost 参数是放在指数上的,最大取值就 31)。如果我们控制 BCrypt 的执行时间大概是 0.1 秒完成一次哈希计算的话,按照 1 秒生成 10 个哈希值的速度,算完所有的 10 位大小写字母和数字组成的弱密码大概需要 P(62,10)/(3600×24×365)/0.1=1,237,204,169 年时间。
client_hash = BCrypt(MD5(password) + salt) // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
- 只需防御被拖库后针对固定盐值的批量彩虹表攻击。具体做法是为每一个密码(指客户端传来的哈希值)产生一个随机的盐值。笔者建议采用“密码学安全伪随机数生成器”(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG)来生成一个长度与哈希值长度相等的随机字符串。对于 Java 语言,从 Java SE 7 起提供了
java.security.SecureRandom
类,用于支持 CSPRNG 字符串生成。SecureRandom random = new SecureRandom(); byte server_salt[] = new byte[36]; random.nextBytes(server_salt); // tq2pdxrblkbgp8vt8kbdpmzdh1w8bex
- 将动态盐值混入客户端传来的哈希值再做一次哈希,产生出最终的密文,并和上一步随机生成的盐值一起写入到同一条数据库记录中。由于慢哈希算法占用大量处理器资源,笔者并不推荐在服务端中采用。不过,如果你阅读了 Fenix's Bookstore 的源码,会发现这步依然采用了 Spring Security 5 中的
BcryptPasswordEncoder
,但是请注意它默认构造函数中的 Cost 参数值为-1,经转换后实际只进行了 2^10^ =1024 次计算,并不会对服务端造成太大的压力。此外,代码中并未显式传入 CSPRNG 生成的盐值,这是因为BCryptPasswordEncoder
本身就会自动调用 CSPRNG 产生盐值,并将该盐值输出在结果的前 32 位之中,因此也无须专门在数据库中设计存储盐值字段。这个过程以伪代码表示如下:server_hash = SHA256(client_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67 DB.save(server_hash, server_salt);
以上加密存储的过程相对复杂,但是运算压力最大的过程(慢哈希)是在客户端完成的,对服务端压力很小,也不惧怕因网络通信被截获而导致明文密码泄漏。密码存储后,以后验证的过程与加密是类似的,步骤如下:
- 客户端,用户在登录页面中输入密码明文:
123456
,经过与注册相同的加密过程,向服务端传输加密后的结果。authentication_hash = MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2
- 服务端,接受到客户端传输上来的哈希值,从数据库中取出登录用户对应的密文和盐值,采用相同的哈希算法,对客户端传来的哈希值、服务端存储的盐值计算摘要结果。
result = SHA256(authentication_hash + server_salt); // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
- 比较上一步的结果和数据库储存的哈希值是否相同,如果相同那么密码正确,反之密码错误。
authentication = compare(result, server_hash) // yes
下一节:传输(Transport Security)系统如何保证通过网络传输的信息无法被第三方窃听、篡改和冒充?