linux源码解读(三十):quic协议分析(一)
1、网络通信时,为了确保数据不丢包,早在几十年前就发明了tcp协议!然而此一时非彼一时,随着技术进步和业务需求增多,tcp也暴露了部分比较明显的缺陷,比如:
- 建立连接的3次握手延迟大; TLS需要至少需要2个RTT,延迟也大
- 协议缺陷可能导致syn反射类的DDOS攻击
- tcp协议紧耦合到了操作系统,升级需要操作系统层面改动,无法快速、大面积推广升级补丁包
- 对头阻塞:数据被分成sequence,一旦中间的sequence丢包,后面的sequence也不会处理
- 中转设备僵化:路由器、交换机等设备“认死理”,比如只认80、443等端口,其他端口一律丢弃
为了解决这些问题,牛逼plus的google早在10年前,也就是2012年发布了基于UDP的quic协议!为啥不基于tcp了,因为tcp有上述5条缺陷的嘛,所以干脆“另起炉灶”重新开搞!
2、正式介绍前,先看一张图:quci在右边,底层用了udp的协议;自生实现了Multistreaming、tls、拥塞控制,然后支撑了上层的http/2,所以我个人理解quic是一个夹在应用层和传输层之间的协议!
上面“数落”了tcp协议的5点不是,quic又是怎么基于udp解决这些问题的了?quic 是基于 UDP 实现的协议,而 UDP 是不可靠的面向报文的协议,这和 TCP 基于 IP 层的实现并没有什么本质上的不同,都是:
- 底层只负责尽力而为的,以 packet 为单位的传输;
- 上层协议实现更关键的特性,如可靠,有序,安全等。
(1)由于quic并未改造udp,而是直接使用udp,所以不需要改动现有的操作系统,也兼容了现有的网络中转设备,这些都不需要做任何改动,所以quic部署的改造成本相对较低!但是quic毕竟是新的协议,在哪部署和使用了?只有应用层了!这个和操作系统是解耦的,全靠3环的app自己想办法实现(和之前介绍的协程是不是类似了?)!google已经开源了算法,下载连接见文章末尾的参考5;PS:微软也实现了QUIC协议,名称叫MsQuic,源码在这:https://github.com/microsoft/msquic;
这里多说几句:应用层app能操作的最底层协议就是传输层了。大家在用libc库编写通信代码时可以对指定的ip地址和端口收发数据,没法改自己的mac地址吧?也没法改自己的ip地址吧?这些都是操作系统内核封装的,app的开发人员是不需要、也是没法改变的,所以站在安全防护的角度,部分大厂基于传输层自研了类似quic的通信协议,逆向时需要人工挨个分析协议字段的含义了,现成的fiddler/charles/burpsuit等https/http的抓包工具是无效的,用wireshark这类工具抓包也无法自动解析这些厂家自研的协议!
(2)TCP连接需要3次握手,tls最少需要2次RTT,两个加起来一共要耗费5个RTT,究其原因一方面是 TCP 和 TLS 分层设计导致的:分层的设计需要每个逻辑层次分别建立自己的连接状态。另一方面是 TLS 的握手阶段复杂的密钥协商机制导致的,quic又是怎么改进的了?quic建立握手的步骤如下:
- 客户端判断本地是否已有服务器的全部配置参数(证书配置信息),如果有则直接跳转到(5),否则继续 。
- 客户端向服务器发送 inchoate client hello(CHLO) 消息,请求服务器传输配置参数。
- 服务器收到 CHLO,回复 rejection(REJ) 消息,其中包含服务器的部分配置参数
- 客户端收到 REJ,提取并存储服务器配置参数,跳回到 (1) 。
- 客户端向服务器发送 full client hello 消息,开始正式握手,消息中包括客户端选择的公开数。此时客户端根据获取的服务器配置参数和自己选择的公开数,可以计算出初始密钥 K1。
- 服务器收到 full client hello,如果不同意连接就回复 REJ,同(3);如果同意连接,根据客户端的公开数计算出初始密钥 K1,回复 server hello(SHLO) 消息, SHLO 用初始密钥 K1 加密,并且其中包含服务器选择的一个临时公开数。
- 客户端收到服务器的回复,如果是 REJ 则情况同(4);如果是 SHLO,则尝试用初始密钥 K1 解密,提取出临时公开数。
- 客户端和服务器根据临时公开数和初始密钥 K1,各自基于 SHA-256 算法推导出会话密钥 K2。
- 双方更换为使用会话密钥 K2 通信,初始密钥 K1 此时已无用,QUIC 握手过程完毕。之后会话密钥 K2 更新的流程与以上过程类似,只是数据包中的某些字段略有不同。这里为啥不继续使用key1,而是要重新生成key2来加密了?核心是为了前向安全!万一key1泄漏,之前用key1加密的数据全都被解密。所以为了前向安全,每次通信时会重新生成key2加密!
总的来说:
- udp本身就不是面向连接的协议,所以省略了tcp 3次握手连接的耗时;直接通过事先内置的服务器参数发起通信请求;
- 既然不是面向连接的,怎么确保所有的数据都能到达了?通过stream id和stream offset确保数据包不会丢失,接收方能收到完整的全量数据
- 第一次用DH算法计算对称加密的密钥需要1个RTT;后续每次都用这个缓存的密钥加密,又省了一个RTT;本质上是把tcp的打招呼、握手,还有tls交换密钥的工作在1个RTT中全做了,这就是相比于tcp实现的tls效率高的根本原因!
注意:通信双方用于密钥交换的DH算法无法防止中间人攻击,所以仅通过密钥交换是无法防止被抓包的,所以还要通过证书等其他方式验证身份!x音就是通过libboringssl.so(google开源的一个openssl分支)SSL_CTX_set_custom_verify函数验证客户端是否是原来的client,而不是抓包软件!
(3)拥塞控制:QUIC 使用可插拔的拥塞控制,相较于 TCP,它能提供更丰富的拥塞控制信息。比如对于每一个包,不管是原始包还是重传包,都带有一个新的序列号(seq),这使得 QUIC 能够区分 ACK 是重传包还是原始包,从而避免了 TCP 重传模糊的问题。QUIC 同时还带有收到数据包与发出 ACK 之间的时延信息。这些信息能够帮助更精确的计算 RTT!同时,因为quic不依赖操作系统,而是在应用层实现,所以开发人员对于quic有非常强的操控能力:完全可以根据不同的业务场景,实现和配置不同的拥塞控制算法以及参数;比如Google 提出的 BBR 拥塞控制算法与 CUBIC 是思路完全不一样的算法,在弱网和一定丢包场景,BBR 比 CUBIC 更不敏感,性能也更好;
(4)队头阻塞:TCP 为了保证可靠性,使用了基于字节序号的 Sequence Number 及 Ack 来确认消息的有序到达;一旦中间某个sequence的包丢失,哪怕是这个sequence后面的数据已经到达接收端,操作系统也不会立即把数据发给上层的应用来接受处理,而是一直等待发送端重新发送丢失的sequence包,举例如下:
应用层可以顺利读取 stream1 中的内容,但由于 stream2 中的第三个 segment 发生了丢包,TCP 为了保证数据的可靠性,需要发送端重传第 3 个 segment 才能通知应用层读取接下去的数据。所以即使 stream3、stream4 的内容已顺利抵达,应用层仍然无法读取,只能等待 stream2 中丢失的包进行重传。在弱网环境下,HTTP2 的队头阻塞问题在用户体验上极为糟糕!quic是怎么既确保数据传输可靠不丢失,又解决队头阻塞的这个问题的了?
对于数据包的传输,肯定是要编号的,否则接受方在拼接这些数据包的时候怎么知道顺序了?quic协议用Packet Number 代替了 TCP 的 Sequence Number,不同之处在于:
- 每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值,比如Packet N+M;
- 数据包支持乱序确认,不再要求 TCP 那样必须有序确认
当数据包 Packet N 丢失后,只要有新的已接收数据包确认,当前窗口就会继续向右滑动。待发送端获知数据包 Packet N 丢失后,会将需要重传的数据包放到待发送队列,重新编号比如数据包 Packet N+M 后重新发送给接收端,对重传数据包的处理跟发送新的数据包类似,这样就不会因为丢包重传将当前窗口阻塞在原地,从而解决了队头阻塞问题;但是问题又来了:怎么确认Package N+M就是重传PackageN的数据包了?这就涉及到quic另一个重要的特性了:多路复用!比如用户访问某个网页,这个页面有两个文件,分别是index.htm和index.js,可以同时、分别传输这两个文件!每个传输的stream都有各自的id,所以可以通过id确认是哪个stream超时丢包了!但包的Packet 编号是N+M,怎么进一步确认就是重传的Packet N包了?这就需要另一个重要的变量了:offset!怎么样,单从英语是不是就能猜到这个变量的作用了?每个数据包都有个offset字段,用于标识在stream id中的偏移!接收方完全可以根据offset来拼接收到的数据包!
总结:quic协议可以在乱序发送的情况下任然可靠不丢失,靠的就是每个数据包的offset字段;再搭配上stream id字段,接收方完全可以在乱序的情况下无误拼接收到的数据包了!
(4)除了以上通过stream id和stream offset确保数据不丢失外,quic还采用了另一个叫向前纠错 (Forward Error Correction,FEC)的校验方式:即每个数据包除了它本身的内容之外,还包括了部分其他数据包的数据,因此少量的丢包可以通过其他包的冗余数据直接组装而无需重传。向前纠错牺牲了每个数据包可以发送数据的上限,但是减少了因为丢包导致的数据重传,因为数据重传将会消耗更多的时间(包括确认数据包丢失、请求重传、等待新数据包等步骤的时间消耗);这个原理和纠删码没有本质区别!
(5)通信双方不论使用何种协议,发送的数据必须事前约定好格式,否则接受方怎么从数据包(本质就是一段字符串)中解析和提取关键的信息了?quic协议的格式如下:
数据包中除了个别报文比如 PUBLIC_RESET 和 CHLO,所有报文头部(上图红色部分)都是经过认证的(哈希散列值),报文 Body (上图绿色部分)都是经过加密的,这样只要对 QUIC 报文任何修改,接收端都能够及时发现;每个字段的含义如下:
- Flags:用于表示 Connection ID 长度、Packet Number 长度等信息;
- Connection ID:客户端随机选择的最大长度为64位的无符号整数,用于标识连接;如果app更换了ip地址(比如wifi和4G之间切换了),仍然可以通过这个id和服务端在0 RTT下通信!
- QUIC Version:QUIC 协议的版本号,32 位的可选字段。如果 Public Flag & FLAG_VERSION != 0,这个字段必填。客户端设置 Public Flag 中的 Bit0 为1,并且填写期望的版本号。如果客户端期望的版本号服务端不支持,服务端设置 Public Flag 中的 Bit0 为1,并且在该字段中列出服务端支持的协议版本(0或者多个),并且该字段后不能有任何报文;
- Packet Number:长度取决于 Public Flag 中 Bit4 及 Bit5 两位的值,最大长度 6 字节。发送端在每个普通报文中设置 Packet Number。发送端发送的第一个包的序列号是 1,随后的数据包中的序列号的都大于前一个包中的序列号;
- Stream ID:用于标识当前数据流属于哪个资源请求,用于消除队头阻塞;
- Offset:标识当前数据包在当前 Stream ID 中的字节偏移量,用于消除队头阻塞。
(6)为了便于理解和记忆,这里把quic的要点做了总结,如下:
3、正式因为quic有这么多优点,国内很多互联网一、二线厂商都开始采用,其中比较著名的app就是x音了!lib库中有个libsscronet.so就支持quic协议!
参考:
1、 https://zhuanlan.zhihu.com/p/32553477