相对于SOCKET开发者,TCP创建过程和折除过程是由TCP/IP协议栈自动创建的.因此开发者并不需要控制这个过程.但是对于理解TCP底层运作机制,相当有帮助.

TCP报文格式

上图中有几个字段需要重点介绍下:

  1. 序号:Seq序号,占32位,用来标识从TCP源端向目的端发送的字节流,发起方发送数据时对此进行标记。

  2. 确认号:Ack序号,占32位,只有ACK标志位为1时,确认序号字段才有效,Ack=Seq+1。

  3. 标志位:共6个,即URG、ACK、PSH、RST、SYN、FIN等,具体含义如下:

     URG:紧急指针(urgent pointer)有效。
     ACK:确认序号有效。
     PSH:接收方应该尽快将这个报文交给应用层。
     RST:重置连接。
     SYN:发起一个新连接。
     FIN:释放一个连接。
    

需要注意的是:

  1. 不要将确认序号Ack与标志位中的ACK搞混了。

  2. 确认方Ack=发起方Req+1,两端配对。

TCP三次握手

过程详解

所谓三次握手(Three-way Handshake),是指建立一个TCP连接时,需要客户端和服务器总共发送3个包。

三次握手的目的是连接服务器指定端口,建立TCP连接,并同步连接双方的序列号和确认号并交换 TCP 窗口大小信息.在socket编程中,客户端执行connect()时。将触发三次握手。

第一次握手:

Client将标志位SYN置为1,随机产生一个值seq=J,并将该数据包发送给Server,Client进入SYN_SENT状态,等待Server确认。

第二次握手:

Server收到数据包后由标志位SYN=1知道Client请求建立连接,Server将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给Client以确认连接请求,Server进入SYN_RCVD状态。

第三次握手:

Client收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给Server,Server检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,

Client和Server进入ESTABLISHED状态,完成三次握手,随后Client与Server之间可以开始传输数据了。

实际中还会出现同时发起主动打开的情况:通信的两端几乎在相同的时刻都会发送一个SYN报文段,然后它们进入SYN_SENT状态。当它们接收到对方发来的SYN报文段时会将状态迁移到SYN_RCVD,然后重新发送一个新的SYN并确认之前接收到的SYN,然后发送ACK。当通信两端都接收到了SYN和ACK,它们的状态都会迁移到ESTABLISHED,并且只建立一条连接。

为什么不能用两次握手进行连接

为了防止已失效的连接请求报文段突然又传送到了服务端,产生错误,产生的错误有两类:

client发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达server。本来这是一个早已失效的报文段。但server收到此失效的连接请求报文段后,就误认为是client再次发出的一个新的连接请求。于是就向client发出确认报文段,同意建立连接。假设不采用“三次握手”,那么只要server发出确认,新的连接就建立了。此时因为client没有发起建立连接请求,所以client处于CLOSED状态,接受到任何包都会丢弃。之后server一直等待接受数据,浪费大量资源。

如果服务器发送对这个延误的旧连接报文的确认的同时,客户端调用connect函数发起了连接,就会使客户端进入SYN_SEND状态,当服务器那个对延误旧连接报文的确认传到客户端时,因为客户端已经处于SYN_SEND状态,所以就会使客户端进入ESTABLISHED状态,此时服务器端反而丢弃了新的SYN包,而连接建立之后,发送包由于SEQ是以被丢弃的新的SYN包的序号为准,而服务器接收序号是以那个延误旧连接SYN报文序号为准,导致服务器丢弃后续发送的所有数据包。

采用“三次握手”的办法可以防止上述现象发生。例如刚才那种情况,由于ACK不正确,client不会向server的确认发出确认。server由于收不到确认,就知道client并没有要求建立连接。

TCP 四次挥手

过程详解

TCP的连接的拆除需要发送四个包,因此称为四次挥手(four-way handshake)。客户端或服务器均可主动发起挥手动作,在socket编程中,任何一方执行close()操作即可产生挥手操作。

由于TCP连接是全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当一方完成数据发送任务后,发送一个FIN来终止这一方向的连接,收到一个FIN只是意味着这一方向上没有数据流动了,即不会再收到数据了,但是在这个TCP连接上仍然能够发送数据,直到这一方向也发送了FIN。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭,上图描述的即是如此。

