三次握手,TCP连接的建立

TCP 是面向连接的传输协议,因此通信双方必须先建立连接,才能传输数据。

上一小节,我们已经初步学习了 TCP 三次握手建立连接的主要步骤,但有不少细节还来不及展开。现在我们再接再厉,先通过一个实验来观察三次握手的过程,进而深入研究其中的细节。

观察三次握手过程

如下图,局域网中有一台服务器,IP 地址是 10.0.0.2 ,其他主机可以通过 SSH 协议来登录服务器:

SSHsecure shell 的缩写,这是一种加密的应用层协议,常用于 远程登录

SSH 服务通过 TCP 协议来通信,默认端口为 22 。其他主机想要登录服务器,需要先建立到服务器 22 端口的 TCP 连接。接下来,我们就从 10.0.0.3 上来连接服务器 10.0.0.2 的 22 端口,观察 TCP 连接的建立过程。

执行下面这个命令,即可启动我们的实验环境,进入客户机 10.0.0.3

1
docker run --name switch-lab --rm -it --privileged --cap-add=NET_ADMIN --cap-add=SYS_ADMIN -v /data -v /tmp:/tmp-data -h switch fasionchan/netbox:0.7 bash /script/tcp-lab.sh

为了方便操作,我在客户机中准备了两个窗口,环境启动后就可以看到。

我们先在客户机上执行 tcpdump 命令,嗅探 eth0 网卡上的网络流量,以便观察 TCP 的通信过程,:

1
2
3
root@client [ ~ ]  ➜ tcpdump -ni eth0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes

准备完毕后,我们切到另一个窗口来连接服务器。

如何在不同窗口间进行切换呢? 下面一起来复习 tmux 窗口切换操作:

  1. 按下 tmux 功能键 Ctrl-B
  2. 再按下 Q ,这时每个窗口会出现一个数字;
  3. 按下窗口上的数字,即可切到对应的窗口;

我们执行 telnet 命令,连接到服务器 10.0.0.2 的 22 端口:

1
2
3
4
5
root@client [ ~ ]  ➜ telnet 10.0.0.2 22
Trying 10.0.0.2...
Connected to 10.0.0.2.
Escape character is '^]'.
SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.3

SSH 远程登录本来应该执行 ssh 命令,但 ssh 连接成功后会跟服务器交换数据,影响观察。因此,我们用 telnet 命令来连接服务器。telnet 是一种不安全的远程登录协议,现在多用于连接服务端口,并判断连通性。

telnet 命令输出 Connected to 10.0.0.2. ,这表明 TCP 连接已经成功建立!与此同时,tcpdump 命令也输出了它嗅探到的网络流量,这应该就是 三次握手 的通信过程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
root@client [ ~ ]  ➜ tcpdump -ni eth0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
17:31:57.624391 ARP, Request who-has 10.0.0.2 tell 10.0.0.3, length 28
17:31:57.624439 ARP, Reply 10.0.0.2 is-at 0e:bd:60:1c:69:9d, length 28
17:31:57.624450 IP 10.0.0.3.55692 > 10.0.0.2.22: Flags [S], seq 386101196, win 29200, options [mss 1460,sackOK,TS val 811948031 ecr 0,nop,wscale 7], length 0
17:31:57.624495 IP 10.0.0.2.22 > 10.0.0.3.55692: Flags [S.], seq 1155103769, ack 386101197, win 28960, options [mss 1460,sackOK,TS val 3541712191 ecr 811948031,nop,wscale 7], length 0
17:31:57.624522 IP 10.0.0.3.55692 > 10.0.0.2.22: Flags [.], ack 1, win 229, options [nop,nop,TS val 811948031 ecr 3541712191], length 0
17:31:57.635739 IP 10.0.0.2.22 > 10.0.0.3.55692: Flags [P.], seq 1:42, ack 1, win 227, options [nop,nop,TS val 3541712202 ecr 811948031], length 41
17:31:57.635778 IP 10.0.0.3.55692 > 10.0.0.2.22: Flags [.], ack 42, win 229, options [nop,nop,TS val 811948042 ecr 3541712202], length 0

根据 telnet 输出的提示(第 3 行),我们按下 Ctrl-] 回到提示符,然后按下 Ctrl-D 即可关闭连接。同样,tcpdump 命令也输出了它嗅探到的网络流量,这应该是 四次挥手 关闭连接的过程。

tcpdump 嗅探到的网络流量已保存为 pcap 文件,可以从 Github 上下载。pcap 抓包文件可以执行 tcpdump -nr xxxx.pcap 打开,也可以用图形化工具 Wireshark 来分析。

通信分析

前面 tcpdump 命令输出中的第 3~5 个包就是 TCP 建立连接时的三次握手包:

1
2
3
17:31:57.624450 IP 10.0.0.3.55692 > 10.0.0.2.22: Flags [S], seq 386101196, win 29200, options [mss 1460,sackOK,TS val 811948031 ecr 0,nop,wscale 7], length 0
17:31:57.624495 IP 10.0.0.2.22 > 10.0.0.3.55692: Flags [S.], seq 1155103769, ack 386101197, win 28960, options [mss 1460,sackOK,TS val 3541712191 ecr 811948031,nop,wscale 7], length 0
17:31:57.624522 IP 10.0.0.3.55692 > 10.0.0.2.22: Flags [.], ack 1, win 229, options [nop,nop,TS val 811948031 ecr 3541712191], length 0

当客户机 10.0.0.3 通过 TCP 连接服务器 10.0.0.2 时,它先向服务器发一个 SYN 分组,请求建立连接。

注意到,这个 SYN 分组目标端口为 22 ,源端口是客户机系统随机分配的,为 55692 。头部长度字段值是 10 ,以 4 字节为单位,因此分组头部长度为 $10\times4 = 40$ 字节。

分组标志位只设置了 SYN 标志,表示这是一个 SYN 包,希望跟服务器同步序号。客户机随机选择的初始序号,则通过序号字段告诉服务器: 386101196 。

紧接着,服务器给客户机发送 SYN/ACK 分组:

其中,ACK 标志表示服务器确认客户机选择的序号,期望接收序号为 386101197 的数据。由此可见,SYN 操作会占用一个序号,因为确认号值为同步序号的值加一。SYN 标志则表明,服务器反过来向客户机同步自己的序号。

最后,客户机通过一个 ACK 分组,对服务器选择的序号进行确认。但由于原始的序号和确认号不够直观,tcpdump 随后只显示相对初始序号的偏移量。这个分组中 ack 1155103770 被简化显示为 ack 1155103770-1155103769,即 ack 1 ,这样更便于阅读。

报文封装

例子中的 SYN 分组工作在传输层,它告诉服务器:我的端口是 55692 ,想跟你的 22 端口建立连接。

传输层分组必须借助网络层的传输能力,由 IP 携带发送到服务器。因此,客户机将 SYN 分组作为数据,封装成一个 IP 包,源地址是客户机的 10.0.0.3 ,目的地址是服务器的 10.0.0.2 ,发给服务器。

由于服务器和客户机在同一个网络,这个 IP 包可以通过数据链路层,由以太网帧直接发给服务器。因此,客户机将 IP 包封装在以太网帧中,并通过网卡发送出去。

客户机想要封装以太网帧,必须先获悉服务器的 MAC 地址。还记得我们前面介绍的 ARP 协议吗?客户机先通过 ARP 协议,来查询服务器 10.0.0.2 的地址。tcpdump 命令嗅探到的前两个包,正是对应的 ARP 请求和应答包。

这就是客户机逐层封装并发送报文的过程,服务器接收并逐层解包的过程则刚好相反。

首先,服务器网卡收到客户机发来的以太网帧;然后,服务器解开以太网帧,里面是一个由客户机 10.0.0.3 发往服务器 10.0.0.2 的 IP 包;最后,服务器解开 IP 包,里面是一个请求连接 22 端口的 TCP 分组。由此,服务器知道,有一个客户机 10.0.0.3 想跟自己的 22 端口建立连接。

选项

我们注意到例子中的 SYN 分组,头部包含一些选项。那么,TCP 头部选项,格式又是怎样的呢?

如上图,选项由 1 字节长的 类型 字段,1 字节长的 长度 字段,以及可选的 数据 组成。由于选项数据有长有短,因而需要长度字段来记录选项总长度。有一个特殊的 无操作noop )选项甚至连长度字段也没有,它主要用于填充选项表,以达到字节对齐目的。

TCP 头部中的选项字段可以包含若干条选项,以例子中的 SYN 分组为例:

该分组头部中总共包含 5 个选项,分别是:

  1. 类型 2 ,即最大报文长度( MSS ),该选项长度为 4 字节;
  2. 类型 4 ,即 SACK ,该选项长度为 2 字节;
  3. 类型 8 ,即时间戳,该选项长度为 10 字节;
  4. 类型 1 ,即无操作,该选项比较特殊,没有长度字段,仅 1 字节;
  5. 类型 3 ,即窗口扩张因子,该选项长度为 3 字节;

注意到无操作选项的引入,让所有选项的总长度达到 $4+2+10+1+3 = 20$ 字节,刚好是 4 的整数倍。

TCP 有很多不同选项,我们不会一一列举。接下来,我们挑选其中最常见也是最重要的两个,展开介绍。

MSS

TCP 分组需要搭载在网络层包中进行网际传输,而 IP 包需要搭载在数据链路层帧中进行局域网传输。每个网络协议,传输单元能够携带的数据量都是有限的。

