13.1 引言

TCP是一种面向连接的单播协议,通信双方向对方发送数据前必须建立连接。本章将详细研究TCP连接的概念,它是如何建立以及如何终止的。回想一下,TCP服务模型是字节流,它需要检测并修复IP层(或更低层)丢包、重复或差错引起的所有数据传输问题。

由于需要对连接状态(通信双方都需要保存的连接信息)进行管理,TCP是一个比UDP(见第10章)复杂得多的协议。 UDP是一种无连接的协议,不涉及连接的建立或终止,二者之间的主要区别之一是正确处理各种TCP状态细节所需要的工作量:当连接建立时、当连接正常终止时和没有警告的情况下连接重置时。在其它章节中,我们将探讨建立连接并开始传输数据后会发生什么。

在连接建立的过程中,通信双方需要交换有关连接参数的选项。有些选项只允许在连接建立时发送,其它选项可以稍后发送。回顾第12章,TCP首部有一个有限的空间(40字节)用来保存选项。

13.2 TCP连接的建立与终止

TCP连接是由两个IP地址和两个端口号构成的4元组。更准确地说,它是由一对端点或套接字构成的,其中每个端点由一对(IP地址、端口号)标识。

一个TCP连接通常会经历3个阶段:建立、数据传输(称为“连接已建立”)和拆除(关

闭)。正如我们将看到的,创建一个健壮的TCP实现的困难在于正确处理这些阶段之间的所有转换。典型的TCP连接建立和关闭(没有任何数据传输)过程如图13-1所示。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(1)

图 13‑1 一个正常的TCP连接建立和终止。通常由客户端发起三次握手,并在SYN报文段中交换客户端和服务器的初始序号(ISN(c)和ISN(s))。每一端发送FIN并收到了对FIN的确认后连接终止。

图13-1所示的时序图展示了连接建立过程中所发生的事情。为了建立一个TCP连接,通常会发生以下事件:

1. 主动打开者(通常称为客户端)发送一个SYN报文段(即TCP首部SYN位打开的TCP/IP分组),此报文段指定了它想要连接的对方端口和客户端初始序号(ISN(c),见13.2.3节),报文段中还会携带一个或多个选项,这个SYN报文段我们称为段1。

2. 服务器发送自已的SYN报文段作为响应,并包含了自己的初始序号ISN(s),这个SYN报文段我们称为段2。服务器通过ISN(c) 1的确认号来对客户端的SYN进行确认。SYN报文段会消耗一个序号,如果丢失则重传。

3. 客户端通过ISN(s) 1的确认号来对服务器的SYN进行确认,这个段我们称为段3。

这3个报文段完成了连接的建立,通常被称为三次握手。三次握手的主要目的是让连接的每个端点知道连接正在建立及选项携带的细节信息,并交换初始序号(ISN)。

发送第一个SYN的一方执行的主动打开,如前所述,它通常是客户端。接收第一个SYN并发送第二个SYN的一端执行的被动打开,它通常被称为服务器。(13.2.2节描述了协议支持但不常见的同时打开,即双方可以同时执行主动打开,同时成为客户端和服务器)

注意

TCP的SYN段也能传输应用程序数据,然而很少使用,因为伯克利套接字API不支持。

图13-1还展示了TCP连接是如何关闭的(也称为清除或终止)。连接的任何一方都能够发起关闭操作,TCP还支持同时关闭,但这种情况非常少见。习惯上通常由客户端发起关闭操作(如图13-1所示),然而,有些服务器(如Web服务器)在完成客户端请求后也会发起关闭操作。通常应用程序发起的关闭操作表示应用程序想要终止连接(例如使用close()系统调用),即将关闭的一方通过发送一个FIN报文段(即设置了FIN位的TCP报文段)来发起关闭操作,当双方完成关闭操作后连接完全关闭。

1. 主动关闭者发送一个FIN报文段,并带有接收方希望看到的当前序号(图13-1中的K),这个报文段还包含了对反方向上最近发来的数据的确认(图13-1中的L)。

2. 被动关闭者使用确认号为K 1的响应报文段表明它成功接收到了主动关闭者的FIN报文段。此时,应用程序被告知连接的另一端已经执行了关闭操作,这通常会导致应用程序发起自己的关闭操作,被动关闭者实际上将变为主动关闭者并发送自己的FIN,访报文段序号为L。

3. 为了完成连接的关闭,最后一个报文段包含了对上一个FIN的确认。注意,如果一个FIN丢失,就会重传直接收到FIN的确认。

建立一个连接需要3个报文段,而关闭一个连接需要4个报文段。尽管并不常见,但连接可以处于半打开状态(见13.6.3节),这是因为TCP的通信模型是双向的,这意味着两个方向上可能只有一个方向进行数据传输。 TCP的半关闭操作只关闭数据流的一个传输方向,两个半关闭合在一起才能够关闭整个连接。通信的任何一方发送完数据后都可以发送FIN,当TCP收到这个FIN时,就必须通知应用程序另一方已经终止了那个方向上的数据传输。发送FIN通常是应用程序调用了close操作,该操作通常会导致两个方向上的数据传输都关闭。

7个报文段是任何“优雅地”建立和关闭TCP连接的基本开销(还有更突然的关闭TCP连接的方法,使用reset报文段,我们稍后会讲到)。当需要交换少量数据时,这就是为什么一些应用程序更愿意使用UDP的原因,因为发送与接收数据不需要建立连接。然而,这些应用程序将面临自己处理错误恢复、拥塞管理和流量控制。

13.2.1 TCP半关闭

正如我们提到的, TCP支持半关闭操作。很少有应用程序需要这种功能,所以它并不常见。要使用该功能,API必须向应用程序提供一种方法,即“我已发送完数据,所以向另一端发送一个FIN,但是我仍然希望从另一端接收数据,直到它向我发送FIN为止”。伯克利套接字API支持半关闭,应用程序调用shutdown()函数而不是通常的close()函数即可。然而,大多数应用程序通过调用close来终止连接的两个方向。图13-2展示了一个正在使用的半关闭示例,左侧的客户端发起了半关闭,但两端都可以这样做。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(2)

图 13‑2 使用TCP半关闭操作,连接的一个方向关闭,另一个方向仍可传输数据直到关闭为止。很少有应用程序使用这个特性

译者注:服务器已发过数据,所以服务器发起的FIN段中序号不可能再为L。

前两个报文段与正常关闭相同:发起者发送FIN,接着是接收者对FIN的ACK。后面的操作不同于图13-1,因为接收到半关闭的一方仍然可以发送数据。我们只展示了一个数据段和紧随其后的ACK,实际上可以发送任意数量的数据段(我们将在第15章详细讨论数据段交换和确认)。接收到半关闭的一方发送完数据后,关闭本端连接,导致发送FIN,这将向发起半关闭连接的应用程序传递文件结束符。这个FIN被确认后,连接完全关闭。

13.2.2 同时打开与关闭

尽管可能性非常小,但在特定安排下两个应用程序同时执行主动打开是可能的。通信双方在接收到对方的SYN之前必须先发送一个SYN ,两个SYN经过网络送达对方。该场景还要求通信双方都有一个对方知道的IP地址和端口号,这种情况很少见(我们在第7章看到的防火墙“打孔”技术除外)。如果发生了这种情况,就称为同时打开。

例如,主机A的一个应用程序使用本地的7777端口向主机B的8888端口发送一个主动打开请求,同时主机B的一个应用程序使用本地的8888端口向主机A的7777端口发送一个主动打开请求,此时就会发生同时打开的情况。这种情况不同于主机A的一个客户端连接主机B的服务器,而同时又有主机B的一个客户端连接主机A的服务器。在这种情况下,服务器是被动打开者而非主动打开者,客户端会被分配不同的临时端口号,这将导致形成两个不同的TCP连接。图13-3展示了同时打开过程中交换的报文段。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(3)

图 13‑3 同时打开时交换的报文段。与正常的连接建立过程相比,需要一个额外的报文段。每个段中的SYN都是打开的,直到收到报文段的ACK为止。

同时打开需要交换4个报文段,比正常的三次握手多一个报文段。注意,我们并没有将任何一端称为客户端或服务器,因为两端都扮演了客户端与服务器的角色。同时关闭并没有太大区别,我们前面说过,一方(通常是客户端,但不总是)执行主动关闭,导致发送第一个FIN。在同时关闭时,双方都会执行主动关闭。图13-4展示了同时关闭过程中交换的报文段。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(4)

图 13‑4 同时关闭过程交换的报文段,与正常关闭类似,只是报文段顺序是交叉的

同时关闭交换的报文段与正常关闭交换的报文段数量一样。真正唯一的区别在于报文段序列是交叉的而不是顺序的。稍后,我们将看到同时打开和关闭使用了TCP实现中不常用的特殊状态。

13.2.3 初始序号(ISN)

一个连接打开后,序号有效(即在窗口内)且校验和正确,具有恰当的两个IP和端口号的任何段都被视为有效。这就带来了一个问题:通过网络路由的TCP段是否可能稍后出现并打乱另一个连接。这这个问题可以通过仔细选择ISN来解决,我们现在对此进行研究。

每一端在发送SYN来建立连接之前,它需要为该连接选择一个ISN。ISN应随着时间而变化,以便每个连接都有一个不同的ISN。[RFC0793]指出应将ISN视为每4us递增1的32位计数器。这样做的目的是避免一个连接上的序号与另一个(新的)连接的序号重叠。特别地,相同连接的不同实例(化身)的序号不允许重叠。

当我们回忆起TCP连接是由一对端点标识的,它创建了由两个地址/端口对组成的4元组,同一连接不同实例的概念就很清楚了。如果一个连接的某个报文段在网络中滞留了很长时间并且连接已关闭,但随后又以相同的4元组再次打开,可以想象,延迟的报文段可以作为有效数据重新进入新连接的数据流,这个问题比较棘手。采取一些措施避免连接的不同实例间序号重叠,可以将风险降至最低。对数据完整性有较高要求的应用程序应该在应用层使用CRC或校验和来保证传输的数据没有错误。在任何情况下这都是一种很好的方法,并已普遍用于大文件的传输。

正如我们将看到的,知道连接的4元组以及当前活动窗口序号是构建被认为对TCP端点有效的TCP段所需要的全部,这也说明了TCP的脆弱性:任何人都可以伪造出一个TCP报文段,如果序号、IP地址和端口选择恰当,就可以打断TCP连接[RFC5961]。防止这种情况的一种方法是使初始序号(或临时端口号[RFC6056])相对难以猜测,另一种方法是加密(见第18章)。

现代系统通常采用半随机的方法选择初始序号,CERT Advisory CA-2001-09 [CERTISN]中包含了正确处理这个细节的有趣讨论。Linux系统使用一个相当复杂的过程来选择它的ISN,它使用基于时钟的方案,并且时钟在每个连接的ISN中的偏移量是随机的,随机偏移量是在连接标识(4元组)上进行加密散列得到,散列函数的输入参数secret每隔5分钟就会更改一次。在32位的ISN中,最高8位是保密序号,其余位由哈希函数生成,这会生成一个很难猜测ISN,并且会随着时间而增加。据报道,Windows使用了基于RC4[S94]的类似方案。

13.2.4 例子

我们对TCP连接如何建立和关闭有了基本了解,现在来看看数据包级别的细节。为此,我们与附近运行在IPv4地址为10.0.0.2的Web服务器建立一个TCP连接,客户端是Windows上的Telent应用。

C:\> Telnet 10.0.0.2 80

Welcome to Microsoft Telnet Client

Escape Character is 'CTRL ]'

... wait about 4.4 seconds ...

Microsoft Telnet> quit

telnet命令与IPv4地址为10.0.0.2端口为http或Web服务(80端口)的主机建立TCP连接。当Telenet程序连接到23(Telnet协议知名端口[RFC0854])以外的端口时,它不参与应用程序协议解析。相反,它仅仅将字节流从输入复制到TCP连接,反之亦然。当Web服务器收到到来的连接请求时,它做的第一件事就是等待对Web页面的请求。在本例中,我们没有提供Web请求,所以服务器不会产生任何数据。这正好符合我们的期望,因为我们只对连接建立和终止过程中的数据包交换感兴趣。该命令产生的报文段在Wireshark中解析结果如图13-5所示。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(5)

图 13‑5 192.168.35.130与10.0.0.2建立TCP连接,未发送任何数据并关闭连接。PSH位说明第6个报文段将缓冲区(现在为空)的数据已全部发送完