第一次挥手:

Client发送一个FIN,用来关闭Client到Server的数据传送,Client进入FIN_WAIT_1状态。(发送了FIN只是表示这端不能继续发送数据(应用层不能再调用send发送),但是还可以接收数据。)

第二次挥手:

Server收到FIN后,发送一个ACK给Client,序号为收到序号+1(与SYN相同,一个FIN占用一个序号),Server进入CLOSE_WAIT状态。Client接受到ACK序号后进入FIN_WAIT_2状态。

第三次挥手:

Server发送一个FIN,用来关闭Server到Client的数据传送,Server进入LAST_ACK状态。

第四次挥手:

Client收到FIN后,Client进入TIME_WAIT状态,2MSL后发送一个ACK给Server,确认序号为收到序号+1,Server进入CLOSED状态,完成四次挥手。

上面是一方主动关闭,另一方被动关闭的情况,实际中还会出现同时发起主动关闭的情况,具体流程如下图:

CLOSING: 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送FIN报文后,按理来说是应该先收到(或同时收到)对方的ACK报文,再收到对方的FIN报文。但是CLOSING状态表示你发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?其实细想一下,也不难得出结论:那就是如果双方几乎在同时close一个SOCKET的话,那么就出现了双方同时发送FIN报文的情况,也即会出现CLOSING状态,表示双方都正在关闭SOCKET连接。

TIME_WAIT

从以上TCP连接关闭的状态转换图可以看出,主动关闭的一方在发送完对对方FIN报文的确认(ACK)报文后,会进入TIME_WAIT状态。TIME_WAIT状态也称为2MSL状态。

为什么需要TIME_WAIT

  1. 可靠地实现TCP全双工连接的终止

    虽然双方都同意关闭连接了,而且握手的4个报文也都协调和发送完毕,按理可以直接回到CLOSED状态(就好比从SYN_SEND状态到ESTABLISH状态那样);但是因为我们必须要假想网络是不可靠的,你无法保证你最后发送的ACK报文会一定被对方收到,因此对方处于LAST_ACK状态下的SOCKET可能会因为超时未收到ACK报文,而重发FIN报文,所以这个TIME_WAIT状态的作用就是用来重发可能丢失的ACK报文。

  2. 允许老的重复segment在网络中消逝

    假设在A端口和B端口之间有一个TCP连接。我们关闭这个连接,过一段时间后在相同的IP地址和端口建立另一个连接。后一个连接成为前一个的化身。因为它们的IP地址和端口号都相同。TCP必须防止来自某一个连接的老的重复分组在连接已经终止后再现,从而被误解成属于同一连接的某一个某一个新的化身。

    为做到这一点,TCP将不给处于TIME_WAIT状态的连接发起新的化身。既然 TIME_WAIT状态的持续时间是MSL的2倍,这就足以让某个方向上的分组最多存活msl秒即被丢弃,另一个方向上的应答最多存活msl秒也被丢弃。 通过实施这个规则,我们就能保证每成功建立一个TCP连接时。来自该连接先前化身的重复分组都已经在网络中消逝了。

什么是2MSL

MSL就是maximum segment lifetime(最大分节生命期),这是一个IP数据包能在互联网上生存的最长时间,超过这个时间IP数据包将在网络中消失 。MSL在RFC 1122上建议是2分钟,而源自berkeley的TCP实现传统上使用30秒。

当主动关闭连接的A端在TIME_WAIT状态等待2MSL时间中, 如果对端B端未收到ACK则重发FIN报文,那么A端仍可以回复ACK。因此在2MSL时间中,如果A端没有收到B端的FIN报文,则表明B端正确接收ACK。所以将等待时间设为2MSL。

