bigywater.tech
网络协议 · 38

三次握手:SYN、SYN-ACK、ACK 与初始序号

三次握手里的序号、确认号到底怎么算?初始序号是怎么选的?SYN 没人回时多久重发一次?connect() 和 accept() 谁先返回?这篇抓真实的握手包,连续建立 2000 个连接看 ISN 的分布,并用 netlab 延迟量出两端“完成握手”的时刻差。

+1
握手里每个确认号 = 对方 ISN + 1(SYN 占一个序号),三处全部验证成立
9 个 SYN
对无回应地址连接 25 秒内 SYN 的发送次数:0~5 秒每秒一个,之后 2、4、8 秒退避
101 ms 与 151 ms
往返 100 ms 时 connect() 与 accept() 的返回时刻:相差半个往返

握手的序号

实测 · 本机执行输出
  lo0 SYN:客户端→服务端,标志 SYN,序号 1721712522,确认号 0
  lo0 SYN-ACK:服务端→客户端,标志 ACK+SYN,序号 2719271520,确认号 1721712523
  lo0 ACK:客户端→服务端,标志 ACK,序号 1721712523,确认号 2719271521
  关系:服务端的确认号 1721712523 = 客户端 ISN 1721712522 + 1:True;客户端最后的确认号 2719271521 = 服务端 ISN 2719271520 + 1:True;第三个包的序号 1721712523 = 客户端 ISN + 1:True
  lo0 上 connect() 用时 4758 µs
  en0 到 example.com:connect() 用时 176.7 ms;抓包里 SYN → SYN-ACK 间隔 175.2 ms(就是一个往返),connect 返回时刻与收到 SYN-ACK 基本同时

SYN 占一个序号。SYN-ACK 的确认号是客户端 ISN + 1,最后一个 ACK 的确认号是服务端 ISN + 1,序号是客户端 ISN + 1。

ISN

实测 · 本机执行输出
  抓到 2000 个 SYN,不同的 ISN 2000 个;范围 561029 ~ 4289965881(32 位空间 0 ~ 4294967295)
  相邻两个连接的 ISN 之差(模 2^32)中,小于 2^20 的只有 0 个(若是按时钟递增会几乎全部很小);均值 0.493×2^32(均匀随机应接近 0.5);前 8 个 ISN:[1391584131, 2407812438, 2883483316, 2297013526, 237829798, 3735851509, 169266384, 3701917642]
  把 32 位空间分 16 份,每份的个数:[132, 119, 134, 160, 123, 117, 111, 132, 118, 117, 117, 125, 115, 114, 148, 118](均匀随机时每份约 125)
  老系统用 4 微秒一次的时钟递增 ISN,容易被猜到后伪造连接(RFC 6528 要求加哈希随机化);这里看到的是随机分布

2000 个连接的 ISN 随机分布在整个 32 位空间,不是按时钟递增。这是对抗伪造连接的第一道防线。

SYN 重传

实测 · 本机执行输出
  net.inet.tcp.keepinit: 75000
  net.inet.tcp.always_keepalive: 0
  (net.inet.tcp.syn_retries 在本机不存在)
  25 秒的超时里 connect 结果 TimeoutError,用时 25.0 秒;抓到 SYN 9 个,发送时刻(秒):[0.0, 1.0, 2.0, 3.01, 4.01, 5.01, 7.01, 11.01, 19.01]
  相邻 SYN 的间隔:[1.0, 1.0, 1.01, 1.0, 1.0, 2.0, 4.0, 8.0](固定 1 秒还是指数退避?逐步变长);所有 SYN 的序号相同:True;带 ECN 的 SYN 1 个、不带的 8 个

没有回应时本机前 6 个 SYN 间隔 1 秒,之后 2、4、8 秒退避;ECN 只有第一个 SYN 带。总超时由 keepinit(75 秒)决定。

参数协商

实测 · 本机执行输出
  本机 lo0:我的 SYN 选项 {'MSS': 16344, 'WScale': 6, 'TS': True, 'SACK': True};对方 SYN-ACK 选项 {'MSS': 16344, 'WScale': 6, 'TS': True, 'SACK': True},窗口字段 65535,标志 ACK+SYN
  example.com:我的 SYN 选项 {'MSS': 1460, 'WScale': 6, 'TS': True, 'SACK': True};对方 SYN-ACK 选项 {'MSS': 1400, 'SACK': True, 'TS': True, 'WScale': 13},窗口字段 65535,标志 ACK+SYN+ECE
  另一个公网主机:我的 SYN 选项 {'MSS': 1460, 'WScale': 6, 'TS': True, 'SACK': True};对方 SYN-ACK 选项 {'MSS': 1452, 'SACK': True, 'TS': True, 'WScale': 13},窗口字段 65535,标志 ACK+SYN+ECE
  双方各自在 SYN / SYN-ACK 里通告自己的 MSS、窗口缩放、是否支持时间戳和 SACK;只有双方都带了某个选项,这个功能才会启用(窗口缩放要求两边都带,否则都不启用)

MSS、窗口缩放、时间戳、SACK 在握手里一次协商;窗口缩放要求双方的 SYN 都带才启用。

两端的完成时刻

实测 · 本机执行输出
  往返 100 ms:客户端 connect() 在 101 ms 时返回(1 个往返:SYN → SYN-ACK),服务端 accept() 在 151 ms 时返回(再过半个往返,等到客户端的第三个 ACK 到达)
  所以客户端先认为连接建立,服务端后认为;如果第三个 ACK 丢了,客户端会以为已连接并开始发数据,服务端还在 SYN_RCVD(下一篇状态机再看)

客户端 connect() 在收到 SYN-ACK 时返回,服务端 accept() 要等第三个 ACK。差半个往返。

分步演示

三次握手
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第一节 lo0 上是本机回环的握手,en0 上是对 example.com 的真实握手;第二节在 lo0 上连续建立 2000 个连接;第三节用一个会丢弃 SYN 的公网地址(example.com 的 81 端口);第五节的延迟由 netlab 在 lo0 的 9400~9499 端口注入
    • 没有验证:SYN cookie 打开时的握手(见 SYN Flood 一篇);同时打开(见后面);第三个 ACK 丢失时服务端的行为(见状态机一篇);其它操作系统的 SYN 重传时间表;ISN 的具体生成算法(只看了统计分布)
    京ICP备2026067502号-1京公网安备11010502063487号