在图中,我们可以看到客户端以一个SYN报文段开始,ISN的值为685506836,窗口通告的值65535,这个报文段还包含了第13.3节中讨论的几个选项。第二个报文段既是服务器的SYN也是对客户端的ACK,序号(服务器的ISN)为1479690171,确认号为685506837,比客户端的ISN大1,这表明服务器成功接收了客户端的ISN。这个报文段也包括一个窗口通告,表明服务器愿意接收最多64240字节。报文段3完成了三次握手,该段包含确认号为1479690171。确认号是累积的,并且总是表示确认号发送方希望看到的下一个序号(而不是它最后接收到的序号)。

暂停大约4.4秒之后,Tehet应用程序关闭连接。这导致客户端的TCP发送第4个报文段FIN,序号为685506837,并由第5个报文段确认(确认号为685506838)。不久之后,服务器发送了自己的序号为1479690172的FIN,这个段对客户端的FIN进行了再次确认。注意PSH位是打开的,这对关闭连接没有影响,但通常表明服务器已没有额外的数据要发送。最后一个段对服务器的FIN进行确认,确认号为1479690173。

注意

[RFC1025]将具有最大数量特性(例如标志和选项)的报文段称为“神风(Kamikaze)”段,其它有趣的称呼还有“圣诞树分组(nastygram)”、“圣诞树包(Christmas tree packet)”、“灯测试报文段(lamp test segment)”。

从图13-5中我们可以看到SYN报文段包含了一个或多个选项,这些选项将占用TCP首部额外的空间。例如,第一个TCP首部的长度为44字节,比最小的长度大24字节。TCP还有一些支持的选项,我们看下无法建立连接会发生什么后再详细介绍这些选项。

13.2.5 建立连接超时

有几种情况连接不能建立,一个明显的例子是服务器主机宕机。为模拟这个场景,我们telnet同一子网中不存在的主机。如果在不修改ARP表的情况下这样做,客户端会报“No route to host”并退出,这是因为ARP请求没有ARP应答(见第4章)。如果我们我们先将ARP表中的项置为一个不存在的主机,系统就不再发ARP请求,而是立即尝试使用TCP/IP与不存在的主机建立连接。首先,命令如下:

Linux# arp -s 192.168.10.180 00:00:1a:1b:1c:1d

Linux% date; telnet 192.168.10.180 80; date

Tue June 7 21:16:34 PDT 2009

Trying 192.168.10.180...

telnet: connect to address 192.168.10.180: Connection timed out

Tue June 7 21:19:43 PDT 2009

Linux%

这里简单的选择局域网中未使用的MAC地址00:00:1a:1b:1c:1d作为MAC地址,并没有其它特别的意义。第一条命令执行后过了约3.2分钟超时,因为没有主机进行响应,所以所有的报文段都是由客户端产生的。Wireshark数据包摘要(文本)模式输出信息如清单13-1所示。

清单 13‑1 连接超时时Wireshark输出

No.

Time

Source

Destination

Protocol

Info

1

0.000000

192.168.10.144

192.168.10.180

TCP

32787 > http

2

2.997928

192.168.10.144

192.168.10.180

TCP

32787 > http

3

8.997962

192.168.10.144

192.168.10.180

TCP

32787 > http

4

20.997942

192.168.10.144

192.168.10.180

TCP

32787 > http

5

44.997936

192.168.10.144

192.168.10.180

TCP

32787 > http

6

92.997937

192.168.10.144

192.168.10.180

TCP

32787 > http

这个输出中有趣的一点是,客户端发送SYN以尝试建立连接的频率有多高。第2个报文段在第1个报文段3秒后发送,第3个报文段在第2个报文段6秒后发送,第4个报文段在第3个报文段12秒后发送,以此类推,这种行为被称为指数回退。很像之前我们讨论过的以太网CSMA/CD介质访问控制协议(见第3章),不过有一些不同。这里每次的回退值是上次回退值的两倍,然而在以太网中,每次回退值是随机选择的,最大是上一次的两倍,

有些系统上初始SYN段的重试次数是可以配置的,并且通常是一个相对较小值,比如5。

在Linux系统中,系统变量net.ipv4.tcp_syn_retries给出了主动打开时SYN报文段尝试重新发送的最大次数。另一个相关的变量net.ipv4.tcp_synack_retries给出了响应对方主动打开请求时尝试重新发送SYN ACK报文段的最大次数。还可以在单个连接上设置Linux特有的TCP_SYNCNT选项,,它的默认值为5次重试。这些重传之间的指数回退时间是TCP拥塞管理响应的一部分。我们将在讨论Karn算法时详细研究它(见第16章)。

13.2.6 连接与转换器

在第7章中,我们讨论了传统NAT如何转换TCP和UDP等协议使用的地址和端口号,我们还研究了IP数据包如何在IPv6和IPv4之间转换。当NAT与TCP一起使用时,伪首部校验和通常需要调整(除非使用校验和中立的地址修饰符[h1] )。对于其他使用伪首部校验和的协议也是如此,因为计算涉及到传输层和网络层的信息。

当TCP连接建立时,NAT(或NAT64)可以探测出,因为报文段存在SYN位。它还可以通过查找包含适当序号的后续SYN ACK和ACK报文段来判断连接何时完全建立。这种方法同样适用于连接的终止。通过在NAT中实现TCP状态机的一部分(例如,参见RFC6146的3.5.2.1和3.5.2.2节),可以跟踪连接,包括当前状态、每个方向的序列号和相应的ACK号,这种状态跟踪对于NAT实现来说是比较典型的。

当NAT作为编辑者并重写传输协议数据中有效载荷的内容时,会出现更复杂的情况。对于TCP,这可能涉及到在数据流中删除或添加字节,从而影响序列号(和段)长度,这种做法也会影响校验和数据顺序。当NAT在数据流中插入或删除数据时,这些值需要适当调整。这样做有点不灵活,因为如果NAT状态与终端主机的状态不同步,连接将不能正常运行。

13.3 TCP选项

TCP首部可以包含选项(见图12-3)。原始TCP规范中定义的仅有的选项是选项列表结束(EOL,End of Option List)、无操作(NOP ,No Operation)和最大段大小(MSS,Maximum Segment Size)选项。从那时起,又定义了若干选项。整个选项列表由IANA [TPARAMS]维护。表13-1给出了当前感兴趣的选项(即那些具有标准跟踪的RFC描述的选项)。

表格 13‑1 TCP选项值。最多40个字节可用于保存选项。

种类(kind)

长度

名称

参考

描述和目的

0

1

EOL

[RFC0793]

选项列表结束

1

1

NOP

[RFC0793]

无操作(用于填充)

2

4

MSS

[RFC0793]

最大段大小

3

3

WSOPT

[RFC0793]

窗口缩放因子(窗口左移量)

4

2

SACK-Permitted

[RFC2018]

发送方支持SACK选项

5

可变

SACK

[RFC2018]

SACK块(接收到的乱序数据)

8

10

TSOPT

[RFC1323]

时间戳选项

28

4

UTO

[RFC5482]

用户超时(空闲时间后终止)

29

可变

TCP-AO

[RFC5925]

认证选项(使用各种算法)

253

可变

实验

实验

留作实验用

254

可变

实验

实验

留作实验用

每个选项以一个字节的“种类”(kind)开始,它指定了选项的类型。根据[RFC1122],不能被理解的选项会被简单地忽略。种类为0或1的选项占一个字节,其它选项在种类后面有一个长度,长度指的是总长度,包括种类和长度所占字节。之所以有NOP选项是因为允许发送方在需要时将字段填充为4个字节的倍数。需要记住的是TCP首部长度始终为32位的倍数,因为TCP首部长度字段使用此单位。EOL选项表示选项列表的结束,不再对选项列表执行进一步的处理。现在我们来看看其余的选项。

13.3.1 最大段大小(MSS)选项

最大段大小(MSS)是TCP愿意从对方接收的最大段,因此也是对方发送时曾经使用过的最大大小。MSS值只包括TCP数据,不包括相关的TCP首部或IP首部大小[RFC0879]。当一个连接建立时,每一方通常在SYN报文段携带的MSS选项中说明它的最大段大小。这个选项使用16位来指定MSS的值,如果没有提供MSS选项,则使用默认值536字节。任何主机至少能够处理不大于576的IPv4数据报。TCP使用发送的最大段大小为536字节时,并使用最小的IPv4和TCP首部所产生的IPv4数据报大小为20 20 536 = 576字节。

图13-5中的MSS值均为1460,这是IPv4的典型值。产生的IPv4数据报通常要大40个字节(总共1500个字节,典型的以太网MTU大小和Internet路径MTU大小):20字节的TCP首部和20字节IPv4首部。使用IPv6时MSS通常是1440,比1460少20字节是因为IPv6的首部比IPv4多20个字节。在IPv6巨型数据报(jumbograms)中使用65535这个特殊的MSS值表示有效段大小为无穷大[RFC2675],在这种情况下,发送方最大段大小(SMSS)等于路径MTU(PMTU)减去60字节(IPv6首部40字节,TCP首部20字节)。注意,MSS选项不是通信双方协商,而是一个极限值。当通信一方将自己的MSS告诉对方时,它表示在连接期间不愿意接受任何超过该大小的报文段。

13.3.2 选择确认选项(SACK)

在第12章,我们介绍了滑动窗口的概念,并描述了TCP如何处理它的序号和确认。因为它使用累积确认,所以TCP无法确认已正确接收但序号与之前并不连续的数据。在这种情况下,TCP接收方的数据队列中会出现空洞。因为TCP提供了字节流抽象,所以接收方TCP阻止应用程序使用超出空洞的数据。

如果TCP发送端知道接收端存在空洞(以及序号空间中超出空洞的乱序数据块),那么当接收端丢包或其他情况下丢包时,它可以更好地选择要重新发送哪些特定的TCP段。TCP选择确认(SACK)选项[RFC2018][RFC2883]提供了这种功能。但是,只有当TCP发送方能够有效地利用从接收方(具有SACK能力)收到的SACK信息时,该方案才能有效地工作。

TCP通过SYN(或SYN ACK)段中的SACK- Permitted选项知道对方能够发送SACK信息。TCP接收到乱序数据就会使用SACK选项来描述这些乱序的数据,从而帮助对方有效地进行重传。SACK选项中的SACK信息由一系列序号范围(表示接收方已成功接收到的数据块)组成,每个范围被称为一个SACK块,由一对32位的序号表示。因此,包含n个SACK块的SACK选项的长度为(8n 2)字节,2个字节用来保存SACK选项的类型和长度。

由于TCP首部选项的空间是有限的,因此一个报文段中发送的SACK块数最大为3(假设也使用了13.3.4节所描述的时间戳选项,这是典型的现代TCP实现)。尽管SACK- Permitted选项只能在SYN段中发送,一旦发送方发送了该选项,SACK块就可以在任何报文段中发送。由于SACK操作最容易(也是最重要的)且与TCP的错误和拥塞控制有关,我们在第14章和第16章讨论这些主题时将对其进行更详细的讨论。

13.3.3 窗口缩放(WSCALE或WSOPT)选项

窗口缩放选项(记为WSCALE或WSOPT)[RFC1323]能够将TCP窗口通告从16位增加到大约30位。然而这并不是通过改变窗口通告字段域长度实现的,首部保存的仍然为一个16位的窗口通告值,而是定义了一个选项对这个16位的值进行缩放。这个比例因子能够将窗口通告值左移,实际上是将窗口值乘以2s,其中s是缩放因子。1个字节表示的左移位数取值为0到14(含)。左移位数0表示无缩放。最大缩放值14提供的最大窗口为1073725440字节(65535×214),接近1073741823(230−1),实际上为1GB。TCP实现在内部使用一个32位的值维护“真实”窗口大小。

这个选项只能出现在SYN报文段中,所以当连接建立时,每个方向的缩放因子都确定了。要启用窗口缩放,通信双方必须在各自的SYN段中发送该选项。执行主动打开的一方在其SYN中发送该选项,但执行被动打开的一方只有在收到的SYN中有该选项时才能发送该选项。每个方向的缩放因子可以不同。如果执行主动打开的一方发送了一个非零缩放因子,但没有从对方收到窗口缩放选项,则它将自己的发送和接收缩放因子设置为0,这样可让不理解该选项的系统与理解该选项的系统能够进行互操作。