TIME_WAIT状态过多的处理方法

  1. net.ipv4.tcp_tw_reuse = 1
    • 开启后,作为客户端时新连接可以使用仍然处于 TIME-WAIT 状态的端口
    • 由于 timestamp 的存在,操作系统可以拒绝迟到的报文
      • net.ipv4.tcp_timestamps = 1
  2. net.ipv4.tcp_tw_recycle = 0
    • 开启后,同时作为客户端和服务器都可以使用 TIME-WAIT 状态的端口
    • 不安全,无法避免报文延迟、重复等给新连接造成混乱
  3. net.ipv4.tcp_max_tw_buckets = 262144
    • time_wait 状态连接的最大数量
    • 超出后直接关闭连接

SO_REUSEADDR

当某个连接的一端处于TIME_WAIT状态时,该连接将不能再被使用。事实上,对于我们比较有现实意义的是,这个端口将不能再被使用。某个端口处于TIME_WAIT状态(其实应该是这个连接)时,这意味着这个TCP连接并没有断开(完全断开),那么,如果你bind这个端口,就会失败。对于服务器而言,如果服务器突然崩溃掉了,那么它将无法在2MSL内重新启动,因为bind会失败。解决这个问题的一个方法就是设置socket的SO_REUSEADDR选项。这个选项意味着你可以重用一个地址。

SO_LINGER

SO_LINGER选项决定调用close时TCP的行为。SO_LINGER涉及到linger结构体,如果设置结构体中l_onoff为非0,l_linger为0,那么调用close时TCP连接会立刻断开,TCP不会将发送缓冲中未发送的数据发送,而是立即发送一个RST报文给对方,这个时候TCP连接就不会进入TIME_WAIT状态。如你所见,这样做虽然解决了问题,但是并不安全。通过以上方式设置SO_LINGER状态,等同于设置SO_DONTLINGER状态。

CLOSE_WAIT大量出现原因与解决方案

close_wait大量出现的原因: 某一方在网络连接断开后,对等方没有检测到这个错误(对方断开)而没有调用 closesocket,导致了这个状态的出现.

那么,服务器上是怎么产生大量的失去控制的COLSE_WAIT状态的socket呢?

一个本质的原因是,服务器没有继续发FIN包给客户端。

服务器为什么不发FIN,可能是业务实现上的需要,现在不是发送FIN的时机,因为服务器还有数据要发往客户端,发送完了自然就要通过系统调用发FIN了,这个场景并不是上面我们提到的持续的COLSE_WAIT状态,这个在受控范围之内。

那么究竟是什么原因呢,咱们引入两个系统调用close(sockfd)和shutdown(sockfd,how)接着往下分析。

在这儿,需要明确的一个概念:一个进程打开一个socket,然后此进程再派生子进程的时候,此socket的sockfd会被继承。socket是系统级的对象,现在的结果是,此socket被两个进程打开,此socket的引用计数会变成2。

继续说上述两个系统调用对socket的关闭情况。

调用close(sockfd)时,内核检查此fd对应的socket上的引用计数。如果引用计数大于1,那么将这个引用计数减1,然后返回。如果引用计数等于1,那么内核会真正通过发FIN来关闭TCP连接。

调用shutdown(sockfd,SHUT_RDWR)时,内核不会检查此fd对应的socket上的引用计数,直接通过发FIN来关闭TCP连接。

现在应该真相大白了,可能是服务器的实现有点问题,父进程打开了socket,然后用派生子进程来处理业务,父进程继续对网络请求进行监听,永远不会终止。客户端发FIN过来的时候,处理业务的子进程的read返回0,子进程发现对端已经关闭了,直接调用close()对本端进行关闭。实际上,仅仅使socket的引用计数减1,socket并没关闭。从而导致系统中又多了一个CLOSE_WAIT的socket。

如何避免这样的情况发生?

有两种方法:

1. 父进程提前关闭套接字,让套接字在子进程中时引用计数为1,从而在子进程中顺利发送FIN

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
listenfd=socket(....);
for(;;){
   connfd=accept(.....);
   if((pid=fork())==0){
       close(listenfd);    //关闭监听
       doit(connfd);   //进行处理
       close(connfd);  //显式关闭套接字,不是必须,因为子进程结束时会自动关闭
   }
   close(connfd);  //父进程显式关闭套接字,减少引用次数。
}

父进程显式关闭socket只是减少CLOSE_WAIT的socket,如果不加这个语句,在下次循环前,该socket也会自动消失,不影响正确性。

