连接?
首先要明白,传输层是端到端之间的通信,这个“端”姑且认为是本地ip地址和本地端口组合而成的套接字端点。不管是基于UDP传输还是基于TCP传输,应用进程总是借助套接字端点实现数据的收发(通过系统调用)。也就是说,如果想和另一端的应用进程通信,首先要有一个套接字端点,通过socket()函数调用即可得到,当两边的进程都有一个各自的端点,他们便可以尝试通信了。
UDP特点
如果通信是基于UDP协议的,客户端拿到用户待传输的数据,二话不说,加上一个UDP的头部就直接发出,我管你对面有没有接收能力、反正咱们都是基于UDP协议——咱们一开始就说好了,接收不了就丢弃,丢包了也不需要你重传,反正咱们一开始也没打算可靠传输。接收方更是毫不在乎,你的报文只要能定位到我的端点,我就收下而且不做任何确认(反正一次发完所有的数据,也没有确认的必要),反正我知道你也不在乎,我更不在乎是谁发送给我的。所以说UDP是无状态的协议,而且一对一、一对多、多对多、多对多都支持,它压根不保存状态。但是它很快啊,同样的一个数据包,TCP是“慢慢发”,而且边发边确认,而UDP直接把数据报全部发出去了。
http和udp都是无状态的,其中http可以使用cookie和session实现状态跟踪,UDP难道就一定不能够记录状态吗?HTTP3的传输层协议是QUIC,QUIC是基于UDP的,但是可以记录状态和可靠传输
TCP特点
TCP“懂规矩”,发送正式数据之前,咱们俩必须先认识一下。停止发送数据之后,咱们俩必须还互相告个别。而建立连接的过程就是一个确定状态的过程。那么什么是“状态”?
我要知道"我和谁通信",对方也要知道"我和谁通信",这是一个交换“名片”的过程。服务器总是有一个监听端口号在等待连接请求的到来,客户端connect(ip,port)调用,向套接字端口发送一个同步报文,这时服务器会通过这个同步报文知道**“谁和我建立连接”,并回传一个同步确认报文,这个报文同时有两个意思,根据确认语义,客户端知道了“服务器知道我要和它建立连接”,其中同步语义表明“我和谁建立连接”**。当客户端确认之后,便进入连接建立状态——互换名片完成。而当服务器收到客户端的确认后,也将进入建立连接状态。
报文到达主机之后,剥离ip头可以得到源和目的ip,剥离TCP头可以得到源和目标port,而ip头部包含了协议字段。如果协议字段是6(TCP),TCP报文段就可以根据(目的ip:目的端口,源ip:源端口)交付到具体的TCP套接字,而如果是17(UDP)就会根据(目的ip:目的端口)交付到具体的UDP套接字。
TCP通过(目的ip:目的端口,源ip:源端口)定位套接字,如果没有建立连接,那么总是定位到监听套接字(serverSocket),因此第一个和第三个报文都会定位到监听套接字。当serverSocket收到第三个握手报文后,才会将连接(保存连接信息的对象)放入accept队列,只会服务器通过accept调用为之创建一个真正用于通信的socket。
设计serverSocket是为了处理并发请求,因此攻击服务器总是优先攻击serverSocket,一旦serverSocket瘫痪那么整个服务器将无法正常提供服务,如SYN洪泛攻击
以上描述的仅仅是建立连接的作用之一——建立状态。
另外通过报文的交换,双方都知道了对方的接收能力(全双工通信)、以及协商MSS、超时时间RTO(根据RTT计算)、是否需要选择确认服务SACK、初始序列号ISN等。(TCP总是需要维护各种变量)
TCP拿到数据,不像UDP那样憨憨地将数据一股脑的发出去,TCP通过三次握手协商出最大报文段、超时时间,生怕出了差池。而且发送数据也是慢慢地发送,一个字节流一组组的发送,而且总是需要对方确认。
因此,如果你问我什么是连接,一句话:一组状态信息。这个状态信息对被动通信方更加有意义。TCP服务器知道我和谁正在通信,而UDP服务器仅知道有一些客户向我发送过数据。
连接建立的目的是什么:
【1】建立状态【2】协商双方各自的初始序列号ISN【3】协商一些通信细节如MSS、SACK等。
序号
序号是可靠传输的基础,不重复、有序、差错恢复、重传等都离不开序号。
UDP一次发送所有的数据,它不需要序号去标识报文。而TCP将待传输数据看作有序、无结构的字节流,并且给每一个字节都编上号。发送方发送分组后并不会释放相应的缓存,一旦出现超时,便根据序号去重传某些分组。另一方面,确认号依赖序号的值。当接收方收到报文段后,确认号的值等于下一个期望接收分组的首字节序号。
其实是四次握手?
而TCP连接的其中一个重要作用是协商/同步双方的初始化序号。
三次握手完全可以看作四次握手,并看作两阶段。【1】客户端向服务器发送同步报文,选取一个初始化序号。收到服务器确认(纯ACK无数据也不占序号,不用进行“确认的确认”),此时得知“对方收到我的ISN了,客户端作为发送方的初始序号被确认了”,这是前两次握手【2】服务器发送同步报文,也选取一个初始化序号。收到客户端确认,得知“对方收到我的ISN了,服务器作为发送方的初始序号被确认了”,这是后两次握手
个人理解:当客户端发送同步报文,收到确认报文就已经进入建立连接状态了(服务器SYN到达时,客户端已经进入建立连接状态了)。这是合理的,因为当客户端得知“我的ISN已经和对方协商完毕”后,就已经单方面的同步了。如果服务器是一个“只接受和确认,但从不发送”的哑巴服务器,那么通信的前提已经达到了——客户端——>服务器方向的数据传输同步完毕。
什么是同步?A:嘿,服务器,一会儿我传输数据时,序号从888开始发,告诉你一声。B:哦,我知道了。 此时A——>B的数据传输就单方面同步了
因此,三次握手完全可以看作四次握手来理解,而两组握手各自对某一方向的ISN进行了确认。只是这样“没必要”,因为ACK和SYN完全可以合并为同一个包。因为第二个是纯ACK,而且SYN本身的确认位一定是1,因此合并为一个包就十分合理了。
通过HTTP抓包分析序号
当你在浏览器地址栏敲下回车键后,传输层首先会建立三次握手,之后便可以使这条连接发送数据。短连接的情况下,这个连接完成请求报文与响应的报文传输后就会执行四次挥手。而长连接情况下,不会立即执行四次挥手,而是继续利用这个连接传输一些img标签指向的静态资源。
因为这个http报文很小,所以一次就传输完毕了。上图第一行把TCP盖住了,其实是3372 >> 80端口的一次请求报文数据的传输,报文长度479。之后便得到确认报文,确认号480.
当用户请求报文的数据分组被服务器完全接收后,服务器才会开始传输响应报文,而此时客户端仅仅给出纯ACK进行确认。(1380是MSS)。
可以看到,虽然确认报文的seq等于上一个接收报文的ack,但是由于纯ACK不占用序号,因此下一个报文的序号仍然是seq,直到发送一个len不为0的报文。
上面是三次握手的抓包,其中客户端>服务器的MSS是1460,而服务器>客户端的MSS是1380.而且两个方向都是基于选择重传SACK的。
值得注意的是,虽然三次握手的数据部分为0(len=0),但是SYN报文占用一个序号。(从第三个报文ack=1而不是ack=0就可以看出来。不占用序号的表现就是一方的ack总是相同)。第一个报文的ACK置0,因此它没有ack或者说没有意义。【以上的seq都是一个视图,是为了可读性,真正的seq肯定不会相同且从0开始】
注意一个细节,请求报文或响应报文在传输的过程中,一方总是发送纯ACK报文(len=0),也就是说,虽然TCP是全双工的,但是实验表明,一边发送数据的时候,另一边总是纯粹的确认,而不是确认的同时传输数据。
我的理解:TCP理论上可以两边同时发送数据,但是由于HTTP完整的报文被拆分为字节流后,服务器无法仅仅凭借“碎片内容”就知道用户在请求什么,因此总是当收到一个完整的请求数据后,才会做出响应。(不是一定的,至少HTTP2以下版本是这样的)
官方文档规定:除了第一个握手包,其他所有报文的确认位都是置1的(有效)。
我的理解:因为ACK的意义就是“对前一个请求”的确认。确认号有用的前提是ACK置1。可以将ack=xxx seq=100 len=200拆分为ack=xxx和seq=100 len=200分两次发出,但是没有必要,这是更像一种优化。
总结:一个报文段通常有两方面意义——作为接收方:对前一个报文的确认(xxx之前的字节全部接收完毕,请发送xxx+1对应的字节)。作为发送方:传输数据。而第一个握手报文ACK=0显而易见,你认为它可以对哪个报文进行确认?
总结:HTTP传输前先进行三次握手建立连接,客户端首先使用这个连接传输请求报文对应的字节流,传输过程中服务器端仅仅不断给出纯ACK报文。请求报文传输完毕,服务器开始传输响应报文,过程中客户端同样仅仅给出纯ACK报文进行确认。(不是一定的,但至少http1.0是这样的)
选择合适的ISN
这个ISN你可以全部选择从0开始,但是很容易接收到历史报文,还有可能受到构造RST报文攻击。因此TCP实现中通常动态随机生成ISN(ISN可以看作一个32位的计时器),TCP内部会维护一个全局时钟,每四毫秒自增一次,生成ISN通常使用一个哈希函数,四元组和时钟都纳入参考。
(ISN的作用?如何选取?ISN简单的隐患?是考察点)
【1】两个报文如何判断属于同一个连接?如果四元组全部相同,那么唯一参考因素就是该报文的序号了。假设一个连接并非正常关闭(RST异常关闭不经过四次挥手,而是直接拆除),而且这个连接很快得到复用(四元组相同),那么如果一个历史报文重新到达对端,对端如何辨认?如果新连接的序号能与旧报文序号进行区分,那么这个问题就迎刃而解了。
再考虑一个场景,还是刚才说的,连接被异常重启,一个旧SYN报文比新SYN报文更先到达接收端,服务器会发出一个确认,当客户端接收到确认报文后,发现这不是它的同步确认报文,则会发送一个RST报文或者不进行确认(看具体实现)。这避免连接的异常建立。因此三次握手还可以及时排除错误的历史报文。
【2】如果攻击者可以猜出序列号,那么它可以通过推理,发出符合序号要求的RST报文,强制拆除连接
三次握手
三次握手是重度考点,此处模拟真实场景进行采用一问一答的形式讲述
Q:请你说一下描述一下三次握手吧
A:好的。传输层是端对端的通信,基于TCP协议的两端在正式进行数据传输之前,需要进行一个建立状态的动作。假设现在有两台主机分别运行着客户端进程和服务器进程,其中服务器进程处于监听状态。当客户端希望与服务端通信时,客户端进程主动向服务器进程发送同步报文,其中同步位置1,同时选取一个初始序号。发送完毕客户端处于SYN-SENT状态。当报文到达对端后,服务器向客户端发送同步确认报文,其中同步位和确认位置1,也选取一个初始序号,同时确认号设为下一个期望接收的字节的序号。发送完毕服务器处于SYN-RECV状态。当报文达到客户端时,客户端进入established状态并且返回一个确认报文,当服务器收到确认报文后也进入established状态,此时三次握手完毕。
具体到socket编程也要知道,通信前先要通过socket()创建套接字,面向过程中,返回值是一个套接字描述符,而面向对象语言中通常是具体的对象。服务器套接字通过listen()声明为监听套接字,再次之前服务器套接字还需要通过bind()绑定一个熟知端口号,因为服务器端口需要向外暴露,而客户端的可以使用系统分配的端口号。系统为监听套接字维护了半连接队列和全连接队列,服务器阻塞调用accept(),一旦全连接队列有元素存在(一旦第三个报文到达,accept()返回),就创建一个连接套接字和新的线程与客户端通信。客户端socket主动阻塞调用connect()向服务器发送连接请求,服务器收到第一个握手报文则将连接放入半连接队列(收到第二个握手报文后,客户端connect()返回),而完成三次握手则将连接迁移到全连接队列等待服务器主线程消费。
常见问题
Q:三次握手报文可以携带数据吗
A:HTTP报文传输之前需要在传输层进行三次握手建立连接,也就是说真正的数据传输发生在三次握手之后。不过客户端在接收到第二个握手报文之后就已经进入established状态了,所以第三次握手报文是可以携带数据。(如果理解为捎带传输也没问题)
如果TCP规定第一次握手可以携带数据,那么服务器收到攻击的风险很大。当服务器收到第一次握手报文,它需要将连接信息对象存储在半连接队列,这是可能被“SYN洪泛攻击”的,而如果一次报文如果存在数据,那么服务器有必须开辟内存接收它。这使得服务器更容易收到攻击。而第二次包含数据也没有必要,此时服务器只是知道“好像有人和我通信”,但是这可能是一个伪造的IP或者过期的同步报文,它必须收到第三次报文后(进入established)才能确认客户端的身份。
如果问“为啥不能二次握手”,原因也是类似的——没有收到对方的确认,无法明确对方的身份就草草地进入established状态,很可能造成很大的代价:【1】如果这是一个过期连接,那么对方返回RST,服务器就不得不拆除无效的连接【2】如果对方是攻击者,那么服务器和一个“虚假的端点”建立了连接,服务器如果维护大量无效的连接,那么将极大耗费系统资源(系统能够打开的套接字有限、每个端点都需要维护变量和缓冲区、全连接队列被迅速占满导致无法正常服务合法用户,这一点主要还是因为“套接字有限”)
三次握手不能省略,客户端和服务器都需要保存对方的各种信息“状态”,而且通信的初始序号、选择重传选项和最大报文段等都是在三次握手中协商出来的,同时也包含了两端分别作为发送方第一次的窗口值、超时时间等
三次握手本质上就是客户端和服务端分别互换接收方和发送方角色进行的一次通信前的协商。
在正式发送数据之前,TCP的三次握手至少存在一个RTT的时延(前两个报文的一来一回)
Q:SYN洪泛攻击的解决方案
A:SYN洪泛攻击使得服务器瘫痪的主要原因,是由于服务器收到第一个握手报文后总是要将连接对象存入半连接队列,一旦半连接队列满了就会拒绝后面的同步报文。TCP提出了SYNcookie策略。服务器如果开启了SYNcookie功能,则在第一次收到同步报文后不会保存连接信息,而是根据该同步报文的信息生成一个数字,作为同步确认报文的初始序号ISN。如果服务器可以成功收到客户端的确认报文,且确认号是正确的,那么会直接将其放入全连接队列。这种情况下,非法的第一次握手报文将不能对服务器造成实质性的伤害。
TCP服务器可能是直接采用SYNcookie或者当半连接队列满了再使用,或者不使用。可以通过参数具体调节。其他解决方式如增大半连接队列、报文过期时间调小等都不能解决攻击本身
丢包问题分析
前两次报文携带序号,因此本身是可以被重传的。而第三次报文不携带数据的情况下就是一个纯ACK不占用序号。因此不会被重传。而且三次握手不涉及数据的传输,不存在快重传。
如果第一次丢包,超时后客户端会重传。而第二次丢包后,两方都会重传,一般是客户端由于得不到确认而先进行重传,如果仍然丢失,则服务器也会由于得不到确认而超时重传。
第一次丢包可以看作断网了,发不出去自然得不到确认。而第二次丢包可以看作客户端加了防火墙,报文到达不了客户端。
第三次报文发出后,客户端就进入established状态了。丢包可以分为两种情况:如果是纯ACK,那么客户端不会重传,而等待第二个报文重传。如果携带数据那么客户端会重传(但是一般还是服务器更早超时)。如果客户端先发出纯ACK紧接着发送数据(不是捎带),那么即使第一个ACK丢失,第二个数据报文到达,服务器依然可以转为established状态。(数据报文ACK=1的意义的体现之一:累积确认,只要第二个报文的序号在数据报文确认号范围内,就相当于被确认了)如果第三个报文丢失,客户端什么也不做,而服务器的重传报文一直丢失,那么当服务器重传达到一定次数可能会主动断开连接(异常断开,不执行挥手动作),此时客户端处于连接的半开状态,它不知道另一方已经断开连接了,它一直等待。(如果一直不发报文,就一直等待,直到客户端一方的保活计时器超时,才主动发送探测报文)。由于对方已经关闭连接,会返回一个RST,之后连接被拆除。
四次挥手
上图中第二次和第三次挥手报文几乎一模一样,其实可以合并为一次(三次挥手)
Q:描述一下四次挥手吧
A:好的。建立TCP连接的客户端和服务器,任意一方都可以主动关闭连接。假设客户端主动断开连接,客户端向服务器发送终止报文,并处于FIN-WAIT1状态,当服务器收到终止报文后返回一个确认报文,并处于CLOSE-WAIT状态。当客户端收到确认报文,进入FIN-WAIT2状态。服务器可能会继续传输一段数据,传输完毕后发送终止报文,并且等待客户端的最后一个确认报文,随后进入LAST-ACK状态。客户端收到客户端的终止报文后,发送确认报文,随后进入TIME-WAIT状态,持续2MSL后进入CLOSE状态。当服务器收到确认报文后进入CLOSE状态,系统将回收相应资源(套接字、变量和缓冲区占用的内存等)
这里可以展开,发送FIN报文前通常使用系统调用close(),该调用同时关闭套接字的读写缓冲区,之后即使对端传来数据,本地也只确认不保存。而被动关闭方收到FIN报文后,就会在读缓冲区尾部插入一个EOF,一旦读取到EOF就知道对方不进行发送数据了。此时服务器进入closeWait——等待close调用,当服务器将写缓冲区的数据发送完毕后,就调用close()。
系统调用shutdown()比close()更加灵活,可以选择全部关闭、关读连接、关写连接的功能。
FIN-WAIT1状态中主动关闭方可以重传占用序号的终止报文,而FIN-WAIT2中主动关闭方只能返回不占用序号的确认报文,此时主动关闭方的发送通道已经关闭(接收通道是否关闭看具体调用,如果close()那么FIN-WAIT2很短暂),等待“对端发送通道关闭”的通知
常见问题
然后就是经典的问题:
Q:为什么不是三次挥手
A:可以是三次挥手,前提是被动关闭方收到主动方的终止报文后,没有要发送的数据,就可以将确认报文和终止报文复用一个报文段发出。(TCP是全双工通信),如果被动关闭方的发送缓冲区仍然有数据未发送,那么被动方则需要先对主动关闭方的终止报文进行确认,然后发送完毕剩余数据部分,再发送自己的终止报文。
虽然是四次挥手,但是第二次和第三次之间可能包含一系列单向的数据传输,这些书上通常没画出来,其实这种情况应该不多,就算真的有数据,第三次挥手一次也基本能够传递完毕
Q:timeWait的意义,为什么是2MSL,大量timeWait的危害和原因,大量closeWait的原因和危害。
A: 当主动关闭方收到被动关闭方的终止报文就会启动一个2MSL的计时器,其中MSL是报文在网络中的最大存活时间。2MSL确保最后一个确认报文能够达到被动关闭方,且一旦发生重传,被动关闭方的终止报文能够达到主动关闭方。2MSL分别对应确认报文和终止报文的存活时间。
timeWait的存在保证连接被复用后,历史报文全部过期。而且保证主动关闭方一定可以收到被动关闭方重传终止的报文。
MSL是报文段的最大存活时间,由时间等待计时器控制。而TTL是ip报文的存活时间记录在ip首部,单位是跳数,每经过一个路由器都会重新计算TTL,一旦过期就会被路由器丢弃并回传超时ICMP报文,MSL通常大于等于TTL变为0的时间。
客户端和服务器都可以是主动关闭连接的一方,如果客户端积攒大量timeWait问题不大,因为如果客户端想再次建立连接,便重新选取一个端口即可(无法立刻使用的socket占用一部分内存,但是端口够用)。而服务器积攒大量timeWait会导致系统保持大量打开的套接字描述符,而系统中可以打开的套接字描述符是有限的,全连接队列会溢出,而accept()阻塞无法返回,无法正常服务。(执行完close(),线程可以回收,但是操作系统暂时无法回收socket_fd)。
如果服务器主动断开连接,而客户端又向服务器重新发起连接,原端口是无法立刻复用的(五元组不可用)。
总结:客户端出现大量timeWait的极端情况就是导致端口耗尽,客户端无法与服务器建立连接(因为服务器两个ip/port和客户端的ip一定,能否建立连接看客户端的port),而对于服务器来说主要的影响就是无法及时释放套接字占用的内存,而且能够打开的套接字有限,如果无法及时复用套接字资源则影响服务质量。
而closeWait的大量出现基本就是程序的编程问题,被动关闭方收到对端终止报文后,无法正常调用close()(如死锁、阻塞或压根没写close()),导致端点一直处于closeWait状态。
比如Telnet和服务器建立连接,然后异常关闭Telnet,这时如果close()不是写在finally块中,则无法退出,而一直处于closeWait状态。
closeWait大量出现和timeWait大量出现的危害差不多,无法释放socket,占用内存资源等。
解决:close()写在finally块中、使用一个后台线程监听异常事件,一旦出现大量closeWait就对目标端点主动close()。
如果客户端程序崩溃,OS会发送FIN,当客户端收到服务器消息时,由于socket已经关闭会触发RST响应。而如果客户端主机崩溃,则不会发送任何信息,对端想要感知到需要传输层保活机制或者应用层传输心跳报文
保活
如果一条连接处于空闲,而一端希望感知另一端的状态,就会传输小的数据流。TCP保活可以由任意一段开启,如果一段时间内连接上没有数据流动(开启保活的一方未收到报文,但是也不需要对方给出确认,例如刚刚传输完了http请求报文,但是对方迟迟不传输http响应报文),开启保活功能的一方将向另一方发送探测报文,如果对端没有响应,隔一段时间后会再次发送,直到达到预设的探测数,于是任务对端不可达,主动释放连接(非正常关闭)。
探测报文通常包含没有意义的数据,但不被重传(不开启超时计时器),序号是一个重复ACK,没有影响,对端收到后也仅仅重传返回的冗余ACK。TCP通过维护一个保活计时器实现该功能
探测报文发出后可能无响应(断网或主机崩溃),或者正常响应。还有可能是RST(崩溃并重启,感知报道连接的状态信息,相当于维护半开连接的一方触发对端的RST响应)
TCP的保活机制和HTTP的长连接不是一回事,保活用于探测对端存活状态,而HTTP是为了复用连接传输数据。
保活缺点:占用额外带宽、如果出现短暂的网络波动,保活探测可能会是一个正常的连接断开、保活探测报文占用流量,会花费更多的流量费、为网络注入无实际意义的报文等
实战:我的回答
面试真实记录,我被问到后如何回答的
Q:能描述一下三次握手的过程吗
A:嗯,好的,那我就那mysql举一个例子吧。我现在有一个mysql客户端,远端有一个mysql服务器,现在我去连接远端的服务器,这时我客户端底层的套接字就调用了connect去和服务器底层监听套接字建立连接,这时就涉及一个三次握手。
我客户端先发出一个同步报文,告诉服务器我想和你通信,咱们约定个初始序号吧,然后选择了一个初始序号,这个初始序号是随机的而且是不重复的,这里我就以0为例了。客户端发完这个报文后就进入同步报文已发送状态,等待服务器响应。服务器收到这个同步报文后,知道了“客户端与我通信,而且从0开始发送报文”,于是服务器生成一个确认报文,这里服务器也会和客户端进行一个同步,但是考虑到优化,这里的同步报文和确认报文是合在一起的,因此服务器发出的这个报文同时具有确认和同步的双重意义,因此它也选择了一个初始序列号,这里我也以0为例,因为第一个报文占用一个序号/字节,因此第二个报文的确认号是1,意思是,告诉客户端,我收到你发出的第一个报文了,你可以接着发送下一个报文。服务器发送完后进入同步已接收,等待客户端的确认报文。客户端收到这第二个报文后,它就知道了,客户端到服务器方向已经同步了,这个同步就是“你走的每一步我都知道”,而且服务器的初始序号为0,然后客户端发送一个确认报文,确认报文也是1,告诉服务器我已经收到你第二个报文了,然后进入连接建立状态,服务器收到这个报文后也进入连接建立状态。连接建立完毕后mysql客户端的connect调用就会返回,mysql服务器的accept()也会返回一个套接字的描述符,mysql服务器会创建一个子线程使用这个套接字与mysql客户端通信,这个返回的套接字描述符就可以看作mysql服务器对客户端状态的保存和描述。