假设我们正在使用窗口缩放选项,发送的窗口左移位数为S,接收的窗口左移位数为R。则每次从对方接收到的16位通告窗口左移R位才能得到真实的通告窗口大小,每次向对方发送通告窗口时,需要将实际的32位窗口大小右移S位,然后将16位的结果值放到TCP首部。

左移位数是TCP根据接收缓存的大小自动选取的,而缓存的大小是由系统设置的,但应用程序通常可以设置其缓存大小。具有较大的带宽时延积网络使用TCP传输大量数据时窗口缩放选项比较有意义。因此,我们在第16章进一步讨论该选项的重要性与使用。

13.3.4 时间戳选项与防序号回绕(PAWS)

时间戳选项(记作TSOPT或Tsopt)要求发送方在每一个报文段中添加两个4字节的时间戳值,接收方在确认中回传这些数值,这样允许发送方用收到的每一个ACK来估计连接的RTT(我们说“收到的每一个ACK”而不是“每个报文段”,是因为TCP每个ACK通常确认多个报文段,我们将在第15章看到)。当使用时间戳选项时,发送方在时间戳选项第一部分(时间戳值域,Timestamp Value field)填充一个32位的值(称为TSV或Tsval),而接收方将在时间戳选项的第二部分(时间戳回传域,Timestamp Echo Retry field,称为TSER或TSecr)原样不动的返回时间戳值。包含此选项的TCP首部会增加10个字节(8个字节表示两个时间戳值,2个字节表示种类和长度)。

时间戳是一个单调递增的值。由于接收方只是回显接收到的数值,所以它并不关心时间戳值的单位或数值实际上是什么。该选项不需要两台主机之间进行任何形式的时钟同步。 [RFC1323]建议发送方时间戳值每秒钟至少增加1。图13-6显示了时间戳选项,如Wireshark软件中所示。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(6)

图 13‑6 使用时间戳选项、窗口缩放选项和MSS选项的TCP连接。TCP首部长度为44字节。初始SYN(包1)TSV为81813090。高亮显示的第二个包将这个值回传给主动打开连接的一方,并包含了它自己的时间戳值349742014。

在这里,双方都生成并回传了对方的时间戳。第一个报文段(客户端的SYN)使用初始时间戳值81813090,这个值放在TSV中。由于客户端还不知道服务器的时间戳值,所以该报文段的TSER的值为0。

计算连接的RTT良好估计的主要原因是设置重传超时时间,重传超时时间告诉TCP什么时候应该尝试重传可能丢失的段。在第12章,我们讨论了根据RTT的某些功能来设置此超时的必要性,使用时间戳选项,我们可以获得相对细粒度的RTT测量值。在使用时间戳选项之前,大多数TCP对每个窗口的数据只取一个RTT样本,使用时间戳选项,可以取更多的样本,从而获得更好的RTT估计(参见[RFC1323]和[RFC6298])。

由于时间戳选项与重传计时器的设置紧密相关,所以我们将在第14章讨论重传时更详细地讨论它的用途。我们之所以说“它的用途”是因为时间戳选项除了能得到更频繁的RTT样本外,它也为接收者提供了一种避免接收到认为有效的旧报文段的方法,这被称为防序列号回绕(PAWS),在[RFC1323]时间戳选项中有描述。现在我们来看看它是如何工作的。

考虑一个使用窗口缩放选项并具有最大的窗口的TCP连接,大约1GB(230),再假设使用了时间戳选项且发送者每个发送窗口的时间戳值加1(这一假设是比较保守的,通常时间戳的增量要比这个快)。表13-2显示了两台主机传输6GB数据时可能的数据流主,为了避免大量的十进制数字,我们用G表示1073741824的倍数,我们还使用tcpdump中的符号,即J:K表示从第J到第K-1(含)个字节。

表格 13‑2 TCP时间戳选项通过提供一个额外的32位有效序号空间来消除了具有相同序号的报文段之间的二义性

时间

发送字节

发送序号

发送时间戳

接收

A

0G:1G

0G:1G

1

完好

B

1G:2G

1G:2G

2

完好,但一个报文段丢失并重传

C

2G:3G

2G:3G

3

完好

D

3G:4G

3G:4G

3

完好

E

4G:5G

0G:1G

4

完好

F

5G:6G

1G:2G

5

完好,但重传的报文段出现

32位序号字段在时间D和E之间回绕。假设一个报文段在时刻B丢失并被重传,我们还假设丢失的报文段在时刻F重新出现。假设丢失的报文段和重新出现的报文段之间的时间差小于一个报文段在网络中可以存在的最大时间(称为MSL,参见13.5.2),否则,当TTL过期时,该报文段将被路由器丢弃。正如我们前面提到的,只有在相对高速的连接中才会出现这个问题(旧的报文段重新出现并包含当前正在传输的序号)。

从表13-2中看到,使用时间戳选项可以防止这个问题。接收方认为时间戳是序号的32位扩展。因为在时刻F重新出现的丢失报文段的时间戳为2,小于最近的有效时间戳(5或6),所以PAWS算法会将其丢弃。PAWS算法不需要在发送方和接收方之间进行任何形式的时钟同步。接收方所要求的只是时间戳值单调地增加,并且每个数据窗口至少增加1。

13.3.5 用户超时(UTO)选项

[RFC5482]中描述的用户超时(UTO)选项是一个相对较新的TCP功能。UTO值(也称为USER_TIMEOUT)指定了TCP发送方在断定对方失败之前愿意等待在途数据的ACK的时间,根据TCP [RFC0793],USER_TIMEOUT一般是一个本地配置参数。UTO选项允许TCP将其USER_TIMEOUT值发给对方,以便TCP接收方调整其行为(例如,在终止连接之前容忍较长时间的连接中断),NAT设备也能理解这些信息以帮助设置它们自己的连接活动计时器。

UTO选项值是建议性的,连接的一端可能希望使用较大或较小的UTO值,并不意味着另一端必须遵守。[RFC1122]细化了USER_TIMEOUT的定义,建议TCP达到3次(R1)重传的阈值时应该通知应用程序,并在100秒(R2)后关闭连接。一些实现提供API函数来更改R1和R2。由于较长的UTO可能导致资源耗尽,而较短的UTO可能导致连接提前断开(一种DoS攻击),所以对UTO的值设置了上限和下限。设置USER_TIMEOUT的方法如下:

USER_TIMEOUT = min(U_LIMIT, max(ADV_UTO, REMOTE_UTO, L_LIMIT))

其中ADV_UTO是本端通告给对方的UTO值,REMOTE_UTO是对方通告给本端UTO值,U_LIMIT是本地系统UTO值的上限,L_LIMIT是本地系统UTO值的下限。注意,这个公式并不保证同一连接两端获得相同的USER_TIMEOUT值。在任何情况下, L_LIMIT的值必须大于对应连接的重传超时(RTO)值(见第14章),建议设置为100秒,以保持与[RFCl122]兼容。

建立连接的SYN报文段、USER_TIMEOUT值发生改变的首个非SYN段都会包含UTO选项。该选项值大小使用15位来表示,并由紧跟的1位(“粒度”)位字段来说明单位是分(1)还是秒(0)。因为是一个相对较新的选项,它还没有得到广泛使用。

13.3.6 认证选项(TCP-AO)

TCP认证选项(TCP-AO)[RFC5925]用于增强TCP连接的安全性,它被设计用来增强和取代早期的TCP-MD5机制[RFC2385]。它使用了一种加密散列算法(见第18章)并结合TCP双方都知道的密钥(secret)来认证每一个报文段。TCP-AO通过支持多种加密算法和使用带内信令识别密钥的变化对TCP-MD5进行了改进。但是,它并没有提供全面的密钥管理解决方案。也就是说,每个端在操作之前仍然必须有一种方法来建立共享密钥集。

当发送数据时,TCP从共享密钥中获取一个通信密钥,并根据特定的加密算法计算散列值[RFC5926]。拥有相同密钥的接收方以同样的方式获取通信密钥,并使用它来确保到达的报文段在传输中没有被修改(具有很高的概率)。这个选项是针对各种TCP欺骗攻击强有力的抵御策略(见第13.8节)。但因为它需要创建和分发共享密钥(这是一个相对较新的选项),所以还没有得到广泛应用。

13.4 TCP的路径最大传输单元(Path MTU)发现

在第3章中,我们介绍了路径MTU的概念,它是两台主机间路径所有网段的MTU的最小值。知道路径MTU有助于TCP等协议避免分片。在第10章中,我们介绍了路径MTU (PMTUD)发现是如何基于ICMP消息完现的,但在那种情况下,UDP通常无法调整其数据报大小,因为应用程序指定了数据报大小(而不是传输层协议)。TCP在提供实现的字节流抽象时,决定了要使用的段大小,因此对最终生成的IP数据报的大小有更大程度的控制权。

本节将研究TCP如何使用PMTUD,我们的讨论适用于TCP/IPv4和TCP/IPv6,更多细节见[RFC1191]和[RFC1981]。分组层路径最大传输单元发现(Packetization Layer Path MTU Discovery,PLPMTUD)方法避免了对ICMP的使用,可被用于TCP[RFC4821]或其他传输层协议。我们使用ICMPv6数据包太大(PTB)术语代表ICMPv4目的地不可达(需要分片)或ICMPv6数据包太大的消息。

TCP的常规PMTUD处理过程如下:当连接建立时,TCP使用出接口MTU中最小的一个或对方通告的MSS作为选择发送方最大段大小(SMSS)的依据。PMTUD不允许TCP超过对方通告的MSS,如果对方没有指定MSS,发送方采用默认的536字节,但这种情况现在很少见。实现也可以保存每个目的地的路径MTU信息以帮助它选择段大小。注意,连接的两个方向上的路径MTU可能是不同的。

一旦选择了初始的SMSS,TCP通过此连接发送的所有IPv4数据报会将DF位字段置为1。对于TCP/IPv6来说这是没必要的,因为没有DF位字段,假设所有的数据报都隐式地设置了它。如果收到了PTB消息,TCP会减小段大小并使用新的段大小进行重传。如果PTB中包含了建议下一跳MTU,则可以将段大小设置为下一跳MTU减去IPv4(或IPv6)及TCP首部大小。如果下一跳MTU不存在(例如,返回的为旧版本的ICMP错误消息缺少此信息),发送方可以尝试不同的值(例如,二分查找法选择一个可用的值),这也会影响到TCP的拥塞控制管理(见第16章)。对于PLPMTUD,除了没有使用PTB消息外其它情况类似。执行PMTUD的协议必须能够快速检测到消息丢弃并对自己的数据报大小进行调整。

因为路由是动态变化的,所以距离上次减小段大小一段时间后,可以尝试更大的值(直到初始的SMSS)。[RFC1191]和[RFC1981]建议该时间间隔为10分钟左右。

在有阻塞PTB消息的防火墙的互联网环境中,PMTUD运行时会有许多问题。在PMTUD的各种运行问题中,尽管情况正在改善(在[LS10]中,被调查的80%的系统能够正确处理PTB消息),黑洞问题仍悬而未决。如果TCP实现依赖ICMP消息传递来调整段大小但从来没有收到它们就会出现PMTUD黑洞问题。这可能有几个原因,包括防火墙或NAT配置禁止转发此类ICMP消息。造成的结果是TCP连接一旦开始使用较大的数据包就无法处理,这可能很难诊断,因为只有大的数据包不能转发,较小的报文(如建立连接时使用的SYN报文、SYN ACK报文)一般都能成功。一些TCP实现有“黑洞检测”功能,当一个报文段被重传几次后将尝试较小的报文段。

13.4.1 例子

当中间路由器的MTU小于任一端的MSS时,我们可以看到PMTUD的调整行为。为了创造这一场景,我们使用一台PPPoE接口连接到DSL服务提供商的路由器(本地地址为10.0.0.1的Linux主机),PPPoE链路使用的MTU为1492(以太网MTU为1500,减去6字节PPPoE开销,减去2字节PPP开销;见第3章)。拓扑结构如图13-7所示。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(7)

图 13‑7 PPPoE封装使TCP路径MTU从1500字节(以太网的典型MTU)减少至1492字节。为了演示TCP对PMTUD使用,我们将MTU设置的更小(288字节)。

为了产生这种行为,我们将PPPoE链路上的MTU大小从1492减少到288字节。在网关主机上,执行如下命令完成此任务:

Linux(GW)# ifconfig ppp0 mtu 288