2. 子进程处理结束后利用shutdown强制发送FIN,这是最快速高效的手段

shutdown(sockfd, SHUT_RDWR);
close(sockfd);

这样处理,服务器的FIN会被发出,socket进入LAST_ACK状态,等待最后的ACK到来,就能进入初始状态CLOSED。

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

服务端的LISTEN状态下的SOCKET当收到SYN报文的连接请求后,它可以把ACK和SYN(ACK起应答作用,而SYN起同步作用)放在一个报文里来发送。

但关闭连接时,当收到对方的FIN报文通知时,它仅仅表示对方没有数据发送给你了;但未必你所有的数据都全部发送给对方了,所以你可能未必会马上会关闭SOCKET,也即你可能还需要发送一些数据给对方之后,再发送FIN报文给对方来表示你同意现在可以关闭连接了,所以它这里的ACK报文和FIN报文多数情况下都是分开发送的。

与TCP连接管理相关的攻击

SYN泛洪

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

Syn泛洪就是攻击客户端在短时间内伪造大量不存在的IP地址,向服务器不断地发送syn包,服务器回复确认包,并等待客户的确认,由于源地址是不存在的,服务器需要不断的重发直 至超时,这些伪造的SYN包将长时间占用未连接队列,正常的SYN请求被丢弃,目标系统运行缓慢,严重者引起网络堵塞甚至系统瘫痪。

Syn泛洪是一个典型的DDOS攻击。检测SYN攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击.在Linux下可以如下命令检测是否被Syn攻击

netstat -n -p TCP | grep SYN_RECV

因为区分合法的连接尝试与SYN泛洪并不是一件容易的事情,所以抵御上述进攻存在一定的难度。一种针对此问题的机制被称作SYN cookies 。 它的主要思想是,当一个SYN到达时,这条连接存储的大部分信息都会被编码并保存在SYN+ACK报文段的序列号字段。采用SYN cookies 的目标主机不需要为进入的连接请求分配任何存储资源——只有当SYN+ACK报文段本身被确认后(并且已返回初始序列号)才会分配真正的内存。在这种情况下,所有重要的连接参数都能够重新获得,同时连接也能够被设置为ESTABLISHED状态。

在执行SYN cookies 过程中需要服务器仔细地选择TCP初始序列号。本质上,服务器必须将任何必要的状态编码并存于SYN+ACK报文段的序列号字段。这样一个合法的客户端会将其值作为报文段的ACK号字段返回给服务器。很多方法都能够完成这项工作,下面介绍Linux系统所采用的技术。

服务器在接收到一个SYN后会采用下面的方法设置初始序列号(保存于SYN+ACK报文段,供于客户端)的数值:首5位是t模32的结果,其中t是一个32位的计数器,每隔64秒增1;接着3位是对服务器最大段大小的编码值,剩余的24位保存了4元组与t值得散列值。该数值是根据服务器选定的散列加密算法计算得到的。

在采用SYN cookies方法时,服务器总是以一个SYN+ACK报文段作为响应。在接收到ACK后,如果根据 其中的t值可以计算出与加密的散列值相同的结果,那么服务器才会为该SYN重新构建队列。

这种方法至少有两个缺陷。首先,由于需要对最大段大小进行编码,这种方法禁止使用任意大小的报文段。其次,由于计数器会回绕,连接建立过程会因周期非常长而无法正常工作。基于上述原因,这一功能并未作为默认设置。

序列号攻击

序列号攻击涉及破坏现有的TCP连接,甚至可能将其劫持。这一类攻击通常包含的第一步是使两个之前正在通信的TCP节点“失去同步”。这样它们将使用不正确的序列号。至少有两种方法能实现上述攻击:在连接建立过程中引发不正确的状态传输,在ESTABLISHED状态下产生额外的数据。一旦两段不能再进行通信(但却认为它们间拥有一个打开的连接),攻击者就能够在连接中注入新的流量,而且这些注入的流量会被TCP认为是正确的。

参考

http://www.cnblogs.com/zmlctt/p/3690998.html

http://www.52im.net/space-uid-1.html