前置知识
三次握手,一包一包拆开看
为什么 TCP 开连接恰好需要三个包,SYN 和 ACK 位到底在做什么,以及初始序号一旦可被预测,为什么就演变成了一场安全灾难。
TL;DR
每一个 TCP 连接都以三个包开始。客户端发 SYN,服务器发 SYN-ACK,客户端发 ACK。两个包足以同步状态,但不足以抵御来自以前连接的重复或迟到的包。三个包,是让这段对话既同步又无歧义的最少数量。在这三个包中交换的序号,变成 TCP 之后一切的坐标系——而当 Kevin Mitnick 在 1994 年证明它们是可预测的之后,整整一类连接劫持攻击就有了名字。
TCP 在启动时要解决什么
在两台机器通过 TCP 交换数据之前,它们需要就三件事达成一致:
- 连接是存在的。 双方都知道它们在跟对方说话,并且都分配了资源。
- 编号的起点。 对话的每一个方向有自己的序号空间。每发一个字节都被编号,TCP 就用它来处理重传、排序和重复检测。
- 对端现在真的可达。 不只是某个包到了——而是对端在这个时刻、用这个特定地址,也能把数据发回给你。
三次握手就是实现这三件事、又能稳健地抵御旧连接遗留在网上的残留包漂回来的最小包交换。
SYN、SYN-ACK、ACK
下面是这三个包各自携带什么。图示很熟悉,但细节很重要。
Client Server
│ │
│── SYN, seq=x ─────────────────────────────► │
│ │ 分配连接状态,
│ │ 选择 ISN y
│ │
│ ◄────────────── SYN, ACK, seq=y, ack=x+1 ── │
│ │
分配连接状态, │
推进客户端窗口 │
│ │
│── ACK, seq=x+1, ack=y+1 ──────────────────► │
│ │
│══════════════ 数据开始流动 ═══════════════│
- SYN(synchronize)——TCP 首部里
SYN标志位被置位的包。客户端挑选一个初始序号(ISN)x,这个包说:“我想开一个连接,我这边的字节编号从x开始。” - SYN-ACK — 服务器的回复。它给另一方向选一个自己的 ISN
y,通过设置ack = x + 1来确认客户端的 SYN。两个标志同时置位:SYN(“我这边也从y开始同步”)和ACK(“我收到了你那边的字节x”)。 - ACK — 客户端确认服务器的 ISN:
ack = y + 1,这次不再置SYN位。握手完成。
第三个包之后,两端已经就下面几件事达成一致:连接的端点、两个独立的序号空间、以及双方都能成功发给对方这件事。
为什么两个包不够
一个显而易见的问题:为什么要三?SYN + SYN-ACK 难道不够——客户端说”咱俩聊聊”,服务器说”好啊从 y 开始”,然后就行了?
问题出在旧包。想象昨天客户端开过一个连接,发了一个 ISN 为 x 的 SYN,没收到回应,放弃了。今天同一个客户端开一个新连接,发出一个新的 ISN x' 的 SYN。而与此同时,昨天的那个原始 SYN——被困在某个路由器队列里——终于到达了服务器。
如果协议只有两步,服务器看到一个 ISN 为 x 的 SYN,分配状态,回复 SYN-ACK,开始等从 x+1 开始的数据字节。客户端对这一切毫不知情,在自己新建的连接上从 x'+1 开始发数据。服务器收到带着它不预期的序号的字节,就丢掉——或者更糟,把它塞进错误的会话里。
第三个包解决了这个问题。服务器要等客户端确认了服务器的 ISN,才会完全打开连接。一个旧 SYN 仍然可能到达服务器并触发一次 SYN-ACK 回复,但客户端永远不会发出最终的 ACK——因为它根本不知道这个幻影连接的存在——所以服务器会超时并拆除半开状态。
这套论证的形式化版本,有时被叫做应用到连接建立上的**“两将军问题”**。用有限的消息交换,你永远没法保证双方获得完美的相互知识,但三个包在实践意义上已经足够接近。
初始序号与 Mitnick 攻击
很长一段时间里,ISN 的生成非常朴素。早期 BSD 实现,每秒给一个全局计数器加一个固定量,每建一条新连接再加一个固定量。只要你知道某台机器上次什么时候启动、服务过多少连接,你就可以预测它下一个 ISN。
这听起来很学院派,直到你意识到 TCP 隐含地假设:只有看到过服务器 SYN-ACK 的人,才知道服务器的 ISN。 正是这件事,证明你真的能在你声称的那个地址上收到流量。如果服务器的 ISN 是可预测的,攻击者不需要收到第二个包,就能伪造握手的第三个包。
1994 年,Kevin Mitnick 正是用这个手法闯入了 Tsutomu Shimomura 的工作站。攻击流程:
- Mitnick 的系统向 Shimomura 的机器发一个 SYN,源 IP 伪装成一台受信任机器的。
- Shimomura 的机器把 SYN-ACK 发回给那台受信任的机器(不是 Mitnick)。
- Mitnick 早已用大量 SYN 压制住那台受信任机器,让它忽略进来的数据包。
- Mitnick 根据时序猜出 Shimomura 的 ISN,自己发出最后的 ACK,同样伪造成那台受信任机器的源地址。
- Shimomura 的机器现在相信自己与那台受信任机器有一条打开的 TCP 连接,并开始接受命令。
补救是随机化的 ISN。RFC 6528(2012)把这项要求形式化:ISN 必须从”连接五元组 + 一个秘密”的加密哈希推导出来,对在路径之外的攻击者来说实际上就是不可猜测的。这成了硬性要求;任何现代操作系统如果不实现这一点,在公共互联网上就没法安全互操作。
SYN 洪水与半开连接
握手还打开了第二种攻击的门。服务器收到 SYN 时,就分配连接状态——内核数据结构里的一个槽位——并等待最后的 ACK。如果 ACK 永远不来,这个槽位就会一直被占着,直到超时。
1996 年,攻击者开始用伪造源 IP 的 SYN 洪水淹没服务器。服务器回去的 SYN-ACK 哪儿也到不了,它的半开连接表就会被填满,合法客户端拿不到槽位,服务器等同于停止接受连接。
防御手段是 Daniel J. Bernstein 和 Eric Schenk 发明的 SYN cookie。服务器不再在第一个 SYN 到来时分配状态,而是用加密哈希把连接参数编码进自己的 ISN 里。客户端如果用有效的 cookie 来 ACK,服务器就能重建状态;如果不是,压根就没分配过什么东西。这样,握手在过载期间就从有状态变成无状态,SYN 洪水作为攻击手段的效力就大大降低了。
关闭连接
握手在拆连接时有一个对称的对应物,有时被称为四次握手:
Client Server
│── FIN ────────────────────────────► │
│ ◄─────────────────────── ACK ────── │
│
│ (服务器还有数据要发)
│
│ ◄─────────────────────── FIN ────── │
│── ACK ────────────────────────────► │
TCP 连接是全双工的,两个方向可以独立关闭。客户端为自己方向发 FIN,服务器 ACK 它——但服务器想继续发多久都行,直到它也发自己的 FIN。四次握手本质上是两次单向关闭,每次各带一个 ACK。
最后 ACK 之后,执行关闭的那一端进入著名的 TIME_WAIT 状态——通常是 60 到 120 秒——这段时间里它拒绝复用那个本地端口号。这正是为了捕获刚刚关闭的连接可能残留的漂浮包,免得它们意外落进一条复用同样四元组的新连接里。TIME_WAIT 就是高流量服务器在满载时会周期性耗尽临时端口的原因;它是握手正确性保证的直接代价。
让握手消失
三次握手在任何数据开始流动之前要付出一轮 RTT 的延迟。在局域网里这不可见;在卫星链路上,每开一次连接就是 500+ 毫秒的死时间。
有几种技术试图绕开它:
- TCP Fast Open(RFC 7413)允许一个跟服务器对话过的客户端,把数据直接放进 SYN 包里发,用上一次连接的加密 cookie 证明自己不是攻击者。能用,但各种中间盒对它的支持很不一致。
- TLS 1.3 0-RTT 允许一个恢复会话的客户端在第一个 TLS 消息里发加密后的应用数据——代价是那条首条消息的重放保护更弱。
- QUIC 把传输握手和加密握手折在一起。对恢复的连接,QUIC 可以做到实质上的 0-RTT:第一个包就带数据。
这些方案都有一个共同主题:三次握手的成本在 RTT 是 10 毫秒时还能接受,但在服务全球化、p99 RTT 是 200 毫秒时就不行了。握手没被替换掉——而是被摊销了。
五十年,就是这三个包
TCP/IP 那篇文章用四行展示了握手示意图。这四行,曾是每一条 TCP 连接的起点——1974 年的 ARPANET 上、2026 年的手机上、和两者之间机架里跑着的某个东西。细节被打磨过:序号现在是加密派生的,cookie 处理过载,现代的传输把握手折进了内置加密的更长谈判里。但结构没变:
- 三个包,因为两个不足以排除旧状态。
- 两个独立的序号空间,因为对话是全双工的。
- 状态只在双方都确认对方活着之后分配,因为在第一个包上就分配是可被利用的。
TCP 之后的每一个特性——可靠性、排序、流控、拥塞控制——都依赖于先由握手建立的这套共享坐标系。它是连接打开之后你看不见的地基。它也是任何新传输协议首先要决定”要不要保留”的东西。