另外,我们需要设置客户端系统(C)允许小的报文段:

Linux(C)# sysctl -w net.ipv4.route.min_pmtu=68

如果不执行第二个操作,Linux会把它的最小路径MTU设置为默认值552字节以避免某些较小的MTU攻击(见13.8节)。本例中我们这样做的结果是,任何大于288字节的数据包都将被分片。为了避免分片并更好的演示PMTUD,我们删除了522这个最小值。我们通过因特网从主机C(地址10.0.0.123)向服务器S(地址169.229.62.97)传输文件。清单13-2显示了tcpdump对报文交换过程的追踪结果。为了清晰起见,有些行做了换行,并删除了无关的字段。

清单 13‑2 当中间链路的MTU比端点的MTU小时,路径MTU发现机制会找到一个合适的段大小来使用。

1

20:20:21.992721 IP (tos 0x0, ttl 45, id 43565, offset 0, flags [DF],proto 6, length: 588)

169.229.62.97.22 > 10.0.0.123.1027: P [tcp sum ok] 41:577(536) ack 23

2

20:20:21.993727 IP (tos 0x0, ttl 64, id 57659, offset 0, flags [DF],proto 6, length: 588)

10.0.0.123.1027 > 169.229.62.97.22: P [tcp sum ok] 23:559(536) ack 577

3

20:20:21.994093 IP (tos 0xc0, ttl 64, id 57547, offset 0, flags[none], proto 1, length: 576)

10.0.0.1 > 10.0.0.123: icmp 556:169.229.62.97 unreachable - need to frag (mtu 288) for

IP (tos 0x0, ttl 63, id 57659, offset 0, flags [DF],proto 6, length: 588)

10.0.0.123.1027 > 169.229.62.97.22:P 23:559(536) ack 577

4

20:20:21.994884 IP (tos 0x0, ttl 64, id 57660, offset 0, flags [DF],proto 6, length: 288)

10.0.0.123.1027 > 169.229.62.97.22: . [tcp sum ok] 23:259(236) ack 577

5

20:20:22.488856 IP (tos 0x0, ttl 45, id 6712, offset 0, flags [DF],proto 6, length: 836)

169.229.62.97.22 > 10.0.0.123.1027: P [tcp sum ok] 857:1641(784)ack 855

6

20:20:29.672947 IP (tos 0x8, ttl 64, id 57679, offset 0, flags [DF],proto 6, length: 1452)

10.0.0.123.1027 > 169.229.62.97.22: . [tcp sum ok] 1431:2831(1400) ack 2105

7

20:20:29.674123 IP (tos 0xc8, ttl 64, id 57548, offset 0, flags[none], proto 1, length: 576)

10.0.0.1 > 10.0.0.123: icmp 556:169.229.62.97 unreachable - need to frag (mtu 288) for

IP (tos 0x8, ttl 63, id 57679, offset 0, flags [DF],proto 6, length: 1452)

10.0.0.123.1027 > 169.229.62.97.22: . 1431:2831(1400) ack 2105

8

20:20:29.673751 IP (tos 0x8, ttl 64, id 57680, offset 0, flags [DF],proto 6, length: 1452)

10.0.0.123.1027 > 169.229.62.97.22: . [tcp sum ok] 2831:4231(1400) ack 2105

9

20:20:29.675180 IP (tos 0xc8, ttl 64, id 57549, offset 0, flags[none], proto 1, length: 576)

10.0.0.1 > 10.0.0.123: icmp 556: 169.229.62.97 unreachable - need to frag (mtu 288) for

IP (tos 0x8, ttl 63, id 57680, offset 0, flags [DF],proto 6, length: 1452)

10.0.0.123.1027 > 169.229.62.97.22: . 2831:4231(1400) ack 2105

10

20:20:29.674932 IP (tos 0x8, ttl 64, id 57681, offset 0, flags[DF], proto 6, length: 288)

10.0.0.123.1027 > 169.229.62.97.22: . [tcp sum ok] 1431:1667(236) ack 2105

11

20:20:29.675143 IP (tos 0x8, ttl 64, id 57682, offset 0, flags[DF], proto 6, length: 288)

10.0.0.123.1027 > 169.229.62.97.22: . [tcp sum ok] 1667:1903(236) ack 2105

根据tcpdump的输出,已经建立了连接并且交换了MSS选项。连接中所有的数据包都设置了DF位字段,所以两端都执行PMTUD。虽然我们配置的PPPoE链路的MTU是288字节,对端第一个数据包长度为588字节还是成功的通过了路由器,这是因为MTU配置并不对称。虽然本端PPPoE链路使用的MTU为288字节,但另一端使用使用了较大的SMSS,大概为1492字节,这会造成如下情况,向外发送的数据包需要较小(288字节或更小)的段大小,反方向的包可以使用较大的段大小。

当本端尝试发送一个588字节且DF置位的数据包时,路由器(10.0.0.1)产生了一个PTB消息,表明下一跳链路MTU为288字节。TCP紧接着发送一个288字节的数据包作为响应。为了发送原计划588字节数据包中剩余序号,TCP发送了另外两个288和116字节的数据包,在文件传输过程中,类似的包大小模式不断重复出现。

PMTU发现过程是TCP连接开始后显式调整段大小的唯一途径。段大小影响吞吐量总体性能及窗口大小,我们在第15章讨论这些因素1是如何影响总体性能的。

13.5 TCP状态转换

我们已经介绍了关于TCP连接的建立和终止的许多规则,并看到了在连接的不同阶段发送哪些类型的段。决定TCP做什么是由TCP处于什么状态决定的,当前状态根据不同的场景而改变,例如传输或接收到报文段、计时器超时、应用程序读或写,或来自其他层的信息。这些规则可以概括在TCP的状态转换图中。

13.5.1 TCP状态转换图

TCP的状态转换图如图13-8所示。状态用椭圆表示,状态间的转换用箭头表示。连接的每个端点可以在这些状态中进行转换,有些转换是由于收到了某些控制位置位的段(例如SYN、ACK、FIN)触发的,有些转换会导致发送特定控制位置位的段,其它转换可能由应用程序动作或计时器超时触发,上述各种情况都以文本注释的方式标记在相关的转换箭头旁。开始时,TCP处于CLOSED状态,通常根据TCP执行主动打开还是被动打开立即转换到SYN_SENT或LISTEN状态。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(8)

图 13‑8 TCP状态转换图(也称作有限状态机)。箭头表示因报文段传输、报文段接收或计时器超时而引起的状态转换。粗箭头表示典型的客户端行为,虚线箭头表示典型的服务器行为。粗体命令(例如,打开、关闭)是由应用程序执行的动作。

注意,此转换图中只有一个子集是“典型的”。我们将正常的客户端状态转换用带黑色实线的箭头表示,正常的服务器状态转换用带虚线的箭头表示,这两个状态转换达到ESTABLISHED时对应于打开一个连接,这两个状态转换从ESTABLISHED出发对应于终止一个连接。ESTABLISHED状态是两端两个方向间可以进行数据传输的状态,第14-17章介绍了这种状态下会发生什么。

图中将FIN_WAIT_1、FIN_WAIT_2和TIME_WAIT状态放在了标记为“主动关闭”的框中,它们是本地应用程序发起关闭请求时进入的状态集合。另外两个状态(CLOSE_WAIT和LAST_ACK)放在了标记为“被动关闭”的框中,对应与等待对方对FIN报文段的确认并执行自己的关闭。同时关闭(通讯双方主动关闭的一种形式)使用CLOSING状态。

图中11个状态的名称(CLOSED、LISTEN、SYN_SENT等)来自UNIX、Linux和Windows的netstat命令输出的名称,它们本身来自[RFC0793]中最初使用的名称。CLOSED状态并不是真正的“正式”状态,被添加进来只是作为图的起点和终点。

从LISTEN到SYN_SENT的状态转换在TCP协议中是合法的,但伯克利套接字不支持,而且很少见到。只有当SYN_RCVD状态是从LISTEN状态(正常情况)转换而来而不是从SYN_SENT状态(同时打开)转换而来,从SYN_RCVD转换到LISTEN才有效。这意味着如果执行被动打开(进入LISTEN状态),收到一个SYN,发送一个带有ACK的SYN(进入SYN_RCVD状态),然后收到一个RST而不是ACK,端点会回到LISTEN状态并等待另一个连接请求到达。

图13-9展示了正常的TCP连接建立和终止过程,详细描述了客户端和服务器经过的不同状态。它是图13-1的一个简化版本,显示了相关的状态但没有显示选项及ISN细节。在图13-9中,我们假设左边的客户机执行主动打开,右边的服务器执行被动打开。虽然图中是客户端执行的主动关闭,如前所述,任何一方都可以执行主动关闭。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(9)

图 13‑9 对应于正常连接建立和终止的TCP状态

13.5.2 TIME_WAIT(2MSL Wait)状态

TIME_WAIT状态也称为2MSL等待状态,这种状态下TCP等待的时间等于最大段生存期(Maximum Segment Lifetime,MSL)的两倍,有时也称为加倍等待。每个实现都必须为MSL选择一个值,它是任何报文段被丢弃前在网络中存在的最大时间,这个时间限制是有上限的,因为TCP段以IP数据报的形式传输,IP数据报有TTL字段或跳数限制字段来限制它的有效生存时间(见第五章)。[RFC0793]将MSL指定为2分钟,然而,常见的实现值是30秒、1分钟或2分钟。在大多数情况下,可以修改该值,在Linux操作系统中,net.ipv4.tcp_fin_timeout保存2MSL等待超时值(以秒为单位)。在Windows上,下面的注册表保存了超时时间,取值范围是30~300秒,对于IPv6,将Tcpip替换为Tcpip6即可。

HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\TcpTimedWaitDelay

TCP实现确定好MSL值后,规则如下:当TCP执行主动关闭并发送最后一个ACK后,该连接必须保持TIME_WAIT状态,保持时间为MSL时间的2倍,这样TCP就有机会重新发送最后一个ACK以避免ACK丢失。最后一个ACK被重传不是因为TCP重传了ACK(它们不消耗序列号,TCP也不会重传它们),而是因为对方重传了它的FIN(消耗一个序列号)。实际上,TCP在收到最后一个ACK前总是会重传FIN。

2MSL等待状态的另一个影响是,当TCP等待时,定义该连接的端点(客户端IP地址、客户端端口号、服务器IP地址和服务器端口号)不能重用,只有2MSL等待结束或新连接使用的ISN超过了之前连接实例[RFC1122]的最高序号或使用时间戳选项消除来自之前连接实例报文段歧义时[RFC6191]才能重用。不幸的是,一些实现施加了更严格的限制,在这些系统中,要使用的本地端口号在通信任一端处于2MSL等待状态时,端口就不能被重用,清单13-3和13-4中可以看到这个限制的示例。

大多数实现和API提供了绕过此限制的方法。对于伯克利套接字API,套接字选项SO_REUSEADDR能绕过此限制,它允许调用者即使本地端口处于某个连接的2MSL状态时也能分配这个本地端口号。然而,即使对套接字(地址、端口号对)使用这种绕过机制,TCP规则仍然(应该)阻止处于2MSL等待状态连接的端口被另外一个连接实例重用。连接处于2MSL等待状态时,任何延迟到达的段都会被丢弃。因为地址/端口4元组定义的连接在2MSL等待状态下不能被重用,当有效的连接最终建立时,此连接先前实例的延迟报文段不会被误认为是新连接的一部分。

对于交互式应用程序,通常是客户端执行主动关闭并进入TIME_WAIT状态,服务器通常执行被动关闭,不经过TIME_WAIT状态。这意味着,如果我们终止客户端并立即重新启动此客户端,新客户端不能重用相同的本地端口号,这通常并不是问题,因为客户端通常使用由操作系统分配的临时端口号而不关心分配的端口号是多少(回想一下,实际上出于安全原因推荐做法是对端口随机化[RFC6056])。这一点很重要,客户端快速建立大量的连接(特别是到同一台服务器)时如果临时端口不够,客户端不得不等待其它连接终止。

但对于服务器,情况就不同了。他们通常使用知名端口。如果我们终止一个已经建立连接的服务器进程并立即尝试重新启动它,服务器无法使用已分配的端口号(会得到“Address already in use”绑定错误),因为那个端口号是2MSL等待状态连接的一部分。根据系统的MSL值,服务器重启可能需要1到4分钟。我们可以使用sock程序来观察这一场景。在清单13-3中,我们启动服务器,从客户机连接到它,然后终止服务器。

