learn-tech/专栏/周志明的架构课/27_保密:系统如何保证敏感数据无法被内外部人员窃取滥用?.md
2024-10-16 06:37:41 +08:00

184 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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