Day 7
TCP 连接建立
TCP 三次握手过程是怎样的?
TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手来进行的。三次握手的过程如下图:
- 一开始,客户端和服务端都处于
CLOSE
状态。先是服务端主动监听某个端口,处于LISTEN
状态
- 客户端会随机初始化序号(
client_isn
),将此序号置于 TCP 首部的序号字段中,同时把SYN
标志位置为1
,表示SYN
报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于SYN-SENT
状态。
- 服务端收到客户端的
SYN
报文后,首先服务端也随机初始化自己的序号(server_isn
),将此序号填入 TCP 首部的序号字段中,其次把 TCP 首部的确认应答号字段填入client_isn + 1
, 接着把SYN
和ACK
标志位置为1
。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于SYN-RCVD
状态。
- 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部
ACK
标志位置为1
,其次确认应答号字段填入server_isn + 1
,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于ESTABLISHED
状态。 - 服务端收到客户端的应答报文后,也进入
ESTABLISHED
状态。
从上面的过程可以发现第三次握手是可以携带数据的,前两次握手是不可以携带数据的。
一旦完成三次握手,双方都处于 ESTABLISHED
状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。
如何在 Linux 系统中查看 TCP 状态?
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt
命令查看。
为什么是三次握手?不是两次、四次?
常见回答的是:“因为三次握手才能保证双方具有接收和发送的能力。”
TCP 连接是用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。
所以,重要的是为什么三次握手才可以初始化 Socket、序列号和窗口大小并建立 TCP 连接。
接下来,以三个方面分析三次握手的原因:
- 三次握手才可以阻止重复历史连接的初始化(主要原因)
- 三次握手才可以同步双方的初始序列号
- 三次握手才可以避免资源浪费
原因一:避免历史连接
来看看 RFC 793 指出的 TCP 连接使用三次握手的首要原因:
The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.
简单来说,三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。
考虑一个场景,客户端先发送了 SYN(seq = 90)报文,然后客户端宕机了,而且这个 SYN 报文还被网络阻塞了,服务端并没有收到,接着客户端重启后,又重新向服务端建立连接,发送了 SYN(seq = 100)报文(注意!不是重传 SYN,重传的 SYN 的序列号是一样的)。
看看三次握手是如何阻止历史连接的:
客户端连续发送多次 SYN(都是同一个四元组)建立连接的报文,在网络拥堵情况下:
- 一个旧 SYN 报文比最新的 SYN 报文早到达了服务端,那么此时服务端就会回一个
SYN + ACK
报文给客户端,此报文中的确认号是 91(90+1)。 - 客户端收到后,发现自己期望收到的确认号应该是 100 + 1,而不是 90 + 1,于是就会回 RST 报文。
- 服务端收到 RST 报文后,就会释放连接。
- 后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。
上述中的旧 SYN 报文称为历史连接,TCP 使用三次握手建立连接的最主要原因就是防止历史连接初始化了连接。
TIP
TIP
如果服务端在收到 RST 报文之前,先收到了新 SYN 报文,也就是服务端收到客户端报文的顺序是:旧 SYN 报文->新 SYN 报文,此时会发生什么?
当服务端第一次收到 SYN 报文,也就是收到 旧 SYN 报文时,就会回复 SYN + ACK
报文给客户端,此报文中的确认号是 91(90+1)。
然后这时再收到新 SYN 报文时,就会回 Challenge Ack (opens new window)报文给客户端,这个 ack 报文并不是确认收到新 SYN 报文的,而是上一次的 ack 确认号,也就是91(90+1)。所以客户端收到此 ACK 报文时,发现自己期望收到的确认号应该是 101,而不是 91,于是就会回 RST 报文。
如果是两次握手连接,就无法阻止历史连接,那为什么 TCP 两次握手为什么无法阻止历史连接呢?
主要是因为在两次握手的情况下,服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接,造成资源浪费。
你想想,在两次握手的情况下,服务端在收到 SYN 报文后,就进入 ESTABLISHED 状态,意味着这时可以给对方发送数据,但是客户端此时还没有进入 ESTABLISHED 状态,假设这次是历史连接,客户端判断到此次连接为历史连接,那么就会回 RST 报文来断开连接,而服务端在第一次握手的时候就进入 ESTABLISHED 状态,所以它可以发送数据的,但是它并不知道这个是历史连接,它只有在收到 RST 报文后,才会断开连接。
可以看到,如果采用两次握手建立 TCP 连接的场景下,服务端在向客户端发送数据前,并没有阻止掉历史连接,导致服务端建立了一个历史连接,又白白发送了数据,妥妥地浪费了服务端的资源。
因此,要解决这种现象,最好就是在服务端发送数据前,也就是建立连接之前,要阻止掉历史连接,这样就不会造成资源浪费,而要实现这个功能,就需要三次握手。
所以,TCP 使用三次握手建立连接的最主要原因是防止历史连接初始化了连接。
TIP
有人问:客户端发送三次握手(ack 报文)后就可以发送数据了,而被动方此时还是 syn_received 状态,如果 ack 丢了,那客户端发的数据是不是也白白浪费了?
不是的,即使服务端还是在 syn_received 状态,收到了客户端发送的数据,还是可以建立连接的,并且还可以正常收到这个数据包。这是因为数据报文中是有 ack 标识位,也有确认号,这个确认号就是确认收到了第二次握手。如下图:
所以,服务端收到这个数据报文,是可以正常建立连接的,然后就可以正常接收这个数据包了。
原因二:同步双方初始序列号
TCP 协议的通信双方, 都必须维护一个序列号, 序列号是可靠传输的一个关键因素,它的作用:
- 接收方可以去除重复的数据;
- 接收方可以根据数据包的序列号按序接收;
- 可以标识发送出去的数据包中, 哪些是已经被对方收到的(通过 ACK 报文中的序列号知道);
可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带初始序列号的 SYN
报文的时候,需要服务端回一个 ACK
应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送初始序列号给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。
四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了三次握手。
而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。
原因三:避免资源浪费
如果只有两次握手,当客户端发生的 SYN
报文在网络中阻塞,客户端没有接收到 ACK
报文,就会重新发送 SYN
,由于没有第三次握手,服务端不清楚客户端是否收到了自己回复的 ACK
报文,所以服务端每收到一个 SYN
就只能先主动建立一个连接,这会造成什么情况呢?
如果客户端发送的 SYN
报文在网络中阻塞了,重复发送多次 SYN
报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
即两次握手会造成消息滞留情况下,服务端重复接受无用的连接请求 SYN
报文,而造成重复分配资源。
TIP
在TCP三次握手过程中,客户端会向服务器发送SYN报文,请求建立连接。服务器收到SYN报文后,会返回一个SYN+ACK报文作为应答。如果服务器在一段时间内没有收到客户端的ACK报文,就会认为这个SYN报文是过时的或重复的,从而将其丢弃。
那么两次握手不是也可以根据上下文信息丢弃 syn 历史报文吗?
这里两次握手是假设由于没有第三次握手,服务端不清楚客户端是否收到了自己发送的建立连接的 ACK 确认报文,所以每收到一个 SYN 就只能先主动建立一个连接这个场景。
当然你要实现成类似三次握手那样,根据上下文丢弃 syn 历史报文也是可以的,两次握手没有具体的实现,怎么假设都行。
小结
TCP 建立连接时,通过三次握手能防止历史连接的建立,能减少双方不必要的资源开销,能帮助双方同步初始化序列号。序列号能够保证数据包不重复、不丢弃和按序传输。
不使用两次握手和四次握手的原因:
- 两次握手:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
- 四次握手:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢?
主要原因有两个方面:
- 为了防止历史报文被下一个相同四元组的连接接收(主要方面);
- 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收;
接下来,详细说说第一点。
假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始:
过程如下:
- 客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。
- 紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;
- 在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。
可以看到,如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。
如果每次建立连接客户端和服务端的初始化序列号都不一样,就有大概率因为历史报文的序列号不在对方接收窗口,从而很大程度上避免了历史报文,比如下图:
相反,如果每次建立连接客户端和服务端的初始化序列号都一样,就有大概率遇到历史报文的序列号刚好在对方的接收窗口内,从而导致历史报文被新连接成功接收。
所以,每次初始化序列号不一样很大程度上能够避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了(因为序列号会有回绕的问题,所以需要用时间戳的机制来判断历史报文。
初始序列号 ISN 是如何随机产生的?
起始 ISN
是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。
RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。
M
是一个计时器,这个计时器每隔 4 微秒加 1。F
是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。
可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。
既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?
MTU
:一个网络包的最大长度,以太网中一般为1500
字节;MSS
:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;
如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?
当 IP 层有一个超过 MTU
大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上一层 TCP 传输层。
这看起来井然有序,但这存在隐患的,那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
当某一个 IP 分片丢失后,接收方的 IP 层就无法组装成一个完整的 TCP 报文(头部 + 数据),也就无法将数据报文送到 TCP 层,所以接收方不会响应 ACK 给发送方,因为发送方迟迟收不到 ACK 确认报文,所以会触发超时重传,就会重发整个 TCP 报文(头部 + 数据)。
因此,可以得知由 IP 层进行分片传输,是非常没有效率的。
所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。
第一次握手丢失了,会发生什么?
当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT
状态。
在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发超时重传机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的。
不同版本的操作系统可能超时时间不同,有的 1 秒的,也有 3 秒的,这个超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。
当客户端在 1 秒后没收到服务端的 SYN-ACK 报文后,客户端就会重发 SYN 报文,那到底重发几次呢?
在 Linux 里,客户端的 SYN 报文最大重传次数由 tcp_syn_retries
内核参数控制,这个参数是可以自定义的,默认值一般是 5。
通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,每次超时的时间是上一次的 2 倍。
当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就不再发送 SYN 包,然后断开 TCP 连接。
所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。
举个例子,假设 tcp_syn_retries 参数值为 3,那么当客户端的 SYN 报文一直在网络中丢失时,会发生下图的过程:
具体过程:
- 当客户端超时重传 3 次 SYN 报文后,由于 tcp_syn_retries 为 3,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。
第二次握手丢失了,会发生什么?
当服务端收到客户端的第一次握手后,就会回 SYN-ACK 报文给客户端,这个就是第二次握手,此时服务端会进入 SYN_RCVD
状态。
第二次握手的 SYN-ACK
报文其实有两个目的 :
- 第二次握手里的 ACK, 是对第一次握手的确认报文;
- 第二次握手里的 SYN,是服务端发起建立 TCP 连接的报文;
所以,如果第二次握手丢了,就会发生比较有意思的事情,具体会怎么样呢?
因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以,如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是客户端就会触发超时重传机制,重传 SYN 报文。
然后,因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。
那么,如果第二次握手丢失了,服务端就收不到第三次握手,于是服务端这边会触发超时重传机制,重传 SYN-ACK 报文。
在 Linux 下,SYN-ACK 报文的最大重传次数由 tcp_synack_retries
内核参数决定,默认值是 5。
因此,当第二次握手丢失了,客户端和服务端都会重传:
- 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由
tcp_syn_retries
内核参数决定; - 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由
tcp_synack_retries
内核参数决定。
举个例子,假设 tcp_syn_retries 参数值为 1,tcp_synack_retries 参数值为 2,那么当第二次握手一直丢失时,发生的过程如下图:
具体过程:
- 当客户端超时重传 1 次 SYN 报文后,由于 tcp_syn_retries 为 1,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。
- 当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。
第三次握手丢失了,会发生什么?
客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 ESTABLISH
状态。
因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。
注意,ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文。
举个例子,假设 tcp_synack_retries 参数值为 2,那么当第三次握手一直丢失时,发生的过程如下图:
具体过程:
- 当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。
什么是 SYN 攻击?如何避免 SYN 攻击?
都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN
报文,服务端每接收到一个 SYN
报文,就进入SYN_RCVD
状态,但服务端发送出去的 ACK + SYN
报文,无法得到未知 IP 主机的 ACK
应答,久而久之就会占满服务端的半连接队列,使得服务端不能为正常用户服务。
先跟大家说一下,什么是 TCP 半连接和全连接队列。
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
- 半连接队列,也称 SYN 队列;
- 全连接队列,也称 accept 队列;
先来看下 Linux 内核的 SYN
队列(半连接队列)与 Accpet
队列(全连接队列)是如何工作的?
正常流程:
- 当服务端接收到客户端的 SYN 报文时,会创建一个半连接的对象,然后将其加入到内核的** SYN 队列**;
- 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
- 服务端接收到 ACK 报文后,从** SYN 队列取出一个半连接对象,然后创建一个新的连接对象放入到 Accept 队列**;
- 应用通过调用
accpet()
socket 接口,从** Accept 队列**取出连接对象。
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,默认情况都会丢弃报文。
SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满,这样当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。
避免 SYN 攻击方式,可以有以下四种方法:
方式一:调大 netdev_max_backlog
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000,要适当调大该参数的值,比如设置为 10000:
方式二:增大 TCP 半连接队列
增大 TCP 半连接队列,要同时增大下面这三个参数:
- 增大 net.ipv4.tcp_max_syn_backlog
- 增大 listen() 函数中的 backlog
- 增大 net.core.somaxconn
方式三:开启 net.ipv4.tcp_syncookies
开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,相当于绕过了 SYN 半连接来建立连接。
具体过程:
- 当 ** SYN 队列**满之后,后续服务端收到 SYN 包,不会丢弃,而是根据算法,计算出一个
cookie
值; - 将 cookie 值放到第二次握手报文的序列号里,然后服务端回第二次握手给客户端;
- 服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,将该连接对象放入到** Accept 队列**。
- 最后应用程序通过调用
accpet()
接口,从** Accept 队列**取出的连接。
可以看到,当开启了 tcp_syncookies 了,即使受到 SYN 攻击而导致 SYN 队列满时,也能保证正常的连接成功建立。
net.ipv4.tcp_syncookies 参数主要有以下三个值:
- 0 值,表示关闭该功能;
- 1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
- 2 值,表示无条件开启功能;
那么在应对 SYN 攻击时,只需要设置为 1 即可。
1 | $ echo 1 > /proc/sys/net/ipv4/tcp_syncookies |
方式四:减少 SYN+ACK 重传次数
当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,可以减少 SYN-ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。
SYN-ACK 报文的最大重传次数由 tcp_synack_retries
内核参数决定(默认值是 5 次),比如将 tcp_synack_retries 减少到 2 次:
1 | $ echo 2 > /proc/sys/net/ipv4/tcp_synack_retries |