清单 13‑3 端口号被其它进程重用前TCP连接必须在TIME_WAIT下等待2MSL

Linux% sock -v -s 6666

(现在,另一台计算机[h2] 上的客户端连接到这个服务器)

connection on 192.168.10.144.6666 from 192.168.10.140.2623

(输入中断字符,服务器停止[h3] )

(现在服务器重新启动)

Linux% sock -v -s 6666

can't bind local address: Address already in use

Linux% netstat -n -t

Active Internet connections (w/o servers)

Proto Recv-Q Send-Q Local Address Foreign Address State

tcp 0 0 192.168.10.144:6666 192.168.10.140:2623 TIME_WAIT

(等待1分钟,重新启动服务器)

Linux% sock -v -s 6666

当我们尝试重启服务器时,程序输出错误信息指明由于地址正在使用中无法绑定端口号,这实际上意味着地址与端口号组合正在使用中,由于之前的一个连接它正处于2MSL等待状态,这正是前面提到的对端口号重用更严格的限制。netstat命令的输出显示连接处于TIME_WAIT状态。虽然客户端在2MSL等待状态下通常不会遇到像服务器那样多的问题,但是我们可以通过让客户端指定自己的端口号来演示同样的问题,如清单13-4所示。

清单 13‑4 当端口被处于2MSL等待状态的连接使用时,客户端不能重用该端口号

(在一个窗口中启动服务器)

Linux% sock -s -v 6666

(从另一个窗口[h4] 连接它)

Linux% sock -v 127.0.0.1 6666

(服务器识别出到达的连接)

connection on 127.0.0.1.6666 from 127.0.0.1.2091

(客户端识别出连接建立,并被中断)

connected on 127.0.0.1.2091 to 127.0.0.1.6666

^C

(服务器识别出连接已终止并退出)

connection closed by peer

Linux%

(客户端重新启动,并指定与先前相同的端口号)

Linux% sock -b 2091 -v 127.0.0.1 6666[h5]

bind() error: Address already in use

(等待30秒再试一次)

Linux% sock -b 2091 -v 192.168.10.144 6666[h6]

connect() error: Connection refused

第一次执行客户端时指定了-v选项来查看分配给客户端的本地(临时)端口号是多少(2091),第二次执行客户端时指定了-b选项来告诉客户端使用2091作为本地端口号而不是由操作系统分配其它临时端口号。正如所料,客户端无法完成此操作,因为端口2091是处于2MSL等待状态的连接的一部分。一旦等待结束(在Linux机器上是1分钟)客户端尝试再次连接服务器,服务器由于客户端第一次中断时已经退出所以客户端连接被拒绝,在第13.6节我们将看到TCP 如何利用重置报文段来表示此连接被拒绝。

前面提到过,大多数系统提供了一种覆盖默认行为的方法,即使端口是2MSL等待状态的连接的一部分也允许进程绑定到此端口。现在重试之前的场景,并使用套接字的-A选项启用绕过机制:

Linux% sock -A -v -s 6666[h7]

Linux% sock -A -v -s 6666[h8]

本例中,我们启动服务器时使用-A选项,该选项启用我们之前提到的SO_REUSEADDR套接字选项。这样即使端口是2MSL等待状态的连接的一部分也允许服务器绑定到此端口。但是,如果我们立即使用客户端连接此端口,会发生如下情况:

Linux% sock -b 32840 -v 127.0.0.1 6666[h9]

bind() error: Address already in use

同样的,端点127.0.0.1.32840正在被使用,所以客户端启动失败。如果我们对客户也使用-A选项,则可以强制进行连接不报错。

Linux% sock -A -b 32840 -v 127.0.0.1 6666[h10]

Connected on 127.0.0.1.32840 to 127.0.0.1.6666

TCP_MAXSEG = 16383

我们可以看到,使用-A选项强制连接被允许,即使是使用相同的处于2MSL等待状态的连接(4元组)。当然,这一切都发生在同一台计算机上,因此操作系统能够确定处于2MSL等待状态的连接的每一端是哪个进程,并(至少可能)使它们彼此独立。如果我们再次尝试同样的事情,但从另一个主机建立连接会怎样?我们测试下这个想法:

(在第一台机器上启动服务器)

Linux% sock -v -s 6666

(从第二台Windows机器上连接它[h11] )

C:\> sock -A -v 10.0.0.1 6666

(服务器识别出到来的连接)

connection on 10.0.0.1.6666 from 10.0.0.3.2172

(客户端识别出连接已建立,并被中断)

connected on 10.0.0.3.2172 to 10.0.0.1.6666

^C

C:\>

(服务器识别出连接已终止并退出)

connection closed by peer

Linux%

(客户端重新启动,并指定与先前相同的端口号)

C:\> sock -A -b 2091 -v 10.0.0.1 6666

connect() error: Address already in use

C:\> sock -A -b 2091 -v 10.0.0.1 6666

connect() error: Address already in use

(等待30秒再试一次)

C:\> sock -A -b 2091 -v 10.0.0.1 6666

connect() error: Connection refused

除了客户端与服务器在不同的机器上,这个示例与前一个类似。我们注意到无论客户端是否指定-A标志都会有2MSL等待时间,这里2MSL等待时间持续30秒。此后,客户端尝试连接已经中止的服务器。

如果客户端和服务器互换一下会发生一些有趣的情况。现在将Windows作为服务器,Linux作为客户端,重复上述实验:

(在Windows机器上启动服务器)

C:\> sock -v -s 6666

(从Linux机器连接它)

Linux% sock -A -v 192.168.10.145 6666

(服务器识别出到来的连接)

connection on 192.168.10.145.6666 from 192.168.10.145.32843

(客户端识别出连接已建立,并被中断)

connected on 192.168.10.144.32843 to 192.168.10.145.6666

^C

Linux%

(服务器识别出连接已终止并退出)

connection closed by peer

C:\>

(客户端重新启动,并指定与先前相同的端口号)

Linux% sock -A -b 32843 -v 192.168.10.144 6666

bind() error: Connection refused

此时,我们预期本地端口32843不可用,但由于-A选项在Linux上有效,所以我们可以使用此端口。这违背了之前提到过的最初始的TCP规范,但[RFCl122]和[RFC6191]却是允许的。在基于序号和时间戳的组合情况下,如果有足够的理由相信新连接的报文段不会与先前连接实例的报文段混淆,这些规范允许处于TIME_WAIT状态的连接也可以接受新到来的连接请求。[RFC1337]和[RFC1323]的附录列出了与此规则相关的一些陷阱。

13.5.3 静默时间的概念

2MSL等待可以防止先前连接延迟报文段被误认为具有相同IP地址和端口(4元组)新连接的数据。但只有2MSL等待状态连接所在主机没有崩溃时才起作用。

如果处于TIME_WAIT状态的连接所在主机崩溃,并在MSL时间内重启,并立即建立连接且使用与崩溃前的连接相同的IP和端口号,会发生什么?在这种情况下,崩溃前的连接的延迟报文段可能会被误认为重启后的新连接的报文段。重启后无论怎么选择初始序号都可能发生这种情况。

为防止这种情况发生,[RFC0793]规定,在重启或崩溃后,TCP创建任何新连接前应该等待与MSL相等的时间。这个时间被称为静默时间。很少有实现遵守这一点,因为大多数主机崩溃后重启所需的时间比MSL更长。此外,如果应用程序使用自己的校验和或加密,这样的错误很容易检测出来。

13.5.4 FIN_WAIT_2状态

在FIN_WAIT_2状态下,TCP已经发送FIN报文段并且对方已确认。如果正在执行的不是半关闭,TCP必须等待另一端的应用程序识别出收到了文件结束标志并关闭其连接端,这将导致对方发送FIN报文段。只有当对方应用程序执行关闭(并且它的FIN已经被接收),主动关闭方才会从FIN_WAIT_2状态转换到TIME_WAIT状态。这意味着除非对方应用程序发出关闭,否则连接的一端可以永远保持在FIN_WAIT_2状态,另一端永远保持在CLOSE_WAIT状态。

许多实现使用如下方法防止在FIN_WAIT_2状态下无限等待:如果发起主动关闭的应用程序执行的为完全关闭,而不是还期望接收数据的半关闭,则设置一个计时器。当定时器超时时,如果连接处于空闲状态,则TCP将连接转换到CLOSED状态。在Linux中,变量net.ipv4.tcp_fin_timeout用来设置定时器的秒数,默认值为60秒。

13.5.5 同时打开与关闭时的状态转换

我们已经看到了SYN_SENT和SYN_RCVD状态的常规用法,它们分别对应于已发送和接收到SYN段时的状态。如图13-3所示,TCP可以处理在同一个连接上同时打开的情况。当同时打开时,状态转换与图13-9中所示的不同。两端几乎同时发送SYN报文段并进入SYN_SENT状态,当两端收到对方的SYN报文段时,状态变为SYN_RCVD,双方重新发送SYN报文并确认接收到的SYN报文,当两端收到SYN ACK报文时,状态变为ESTABLISHED。

对于同时关闭,如图13-6所示,当应用程序发出关闭动作时,两端状态会从ESTABLISHED转换到FIN_WAIT_1,这会导致双方发送FIN报文段。当对方的FIN报文段到达时,端点从FIN_WAIT_1转换到CLOSING状态,并发送最终的ACK。一旦收到最终的ACK,端点的状态就会转换为TIME_WAIT并启动2MSL等待。

13.6 重置报文段

我们在第12章中提到了TCP首部中的RST位字段。将此位打开的报文段称为“重置报文段”或简称为“重置”。通常,当一个到达的段对于涉及的连接来说不正确的话,TCP会发送一个重置报文段(我们使用术语“涉及的连接”指重置报文段的TCP和IP首部中四元组所指定的连接)。重置通常会导致TCP连接的快速关闭。我们可以构建场景来演示重置报文段的使用。

13.6.1 连接请求不存在的端口

生成重置报文段的一个常见情况是连接请求到达但并没有进程在监听目标端口,我们在之前碰到“connection refused”错误消息时已看到了这一点。以上所说在TCP中比较常见,在使用UDP的情况下(第10章),数据报到达但目标端口不存在会生成一个ICMP目的地不可达(端口不可达)的消息,而TCP使用重置报文段代替。

很容易找到这样的例子——我们使用Telnet客户端并指定一个目标主机上不存在的端口,目标主机也可以是本地计算机:

Linux% telnet localhost 9999

Trying 127.0.0.1...

telnet: connect to address 127.0.0.1: Connection refused

Telent客户端会立即输出错误信息,清单13-5显示了与此命令相对应的数据包交换。

清单 13‑5 尝试打开一个端口不存在的连接生成的重置报文段

1

22:15:16.348064 127.0.0.1.32803 > 127.0.0.1.9999:S [tcp sum ok] 3357881819:3357881819(0) win 32767<mss 16396,sackOK,timestamp 16945235 0,nop,wscale 0>(DF) [tos 0x10] (ttl 64, id 42376, len 60)

2

22:15:16.348105 127.0.0.1.9999 > 127.0.0.1.32803:R [tcp sum ok] 0:0(0) ack 3357881820 win 0

(DF) [tos 0x10] (ttl 64, id 0, len 40)

清单13-5的重置报文段(第2个)中我们需要查看的值是序号和确认号,由于服务端(本机)收到的SYN报文段ACK位未打开,所以重置报文段的序号被设置为0,确认号为收到的ISN号加上报文段中数据长度。虽然收到的SYN报文段中没有数据,SYN位在逻辑上仍然占用一个字节的序号空间,因此例子中的重置报文段的确认号等于ISN加上数据长度(0),再加上SYN位的1字节。

重置报文段只有ACK位打开且确认号在有效窗口内(见第12章)才会被TCP认可,这有助于防止一种简单攻击,即任何人能够生成一个相应连接(4元组)的重置报文段,从而破坏此连接[RFC5961]。

13.6.2 异常终止(abort)一个连接

从图13-1可以看出,终止连接的正常方式是由一方发送FIN报文段。这种方式有时也被称为有序释放,因为FIN报文段是在所有排队数据都发送完毕后才发送的,并且通常不会有数据丢失,但也可以在任何时候发送重置报文段而不是FIN报文段异常终止连接。

