TCP的安全问题

TCP依赖seq和ACK来实现可靠,流式以及全双工的传输模式,而实际过程中却需要通过三次握手来同步双端的 seq,如果我们提前约定好通信双方初始seq,其实是可以避免三次握手的,那么为什么没有这么做呢?答案是安全问题。

TCP的数据是没有经过任何安全保护的,无论是其header还是payload,对于一个攻击者而言,他可以在世界的任何角落,伪造一个合法TCP报文。

一个典型的例子就是攻击者可以伪造一个reset报文强制关闭一条TCP链接,而攻击成功的关键则是TCP字段里的 seq及ACK字段,只要报文中这两项位于接收者的滑动窗口内,该报文就是合法的,而TCP握手采用随机seq的方式(不完全随机,而是随着时间流逝而线性增长,到了2^32尽头再回滚)来提升攻击者猜测seq的难度,以增加安全性。

为此,TCP也不得不进行三次握手来同步各自的seq。当然,这样的方式对于off-path的攻击者有一定效果,对于 on-path的攻击者是完全无效的,一个on-path的攻击者仍然可以随意 reset链接,甚至伪造报文,篡改用户的数据。

TLS(传输安全层)解决了TCP的payload安全问题。TLS为用户提供了一种机制来保证中间人无法读取,篡改的TCP 的payload数据,TLS同时还提供了一套安全的身份认证体系,来防止攻击者冒充Web服务提供者。然而TCP的 header这一层仍然是不在保护范围内的,对于一个on/off-path攻击者,仍然具备理论上随时关闭TCP链接的能力。

由于TLS(用户)和TCP(内核)的分层设计,一个安全数据通道的建立实际上仍是一个相对繁琐的流程。以一次基于 TLS1.3 协议的数据安全通道新建流程为例:

在一个client正式开始发送应用层数据之前,需要3个RTT的交互,这算是一个非常大的开销。

从流程上来看,TCP握手和TLS的握手比较相似,有融合在一起的可能,但实际上需要做大量的重新设计,改动和迭代是一个痛苦且难以落地的过程。

TCP的设计问题

  • ACK的二义性问题
  • 队头阻塞问题
  • 连接迁移问题
  • 复杂的建连和断连
  • 难用的TCP keepalive

QUIC的连接建立

和TCP一样,QUIC的首要目标也是提供一个可靠、有序的流式传输协议。不仅如此,QUIC还要保证原生的数据安全以及传输的高效。

可以说,QUIC就是在以一种更简洁高效的机制去对标TCP+TLS。本质上是将TLS 1.3的加密握手过程承载于QUIC自己的数据包和帧结构之上。

数据包类型

  • Initial Packet: 用于发起连接和传输初始的加密握手数据。使用长报头。
  • Handshake Packet: 用于传输握手中非初始阶段的加密数据。使用长报头。
  • 0-RTT Packet: 用于在 0-RTT 握手中传输早期应用数据。使用长报头。
  • 1-RTT Packet: 握手成功后,用于传输应用数据。使用短报头,开销最小。
  • Retry Packet: 服务器用于验证客户端地址合法性的一种特殊包。使用长报头。

加密级别

QUIC 在握手过程中使用多个级别的加密,每个级别都有自己独立的密钥和包序号空间

  • Initial: 使用基于连接ID的初始密钥,仅用于保护握手初期的ClientHello和ServerHello,安全性较低,主要目的是混淆流量。
  • Handshake: 使用从TLS握手早期密钥交换中派生出的密钥,用于保护握手后半段(服务器证书、身份验证等)。
  • 0-RTT: 使用上一次连接中获取的PSK (Pre-Shared Key)加密,用于发送早期数据。
  • 1-RTT: 使用从TLS握手最终派生出的密钥,用于保护连接中所有后续的应用数据。这是最安全的级别。

CRYPTO帧是QUIC的万能容器,所有TLS握手消息都被封装在CRYPTO帧中,然后在上述不同类型的数据包中发送。

1-RTT握手

