看完这个标题你可能会发笑,如果客户端都完全不可信了,哪还有安全性可言?
然而笔者在今年秋招求职面试中,就遇到了好几位面试官问出了类似的问题。
笔者当时在聊前后端分离(除了网页应用外,也包括大前端应用,比如桌面应用、移动端APP、Unity游戏等)的用户鉴权相关操作。
笔者提出,一般用户权限的校验都是使用jwt一类的用户令牌实现的,对于对安全性要求不高的应用,如果要保持用户的下次启动免登陆,jwt直接存储到localStorage或者Unity游戏当中的PlayerPrefs这种明文持久化存储就可以了。当然,如果直接明文存不妥当,可以适当的对token进行加密,把加密的代码进行混淆即可。
此时,面试官就顺着这个问题问了下去。
他提到,上述的操作方式是不能保证token的安全性的,一些第三方工具、外挂,或者一些病毒或脚本攻击,都可以轻易的获取到这些token,然后进行恶意操作。
以至于最差的情况下,我们认为用户客户端是不可信的,那么有没有什么办法可以完全保证用户的权限安全呢?
笔者当然也认为这句话是个伪命题。客户端不可信和权限安全性完全是对立的,如果客户端不可信,那么权限安全性就是不可能的。
换句话说,我们只能 “尽量”保证客户端的安全性和数据正确性 ,而不能完全防止安全性问题。
在这篇文章中,笔者将会简单探讨一下,如何尽量保证客户端的安全性和数据正确性。尽可能不涉及详细的语言,框架,库的实现,而是从原理上进行讨论。
1. 为什么会造成客户端不可信?
其实面试官讲的确实有一定道理,在软件开发中,我们必须认为用户所在的客户端是完全不可信的,这是因为客户端是用户可以控制的,用户或者恶意程序可以随意修改客户端的代码,甚至可以直接修改客户端的内存,这样就可以轻易地获取到一些敏感信息,比如token,然后进行恶意操作。
由此可见,针对用户鉴权这一块,涉及的安全性问题实在太多太多。
例如刚才所说,虽然我们可能已经把jwt进行了简单加密,甚至混淆,但是这些都是不够的。
只要用户或第三方拥有足够的时间和资源,就可以轻易地通过反编译等手段破解这些加密,获取到token,然后进行恶意操作。
简单举网页应用中常见的几个攻击例子做参考,当然如果你对八股文比较熟,这些东西应该都大体听过:
- XSS攻击:跨站脚本攻击,通过在页面中注入恶意脚本,获取用户的cookie等信息,然后进行恶意操作。比如,有人在评论区发表了一个包含
<script>标签的评论,那么所有访问这个页面的用户都会执行这个脚本,想要读取localStorage等信息就非常容易了。 - CSRF攻击:跨站请求伪造,通过伪造请求,让用户在不知情的情况下,以用户的身份发送请求。比如,有人在论坛发表了一个包含
<img src="http://www.example.com/api/transfer?to=attacker&amount=1000">的帖子,那么所有访问这个帖子的用户都会向http://www.example.com/api/transfer?to=attacker&amount=1000发送请求,如果用户已经登录,那么就会以用户的身份发送请求,这样就可以轻易地进行恶意操作。
上述攻击的解决方案,市面很多八股文已经说得不能再透彻了,这里就不做详细阐述,不妨再聊聊其他前端应用的常见攻击方式。
就举Unity游戏的例子吧,如果外挂获取到了存储在PlayerPrefs当中的token,那么此时他就可以轻松地伪装成游戏客户端,向服务器发送恶意数据报文,进行一些不正当的操作。
十年前市面上很多射击游戏的报文设计是不合理的,例如,某游戏的客户端会通过WebSocket传输这个格式的报文:
{
"type": "shoot",
"bullet": {
"x": 1000,
"y": 1000,
"z": 1000
}
}
我们假设警(CT)家是x: 0, y: 0, z: 0,而匪(T)家是x: 1000, y: 1000, z: 1000。并且我们的玩家站在CT老家(0,0,0)的位置,而(1000,1000,1000)的位置正好有一位T方玩家。
此时,外挂读取了内存数据,知道(1000,1000,1000)的位置正好有一位T方玩家。
外挂又截取了用户token,伪装成游戏客户端,向服务器发送子弹落点为(1000,1000,1000)的报文。
这个时候,位于(0,0,0)的玩家就可以在CT出生点无视任何障碍物,直接击杀位于(1000,1000,1000)的玩家。这就是一个典型的外挂攻击。
又或者说,某款音游中,某首曲目的最高分是100万分,而外挂获取到了token,伪装成游戏客户端,向服务器发送了一个分数为101万分的报文,然后你就会在排行榜上看到一个超越理论值分数的玩家。
上述的种种原因,使得我们认为客户端是不可信的。
2. 第一步——设计安全的报文并做合理性校验
不过再次总结分析,不难发现,上述攻击的根本目的是为了发送恶意报文。
既然如此,我们可以从报文这一块入手。
恶意程序或用户既然能发送恶意报文,就证明这条报文的某些字段涉及了一些非常危险的信息,比如上述的子弹落点、分数等。
我们先从报文的设计入手,设计一个安全的报文格式,不要在报文中暴露过多的敏感信息是非常重要的。
很明显,刚才的子弹射击报文就不是很合理。我们可以让服务端完全把控玩家的坐标信息,玩家如果射出子弹,就通过发送子弹射击的四元数代表子弹的方向,去代替发送子弹的坐标。
{
"type": "shoot",
"bullet": {
"quaternion": {
"x": 0.5,
"y": 0.5,
"z": 0.5,
"w": 0.5
}
}
}
这样,即便外挂能够代替玩家进行操作,那也只能控制子弹的方向,而无法控制子弹的落点,最多做到自动锁头,也就是俗称的“大陀螺”效果。
当然,重设计报文格式是不够的,我们还需要对报文进行合理性校验。
比如说,我们可以抓取玩家的行为逻辑,比如视角的移速,以及爆头率等数据,如果这些数据超过了正常范围,那么就可以判定玩家开启了外挂,然后进行封号处理。这样就可以在一定程度上解决上述的“大陀螺”问题。
又比如说,刚才提到的超越理论值的分数问题,我们可以在服务端对分数进行校验,如果分数超过了理论值,那么就可以判定为作弊行为,直接进行封号处理。不过这点内容都想不到的话,只能证明你的业务设计功底还不够扎实。
顺带一提,如果有条件也可以重新设计一下后端开放的接口,如果接口相对复杂的话,也可以有效降低恶意程序编写者的攻击意愿。
3. 第二步——双重令牌验证
当然,上述的方案最终解决的是报文合理性问题。但往往很多看似合理的报文,会造成毁灭性的打击。
比如,恶意程序可能会操作视频站的UP主账号,删除掉所有视频,注销账号,又或者是操作支付平台账号,对境外用户非法转账等。
如果单纯对报文重设计和合理性校验,是无法解决这些问题的。这个时候,我们还是需要对用户的安全性作保障,即便客户端完全不可信。
双重令牌验证,顾名思义,就是我们在用户登录的时候,除了返回给用户一个jwt令牌,还返回给用户一个refresh令牌。这也是网上绝大部分八股文针对题设问题提出的解决方案。
我们不将jwt令牌存储在localStorage或者PlayerPrefs当中,也不存储在本地的安全存储或加密文件当中,而是直接存储在内存当中。取而代之,我们持久化存储refresh令牌。
jwt的有效期一般是比较短的,比如15分钟,这个时候,我们可以通过refresh令牌,向服务器发送请求,获取新的jwt令牌和新的refresh令牌,然后继续使用。
我们在生成令牌时都是需要传入一些信息进行加权的,比如用户的id,用户的角色等。很明显,jwt和refresh令牌的加权信息不可以一致。
比如,jwt令牌的加权信息可以只包含用户的id,而refresh令牌的加权信息可以包含我们通过三方库或自己实现的算法抓取到的浏览器指纹,或者设备指纹等信息。
浏览器指纹是一种通过浏览器的一些特性,比如浏览器的UA,浏览器的插件,浏览器的屏幕分辨率等信息,生成一个唯一的标识符,用来标记用户的浏览器。设备指纹同理,只不过抓取的是设备的一些特性,比如设备的型号,设备的操作系统等信息。
我们可以让客户端向服务端上传一条指纹信息,服务端将指纹存储,用来标记用户的设备,并将其作为refresh令牌的加权信息,最后将两个令牌传回给客户端。
这样,即便用户的jwt令牌被恶意程序获取,也无法通过refresh令牌获取新的jwt令牌,因为refresh令牌的加权信息是不一致的。即便通过refresh令牌获取到了新的jwt令牌,在极短的时间内也做不了过多恶意操作,jwt很快就失效了。
4. 第三步——数字签名
大部分八股文到这里也就浅尝辄止了,实际上根本没有缓解令牌泄漏的问题。
毕竟,只是将问题切入点从jwt令牌转移到了refresh令牌,只要拿到了refresh令牌,获取新的jwt令牌照样也能进行恶意操作。
这个时候,我们可以引入数字签名的概念。当然,这也是绝大部分八股文不会提到的。
如果你对计算机网络这一块比较熟,应该听说过TLS协议。
实际上,HTTPS、WSS等协议,本质就是HTTP+TLS,或者WebSocket+TLS。
TLS协议是一种基于数字签名的安全传输协议,通过数字签名,可以保证数据的完整性和安全性。
我们可以借鉴TLS协议的数字签名机制,对refresh令牌进行数字签名,然后将签名后的令牌传回给客户端。
那么问题来了,数字签名是如何实现的呢?我们要选择什么样的算法呢?
实际上,如果你对数字签名比较熟,应该知道,数字签名的实现有很多种方式,比如RSA、DSA、ECDSA等。
目前市面上常用的签名算法都是非对称加密算法,比如RSA、DSA等。其中的设计思路非常简单,我们会生成一个密钥对,一个是公钥,一个是私钥。对于加密来说,公钥用来加密,私钥用来解密,对于签名来说,私钥用来签名,公钥用来验证。
这里肯定不能用加密,毕竟客户端都不可信了,加密有什么用呢?
至于解密,如果要提具体实现的话,我们不妨采用RSA2算法,这是一种非常常见的非对称加密算法。
在每次得到refresh令牌的时候,我们在客户端生成一个密钥对,一个是公钥,一个是私钥。公钥传给服务端,存储到db中,私钥存储到本地加密文件中。
后续每次请求新的jwt,我们都通过私钥对refresh令牌进行签名,然后将签名后的令牌随着refresh令牌一起传给服务端。
如果服务端收到了refresh令牌,那么就会通过公钥对refresh令牌进行验证,如果验证通过,那么就会返回新的jwt令牌和新的refresh令牌,否则就会返回401,此时用户的所有令牌全部失效,需要重新登录。
通过这种方式,我们让获取jwt的流程变得更加复杂繁重,可以非常有效地缓解令牌泄漏的问题,降低攻击者的攻击欲望。
但有两个大前提,一是加密文件的安全性要得到保障,代码混淆必须做到位,让一般用户以及恶意程序编写者无法轻易获取到私钥,二是前后端协商的算法流程不可以暴露给第三者,什么字段信息参与了加权,用户必须是完全不可知的。
5. 总结
读到这你会发现,用户权限的安全性是绝对不可能完全保证的,我们只能尽量保证客户端的安全性和数据正确性。
如果面试官问出了这个问题,你首先提到的就应该是 客户端设计的安全性方面多且杂,不可能存在完全的安全性方案 ,然后再浅聊上述的解决方案,这样会让面试官觉得你对安全性问题有一定的认识。
毕竟,如果真的能实现完全意义上的安全性,那么为什么还会存在网络安全工程师和黑客这种职业呢?