对应用程序来说异常终止连接有两个特点:(1)丢弃所有排队数据并立即发送重置报文段(2)重置报文段的接收端知道对方是异常终止而不是正常的关闭。应用程序使用的API必须提供终止连接的方法。

套接字API通过延迟关闭选项(SO_LINGER,linger值设置为0)提供终止连接的能力,这意味着“不会为了确保数据到达对方而延迟关闭然后在终止”。下面的例子展示了产生大量输出的远程命令被用户取消时所发生的情况:

Linux% ssh linux cat /usr/share/dict/words

Aarhus

Aaron

Ababa

aback

abaft

abandon

abandoned

abandoning

abandonment

abandons

... continues ...

^C

Killed by signal 2.

文件words中有45427个单词,这个命令可能由于某种错误,用户决定终止该命令输出。当用户输入中断字符时,系统显示该进程(这里为ssh程序)已被2号信号终止,该信号被称为SIGINT,通用用于终止程序。这个例子的tcpdump的输出如清单13-6所示(因为与讨论无关,我们已删除了许多中间的数据包)。

清单 13‑6 使用重置报文段(RST)而不是FIN报文段来终止连接

Linux# tcpdump -vvv -s 1500 tcp

1

22:33:06.386747 192.168.10.140.2788 > 192.168.10.144.ssh:

S [tcp sum ok] 1520364313:1520364313(0) win 65535

<mss 1460,nop,nop,sackOK>(DF) (ttl 128, id 43922, len 48)

2

22:33:06.386855 192.168.10.144.ssh > 192.168.10.140.2788:

S [tcp sum ok] 181637276:181637276(0) ack 1520364314 win 5840

<mss 1460,nop,nop,sackOK>(DF) (ttl 64, id 0, len 48)

3

22:33:06.387676 192.168.10.140.2788 > 192.168.10.144.ssh:

. [tcp sum ok] 1:1(0) ack 1 win 65535

(DF) (ttl 128, id 43923, len 40)

(…SSH加密认证交换和批量数据传输…)

4

22:33:13.648247 192.168.10.140.2788 > 192.168.10.144.ssh:

R [tcp sum ok] 1343:1343(0) ack 132929 win 0

(DF) (ttl 128, id 44004, len 40)

报文段1-3显示了正常连接的建立。当输入中断字符时连接终止。重置报文段包含序号和确认号。还要注意的是重置报文段不会收到另一端的响应——它根本不会被确认。重置报文段的接收方将终止连接并通知应用程序连接已重置,并通常会有“Connection reset by peer”或类似的错误提示。

13.6.3 半开连接

如果一端在另一端不知道的情况下关闭或终止了连接,则将此TCP连接称为半开连接。这种情况任何时候都可能发生,当通信的一端主机崩溃,只要没在半开连接上尝试传输数据,仍处于正常状态的一端就不会检测到另一端已经崩溃。

产生半开连接的另一个常见原因是主机断电而非正常关闭。例如,当PC用来运行远程登录客户端,并在一天结束时关掉电源就会发生这种情况。如果断电时没有进行数据传输,服务器将永远不会知道客户端已经不存在了(它仍然会认为连接处于ESTABLISHED状态)。当用户第二天早上来的时候,打开PC并打开一个新会话,服务器主机上又会发生上述所说情况,这可能导致服务器主机上有许多半开的TCP连接(第17章我们将看到TCP连接的一端使用keepalive选项发现另一端已经不存在的方法)。

我们可以很容易地创建一个半开连接。在本例中,我们在客户端而不是服务器上执行此操作。我们在10.0.0.1上执行Telnet客户端,连接到Sun RPC服务(sunrpc,端口111)所在的服务器10.0.0.7(参见清单13-7),输入一行数据,并使用tcpdump观察过程,然后断开服务器主机上的网线,并重新启动服务器主机,以此来模拟服务器主机崩溃的情况(重新启动服务器前断开网线是为了阻止它向打开的连接发送FIN报文段,有些TCP关闭时会发送)。服务器重新启动后,重新连接网线,并从客户端尝试向服务器再发送一行数据。重新启动后,服务器端TCP已经没有了之前所有连接信息,所以它对报文段中引用的连接一无所知,TCP规定接收端使用重置报文段响应。

清单 13‑7 服务器主机断开并重新启动,客户机上留下一个半开连接。当服务器主机收到它无法识别的连接上的数据时,会以一个重置报文段响应,两端连接关闭。

Linux% telnet 10.0.0.7 sunrpc

Trying 10.0.0.7...

Connected to 10.0.0.7.

Escape character is '^]'.

foo

(断开以太网电缆并重启服务器)

bar

Connection closed by remote host

清单13-8显示了这个示例的tcpdump输出。

清单 13‑8 使用重置报文段响应半开连接上的数据段

1

23:15:48.804142 IP (tos 0x10, ttl 64, id 20095, offset 0,flags [DF], proto 6, length: 60)

10.0.0.1.1310 > 10.0.0.7.sunrpc:S [tcp sum ok] 2365970104:2365970104(0) win 5840

<mss 1460,sackOK,timestamp 3849492679 0,nop,wscale 2>

2

23:15:48.804742 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF],proto 6, length: 60)

10.0.0.7.sunrpc > 10.0.0.1.1310:

S [tcp sum ok] 2093796387:2093796387(0) ack 2365970105 win 5792

<mss 1460,sackOK,timestamp 654784 3849492679,nop,wscale 0>

3

23:15:48.805028 IP (tos 0x10, ttl 64, id 20097, offset 0,flags [DF], proto 6, length: 52)

10.0.0.1.1310 > 10.0.0.7.sunrpc:. [tcp sum ok] 1:1(0) ack 1 win 1460

<nop,nop,timestamp 3849492680 654784>

4

23:15:51.999394 IP (tos 0x10, ttl 64, id 20099, offset 0,flags [DF], proto 6, length: 57)

10.0.0.1.1310 > 10.0.0.7.sunrpc:P [tcp sum ok] 1:6(5) ack 1 win 1460

<nop,nop,timestamp 3849495875 654784>

5

23:15:51.999874 IP (tos 0x0, ttl 64, id 12773, offset 0,flags [DF], proto 6, length: 52)

10.0.0.7.sunrpc > 10.0.0.1.1310:. [tcp sum ok] 1:1(0) ack 6 win 5792

<nop,nop,timestamp 656421 3849495875>

6

23:17:19.419611 arp who-has 10.0.0.7 (Broadcast) tell 0.0.0.0

7

23:17:20.419142 arp who-has 10.0.0.7 (Broadcast) tell 0.0.0.0

8

23:17:21.427458 arp reply 10.0.0.7 is-at 00:e0:00:88:ad:d6

9

23:17:21.921745 arp who-has 10.0.0.1 tell 10.0.0.7

10

23:17:21.921892 arp reply 10.0.0.1 is-at 00:04:5a:9f:9e:80

11

23:17:23.437114 arp who-has 10.0.0.7 (Broadcast) tell 10.0.0.7

12

23:17:34.804196 arp who-has 10.0.0.7 tell 10.0.0.1

13

23:17:34.804650 arp reply 10.0.0.7 is-at 00:e0:00:88:ad:d6

14

23:17:43.684786 IP (tos 0x10, ttl 64, id 20101, offset 0,flags [DF], proto 6, length: 57)

10.0.0.1.1310 > 10.0.0.7.sunrpc:P [tcp sum ok] 6:11(5) ack 1 win 1460

<nop,nop,timestamp 3849607577 656421>

15

23:17:43.685277 IP (tos 0x10, ttl 64, id 0, offset 0,flags [DF], proto 6, length: 40)

10.0.0.7.sunrpc > 10.0.0.1.1310:R [tcp sum ok] 2093796388:2093796388(0) win 0

段1-3显示了正常连接的建立,段4将行数据“foo”发送到sunrpc服务器(包括回车与换行共5个字节),段5为段4的确认。

此时,我们断开服务器(地址10.0.0.7)的以太网电缆,重新启动并重新连上网线,这大概需要90秒的时间。然后,我们在客户端输入行数据(“bar”)并按下回车时,行数据被发往服务器(清单13-9中ARP流量后的第一个TCP报文段),服务器没有此连接的任何信息,所以使用重置报文段做为响应。

注意,当主机重新启动时,它使用免费ARP(见第4章)来确定自己的IPv4地址是否已使用,它还会请求IPv4地址为10.0.0.1机器的MAC地址,因为它是访问因特网的默认路由。

13.6.4 TWA(TIME-WAIT Assassination)

如前所述,TIME_WAIT状态是为了丢弃来自已关闭连接的延迟的数据报,在2MSL这段时间,TCP只是保持此状态,不做其它任何操作。但是,如果它在此期间收到此连接的一些报文段,或者更具体说比如收到一个RST报文段,连接就会变的不同步,这种情况被称为TIME-WAIT Assassination (TWA) [RFC1337]。考虑图13-10所示的报文交换过程。

tcpip体系讲解(TCPIP详解第13章TCP连接管理)(10)

图 13‑10 RST报文段可以“破坏”TIME_WAIT状态,强制使连接提前关闭,有很多方法可以避免这个问题,包括处于TIME_WAIT状态时忽略RST报文段

在图13-10所示的例子中,服务器已完成了连接的所有工作并清理了所有的状态,客户端还处于TIME_WAIT状态。完成FIN报文段交换时,客户端下一序号为K,服务器下一序号为L。服务器发往客户端的延迟到达报文段的序号为L-100,确认号为K-200,当客户端收到此报文段时,它发现序号和确认号都是“旧”的。当收到这样的旧报文段时,TCP会发送一个带有最新序号和确认号(分别为K和L)的ACK作为响应。然而,当服务器收到这个ACK时,它没有关于此连接的任何信息,因此使用RST作为响应。对于服务器来说这没有任何问题,但会导致客户端过早地从TIME_WAIT状态转换到CLOSED状态。为避免这个问题,大多数系统在TIME_WAIT状态时不会对重置报文段做出反应。

13.7 TCP服务器端操作

我们在第一章中说过大多数TCP服务端是并发的,当一个新的连接请求到达服务器时,服务器接受连接并创建一个新的进程或线程来处理新的客户端。我们比较关注并发服务端的TCP交互,特别是TCP服务端如何使用端口号以及如何处理多个并发客户端。

13.7.1 TCP端口号

可以通过观察任何一台TCP服务器来了解TCP是如何处理端口号的,我们在支持IPv4/IPv6双栈协议的主机上使用netstat命令查看安全外壳服务(secure shell server,称为sshd)。sshd应用实现了安全外壳协议(Secure Shell Protocol)[RFC4254],它提供了加密认证和远程终端的能力。下面的输出来自于没有活动安全外壳连接的系统上(我们已删除了所有与服务无关的输出行)。

Linux% netstat -a -n -t

Active Internet connections (servers and established)

Proto Recv-Q Send-Q Local Address Foreign Address State

tcp 0 0 :::22 :::* LISTEN

-a选项输出所有网络端点,包括那些处于监听和非监听状态的端点。-n选项将IP地址以点分十进制(或十六进制)数字打印,而不是试图使用DNS将其转换为名称,-n选项还打印数字形式的端口号(例如22),而不是服务名称(例如ssh)。-t选项表示只选择TCP端点。

本地地址(实际上是指本地端点)输出为:::22,这是全零地址(也称为通配符地址)和端口号22的IPv6的方式,这意味着请求连接端口22的连接请求可以被任意一个本地接口接受。如果主机是多宿主主机(本机即是),我们可以为本地地址指定单个IP地址(主机多个IP地址中的一个),并且只有该接口才接受连接请求(我们将在本节后面看到一个例子)。端口22是为安全外壳协议保留的知名端口号,其他知名端口号由IANA [ITP]维护。

外部地址输出为:::*,这意味着通配地址及端口号(即它表示通配端点)。这里还不知道外部IP地址和外部端口号,因为本地端点处于LISTEN状态,正等待连接到达。现在,我们在10.0.0.3主机上启动一个连接到此服务器的安全外壳客户端。下面是netstat输出的相关行(Recv-Q和Send-Q列只包含0值,为了清晰起见,已经删除了它们)。

Linux% netstat -a -n -t

Active Internet connections (servers and established)

Proto Local Address Foreign Address State

tcp :::22 :::* LISTEN

tcp ::ffff:10.0.0.1:22 ::ffff:10.0.0.3:16137 ESTABLISHED

