今天开始,我们整理一些关于TCP协议的知识。这块的内容写起来是非常费劲的,因为本身TCP协议就不是一个简单的协议,它能获得如今的地位,和其复杂且出色的表现是分不开的。
什么是面向连接
众所周知,TCP是一款面向连接的协议,那首先,我们需要知道这个“面向连接”指的是什么?
简单的理解,“面向连接”首先得有“连接”。TCP协议在传输数据的时候,并不像UDP那样随心所欲,而是需要在发送数据之前,先建立一条点到点的连接。这就是“面向连接”和“无连接”的区别。(UDP就被称为是一种“无连接”的协议。)
这句话中,点到点(point to point)是我们第一个需要关注的点。在TCP的通讯中,永远只有通讯双方,而不存在第三方。它不像UDP协议,可以一个主机同时向多个主机发送消息,一对多对于TCP而言,是不可以接受的。
再一个重点就是这个连接了。当然,这个连接并不是指物理链路上的连接,而是一种逻辑上的连接。TCP为保证双方可以正常的完成可靠性通讯,所以,需要提前同步一些初始参数(这里的参数很多是和后面我们要接触的TCP具体机制有关,此处不需要具体关注),这个参数同步的过程就是建立连接的过程。其共同状态仅保留在两个通信端系统的TCP程序中,而不会在中间节点进行维护(中间节点只转发数据的路由器,交换机等网络设备)。所以,TCP协议也被称为“端到端”的协议。
不同的TCP连接是如何区分的呢?
区分不同的TCP连接主要靠四个参数 — 源IP地址,源端口,目标IP地址,目标端口。所以,这四个参数被称为是TCP连接的“四元组”。四元组可以唯一的标识一条TCP连接。
TCP的报文结构
在具体的看连接建立的过程之前,我们先来看下TCP的报文结构。因为报文中的一些参数,其作用和我们建立连接是强相关的。
简答的解释一下里面定义的变量。
- 首先是源端口号和目标端口号 — 端口号主要是传输层定义的一种地址,其目的是为了区分和标定不同的上层应用。
- 序列号和确认序列号 — 这两个参数可是TCP在确保传输可靠性时后非常重要的两个参数。 - 序列号 — TCP本身是一种基于字节流的传输层协议(可以理解为TCP在传递数据时是以字节作为单位发送的)。而这个序列号就是建立在传送的字节流之上的。(并不是建立在传送的报文段之上,这一点很容易混淆)。也就是说,并不是一个报文段,序列号要加1,而是每发送一个字节,报文段就需要加1。所以,这个序列号其实就是字节流的编号。
这里关于序列号的使用方法给一个小例子:
假设,TCP的通信双方A和B。A想要通过TCP连接向B发送一个数据流。假设一共有5000个字节。而TCP协议在发送的时候,将这50000个字节的数据拆分为了多个数据段,每一个数据段长度为1000个字节。假定,数据流的首字节编号为0,那第一个报文段的序号取值应该是从0到999的。则第二个报文段分配的序号应该就是1000。其实每一个报文段使用的序号就是这个报文段中第一个字节所分配的序列号,以此类推。
- 确认序列号 — TCP为了保证传输的可靠性,所以,加入了确认机制。即接受方每收到发送方发送的数据时,需要回复一个确认报文(当然,TCP是一种全双工的通讯协议,接受方也是可以给发送方传递信息的,所以这个确认报文中是允许携带数据的。)确认报文中,这个确认序列号就至关重要了,它表明的是接受方期望收到发送方发送的下一个字节的序号。同时也代表接受方已经收到了确认序号之前的所有字节,这种确认模式我们称为累积确认。
这里关于确认序列号也给出一个小例子:
假设,通信双方还是A和B。现在,A给B发送了一个报文段,这个报文段的序列号假设为0,这个报文段所包含的数据部分的字节数假设为1000字节。(就是上个例子中第一个报文段的数据被发送了过来)。B收到该数据段之后,按照要求,需要回复一个确认报文,这个确认报文中的确认序列号应该指示的是B想要的下一个字节的序号,则因该就是1000。(第二个数据段第一个字节的序号)。当然,同时它也代表B已经收到了1000之前所有的字节。则A收到B的确认报文之后,下一个数段就会去携带序号从1000开始的第二段数据。
- 首部长度 — 这个参数指示的是TCP头部(不包含下面数据部分)的长度,单位是字节。之所以需要这个参数是因为TCP的头部是可变长头部,因为它头部下面存在一个选项字段。这部分的内容是根据需求携带的,没有要求的话也可以不带。这就导致了其头部长度是不定的,所以,需要这个参数来说明。通常,选项字段为空,所以TCP首部的典型长度为20字节。
- 保留 — 暂未使用的字段,使用0来填充。
- 6个标记位 — 占6位,每一个标记位置1,都代表该TCP报文的某种指示。 - URG — 该位置1,代表当前TCP报文段中包含需要紧急处理的数据,同时激活紧急指针字段。这个紧急与否是由上层应用来定义的,而TCP则需要将紧急数据告知对端上层应用。TCP会将需要紧急处理的数据放置在数据的最前方,并使用紧急指针用来指出紧急数据中的最后一个字节,和我们普通数据进行区分。- ACK — 该位置1,则代表该TCP报文段具有确认功能,激活确认序列号。- PSH — 该位置1,则代表该TCP报文段的数据需要直接推给进程。正常情况下,TCP的报文段到达接受方之后,需要在接受方设置的缓存空间中进行等待处理,而这个标记位之一则可以直接将数据推送到进程,进行处理。- RST — 该位置1,将强制重置TCP连接,导致TCP连接中断。- SYN — 该位置1,表示希望建立TCP连接,并且为序列号字段随机设定一个初始值。- FIN — 该位置1,表示后续将不再有数据需要发送,希望断开TCP连接。
- 窗口大小 — 这个参数是TCP中实现流控(流量控制)的一个关键参数,这个我们后面再详细说明。
- 校验和 — 用于确保传输数据完整性校验的参数。传输层的这个校验我们称为伪头部校验,因为校验的范围除了TCP的协议头和数据外还会附带三层IP头部的一些信息。一共12个字节,包含32位源IP,32位目标IP,8位保留字节(目前全0,暂时没用),8位协议号,16位报文长度(头部加数据的长度)。
- 后面的紧急指针和选项字段,前面已经提到过了,就不再赘述了。
TCP的三次握手
我们一般将TCP建立连接的过程称为“三次握手”,这个过程中完成的其实就是上面提到的同步参数的任务,只不过,一般这个过程都是由三个数据包的交互来完成的,所以,我们习惯性的称之为“三次握手”。
TCP的连接过程是由参与TCP通讯的双方中的其中一方发起的,我们一般认定发起方为客户端,而另一方为服务器。这个TCP连接一旦建成,则将是一个双向的会话,即客户端可以向服务器发送信息,服务器也可以向客户端发送信息。
- 客户端首先发起TCP连接,它会发出一个不包含数据的TCP报文(因为现在还在建立连接的过程,TCP是在连接建立之后才会发送数据的)。这个TCP报文中,SYN标记位会置1。同时,会设定一个序列号的初始值,这个值我们称为client_isn。
- 服务器收到客户端发送的SYN报文段后,会首先给这个TCP连接分配缓存空间(用来存储TCP数据流),并定义初始的变量(这一步很重要,也是我们服务器记录TCP连接的关键。理论上来说,任何客户端都可以向我们服务器的同一个端口号发起TCP连接的请求,服务器需要给不同的连接分配缓存空间,定义不同的初始变量。这点也将成为SYN泛洪攻击的利用点)。之后,服务器需要向客户端发送第二个报文,这个TCP报文段中,ACK标记位会置1,代表服务器允许客户端建立连接,确认其之前发送的SYN请求。同时,激活确认序列号,并填充client_isn + 1。同时,这个TCP报文段中,也会将SYN标记位置1,同时会设定一个序列号的初始值,这个值我们称为server_isn。这个报文段中也是不包含数据的。
- 客户端在收到第二个SYN+ACK报文段后,也会为这个TCP连接分配缓存和变量。同时,发送第三个TCP报文段,这个报文段中,将ACK标记位置1,代表确认服务器的SYN请求,同时激活确认序列号,并填充server_isn + 1。这个报文段中的序列号将使用client_isn + 1。
值得注意的是,这个TCP报文段是允许携带数据的。因为在客户端接收到服务器发送的AYN+ACK报文段之后,客户端指向服务器的会话就已经建立完成了。服务器已经做好了充足的准备来接收客户端发送的数据了。只是,服务器指向客户端的会话必须得等到这个数据包被服务器接收处理后才能建成。所以,整个TCP连接的建立是需要这完整的三个步骤的,也就是“三次握手”。
为什么每次初始的序列号都使用随机值而不使用固定值?
其实这样设计主要时为了防止历史报文被相同四元组的TCP连接接收。如果一个已经失效的连接被重用了,但是该旧连接的历史报文还残留在网络中,如果序列号相同,那么就无法分辨出该报文是不是历史报文,如果历史报文被新的连接接收了,则会产生数据错乱。所以,每次建立连接前重新初始化一个序列号主要是为了通信双方能够根据序号将不属于本连接的报文段丢弃。
连接建立过程中的状态变化
正常的三次握手建立过程,我们刚才已经说完了。我们观察这张图发现,两边还写了一些close,listen之类的标识。其实,TCP连接在建立的过程中,也是可以分为不同的阶段的。不同阶段,我们客户端或者服务器的状态都不尽相同,所以,我们使用不同的状态来进行标识。
我们先从客户端的角度来看下状态的变化。
- 一开始,在还没有发送建立请求之前,客户端处于Closed(关闭)状态。在发送完SYN请求报文段之后,将进入到下一个状态;
- 发送完SYN请求报文段后,客户端将处于SYN_Sent状态。在这个状态下,客户端在等待服务器返回SYN+ACK报文段;
- 当客户端收到服务器发送的SYN+ACK报文段后,则将进入到最后的Established(建立完成)状态。因为此时客户端指向服务器的会话就已经建立好了,所以,客户端发送的最后一个ACK报文就可以携带数据了。
我们再从服务器的角度看下状态的变化。
- 服务器一开始也是处于Closed(关闭)状态。当服务器的应用程序创建一个监听套接字之后,将进入到Listen(监听)状态。
- 之后,服务器将等待客户端发送的SYN请求报文段。收到后,将回复SYN+ACK报文段。回复之后将进入到SYN_RCVD状态。之后等待客户端的ACK报文。
- 服务器接收到客户端发送的ACK报文之后,将进入到最后的状态Established(建立完成)状态。也标着着服务器指向客户端的会话建立完成。至此,整个TCP的双向会话均建立完成。
TCP的异常连接
我们上面讨论的都是假定客户端和服务器都已经准备好的情况下,但如果连接情况没有那么顺利该怎么办呢?
例如,假如一台主机接收到一个TCP SYN报文段,里面的目的端口号是80端口。但是,该主机80端口并不接受连接,可能是因为它并没有运行web服务器。
在这种情况下,主机就会给发送源发一个TCP报文段,将其中的RST标记位置1。用来中断这次连接。(一般发送到一个无效的TCP连接时,都会使用RST报文段来终止)。
SYN泛洪攻击及应对方案
在上面的描述过程中,提到了一种攻击手段,叫做SYN泛洪攻击,是Dos攻击中的一种。这是一种很经典的攻击手段,虽然已经有了有效的应对之法,但是其原理还是可以略做了解的。
其实,其原理还是非常简单的。我们前面说到了,客户端向服务器发送完SYN请求之后,服务器会为这次连接分配缓存空间,用来去接收后续TCP数据流中的数据。之后再发送SYN+ACK的报文段,并等待来自客户的ACK确认。这时的状态可以被称为是一种半连接状态。当然,如果客户端不发送ACK来完成第三次握手,服务器也会在超时(通常是1分钟)之后终止半连接并回收资源(服务器发送RST报文来终结连接)。
攻击者就是利用这一点,疯狂发送SYN请求报文段,而不去发送最终的ACK应答。这就导致服务器不得不对这些纷至沓来的半连接分配资源。最终,导致服务器的资源被消耗殆尽,达到攻击服务器的效果。
当然,现在其实已经有了一种比较有效的防御手段,我们称为SYN Cookie。
用这种机制之后,服务器将不再收到SYN请求报文后立即给这个TCP连接分配缓存空间了。而是会及将这个SYN报文中的源目IP地址以及端口号和一个随机数一起使用HASH算法生成一个摘要值。我们将这个摘要值称为是SYN Cookie。然后,服务器会使用这个SYN Cookie作为服务器的初始序列号server_isn来发送SYN+ACK报文(这个初始值本身就可以是一个随机值),等待客户端回复ACK。
如果客户端是合法的,则会正常回复ACK报文,并且其中会包含一个确认序列号。这个确认序列号应该是server_isn + 1。服务器将使用这个ACK报文中的源目IP和端口以及之前的随机数运行HASH重新计算一个摘要值。如果这个摘要值+1和客户端返回的确认序列号相同,则认为该连接合法,就会为该连接分配缓存空间。
如果客户不合法,自然不会返回ACK报文,则初始的SYN对服务器而言也并没有产生什么危害,因为服务器没有为他分配任何资源。
为什么必须是三次握手,而不是两次或者四次
前面我们把整个TCP的连接过程搞清楚了,我们已经觉得TCP连接的建立就是需要三次过程才合理的。但我们还是应该避免这样的思维定式,我们还是应该多想一步,为什么就不能是两次或者四次呢?
为什么不是四次?
其实不是四次,这个问题好理解,能三次为啥四次呢?四次握手无非就是服务器用来确认的ACK报文和用来请求的SYN报文分开来发送,但能合在一起并不会造成任何额外的问题,而且还可以节约资源。三次握手其实就是四次握手的一个简化。
为什么不是两次?
这个问题就需要好好的研究一下了,因为如果能两次的话,那肯定是两次更加节约资源,又何必三次呢?
两次握手是一个什么样的概念呢?客户端发送请求建立,服务器收到请求后,给连接分配缓存空间,服务器端进入到ESTABLISHED状态;之后,回复SYN + ACK报文段。客户端收到后为该TCP连接分配缓存,客户端进入到ESTABLISHED状态,代表整个TCP的建立完成。
两次握手的连接过程表面上看,似乎也比较合理,但仔细去琢磨就会发现,这样的偷懒可能会带来不必要的麻烦。
这其中最主要的一个问题就是两次握手无法在发送数据之前识别出是历史连接,而造成资源浪费。三次握手可以防止旧的重复连接造成混乱。这个是在RFC文档中指出的。
这里说的这个旧的历史连接是啥意思呢?我们先给大家一个三次握手中的场景,帮大家理解下这种情况。
假设,客户端发送一个SYN请求报文段,其中的初始序列号Client_isn = 90。之后,因为设备故障,客户端宕机了。祸不单行的是,这个SYN请求报文段,也被阻塞在了这网络世界当中,导致服务器一时间并没有收到这个请求。很快,客户端重新启动了,则将重新发送一个SYN请求报文段,其中的初始序列号Client_isn = 100。(注意,两次都是随机产生的初始序列号。这种并不是客户端因为没有收到服务器的ACK而重传的报文,因为重传的报文中的序列号应该和之前的相同。)
重新发送之后,旧的序号为90的SYN请求报文段先到达了服务器,则服务器会应答一个SYN + ACK报文段,这个报文段中的确认序列号的值为90 + 1。客户端在收到这个报文段之后,发现其中的确认序列号并不是自己所期望的。(因为自己发送的请求报文中的序号为100,期望收到的确认序列号应该是100 + 1)。则此时,客户端将判定这是一个旧的历史连接,则将发送一个RST报文段来关闭这个连接。
之后,新的SYN报文段终于抵达了服务器,服务器收到之后,将应答新的SYN + ACK报文段,这次报文段中的确认序列号应该是100 + 1。客户端收到后,则将应答最终的ACK报文,完成TCP连接的建立。
三次握手在这样的场景中,就表现非常出色,并不会因为旧的历史连接而造成资源的浪费。
但是,如果现在使用的是两次握手呢?我们再来分析一下。
在两次握手中,服务端在收到 SYN 报文后,就进入 ESTABLISHED 状态,意味着这时可以给对方发送数据,但是客户端此时还没有进入 ESTABLISHED 状态,假设这次是历史连接,客户端判断到此次连接为历史连接,那么就会回 RST 报文来断开连接,而服务端在第一次握手的时候就进入 ESTABLISHED 状态,所以它可以发送数据的,但是它并不知道这个是历史连接,它只有在收到 RST 报文后,才会断开连接。
可以看到,如果采用两次握手建立 TCP 连接的场景下,服务端在向客户端发送数据前,并没有阻止掉历史连接,导致服务端建立了一个历史连接,又白白发送了数据,妥妥地浪费了服务端的资源。
因此,要解决这种现象,最好就是在服务端发送数据前,也就是建立连接之前,要阻止掉历史连接,这样就不会造成资源浪费,而要实现这个功能,就需要三次握手。这个就是使用三次握手而不是两次握手的一个最主要的原因。
版权归原作者 临界~ 所有, 如有侵权,请联系我们删除。