这是最典型的情况,客户端之前没有与该服务器建立过连接。目标是在一个往返时延 (1-RTT) 内完成握手并开始发送应用数据。

Client                                                  Server
======                                                  ======

Initial Packet (PN 0)
(CRYPTO[ClientHello])
------------------------------------------------------->

                                          Initial Packet (PN 0)
                                          (CRYPTO[ServerHello], ACK)
                                        <----------------------------

                                          Handshake Packet (PN 0)
                                          (CRYPTO[...TLS Handshake...])
                                        <----------------------------

Handshake Packet (PN 0)
(CRYPTO[...TLS Finished...], ACK)
------------------------------------------------------->

1-RTT Packet (PN 0)
(STREAM[GET /])
------------------------------------------------------->

                                          Handshake Packet (PN 1)
                                          (HANDSHAKE_DONE)
                                        <----------------------------

                                          1-RTT Packet (PN 0)
                                          (STREAM[...Response...], ACK)
                                        <----------------------------
  1. Client -> Server: 客户端发起连接

    • 客户端生成一个随机的目标连接 ID (DCID)。
    • 客户端构造一个 TLS ClientHello 消息。
    • 客户端将此 ClientHello 消息放入一个 CRYPTO 帧中。
    • 客户端将此 CRYPTO 帧放入一个 Initial Packet 中。
      • 加密: 使用 Initial 级别的密钥进行加密。
      • 填充: 为了防止 DDoS 放大攻击,客户端会把这个初始包填充到至少 1200 字节。
    • 客户端发送这个 Initial 包给服务器。
  2. Server -> Client: 服务器响应

    • 服务器收到 Initial 包后,解密并解析出 TLS ClientHello
    • 服务器根据 ClientHello 派生出 Handshake 级别的密钥。
    • 服务器准备好它的 TLS 握手消息,通常包括 ServerHello, EncryptedExtensions, Certificate, CertificateVerify, Finished
    • 服务器会发送多个数据包作为响应,这些包可能会被打包在同一个UDP数据报中:
      1. Initial Packet: 包含 CRYPTO 帧,其中封装了 TLS ServerHello。同时也用 ACK 帧来确认收到了客户端的 Initial 包。
      2. Handshake Packet: 包含 CRYPTO 帧,其中封装了服务器剩余的 TLS 握手消息 (证书、验证等)。此包使用 Handshake 级别的密钥加密。
  3. Client -> Server: 客户端完成握手并发送数据

 +   客户端收到服务器的 `Initial` 和 `Handshake` 包。
 +   客户端使用 `Initial` 密钥解密 `Initial` 包,获取 `ServerHello`。
 +   根据 `ServerHello`,客户端现在也可以派生出 `Handshake` 密钥和 **`1-RTT` 应用程序密钥**。
 +   客户端使用 `Handshake` 密钥解密服务器的 `Handshake` 包,验证服务器的证书和身份。
 +   此时,客户端认为握手已基本完成,可以开始发送应用数据了!它会发送:
     1.  **Handshake Packet:** 包含 `CRYPTO` 帧,其中是客户端的 TLS `Finished` 消息。此包用于向服务器证明客户端已正确完成密钥计算。同时也 `ACK` 服务器的 `Handshake` 包。
     1.  **1-RTT Packet:** 包含一个 `STREAM` 帧,里面是**真正的应用数据**。此包使用最终的 `1-RTT` 密钥加密,并使用短报头。

 **此时,客户端可以开始发送应用数据。**
  1. Server -> Client: 服务器确认握手完成

    • 服务器收到客户端的 Handshake 包,验证 TLS Finished 消息后,确认握手在两端都已完成
    • 服务器收到客户端的 1-RTT 包,解密并开始处理应用请求。
    • 服务器发送一个 Handshake 包,其中包含一个 HANDSHAKE_DONE 帧,这是一个明确的信号,告知客户端握手已全部完成
    • 随后,服务器通过 1-RTT 包发送应用响应数据。

    此时,连接完全建立,双方都使用 1-RTT 包(短报头)进行通信。