还记得我们前面介绍过的以太网协议吗?它是数据链路层中最重要的协议,传输单元是以太网帧。一个典型的以太网帧,最多能够搭载 1500 字节的数据。这也是以太网网卡中的一个重要参数—— MTU 。

因此,经过以太网传输的 IP 包最好不要超过 1500 字节,否则就只能对 IP 包进行分片。而 IP 分片会带来额外的性能开销,而且不够稳定。

这决定了搭载在 IP 包中的 TCP 分组不能超过 $1500-20=1480$ 字节,其中 20 是 IP 包头部的长度。如果再除去 TCP 分组 20 字节的头部,最多只能搭载 $1480 - 20 = 1460$ 字节数据。

这就是 最大分组长度maximum segment size )的含义,它表示一个 TCP 分组最多能够承载的数据量,简称 MSS 。

不同的链路层协议,MTU 略有差异。因此,TCP 通信双方在三次握手阶段,需要通过选项来协商 MSS 。请看上图中 SYN 分组类型为 2 的选项,它告诉服务器,客户机的最大分组长度是 1460 字节。

由于大部分情况下,IP 和 TCP 报文头部均不带选项。因此,在计算最大分组长度时,IP 和 TCP 头部均以 20 字节计算。如果 IP 头部或 TCP 头部带有选项,则最终可能需要对 IP 包进行分片。

窗口大小

TCP 头部中的窗口大小用于通告接收窗口,以便发送方控制发送速度。窗口大小字段最大值是 65535 ,如果单位为字节,则意味着接收窗口在 64KB 以内。这在内存短缺,网速很慢的年代才有可能!

窗口字段表示范围太小怎么办呢?我们可以将它和一个数相乘,由此来扩大它的表示范围,这个乘数就是所谓的 窗口扩大因子window scale )。

那么,窗口扩大因子应该多大合适呢?这不能一概而论,不同场景应该会有所差异。因此,TCP 需要在选项中通告窗口扩大因子。请看前面的图示,窗口扩大因子选项类型为 3 ,长度为 3 字节,包含 1 字节的数据。

1 个字节能表示的最大值是 255 ,如果将 255 与 64KB 相乘,也只是 16MB 不到,似乎还是不够大。因此,TCP 不是简单将两者相乘,而是对窗口大小字段做向左移位。假设窗口大小字段是 $w$ ,窗口扩大因子是 $s$ ,那么实际的窗口大小是 $w \times 2 ^ s$ 。

前面例子选项中的窗口扩大因子值为 7 ,若窗口字段取最大值 65535 ,则意味着实际最大窗口大小可达:$65536 \times 2 ^ 7 = 8388480$ ,大约是 8MB 。一般而言,窗口扩大因子在三次握手阶段通告对端后就保持不变。例子中的扩大因子,在连接的整个生命周期,均保持 $2^7=128$ 倍不变。

复位分组

如果我们连接一个没有开放的端口,会怎样呢?实验中的服务器没有监听 23 端口,我们尝试在客户机连接试试:

1
2
3
root@client [ ~ ]  ➜ telnet 10.0.0.2 23
Trying 10.0.0.2...
telnet: Unable to connect to remote host: Connection refused

telnet 输出表明我们无法连接到远程主机,因为连接被服务器拒绝。与此同时,我们可以通过 tcpdump 命令观察到通信过程:

1
2
3
4
5
root@client [ ~ ]  ➜ tcpdump -ni eth0 tcp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
08:10:35.360244 IP 10.0.0.3.47428 > 10.0.0.2.23: Flags [S], seq 3994748338, win 29200, options [mss 1460,sackOK,TS val 2558252100 ecr 0,nop,wscale 7], length 0
08:10:35.360621 IP 10.0.0.2.23 > 10.0.0.3.47428: Flags [R.], seq 0, ack 3994748339, win 0, length 0

同样,客户机先向服务器发出一个 SYN 分组,请求同步序号以便建立连接。由于服务器没有监听 23 端口,它向客户机回复一个 复位( RESET )分组,让客户机主动放弃连接。

复位分组是设置了 RESET 标志位的 TCP 分组,一般用来告诉对端:连接因严重错误而复位。

如果 TCP 客户端连接了不可达的端口,服务端便通过 RESET 分组来报错,这点跟 UDP 不太一样。由于 UDP 头部简单得连错误字段都没有,UDP 服务端只能借助于 ICMP 端口不可达差错。

此外,当 TCP 连接存在严重错误时,也可以通过 RESET 分组通知对方立即放弃连接。有些网络防火墙,也利用到 RESET 分组来干掉 TCP 连接。具体细节不能再细说了,哈哈~

扩展阅读

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: