Skip to content

Latest commit

 

History

History
331 lines (207 loc) · 24.1 KB

tcp.md

File metadata and controls

331 lines (207 loc) · 24.1 KB
date author title url tags series categories toc draft
2022-04-03 12:50:03 +0800
Rustle Karl
TCP 协议详解
posts/protocols/docs/net/tcp/README
Protocols
README
Protocols 学习笔记
学习笔记
true
false

TCP 报文

TCP报文

TCP数据包每次能够传输的最大长度 = MTU(1500B) - IP头(20B)- TCP头(20B)= 1460Bytes

  • 源端口号与目的端口号:标识了发送方与接收方的地址,IP地址和端口号合称为套接字
  • 序列号和确认号:32 位序列号与 32 位确认序号。序列号与确认号可以理解成两个通信进程在收发数据的时候互相应答的信息。序列号是进程发送消息的号码,而确认号是期望目的进程返回的号码。进行比对,从而验证数据包是否到达。
  • 4 位 TCP 报头长度:这里的四位 TCP 报头长度,可以理解成四个比特位表示长度,四位比特位表示的值乘以四就是该 TCP 头部的长度。由图可知,报头最短长度为 20 字节,也就是说这里的四位 TCP 报头长度默认为 0101。并且 TCP 报头长度不可超过 15*4=60 个字节。
  • 标志位:共 6 个,即 URG、ACK、PSH、RST、SYN、FIN 等,具体含义如下:
    • URG:紧急指针(urgent pointer)有效。紧急标志为"1"表明该位有效。
    • ACK:确认序号有效。表明确认编号栏有效。大多数情况下该标志位是置位的。TCP报头内的确认编号栏内包含的确认编号(w+1)为下一个预期的序列编号,同时提示远端系统已经成功接收所有数据。
    • PSH:推标志。该标志置位时,接收端不将该数据进行队列处理,而是尽可能快地将数据转由应用处理。
    • RST:复位标志。用于复位相应的TCP连接。重置连接,让之前的 SYN 等无效,必须重新发起。
    • SYN:同步标志。表明同步序列编号栏有效。发起一个新连接;
    • FIN:结束标志。释放一个连接。
  • 16 位窗口大小:窗口大小标志着 TCP 缓冲区内部剩余空间的大小,起到一个流量控制的作用。如果窗口满了,那么这个时候是不允许数据接收的。后面到达的数据会被丢失。
  • 16 位校验和:这里的校验和由发送端填充,CRC 校验。接收端校验数据的时候如果校验不通过,那么认为数据有问题。此处的校验和不仅仅校验 TCP 首部,还校验数据部分。
  • 16 位紧急指针:标识哪部分的数据为紧急数据。

三次握手

在 TCP/IP 协议中,TCP 协议提供可靠的连接服务,采用三次握手建立一个连接。

  • 第一次握手:Client 先产生一个初始序列号 Seq = ISN(C), 作为 SYN 并将该数据包发送给 Server,Client 进入 SYN_SENT 状态,等待 Server 确认。

  • 第二次握手:Server 收到数据包后也发送自己的 SYN 报文作为响应,并初始化序列号 Seq = ISN(S),为了确认 Client 的 Seq,Server 将 Client 发送的 Seq 加 1,作为 ACK 发送给 Client,Server 进入 SYN_RCVD 状态。SYN 为 synchronize 的缩写,ACK 为 acknowledgment 的缩写。

  • 第三次握手:为了确认 Server 的 SYN,Client 将 Server 发送的 Seq 加 1,作为 ACK 发送给 Server。Client 和 Server 进入 ESTABLISHED 状态,完成三次握手,随后 Client 与 Server 之间可以开始传输数据了。

通过这样的三次握手,客户端与服务端建立起可靠的双工连接,开始传送数据。 三次握手的最主要目的是保证连接是双工的,可靠更多的是通过重传机制来保证的。

Scapy 实现三次握手

from scapy.layers.inet import IP, TCP
from scapy.sendrecv import sr1

dst = "192.168.0.106"
dport = 5555
sport = 9001

syn_packet = IP(dst=dst) / TCP(dport=dport, sport=sport, flags="S", seq=17)

syn_ack_packet = sr1(syn_packet)

ack_packet = IP(dst=dst) / TCP(
    dport=dport,
    sport=sport,
    flags="A",
    seq=18,
    ack=syn_ack_packet.seq + 1,
)

sr1(ack_packet)

半连接、半打开、半关闭状态

  • 半连接状态:发生在 TCP 三次握手过程中,客户端向服务器发起连接,服务器也进行了回应,但是客户端却不进行第 3 次握手。(SYN_RECV)

  • 半打开状态:在 TCP 连接中,如果某一端关闭了连接或者是异常关闭,则该连接处于半打开状态。解决半打开问题:引入心跳机制就可以察觉半打开状态。

  • 半关闭状态:当 TCP 链接中客户端向服务器发送 FIN 请求关闭,服务端回应 ACK 之后,并没有立即发送 FIN 给客户端,客户端就处于半关闭状态,此时客户端可以接收服务器发送的数据,但是客户端已经不能再向服务器发送数据。

SYN flood 攻击

在三次握手过程中,服务器发送 SYN-ACK 之后,收到客户端的 ACK 之前的 TCP 连接称为半连接(half-open connect)。此时服务器处于 SYN_RCVD 状态。当收到 ACK 后,服务器转入 ESTABLISHED 状态。

SYN 攻击就是攻击客户端在短时间内伪造大量不存在的 IP 地址,向服务器不断地发送 SYN 包,服务器回复确认包,并等待客户的确认,由于源地址是不存在的,服务器需要不断的重发直至超时,这些伪造的 SYN 包将长时间占用未连接队列,在启动监听时程序可以设置等待队列的大小。

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

server.bind(("0.0.0.0", 9090))
server.listen(5) # SYN_RECV 等待队列的大小

while True:
    client, client_address = server.accept()
    client.send("OK")

一旦队列满了,那么正常的 SYN 请求被丢弃,导致无法连接。目标系统运行缓慢,严重者引起网络堵塞甚至系统瘫痪。

一般较新的 TCP/IP 协议栈都对这一过程进行修正来防范 Syn 攻击,修改 TCP 协议实现。主要方法有 SynAttackProtect 保护机制、SYN-cookies 技术增加最大半连接和缩短超时时间等。但是不能完全防范 syn 攻击。

// 检测是否被Syn攻击
netstat -n -p TCP | grep SYN_RCVD

为什么需要三次握手?

为了保证服务端能收接受到客户端的信息并能做出正确的应答而进行前两次(第一次和第二次)握手,为了保证客户端能够接收到服务端的信息并能做出正确的应答而进行后两次(第二次和第三次)握手

防止发送者伪造 IP 来源地址。三次握手,伪造难度很大,几乎不可能伪造。

在三次握手过程中,如果服务器一直收不到客户端的ack会发生什么?

服务端会给每个待完成的半连接都设一个定时器,如果超过时间还没有收到客户端的 ACK 消息,则重新发送一次 SYN-ACK 消息给客户端,直到重试超过一定次数时才会放弃。这个时候服务器需要分配内核资源维护半连接

初始序列号 Seq 为什么要随机初始化?

这样做主要是为了保证网络安全,如果不是随机产生初始序列号,黑客将会以很容易的方式获取到你与其他主机之间通信的初始化序列号,并且伪造序列号进行攻击,这已经成为一种很常见的网络攻击手段。

四次挥手

  • 第一次挥手:Client 发送一个 FIN,Seq=K,用来关闭 Client 到 Server 的数据传送,Client 进入 FIN_WAIT_1 状态;

  • 第二次挥手:Server 收到 FIN 后,发送一个 ACKK+1 给 Client,确认序号为收到序号+1, 与 SYN 相同,一个 FIN 占用一个序号,Server 进入 CLOSE_WAIT 状态;

  • 第三次挥手:Server 发送一个 FIN,Seq=L,用来关闭 Server 到 Client 的数据传送,Server 进入 LAST_ACK 状态;

  • 第四次挥手:Client 收到 FIN 后,Client 进入 TIME_WAIT 状态,接着发送一个 ACK(L+1) 给 Server,确认序号为收到序号 +1,Server 进入 CLOSED 状态,完成四次挥手。

为什么建立连接是三次握手,而关闭连接却是四次挥手呢?

在三次握手时,服务端可以把 ACK 和 SYN(ACK 起应答作用,而 SYN 起同步作用)放在一个报文里来发送。但是四次挥手时,服务端发送的FIN与ACK是分开发送的。

原因在于:首先FIN 信号是由于调用 close 所以才发送的,而ACK 是由内核发送的,所以ACK 报文和 FIN 报文在发送的时间上都是分开的,不一定能同时发送。但是三次握手的时候发送 SYN 是由内核直接完成的,所以这就可以达到一个同步发送的情况。

CLOSE_WAIT 状态有什么影响?

如果服务器的代码没有调用 close,那么意味着并没有发送 FIN 结束报文段。那么也就是说,此连接的服务器长期保持在 CLOSE_WAIT 状态,这会有什么影响?

服务器长期保持在 CLOSE_WAIT 状态,也就是说分配的文件描述符并没有关闭并归还。那么大量的 CLOSE_WAIT 存在的话,就会导致一种资源的泄漏,可能到最后就没有可分配的文件描述符了,那么就会使一些客户端无法连接,从而造成不可估量的影响。

socket 中的 close 是一次就关闭的吗?半关闭状态是怎么产生的?

使用 close 中止一个连接,但它只是减少文件描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为 0 时才关闭连接。

客户端的半关闭状态:收到服务器的 ACK 后,暂时关闭写端,但是读端并没有关闭,依然可以接受来自服务器的数据。

如果已经建立了连接, 但是客户端突发故障了怎么办?

TCP 设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为 2 小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔 75 分钟发送一次。若一连发送 10 个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。

TIME_WAIT 状态

TIME_WAIT 状态对大并发服务器的影响,应尽可能在服务器避免出现 TIME_WAIT 状态,如果服务器端主动断开连接,服务端就会进入 TIME_WAIT 状态主动发起关闭连接的一方,**协议设计上,应该让客户端主动断开连接,这样就把 TIME_WAIT 状态分散到大量的客户端。**如果客户端不活跃了,**一些客户端不断开连接,这样子就会占用服务器端的连接资源。**服务器端也得有个机制踢掉不活跃的连接。

  • 为什么需要 TIME_WAIT 状态?为什么 TIME_WAIT 状态还需要等 2MSL 后才能返回到 CLOSED 状态?

保证可靠可靠性是 TCP 最根本的特征地终止 TCP 连接:处于 TIME_WAIT 状态的客户端会向服务端发送 ACK,如果此时 ACK 丢失,目的端会超时重传 FIN 报文段,目的端收到重传的报文段最少需要 2MSL,所以发送端会等待 2MSL 时间;客户端在发送 ACK 后,再等待 2MSL 时间,可以使本次连接所产生的数据段从网络中消失,从而保证关闭连接后不会有还在网络中滞留的数据段去骚扰服务端。

  • 为什么是 2MSL ?

我们知道服务端收到 ACK,关闭连接。但是客户端无法知道 ACK 是否已经到达服务端,于是开始等待?等待什么呢?假如 ACK 没有到达服务端,服务端会为 FIN 这个消息超时重传 timeout retransmit ,那如果客户端等待时间足够,又收到 FIN 消息,说明 ACK 没有到达服务端,于是再发送 ACK,直到在足够的时间内没有收到 FIN,说明 ACK 成功到达。这个等待时间至少是:服务端的 timeout + FIN 的传输时间,为了保证可靠,采用更加保守的等待时间 2MSL。客户端发出 ACK,等待 ACK 到达对方的超时时间 MSL 最大报文生存时间,等待 FIN 的超时重传,也是 MSL,所以如果 2MSL 时间内没有收到 FIN,说明对方安全收到 ACK。

  • TIME_WAIT会带来哪些问题?