0-RTT 握手

如果客户端之前成功连接过,并且服务器通过 TLS 的 NewSessionTicket 机制提供了一个 PSK,那么客户端可以尝试0-RTT 连接。

Client                                                  Server
======                                                  ======

Initial Packet
(CRYPTO[ClientHello with PSK])
+
0-RTT Packet
(STREAM[GET /])
------------------------------------------------------->

                                          (Same as 1-RTT response...)
                                          Initial Packet
                                          Handshake Packet
                                          1-RTT Packet (Response)
                                        <----------------------------

客户端发送:

  • 客户端在发送第一个 Initial 包(其中 ClientHello 包含了 PSK)的同时,会立即发送一个或多个 0-RTT Packet
  • 这些 0-RTT 包使用从 PSK 派生出的 0-RTT 密钥进行加密,其中包含了实际的应用数据

服务器处理:

  • 服务器收到 Initial 包,发现 PSK 有效,就会接受并尝试解密紧随其后的 0-RTT 数据。
  • 重要安全警告: 0-RTT 数据容易受到重放攻击。服务器必须确保只接受那些不会产生副作用的请求,或者有其他机制来防止重放。
  • 之后,服务器的响应流程与 1-RTT 握手基本相同,它仍然需要发送 Handshake 包来完成完整的 TLS 握手。

0-RTT 的巨大优势在于,应用数据在第 0 个 RTT 就已经发送出去,实现了真正的零延迟连接。

地址验证 (Retry 机制)

为了防止攻击者伪造源 IP 地址,向受害者服务器发送 Initial 包,从而导致服务器向一个无辜的 IP 发送大量数据(DDoS 放大攻击),QUIC 设计了 Retry 机制。

Client                                                  Server
======                                                  ======

Initial Packet (small)
------------------------------------------------------->

                                          Retry Packet
                                          (Contains Token)
                                        <----------------------------

Initial Packet (larger)
(Contains Token from Retry)
------------------------------------------------------->

                                          (Proceeds as normal 1-RTT)
                                        <----------------------------

步骤:

  1. 客户端发送第一个 Initial 包。
  2. 服务器收到后,如果怀疑客户端的地址或为了规避风险,它不会立即进行完整的握手。
  3. 服务器回复一个非常小的 Retry Packet。这个包里包含一个加密的令牌 (Token),该令牌与客户端的 IP 地址和服务器的一些状态相关联。
  4. 客户端收到 Retry 包后,必须中止当前的握手尝试
  5. 客户端重新发起一个新的 Initial 包,但这次必须包含服务器发来的令牌
  6. 服务器收到带有有效令牌的新 Initial 包,它现在可以确认客户端确实拥有该源 IP 地址(因为它收到了 Retry 包)。
  7. 之后,连接建立过程按正常的 1-RTT 流程继续。

Retry 机制增加了一个 RTT 的延迟,但有效地验证了客户端地址的合法性。

QUIC的连接断开

QUIC主要有三种连接断开的方式:

  1. 主动关闭: 通过发送 CONNECTION_CLOSE 帧来明确地、立即地终止连接。这是最常见的方式。
  2. 空闲超时: 当连接在一段时间内没有任何活动时,双方静默地、独立地断开连接。
  3. 无状态重置 : 一种强制的、单向的终止机制,用于处理一端丢失连接状态的特殊情况。

主动关闭

当应用层决定关闭连接,或者当协议栈检测到不可恢复的错误时,就会使用此机制。

一端(无论是客户端还是服务器)发送一个包含 CONNECTION_CLOSE 帧的数据包,来启动关闭流程。

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |          0x1c or 0x1d (8)         |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                      Error Code (i)                         ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                   [Frame Type (i)]                          ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                    Reason Phrase Length (i)                 ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                      Reason Phrase (*)                      ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Frame Type (0x1c or 0x1d): 用于区分错误来源。

  • 0x1c: 表示 QUIC 传输层错误。
  • 0x1d: 表示应用层错误)。

