TCP协议头示意图
了解TCP协议之前先来看看TCP的协议头(此协议头示意图是以大端填写的)
图1
源端口(Source Port, 16位):发送方的端口号。
目的端口(Destination Port, 16位):接收方的端口号。
序列号(Sequence Number, 32位):发送方发送数据的字节序号,用于数据排序和重组。
确认序号(Acknowledgment Number, 32位):接收方期望收到的下一个字节的序列号,同时也是对之前接收数据的确认。
数据偏移(Data Offset, 4位):TCP头的长度(即数据开始前的TCP头部大小),以
32
位字为单位。这个字段指出了TCP
头有多长,同时也表示数据从哪里开始。保留1(Reserved1, 4位):保留未来使用,目前应设置为
0
。保留2(Reserved2, 2位):保留未来使用,目前应设置为
0
。控制位(Control Bits, 8位):包含多个标志位,如:
URG(紧急指针标志)
ACK(确认序号有效标志)
PSH(推送函数标志)
RST(连接重置标志)
SYN(同步序列编号标志,用于建立连接)
FIN(结束连接标志)
窗口大小(Window Size, 16位):接收方允许发送方发送的数据量(基于接收方的缓冲区大小)。
检验和(Checksum, 16位):整个
TCP
段的检验和,用于错误检测。紧急指针(Urgent Pointer, 16位):仅当
URG
标志置位时才有效,指出在本报文段中有紧急数据的字节数。选项(Options, 可变长度):用于支持各种额外的功能,如最大报文段长度
MSS
、窗口扩大因子、选择性确认SACK
等。选项字段的长度可变,所以数据偏移字段非常重要,它表明数据从哪里开始。填充(Padding, 可变长度):确保
TCP
头部长度是32
位字的整数倍。
TCP协议头实现
下面代码是Linux下/usr/include/netinet/tcp.h
内的tcphdr
实现
typedef uint32_t tcp_seq;
/*
* TCP header.
* Per RFC 793, September, 1981.
*/
struct tcphdr
{
__extension__ union
{
struct
{
uint16_t th_sport; /* source port */
uint16_t th_dport; /* destination port */
tcp_seq th_seq; /* sequence number */
tcp_seq th_ack; /* acknowledgement number */
# if __BYTE_ORDER == __LITTLE_ENDIAN
uint8_t th_x2:4; /* (unused) */
uint8_t th_off:4; /* data offset */
# endif
# if __BYTE_ORDER == __BIG_ENDIAN
uint8_t th_off:4; /* data offset */
uint8_t th_x2:4; /* (unused) */
# endif
uint8_t th_flags;
# define TH_FIN 0x01
# define TH_SYN 0x02
# define TH_RST 0x04
# define TH_PUSH 0x08
# define TH_ACK 0x10
# define TH_URG 0x20
uint16_t th_win; /* window */
uint16_t th_sum; /* checksum */
uint16_t th_urp; /* urgent pointer */
};
struct
{
uint16_t source;
uint16_t dest;
uint32_t seq;
uint32_t ack_seq;
# if __BYTE_ORDER == __LITTLE_ENDIAN
uint16_t res1:4;
uint16_t doff:4;
uint16_t fin:1;
uint16_t syn:1;
uint16_t rst:1;
uint16_t psh:1;
uint16_t ack:1;
uint16_t urg:1;
uint16_t res2:2;
# elif __BYTE_ORDER == __BIG_ENDIAN
uint16_t doff:4;
uint16_t res1:4;
uint16_t res2:2;
uint16_t urg:1;
uint16_t ack:1;
uint16_t psh:1;
uint16_t rst:1;
uint16_t syn:1;
uint16_t fin:1;
# else
# error "Adjust your <bits/endian.h> defines"
# endif
uint16_t window;
uint16_t check;
uint16_t urg_ptr;
};
};
};
TCP协议的三次握手
详细了解之前,我们先看个示意图
图2
TCP
三次握手是TCP/IP
协议用于建立一个网络连接的标准过程。这个过程确保了双方都准备好进行数据传输,并同步了它们的初始序列号。以下是详细的三次握手步骤:
SYN:客户端发送一个
SYN
报文到服务器,这标志着连接的开始请求。在这个报文中,客户端设置一个随机的序列号Seq=J
,这个序列号用于标识发送的数据字节流中的第一个字节。客户端在发送SYN
报文后进入SYN-SENT
状态。SYN-ACK:服务器收到客户端的
SYN
报文后,如果接受连接请求,会回复一个SYN-ACK
报文。在这个报文中,服务器设置自己的一个SYN
序列号Seq=K
,并将确认号ACK
设置为客户端的序列号加1Seq=J+1
。服务器在发送SYN-ACK
报文后进入SYN-RECEIVED
状态。ACK:客户端收到服务器的
SYN-ACK
报文后,发送一个ACK
报文作为响应。确认号ACK
设置为服务器的初始序列号加1Ack=K+1
。这个ACK
完成了三次握手,客户端和服务器都进入ESTABLISHED
状态,此时连接建立成功。
TCP三次握手的几个问题
如果第一步丢失了会发生什么问题?
如果客户端发送的初始同步
SYN
报文在到达服务器之前丢失了,服务器不会知道客户端尝试建立连接。因此,服务器不会发送同步确认SYN-ACK
报文,客户端在等待一段时间后,根据TCP的重传机制,会重新发送SYN
报文。如果第二步丢失了会发生什么问题?
如果服务器发送的
SYN-ACK
报文丢失了,客户端将不会接收到对其SYN
报文的响应,因此不会发送最后的确认ACK
报文。客户端会等待一个超时期后,再次发送SYN
报文。服务器在一段时间内没有收到ACK
响应,会重新发送SYN-ACK报文。如果第三步就丢失了会发生什么问题?
如果客户端发送的
ACK
报文在到达服务器之前丢失了,服务器将不会接收到对其SYN-ACK
报文的确认。在这种情况下,服务器可能会重新发送SYN-ACK
报文,等待客户端的确认。客户端在发送ACK
报文后认为连接已经建立,并会开始发送数据。如果服务器收到这些数据报文,它将利用这些报文的序列号来隐式地确认客户端的ACK
,然后连接就真正建立起来了。如果服务器没有收到数据,它会在超时后关闭连接。如果客户端只发送第一次同步请求会发生什么?
如果客户端发送了
SYN
报文然后停止了进一步的操作,服务器发送的SYN-ACK
报文(假设没有丢失)将不会被确认。服务器会等待ACK
报文,但如果在超时之后仍没有收到确认,它将最终放弃,并关闭这个尝试连接。这种情况在网络扫描或SYN
洪泛攻击中是常见的,攻击者发送SYN
报文来消耗服务器资源(如何防御SYN洪泛攻击?)。TCP三次握手改成两次会有什么问题?
无法确保接收方准备就绪:
在三次握手中,第三次握手(客户端发送的
ACK
响应服务器的SYN-ACK
)确认了客户端接收到了服务器的响应,并且准备好接收数据。如果省略这一步,服务器无法确保客户端已准备好接收数据,可能会在客户端未准备好时发送数据,导致数据丢失。
无法防止旧连接初始化段的干扰:
三次握手机制能够防止“延迟的重复连接初始化段”(即在网络中延迟的
SYN
报文)意外初始化新的连接。如果使用两次握手,这样的延迟报文可能会导致错误的连接建立,因为服务器收到SYN
后立即进入连接状态并回应,无法确认客户端是否真的发起了新的连接请求。
无法同步双方的初始序列号:
三次握手过程中,双方各自发送SYN报文来声明自己的初始序列号。通过这种方式,每一方都能知道对方的序列号,并根据这个序列号进行后续的数据传输。如果只进行两次握手,就失去了一次同步序列号的机会,可能会导致序列号不一致,从而影响数据传输的可靠性。
安全风险增加:
两次握手更容易受到SYN洪泛攻击的影响,因为攻击者只需发送一个SYN包就能迫使服务器分配资源并建立连接状态,这比三次握手时更容易耗尽服务器资源。
TCP协议的四次挥手
同样的,详细了解之前,我们先看个示意图
图3
TCP四次挥手是TCP/IP协议用于终止一个网络连接的过程。这个过程确保了双方都能够完成数据的发送和接收。以下是四次挥手的详细步骤:
第一次挥手 - FIN从客户端发送到服务器:
当客户端完成数据发送任务后,它需要关闭连接。客户端发送一个
FIN Seq=M
报文给服务器。这个FIN
报文表示客户端已经没有数据发送,但仍然可以接收数据。客户端在发送FIN
后进入FIN_WAIT_1
状态。
第二次挥手 - 服务器响应ACK:
服务器收到客户端的FIN报文后,发送一个
ACK
报文作为应答,确认号为客户端的序列号加1Seq=M + 1
。此时,服务器进入CLOSE_WAIT
状态,而客户端收到ACK
后进入FIN_WAIT_2
状态。服务器可能还有数据要发送给客户端,所以它不会立即关闭连接。
第三次挥手 - FIN从服务器发送到客户端:
一旦服务器完成数据发送,它也需要终止连接。服务器发送一个
FIN Seq=N
报文给客户端,服务器进入LAST_ACK
状态。
注:如何服务端没有任何数据需要发送,那么通常第二次和第三次挥手会合并为一次。
第四次挥手 - 客户端响应ACK:
客户端收到服务器的
FIN
报文后,发送一个ACK Seq=N + 1
报文作为应答。客户端进入TIME_WAIT
状态,等待足够的时间以确保服务器接收到了最终的ACK
报文。这个等待时间通常是最大报文生命周期Maximum Segment Lifetime, MSL
的两倍。之后,客户端关闭连接。服务器在收到最终的ACK
报文后也关闭连接。
注:Linux下可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout
命令查看tcp_fin_timeout
的时间,一般为60S,通常MSL
以此参数为依据,也就是MSL=tcp_fin_timeout/2
。
TCP四次挥手的几个问题
TCP为何采用四次挥手来释放连接?
关闭连接时,当收到对方的
FIN
报文通知时,它仅仅表示对方没有数据发送给你了,但未必你所有的数据都全部发送给对方了,所以你未必会马上关闭socket
,也即你可能还需要发送一些数据给对方之后,再发送FIN
报文给对方来表示你没有数据发送给对方了,针对每个FIN
报文,都需要一次Ack
报文,故需要四次挥手。四次挥手并非都需要
4
个报文段,例如当你收到对方的FIN
报文通知时,若你所有的数据都全部发送给对方了,那么可以将ACK
与FIN
合并为一个报文段,这样对方收到FIN
后,再发送一个ACK
报文即可,故此时仅需3
个报文。ACK
与FIN
合并的情况请参看图4
中客户端从状态FIN_WAIT_1
直接到状态TIME_WAIT
。而客户端由状态FIN_WAIT_1
直接到状态CLOSING
的情况对应服务器与客户端同时调用close
函数关闭连接,这种情况比较少见。
MSL存在的目的?
避免数据包混淆:MSL确保在相同的源端口和目的端口之间启动新的连接时,网络中不会有来自旧连接的迷失或延迟的数据包干扰新连接。
管理TIME_WAIT状态:在
TCP
连接终止时,连接会在TIME_WAIT
状态下保持一段时间,这个时间通常是MSL
的两倍。这样做是为了确保连接的一方能够接收到最后的ACK
确认报文,并确保任何延迟的数据包在网络中都消失,从而安全地重用端口。控制资源释放:通过限制数据包在网络中的最大生命周期,可以帮助网络设备(如路由器)和终端设备(如服务器和客户端)及时回收和重用资源,避免因为处理过时数据包而造成的资源浪费。
RFC 793(TCP规范)建议
MSL
设定为2分钟(120秒),这是一个广泛接受的标准值。然而,实际应用中,不同的操作系统和网络设备可能会使用不同的MSL
值,甚至某些情况下允许网络管理员自定义这个值以适应特定的网络环境和需求。需要注意的是,
MSL
是一个抽象的概念,它并不直接对应于网络设备中的具体计时器或参数。相反,它更多地被用作设计和实现网络协议时的参考指标。
TIME_WAIT存在的目的?
实现终止
TCP
全双工连接的可靠性假设最终的ACK
丢失,服务器将重发最终的FIN
,因此客户必须维护状态信息以允许它重发最终的ACK
,如果不维护状态信息,它将响应以RST
,而服务器则把该分节解释成一个错误。如果TCP
打算执行所有必要的工作以彻底终止某个连接上两个方向的数据流,那么它必须能够处理连接终止序列四个分节中任何一个分节丢失的情况,也即主动关闭的那一端必须进入TIME_WAIT
状态,因为它可能不得不重发最终的ACK
。允许老的重复分节在网络中消失我们假设
12.106.32.254
端口1500
和206.168.112.219
端口21
之间有一个TCP
连接,我们关闭这个连接后,在以后某个时候又重新建立起相同的IP
地址和端口之间的TCP
连接。后一个连接称为前一个连接的化身,因为它们的IP
地址和端口号是相同的,TCP
必须防止来自某个连接的老重复分组在连接终止后再现,从而被误解成属于同一个连接的化身。要实现这种功能,TCP
不能给处于TIME_WAIT
状态的连接启动新的化身,既然TIME_WAIT
状态的持续时间是2MSL
,这就足够让某个方向上的分组最多存活MSL
秒即被丢弃,另一个方向上的应答最多存活MSL
秒也被丢弃,通过实施这个规则,我们就能保证当成功建立一个TCP
连接时,来自该连接先前的化身的老重复分组都已在网络中消逝了。
TCP状态转换图
TCP连接的建立和终止可以用状态转换图来说明。这些状态可使用netstat
显示,它是一个在调试客户/服务器应用时很有用的工具。
图4
TCP
为一个连接定义了11
种状态,并且TCP
规则规定如何基于当前状态及在该状态下所接收的分节从一个状态转换到另一个状态。
举例来说,当某个应用进程在CLOSED
状态下执行主动打开时,TCP
将发送一个SYN
,且新的状态是SYN_SENT
。如果这个TCP
接着接收到一个带ACK
的SYN
,它将发送一个ACK
,且新的状态是ESTABLISHED
,这个最终状态是绝大多数数据传送发生的状态。
自ESTABLISHED
状态引出的两个箭头处理连接的终止。如果某个应用进程在接收到文件结束符之前调用close(主动关闭)
,那就转换到FIN_WAIT_1
状态。但如果某个应用进程在ESTABLISHED
状态期间接收到一个FIN(被动关闭)
,那就转换到CLOSE_WAIT
状态。我们用实线表示客户的状态转换,用虚线表示服务器状态转换。图中还注明存在两个我们未曾讨论的转换:一个为同时打开Simultaneous Open
,发生在两端几乎同时发送SYN
,并且这两个SYN
在网络中交错的情形下,另一个为同时关闭Simultaneous Close
,发生在两端几乎同时发送FIN
的情形下。TCPv1
的第18章
中有这两种情况的例子和讨论,它们是可能发生的,不过非常罕见。
TCP的定时器
TCP(传输控制协议)使用一系列计时器(Timers)来管理数据传输过程中的各种事件,确保数据的可靠传输和连接的有效管理。以下是TCP协议中常见的七个计时器:
1. 重传计时器(Retransmission Timer)
用途:用于控制丢失或未被确认的数据段的重传。当TCP发送一个段后,它启动一个重传计时器,等待接收方的确认(ACK)。如果在计时器到期之前未收到ACK,段将被重传。
2. 持续计时器(Persistence Timer)
用途:用于处理零窗口通告问题。当接收方通告的窗口大小为零时,发送方启动持续计时器。计时器到期时,发送方将探测接收方的窗口大小,以决定是否可以继续发送数据。
3. 保活计时器(Keepalive Timer)
用途:如果一个连接在一段时间内没有任何数据传输,保活计时器用于定期发送保活探针,以检查对方是否仍然可达和连接是否仍然有效。
4. TIME_WAIT计时器(2MSL计时器)
用途:在TCP连接终止过程中,当一个TCP端点处于TIME_WAIT状态时,这个计时器确保连接在关闭前保持活动状态足够长的时间,以确保网络中所有的重复段都消失。MSL(Maximum Segment Lifetime)是任何报文段在网络中生存的最大时间,2MSL是等待时间的两倍。
5. FIN_WAIT_2计时器
用途:在某些实现中,当连接一方处于FIN_WAIT_2状态时,可能会使用这个计时器来避免无限期地等待对方的FIN报文。
6. 连接建立计时器(Connection Establishment Timer)
用途:在TCP三次握手过程中,用于限制等待对方响应SYN报文的时间。如果在这个计时器到期之前未能成功建立连接,连接尝试将被放弃。
7. 连接终止计时器(Connection Termination Timer)
用途:在四次挥手断开连接过程中,用于限制等待对方响应FIN报文的时间,以确保连接能够被正确关闭。
这些计时器对于维护TCP连接的稳定性和可靠性至关重要,它们帮助TCP在面对网络延迟、拥塞、丢包等不确定性时,能够采取适当的措施来确保数据的正确传输和连接的有效管理。
TCP其他问题
你了解ISN(Initial Sequence Number)吗?
三次握手的一个重要功能是客户端和服务端交换
ISN
,以便让对方知道接下来接收数据的时候如何按序列号组装数据,如果ISN
是固定的,攻击者很容易猜出后续的确认号,ISN
的公式:ISN = M + F(localhost, localport, remotehost, remoteport)
,其中M
是一个计时器,每隔4
毫秒加1
。F
是一个Hash
算法,根据源IP
、目的IP
、源端口、目的端口生成一个随机数值。要保证Hash
算法不能被外部轻易推算得出。
TCP如何处理序列号回绕?
因为
ISN
是随机的,所以序列号容易就会超过2^31-1
. 而TCP
对于丢包和乱序等问题的判断都是依赖于序列号大小比较的。此时就出现了所谓的TCP
序列号回绕sequence wraparound
问题。怎么解决?序列号模运算:
TCP
使用模运算来处理序列号。这意味着序列号空间是一个循环的空间,当序列号达到2^32-1
并进一步增加时,它会自动回绕到0
。通过使用模运算,TCP
能够无缝地处理序列号回绕。
窗口机制:
TCP
使用窗口机制来控制流量,确保数据传输的可靠性。即使发生序列号回绕,只要保持窗口内的序列号连续,接收方就能正确地识别和重组数据段。
PAWS(保护性ACK窗口):
为了解决在高速网络环境下可能出现的序列号回绕问题,
TCP
引入了一种名为PAWS(Protect Against Wrapped Sequence numbers)
的机制。这是通过在TCP
选项中使用时间戳来实现的,时间戳可以帮助接收方区分新的数据段和旧的(回绕的)数据段。
TCP流量控制
滑动窗口是TCP实现流量控制的一种机制,用于控制发送方在等待确认之前可以发送多少数据。窗口的大小(即窗口容量)是由接收方根据其当前的缓冲区容量动态指定的,确保发送方不会发送超过接收方能够处理的数据量。
发送窗口:决定了发送方在必须停下来等待确认之前可以发送多少数据。窗口向前滑动(增大)意味着可以发送更多的数据。
接收窗口:接收方在ACK报文中通告其接收窗口的大小,告诉发送方它的缓冲区还能接收多少字节的数据,从而防止发送方发送过多数据导致接收方缓冲区溢出。
通过
ss -i| grep -E "rcv_space|cwnd|"
命令可以看到系统中正在运行的TCP
连接相关信息,其中rcv_space
是接收窗口大小,cwnd
是拥塞窗口大小,wscale
窗口缩放选项。TCP拥塞控制
TCP拥塞控制的四个主要阶段包括慢启动(Slow Start)、拥塞避免(Congestion Avoidance)、快速重传(Fast Retransmit)和快速恢复(Fast Recovery)。这些阶段使用一系列参数来动态调整网络条件下的数据传输速率。以下是这些阶段和相关参数的详细说明:
慢启动(Slow Start)
cwnd(拥塞窗口):开始时设置为1个最大报文段大小(MSS,可通过命令
ss -i| grep -E "mss"
查看)。ssthresh(慢启动阈值):初始值通常很高,或者是由底层网络的性质决定的一个估计值。
操作:每当收到一个ACK,
cwnd
增加1个MSS。这意味着每个RTT(往返时间),cwnd
翻倍,呈指数增长。转换条件:当
cwnd
达到ssthresh
,或者发生丢包时,结束慢启动阶段,进入拥塞避免阶段。
拥塞避免(Congestion Avoidance)
操作:进入拥塞避免阶段后,每当收到一个
ACK
,cwnd
按照cwnd += MSS*(MSS/cwnd)
的方式增加,导致cwnd
线性增长。转换条件:如果在拥塞避免阶段发生丢包,
ssthresh
设置为当前cwnd
的一半,cwnd
重置为1MSS
,并重新进入慢启动阶段。
快速重传(Fast Retransmit)
触发条件:接收到三个重复的
ACK
。操作:不等待重传计时器到期,立即重传丢失的报文段。
转换动作:进入快速恢复阶段。
快速恢复(Fast Recovery)
ssthresh:设置为发生丢包时
cwnd
的一半。cwnd:设置为
ssthresh
加上3个MSS
(对应于收到的三个重复ACK
)。操作:对于进一步收到的重复
ACK
,cwnd
每次增加1个MSS。当收到新的ACK
时,表明恢复了丢失的报文段,将cwnd
设置为ssthresh
(即退出快速恢复模式,进入拥塞避免阶段)。
这些参数和操作确保了
TCP
在各种网络条件下的可靠性和效率,通过动态调整传输速率来避免网络拥塞,并在发生拥塞时快速恢复。
评论区