作为服务器,短时间内关闭了大量的 Client 连接,就会造成服务器上出现大量的 TIME_WAIT 连接,占据大量的 tuple,严重消耗着服务器的资源;作为客户端,短时间内大量的短连接,会大量消耗 Client 机器的端口,毕竟端口只有 65535 个,端口被耗尽了,后续就无法再发起新的连接了。

  • 服务端 time_wait 过多的处理办法?通过调整内核参数解决,编辑文件 /etc/sysctl.conf,加入以下内容:
/*表示开启SYN Cookies。当出现SYN等待队列溢出时,
启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭*/
net.ipv4.tcp_syncookies = 1;
/*表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,
默认为0,表示关闭*/
net.ipv4.tcp_tw_reuse = 1;
/*表示开启TCP连接中TIME-WAIT sockets的快速回收,
默认为0,表示关闭*/
net.ipv4.tcp_tw_recycle = 1;
/*修改系默认的 TIMEOUT 时间
然后执行 /sbin/sysctl -p 让参数生效*/
net.ipv4.tcp_fin_timeout = 30;

connect()、listen() 和 accept() 三者之间的关系

connect() 函数分析

对于客户端的 connect() 函数,该函数的功能为客户端主动连接服务器,建立连接是通过三次握手,而这个连接的过程是由内核完成,不是这个函数完成的,这个函数的作用仅仅是通知 Linux 内核,让 Linux 内核自动完成 TCP 三次握手连接,最后把连接的结果返回给这个函数的返回值成功连接为 0,失败为 -1。通常的情况,客户端的 connect() 函数默认会一直阻塞可以使用 fcntl() 函数或者 ioctl() 函数把 connect() 变为非阻塞,直到三次握手成功或超时失败才返回正常的情况,这个过程很快完成。

listen() 函数分析

对于服务器,它是被动连接的。这里需要注意的是,listen() 函数不会阻塞,它主要做的事情为,将该套接字和套接字对应的连接队列长度告诉 Linux 内核,然后,listen() 函数就结束。

所以,只要 TCP 服务器调用了 listen(),客户端就可以通过 connect() 和服务器建立连接,而这个连接的过程是由内核完成。

在被动状态的 socket 有两个队列,一个是正在进行三次握手的 socket 队列,一个是完成三次握手的 socket 队列。在握手完成后会从正在握手队列移到握手完成的队列,此时已经建立连接。

accept() 函数分析

accept() 函数功能是,从连接队列头部取出一个已经完成的连接,如果这个队列没有已经完成的连接,accept() 函数就会阻塞,直到取出队列中已完成的用户连接为止。TCP 的连接队列满后,会延时连接

三次握手、四次挥手总结

滑动窗口(流量控制)

在确认应答机制中,对每一个发送的数据段,都要给一个 ACK 确认应答,收到 ACK 后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差。尤其是数据往返时间较长的时候。那么我们可不可以一次发送多个数据段呢:滑动窗口。

所谓的流量控制就是让发送方的发送速率不要太快,让接收方来得及接收。利用滑动窗口机制可以很方便的在 TCP 连接上实现对发送方的流量控制。

窗口类型

接收端窗口 rwndrecv window:接收端缓冲区大小。接收端将此窗口值放在 TCP 报文的首部中的窗口字段,传送给发送端;

拥塞窗口 cwndcongestion window:发送端缓冲区大小;

发送窗口 swndsend window:发送窗口的上限值 = Min [rwnd, cwnd]。

滑动窗口

如图,假设窗口大小为 4000。那么就是是说图上 1001-5001 之间的数据是可以一次性全部发送,并且不需要等待 ACK 响应的。当发送完这 4000 的数据后,此时等待。等到 ACK 响应中确认收到 1001-2000 的数据时,滑动窗口右移。