Error Code (变长整数): 一个数字代码,用于程序化地识别错误原因。例如 NO_ERROR (0x0) 表示优雅关闭。

Frame Type (变长整数, 可选): 如果是传输层错误,此字段可以指明是哪种类型的帧触发了错误。

Reason Phrase (UTF-8 字符串): 一段人类可读的文本,用于调试和日志记录,解释关闭的原因。

流程:

1. 发起关闭:

  • 当一端( A)决定关闭连接时,它会发送一个包含 CONNECTION_CLOSE 帧的数据包。
  • 发送后,A 立即进入 closing 状态。在此状态下,A 不能再发送任何其他数据,只能等待来自对端(B)的数据包。

2. 接收关闭:

  • 当 B 收到包含 CONNECTION_CLOSE 帧的数据包后,它立即进入 draining 状态
  • draining 状态下,B 也不能再发送任何新数据。它的唯一任务是响应 A 可能重传的数据包。如果 B 持续收到来自 A 的CONNECTION_CLOSE数据(说明 A 可能没收到 B 的 ACK),B 会不断地重新发送自己最后的 CONNECTION_CLOSE 确认。

3. 连接终结:

  • 对于 A (发起方): 在进入 closing 状态后,A 会启动一个计时器(通常是 3 倍的 PTO - Probe Timeout)。它会等待这段时间,以接收可能在途中的来自 B 的数据包。计时器结束后,或者收到确认其关闭帧的 ACK 后,A 将删除所有连接状态,连接彻底终结
  • 对于 B (接收方): 在进入 draining 状态后,B 也在等待。如果它在一段时间内没有再收到来自 A 的任何数据,它也认为连接已确认关闭,将删除所有连接状态,连接彻底终结

空闲超时

这是一种静默的关闭方式,用于自动清理那些长时间没有活动的连接,防止资源耗尽。

  1. 超时协商: 在连接建立的握手阶段,双方通过 TLS 握手中的 transport_parameters 扩展来交换各自的 max_idle_timeout 值。最终,连接实际使用的超时时间是双方提议值中的较小者。如果一方没有提供该值,则默认为无限大。
  2. 计时器重置: 在连接的生命周期中,每一端都维护一个空闲计时器。
    • 发送或接收到任何被对端确认 的数据包时,计时器就会被重置。
    • 这意味着只要有有效的网络活动,连接就不会超时。
  3. 超时触发: 如果计时器在没有任何活动的情况下走到了协商的超时时间,该端就会单方面地、静默地认为连接已关闭。它会直接删除所有与该连接相关的状态(密钥、流状态等),不会发送任何通知报文。

无状态重置

无状态重置用于处理一端(通常是服务器)因重启或故障而丢失了连接状态的极端情况。

假设服务器S和客户端C正在通信。突然服务器S重启了。重启后,S完全丢失了与C的连接状态(包括密钥、连接ID等)。但C对此一无所知,它继续向S发送之前连接的 1-RTT 数据包。S收到这些包后,因为它没有对应的密钥,所以无法解密,也不知道该如何处理。这时就需要无状态重置。

  1. 令牌交换: 在连接握手时,每一端都会生成一个16字节的、不可预测的无状态重置令牌,并通过 NEW_CONNECTION_ID 帧发送给对端。每一端都需要存储对端发来的令牌**。
  2. 重置触发: 当一端收到一个它无法处理的有效QUIC数据包时,它就会触发无状态重置。
  3. 发送重置包:
    • 触发端会发送一个特殊的UDP数据报。这个数据报不是一个格式完整的QUIC包
    • 它的内容大部分是随机字节,但其最后16个字节必须是它之前从对端收到的那个无状态重置令牌
    • 为了防止被轻易识别,这个UDP包的长度被设计成一个随机值。
  4. 接收重置:
    • 当另一端收到这个UDP数据报时,它会检查数据报的最后16个字节。
    • 如果这16个字节与它自己当初生成并发给对端的令牌相匹配,它就确认这是一个无状态重置。
    • 它会立即、无条件地终止连接,并清理所有相关状态。