标有端口号22的第二行连接为ESTABLISHED,本地和外部端点的四要素已填充到此连接中:本地IP地址和端口号、外部IP地址和端口号。本地地址对应于连接请求到达的接口(以太网接口,由IPv4映射的IPv6地址标识,::ffff:10.0.0.1)。

处于LISTEN状态的本地端点独自一行,这是并发服务器用于接受将来连接请求的端点,当新的连接请求到达并被接受时,操作系统中的TCP模块将创建ESTABLISHED状态的新节点。还要注意,ESTABLISHED状态的连接的端口号没有改变:还是22,与LISTEN状态的端点相同。

现在我们从同一台主机(10.0.0.3)向服务器发起另一个客户端请求,相关的netstat输出如下:

Linux% netstat -a -n -t

Active Internet connections (servers and established)

Proto Local Address Foreign Address State

Tcp :::22 :::* LISTEN

tcp ::ffff:10.0.0.1:22 ::ffff:10.0.0.3:16140 ESTABLISHED

tcp ::ffff:10.0.0.1:22 ::ffff:10.0.0.3:16137 ESTABLISHED

现在从同一个客户端到服务器有2个ESTABLISHED状态的连接,二者的服务器端口号均为22,这对TCP来说不是问题,因为外部端口号不同。外部端口是不会相同的,因为安全外壳客户端使用临时端口,此临时端口指的是主机(10.0.0.3)尚未使用的端口。

这个例子再次说明TCP通过组成本地和外部端点的4个值对到达的报文段进行多路分用:目的IP地址、目的端口号、源IP地址和源端口号。TCP不能仅仅通过查看目的端口号来决定哪个进程应该得到到达的报文段,端口22上的三个端点中只有处于LISTEN状态的端点接收到达的连接请求,处于ESTABLISHED状态的端点不能接收SYN报文段,而处于LISTEN状态的端点不能接收数据段,操作系统可以确保这一点(如果不能,TCP会变得混乱,不能正常工作)。

接下来,我们从IP地址169.229.62.97发起第三个客户端连接,该IP地址与服务器10.0.0.1通过DSL PPPoE连接,且与服务器不在同一个以太网上(为了清晰起见,下面的输出删除了Proto列,此列只包含了tcp)。

Linux% netstat -a -n -t

Active Internet connections (servers and established)

Send-Q Local Address Foreign Address State

0 :::22 :::* LISTEN

0 ::ffff:10.0.0.1:22 ::ffff:10.0.0.3:16140 ESTABLISHED

0 ::ffff:10.0.0.1:22 ::ffff:10.0.0.3:16137 ESTABLISHED

928 ::ffff:67.125.227.195:22 ::ffff:169.229.62.97:1473 ESTABLISHED

第三个ESTABLISHED状态连接的本地IP地址对应于多宿主主机(67.125.227.195)上的PPPoE链路的接口地址。注意Send-Q状态不是0而是928字节,这意味着服务器已经在连接上发送了928个字节,但还未收到确认。

13.7.2 限制本地IP地址

我们看下当服务器不是通配本地IP地址,而是将其设置为一个特定的本地地址时会发生什么。运行sock程序作为服务端并指定一个特定的IP地址,该地址就成为侦听端点的本地地址。例如:

Linux% sock -s 10.0.0.1 8888

这限制了服务器仅使用到达本地IPv4地址10.0.0.1的连接,netstat的输出反映了这一点:

Linux% netstat -a -n -t

Active Internet connections (servers and established)

Proto Recv-Q Send-Q Local Address Foreign Address State

tcp 0 0 10.0.0.1:8888 0.0.0.0:* LISTEN

这个例子中比较有意思的是,我们的sock程序只绑定到本地IPv4地址10.0.0.1,因此netstat输出看起来明显与之前不同。在前面的例子中,通配符地址和端口号表示通配多宿主主机两个IP,但是在本例中,我们绑定到了一个特定的地址、端口及地址族(仅支持IPv4)。如果我们从本地网络的主机10.0.0.3连接到这个服务器,不会有任何问题:

Linux% netstat -a -n -t

Active Internet connections (servers and established)

Proto Recv-Q Send-Q Local Address Foreign Address State

tcp 0 0 10.0.0.1:8888 0.0.0.0:* LISTEN

tcp 0 0 10.0.0.1:8888 10.0.0.3:16153 ESTABLISHED

如果我们从一台主机尝试使用目标地址不是10.0.0.1(甚至包括本地地址127.0.0.1)连接服务器,连接请求不会被TCP接受,如果使用tcpdump查看,SYN报文段引发了服务器的RST报文段,如清单13-9所示。

清单 13‑9 拒绝基于服务器本地IP地址的连接请求

1

22:29:19.905593 IP 127.0.0.1.1292 > 127.0.0.1.8888:S 591843787:591843787(0) win 32767

<mss 16396,sackOK,timestamp 3587463952 0,nop,wscale 2>

2

22:29:19.906095 IP 127.0.0.1.8888 > 127.0.0.1.1292:R 0:0(0) ack 591843788 win 0

服务器应用程序不会看到连接请求——拒绝是操作系统的TCP模块根据应用程序指定的本地地址和到达的SYN报文段中的目的地址来完成的。可以看到限制本地IP地址是相当严格的。

13.7.3 限制外部端点

在第10章中,我们看到UDP服务器除了指定本地IP地址和本地端口号外,还可以指定外部IP地址和外部端口号。在[RFC0793]中给出的TCP抽象接口函数允许服务器指定全限定的外部端点(等待特定客户端发出主动打开)或不指定外部端点(等待任何客户端)来执行被动打开。

不幸的是,普通的伯克利套接字API没有提供这样做的方法,即服务器不能指定客户端端点,而是等待连接到达,然后检查客户端的IP地址和端口号。表13-3总结了TCP服务器可以建立的三种地址绑定类型。

表格 13‑3 TCP服务器可使用的地址和端口号绑定选项

本地地址

外部地址

限制

说明

local_IP.lport

foraddr.foreign_port

一个客户端

通常不支持

local_IP.lport

*.*

一个本地端点

不常见(用于DNS服务器)

*.local_port

*.*

一个本地端口

最常见,支持多个地址族(IPv4/IPv6)

表中所有情况,local_port是服务器指定的端口号,并且 local_IP必须是本地系统使用的单播IP地址。表中三行的顺序是TCP模块确定哪个本地端点接受到来的连接请求的顺序,首先尝试最特定的绑定(第一行,如果支持的话),最后尝试最不特定的绑定(最后一行,两个IP地址通配符)。对于支持IPv4和IPv6(“双栈”)的系统,端口空间是合并的,这意味着编写一个绑定IPv6地址端口的服务器也会绑定到IPv4地址的同一个端口。

13.7.4 到达连接队列

并发服务器会生成一个新的进程或线程来处理每个客户端,因此侦听服务器应该时刻准备好处理即将到达的连接请求,这是使用并发服务器的根本原因。侦听服务器创建一个新的进程时可能会有多个连接请求到达,或者操作系统正忙着运行其它高优先级进程时也可能会有多个连接请求到达,或者更坏的情况是服务器正遭受虚假连接请求攻击,TCP如何处理这些场景?

为了充分探讨这个问题,我们首先要理解新连接在被应用程序可以使用前可能处于两种状态。第一种情况是尚未完成但已收到SYN报文段(连接处于SYN_RCVD状态),第二种情况是已经完成三次握手并处于ESTABLISHED状态但尚未被应用程序接受的连接,在操作系统内部通常有两个不同的连接队列,分别对应上述两种情况。

应用程序对这些队列大小的控制是有限的。传统上,使用伯克利套接字API的应用程序只能间接控制这两个队列的大小之和。在现代的Linux内核中,这种行为已改为第二种情况的连接数(已建立连接),因此,应用程序可以限制已完成并等待处理的连接的数量。在Linux中,适用以下规则:

1. 当连接请求到达(即SYN报文段)时,检查系统级参数net.ipv4.tcp_max_syn_backlog(默认1000[h12] ),如果处于SYN_RCVD状态的连接数超过这个阈值,则拒绝到达的连接。

2. 每个侦听端点都有一个固定长度的连接队列,这些连接已被TCP完全接受(即三次握手已经完成),但尚未被应用程序接受,应用程序为该队列(通常称为backlog)指定一个上限,backlog必须介于0和系统最大值(net.core.somaxconn,默认128(含))之间。

需要记住的是backlog值指定的仅仅是一个侦听端点的排队连接的最大值,所有排队的连接已被TCP接受并正在等待应用程序接受。backlog对系统已建立的最大连接数或并发服务器可并发处理的客户机端数量没有任何影响。

3. 如果侦听端点队列中仍有空间分配给新连接,TCP模块将确认SYN报文段并完成连接,在接收到三次握手的第三个报文之前,侦听端点的服务器应用程序不会看到这个新连接。另外,当客户端主动打开顺利完成后,服务器应用程序收到有新连接的通知之前,客户端认为服务器已经准备好接收数据。如果发生这种情况,服务器的TCP将对到达的数据进行排队。

4. 如果队列上中没有足够的空间分配给新连接,TCP就会延迟对SYN报文段的响应,以便应用程序有机会跟上节奏。Linux在这方面有些独特——它尽可能的不忽略到达的连接,如果系统控制变量net.ipv4.tcp_abort_on_overflow设置后,新到达的连接会被重置报文段重置。

在队列溢出时发送重置报文段通常是不可取的,而且默认情况下此功能也没有打开。客户端尝试连接服务器,如果在交换SYN报文段期间收到一个重置报文段,它可能错误地认为服务不存在(而不是认为服务存在并且十分繁忙)。繁忙实际上是一种“可更正”错误或暂时错误,而不是系统错误。通常,当队列满时,应用程序或操作系统十分繁忙,并阻止应用程序为到来的连接提供服务,这种情况在短时间内可能会改变。但是,如果服务器的TCP使用重置报文段进行响应,客户端的主动打开将终止(这是服务器未启动时看到的情况)。如果没有使用重置报文段响应,且服务器对填满队列的已接受的连接无法抽出时间处理时,根据正常的TCP机制,客户机的主动打开最终会超时,在Linux中,正在连接的客户端只是在很长一段时间内慢下来——它们既不会超时,也不会被重置。

借助sock程序可以看到到达连接队列满时所发生的情况。运行sock时指定一个新选项(-O),该选项告诉sock程序在创建侦听端点后暂停,然后再接受连接请求。如果在暂停期间运行多个客户端,服务器接受的连接队列会被填充,使用tcpdump可以看到所发生的情况。

Linux% sock -s -v -q1 -O30000 6666

-q1选项将侦听端点的backlog设置为1,-O30000选项导致程序在接受任何客户端连接之前休眠30000秒(基本上是一段很长的时间,大约8小时)。如果我们现在尝试持续连接到这个服务器,前四个连接将会立即完成,在此之后,每9秒完成两个连接。其他操作系统对此的处理方式有很大不同,例如,在Solaris 8和FreeBSD 4.7中,前两个连接会立即处理,第三个连接会超时,后续连接也会超时。

清单13-10显示了连接到FreeBSD服务器的Linux客户端的tcpdump输出,服务器使用刚才给出的参数运行sock程序(当TCP连接建立时——三次握手完成时——我们已用粗体标出了客户端端口号)。

清单 13‑10 FreeBSD服务器立即接受两个连接,后续连接没有收到响应,最终客户端超时

1

21:28:47.399872 IP (tos 0x0, ttl 64, id 46646, offset 0,flags [DF], proto 6, length: 60)

63.203.76.212.2461 > 169.229.62.97.6666:S [tcp sum ok] 2998137201:2998137201(0) win 5808

<mss 1452,sackOK,timestamp 4102309703 0,nop,wscale 2>

2

21:28:47.413770 IP (tos 0x0, ttl 47, id 6876, offset 0,flags [DF], proto 6, length: 60)

169.229.62.97.6666 > 63.203.76.212.2461:

S [tcp sum ok] 5583769:5583769(0) ack 2998137202 win 1460

<mss 1412,nop,wscale 0,nop,nop,timestamp 219082980 4102309703>

3

21:28:47.414058 IP (tos 0x0, ttl 64, id 46648, offset 0,flags [DF], proto 6, length: 52)

63.203.76.212.2461 > 169.229.62.97.6666:. [tcp sum ok] 1:1(0) ack 1 win 1452

<nop,nop,timestamp 4102309717 219082980>

4

21:28:47.423673 IP (tos 0x0, ttl 64, id 19651, offset 0,flags [DF], proto 6, length: 60)

63.203.76.212.2462 > 169.229.62.97.6666:S [tcp sum ok] 2996964252:2996964252(0) win 5808

<mss 1452,sackOK,timestamp 4102309727 0,nop,wscale 2>

5

21:28:47.436897 IP (tos 0x0, ttl 47, id 26581, offset 0,flags [DF], proto 6, length: 60)

169.229.62.97.6666 > 63.203.76.212.2462:

S [tcp sum ok] 3761536245:3761536245(0) ack 2996964253 win 1460

<mss 1412,nop,wscale 0,nop,nop,timestamp 219082983 4102309727>

6

21:28:47.437186 IP (tos 0x0, ttl 64, id 19653, offset 0,flags [DF], proto 6, length: 52)

63.203.76.212.2462 > 169.229.62.97.6666:. [tcp sum ok] 1:1(0) ack 1 win 1452

<nop,nop,timestamp 4102309741 219082983>

7

21:28:47.446198 IP (tos 0x0, ttl 64, id 24292, offset 0,flags [DF], proto 6, length: 60)

63.203.76.212.2463 > 169.229.62.97.6666:S [tcp sum ok] 2991331729:2991331729(0) win 5808

<mss 1452,sackOK,timestamp 4102309749 0,nop,wscale 2>

8

21:28:50.445771 IP (tos 0x0, ttl 64, id 24294, offset 0,flags [DF], proto 6, length: 60)

63.203.76.212.2463 > 169.229.62.97.6666:S [tcp sum ok] 2991331729:2991331729(0) win 5808

<mss 1452,sackOK,timestamp 4102312750 0,nop,wscale 2>

9

21:28:56.444900 IP (tos 0x0, ttl 64, id 24296, offset 0,flags [DF], proto 6, length: 60)

63.203.76.212.2463 > 169.229.62.97.6666:S [tcp sum ok] 2991331729:2991331729(0) win 5808

<mss 1452,sackOK,timestamp 4102318750 0,nop,wscale 2>

10

21:29:08.443031 IP (tos 0x0, ttl 64, id 24298, offset 0,flags [DF], proto 6, length: 60) 6

3.203.76.212.2463 > 169.229.62.97.6666:S [tcp sum ok] 2991331729:2991331729(0) win 5808

<mss 1452,sackOK,timestamp 4102330750 0,nop,wscale 2>

11

21:29:32.439406 IP (tos 0x0, ttl 64, id 24300, offset 0,flags [DF], proto 6, length: 60)

63.203.76.212.2463 > 169.229.62.97.6666:S [tcp sum ok] 2991331729:2991331729(0) win 5808

<mss 1452,sackOK,timestamp 4102354750 0,nop,wscale 2>

12

21:30:20.432118 IP (tos 0x0, ttl 64, id 24302, offset 0,flags [DF], proto 6, length: 60)

63.203.76.212.2463 > 169.229.62.97.6666:S [tcp sum ok] 2991331729:2991331729(0) win 5808

<mss 1452,sackOK,timestamp 4102402750 0,nop,wscale 2>

TCP接受的第一个客户端连接请求来自2461端口(段1~3),第二个客户端连接请求来自2462端口也被TCP接受了(段4~6),服务端应用程序仍然处于睡眠状态,还没有接受这两个连接,这一切都是由操作系统中的TCP模块完成的。另外,两个客户端已成功地从主动打开返回,因为三次握手已完成。

我们尝试启动第三个客户端,它的SYN报文段出现在段7(端口2463),但是服务器端TCP忽略该SYN报文段,因为侦听端点的队列已经满了,段8~12中客户端使用二进制指数退避算法重传其SYN报文段。在FreeBSD和Solaris中,队列满时TCP会忽略到来的SYN。

回想一下,如果侦听者队列还有空间,TCP就会接受到达的连接请求(即SYN报文段),接受连接前应用程序没有机会查看它来自哪里(源IP地址和源端口号),这不是TCP协议所要求的,它只是一种常见的实现技术(即伯克利套接字的工作方式)。如果使用了伯克利套接字API的替代方案(例如,TLI/XTI),应用程序就可以知道连接请求何时到达,并允许应用程序选择是否接受连接。尽管TLI在理论上提供了这种功能,但它从未完全流行起来,因此实际上可选择的只有伯克利套接字提供的TCP接口。

注意在使用伯克利套接字TCP中,当应用程序被告知连接到达时,TCP的三次握手已经结束,这意味着TCP服务器无法让客户端的主动打开失败。当一个新的客户端连接被传给服务器应用程序时,TCP的三次握手已经结束了,并且客户端的主动打开已经成功完成。如果服务器查看客户端的IP地址和端口号后,并且决定不向该客户端提供服务,服务器能做的是要么关闭连接(导致发送FIN报文段)要么重置连接(导致发送RST报文段)。上述任何一种情况下,主动打开完成时客户端认为一切正常,并可能已经向服务器发送了请求。其它传输层协议实现可能会为应用程序提供连接到达与连接接受隔离(即OSI传输层),但TCP没有提供。

13.8 TCP连接管理相关的攻击

SYN Flood是一种TCP DoS攻击,是由一个或多个恶意客户端生成一系列TCP连接尝试(SYN段)并将其发送到服务器,客户端通常使用“伪造”(例如,随机选择)源IP地址。服务器为每个半开连接分配一定数量的连接资源,由于连接从未建立,服务器会保持大量的半开连接,并且会因为内存耗尽开始拒绝为合法请求提供服务。

这种攻击有点难以防御,因为很难区分合法的连接尝试和SYN Flood攻击。用来处理这个问题的一种机制叫做SYN cookie [RFC4987]。SYN cookie的主要思想是,当SYN到达时,连接的大部分信息将会编码并保存到SYN ACK报文段的序号字段中,采用SYN cookie的主机不需要为到来的连接请求分配任何存储空间——只有当SYN ACK报文段被确认后(并且已返回初始序号)才分配实际内存,在这种情况下,可以重新得到重要的连接参数,并将连接置于ESTABLISHED状态。

生成SYN cookie需要服务器仔细的选择TCP ISN,实际上,服务器须将所有必要的状态编码到SYN ACK的序号字段中,这些状态会在合法客户端的确认号中返回给服务器。有很多方法可以做到这一点,这里我们讨论Linux采用的技术。

服务器收到到达的SYN时,它使用如下方式构造自己的ISN(在SYN ACK报文段中返回给客户端):高5位为t mod 32,其中t是一个32位的计数器,每64秒加1。接下来的3位是服务器的MSS的编码值(8种可能之一)。剩下的24位为服务器选择的密码散列算法对连接四元组和t进行加密的结果(关于密码散列的详细解释参见第18章)。

当启用SYN cookie时,服务器总会回应一个SYN ACK(与典型的TCP连接建立一样),并且服务器收到合法的ACK(使用t作为密码散列输入可得到相同输出)时能够重建到达的SYN队列。这种方法至少有两个缺点:首先,由于MSS参与编码,该方案禁止使用任意大小的段。其次,不太严重,建立连接周期过长(超过64秒)可能无法正常工作,因为计数器会回绕。因为这些原因,该功能默认不启用。

对TCP的另一种降低维度攻击与PMTUD有关。在这种情况下,攻击者伪造一个包含非常小的MTU值(例如,68字节)的ICMP PTB消息,迫使TCP将数据放入非常小的数据包中,从而大大降低了性能。有几种方法来解决可以解决这个问题,最粗暴的方法是简单地禁用主机的PMTUD,还可以选择在接收到下一跳MTU小于576字节的ICMP PTB消息时禁用PMTUD,还有一种Linux实现的并在前面简单提到的方法,总是将某个固定值作为最小数据包大小(为了TCP的大数据包),对于更大的数据包简单的关闭IPv4 DF域,这种方法与完全禁用PMTUD相比更具有吸引力。

另一种类型的攻击是破坏现有TCP连接并可能接管它(称为劫持),这些形式的攻击第一步通常是让两个TCP端点“失去同步”,失去同步后再通信,端点就会使用无效的序号,它们是序号攻击的典型例子[RFC1948]。至少有两种方式可实现上述攻击:一种是在连接建立期间引发无效的状态转换(类似于TWA,参见第13.6.4节),另一种是在ESTABLISHED状态下生成额外的数据。一旦两个端点不能再通信(但认为它们间有一个打开的连接),攻击者就可以将流量注入到连接中,且注入的流量被认为(至少TCP认为)是有效的。

有一类攻击被称为欺骗攻击,这类攻击所所用的TCP报文段是由攻击者精心准备的,用以中断或改变现有TCP连接行为。[RFC4953]讨论了此类各种各样的攻击及对应的防御技术。攻击者可以生成一个伪造的重置报文段并将其发给现有的TCP端点,如果连接四元组和校验和正确且序号在范围内,重置报文段将导致连接终止。这种攻击受到越来越多的关注,因为随着网络变得更快,为了维持性能(见第15章)被认为“处于窗口内”的序号范围越来越大。还有一些其它类型的欺骗(并与泛洪攻击相结合)报文段(SYN,甚至ACK)也会导致各种各样的问题。防御技术包括对每个报文段进行认证(例如,使用TCP-AO选项)、要求重置报文段具有特定的序号而不是某个范围、要求时间戳选项中有特定的值、使用其它形式的cookie,在cookie中除了非关键数据都要依赖连接的精确信息或依赖密钥。

欺骗攻击虽然不是TCP协议的一部分,但却影响TCP的运行。例如,ICMP协议可用来改变PMTUD行为,还可以用来表示端口或主机不可用,这通常会导致TCP连接终止。[RFC5927]中描述了许多这种类型的攻击并提出了一些提高健壮性的建议以防御ICMP欺骗消息,这些建议要求不仅要验证ICMP消息,还要尽可能多的验证包含的TCP段,例如,所包含的段应该有正确的4元组和序号。

13.9 小结

两个进程使用TCP交换数据之前,它们必须先建立连接,交换数据完成后终止连接。本章详细介绍了如何使用三次握手建立连接以及如何使用四个报文段终止连接。我们还了解了TCP如何处理同时打开和关闭操作以及如何处理各种选项,包括选择确认、时间戳、最大段大小、认证选项和用户超时选项。

本章使用tcpdump和Wireshark展示了TCP的行为以及TCP首部字段的使用。我们还了解了连接建立是如何超时的、重置报文段是如何发送和解释的、半开连接会发生什么、TCP是如何提供半关闭的。TCP既限制了主动打开时尝试连接的次数,也限制了被动打开后尝试连接的次数。

状态转换图是理解TCP运行的基础,本章介绍了连接建立和终止所发生的状态转换。本章还讨论了TCP连接建立对并发TCP服务器设计的影响。

TCP连接由4元组唯一定义:本地IP地址、本地端口号、外部IP地址和外部端口号。每当连接终止时,端点必须继续保持该连接的信息,这是由TIME_WAIT状态来处理的,执行主动关闭的端点保持此状态2MSL时间,可避免相同连接旧实例的报文段的影响。当新连接尝试使用相同的4元组时,使用时间戳选项可以减少等待时间,还有助于检测序号回绕及更好的RTT测量。

TCP很容易受到资源耗尽和欺骗攻击的攻击,但已经研究出了很多方法来抵御这类问题。此外,TCP还会受到ICMP等其他协议的影响,通过仔细处理ICMP消息返回的原始数据报文,可以为ICMP提供额外的保护。最后,TCP可以与其他层提供安全性的协议结合使用(如第18章描述的IPsec和TLS/SSL),这是现在的标准做法。


[h1]啥意思

[h2]此实验在2台Linux上做,一台为服务器,一台为客户端

[h3]服务器端主动中止,所以服务器端连接状态为TIME_WAIT

[h4]客户端服务器在同一台机器上

[h5]连接还处于TIME_WAIT,所以报地址已使用

[h6]TIME_WAIT已结束,服务器在客户端重启前已退出,所以连接拒绝

[h7]服务器和客户端在2台机器上,服务器中止前的启动要指定-A选项,且中断后不需要再重启客户端了

[h8]服务器中止后的启动也要指定-A选项

[h9]客户端与服务器在同一机器上

[h10]客户端第1次启动时要指定-A,重启时也要指定-A,客户端重启时服务器已退出,不用重启服务器,因为此端口对应的连接为2MSL等待状态

[h11]暂无Windows版

[h12]有的Linux为2048

,