以此类推。滑动窗口内部的数据都是已经发送但是没有收到 ACK 应答的数据,滑动窗口左侧都是已经收到了 ACK 应答的数据,滑动窗口右侧是未发送的数据。

滑动窗口处理丢包问题

发送端发送的数据丢包

这里 1001-2000 的数据报文丢失,此时发送端并不知道,继续发送滑动窗口内的报文。这时候接收到了三条重复的 ACK 响应,TCP 协议规定,收到三条相同的 ACK 响应就出发重传。此时重传 1001-2000 的数据报文。重传完毕后,等待接收端的响应。此时接收端响应为下一条是 6001 的数据报文,也就是说在 2001-6000 的报文都接收到了,这段报文被放到了接收端的接收缓冲区中。那么此时滑动窗口继续移动,发送端继续发送。

接收到三个重复的 ACK 响应,就开始重传响应所要求的报文的机制就是快重传速机制。

接收端 ACK 响应丢包

接连三条的 ACK 响应都丢包,但是第四条 ACK 响应到达。此时并不会影响发送端的发送。因为 ACK 响应的是下一条数据包是 4001,这意味着接收端已接收到前面的所以报文数据。所以接收端 ACK 响应丢包其实对发送端发送的影响并不是那么大,后续的 ACK 响应能够处理好这个问题。

快速重传为什么是三次冗余 ACK ?

假如发送方发送 segment “1234567”,接收方接到的是“1235”,接收方接到 5 的那一瞬间,知道 4 有可能丢了,也有可能乱序了,至于是哪种情况,接收方无从知晓。

如果连续收到两个 ACK,极有可能是乱序问题,如果收到三个 ACK,那么很大概率是丢包了,这个值在实际过程中也不一定就是三,可能远大于 3,只是一个经验值。

拥塞控制

拥塞控制也就是考虑当前的网络环境,动态调整窗口大小,没有发生拥塞情况,则窗口增大,拥塞了窗口减小,如此往复,最终应该接近与接收端的窗口大小。

慢启动和拥塞避免

在开始发送信息时,由于不知道具体的网络环境,为避免大量信息造成的拥塞现象,此时的拥塞窗口以最小值即拥塞窗口和接收端窗口中的较小值进行数据发送,并设定门限值作为慢启动算法和拥塞避免算法的分割点。慢启动是指以最小的拥塞窗口按照指数形式递增,达到门限值后,以拥塞避免算法,即线性递增方式增大拥塞窗口,这里递增时间间隔为一个往返时间 RTT。

在上述过程中,无论是窗口大小指数递增或者线性递增,当发生拥塞现象,则门限值更新为当前窗口大小的一半,拥塞窗口大小变为最小值,重复上述递增过程,此时属于网络环境限制,所以在接收端和拥塞窗口两个限制条件中选择拥塞窗口作为限制。

快重传和快恢复

当发送端连续收到三个重复的 ack 时,表示该数据段已经丢失,需要重发。当收到三个表示同一个数据段的 ack 时,不需要等待计时器超时,立即重新发送数据段(当时这三个 ack 要在超时之前到达发送端),因为能够收到接收端的 ack 确认信息,所以数据段只是单纯的丢失,而不是因为网络拥塞导致,所以此时不需要拥塞窗口更新为最小值进行慢启动(如果这样的话,反倒因为拥塞窗口的增长需要时间,可能导致性能降低),此时需要设置拥塞窗口大小为:门限值大小+3,当然此处的门限值已经更新为拥塞窗口值的一半大小,该行为也就是所谓的“乘法减少”,更新之后按照拥塞避免算法继续进行。

不连续的数据段会严重影响 TCP 的传输效率。而快速修复这种不连续,会释放掉占用的空间,加快发送方的传输效率。

拥塞窗口大小为什么先以指数增加再以线性增加?

窗口大小首先以指数递增去探测一下网络的拥塞程度,执行拥塞避免算法后,拥塞窗口线性缓慢增大,防止网络过早出现拥塞。

TCP 状态转换图