最近,实验室的群里提出了一个问题:TCP连接建立的三次握手过程是否可以携带数据?我突然意识到自己对这个问题并不清楚。在平时使用tcpdump或Wireshark进行抓包时,我从未关注过第三次握手的ACK包是否携带数据。因此,我赶紧使用nc和tcpdump进行了几次抓包实验以验证。然而,通过多次实验,我发现第三次握手的包确实没有携带其他数据(稍后会解释原因)。在进一步探究中,我发现了一些问题,因此整理了探究过程和结论,供后来者参考。
首先,让我们看一下三次握手的图示(下图来自网络,如有侵权,请联系我删除):
根据RFC793文档,带有SYN标志的包在三次握手过程中是不允许携带数据的,也就是说前两次握手不可以携带数据(从逻辑上看,连接还未建立,携带数据似乎不合理)。重点是第三次握手是否可以携带数据。
让我们先来说结论:TCP协议建立连接的三次握手过程中的第三次握手允许携带数据。
根据RFC793文档的说法(省略了不重要的部分):
重点在于这句话:“Data or controls which were queued for transmission may be included”,也就是说,标准表示第三次握手的ACK包可以携带数据。那么Linux内核的协议栈是如何处理的呢?侯捷先生曾说过:“源码面前,了无秘密”。最近,Kernel 4.0正式版发布,我决定追查一下这个版本的内核协议栈源码。
在探索源码之前,我假设读者对Linux的基本socket编程非常熟悉,至少对连接的流程比较了解(可以参考这篇文章《浅谈服务端编程》中关于socket连接过程的图示)。至于socket接口和协议栈的挂接,可以参考《socket接口与内核协议栈的挂接》。
首先,第三次握手的包是由连接发起方(以下简称客户端)发送给端口监听方(以下简称服务端)的,因此我们只需要找到内核协议栈在一个连接处于SYN-RECV(图中的SYNRECEIVED)状态时收到包后的处理过程。经过一番搜索,我找到了位于net/ipv4目录下的tcpinput.c文件中的tcprcvstate_process函数来处理这个过程。如下图所示:
实际上,这个函数是一个TCP状态机,用于处理TCP连接处于各个状态时收到数据包的处理工作。这里有几个并列的switch语句,由于函数很长,所以容易看错层次关系。下图是精简后只保留SYN-RECV状态处理过程的代码:
请注意,这两个switch语句是并列的。因此,当TCPSYNRECV状态收到合法的第二次握手包后,它会立即将socket状态设置为TCP_ESTABLISHED,并继续处理其中包含的数据(如果有的话)。
上述代码表明,当客户端发送的第三次握手的ACK包含有数据时,服务端是可以正常处理的。那么客户端呢?让我们看看客户端在SYN-SEND状态下如何发送第三次ACK包。如下图所示:
一目了然,如果条件不满足,直接回复独立的ACK包;如果任何条件满足,则使用inetcskresetxmittimer函数设置定时器,等待一小段时间。在这段时间内,如果有数据,则随数据一起发送ACK;如果没有数据,则只回复ACK。这解决了之前的疑问。
然而,这三个条件是什么?什么情况下会导致第三次握手包可能携带数据呢?或者说,如果想抓取到一个带有数据的第三次握手包,应该如何操作?请耐心等待,本文将一一解答。
条件1:sk->skwritepending != 0
默认情况下,这个值为0。什么情况下会导致它不为0呢?答案是当协议栈发送数据的函数遇到socket状态不是ESTABLISHED时,会对这个变量进行++操作,并等待一小段时间尝试发送数据。如下图所示:
net/core/stream.c文件中的skstreamwaitconnect函数执行了以下操作:递增sk->skwrite_pending,并等待socket连接达到ESTABLISHED状态后发送数据。这解释了为什么我们无法抓取到第三次握手包带有数据的原因。Linux socket的默认工作方式是阻塞的,也就是说,默认情况下,客户端的connect调用会阻塞,直到三次握手过程结束或遇到错误才会返回。因此,像nc这种完全使用阻塞套接字实现且没有修改默认socket参数的命令行小程序会乖乖地等待connect成功或失败后才发送数据,这就是我们无法抓取到第三次握手包带有数据的原因。
如果将套接字设置为非阻塞,然后在connect之后立即发送数据,如果连接过程不是瞬间成功的话,也许就有机会看到第三次握手包带有数据。然而,即使是非阻塞套接字的开源网络库,也是监听套接字的可写事件,确认连接成功后才会写入数据。为了节省几乎可以忽略不计的性能开销,安全可靠的代码更有价值。
条件2:icsk->icskacceptqueue.rskqdeferaccept != 0
这个条件看起来很奇怪,deferaccept是一个套接字选项,用于推迟accept操作,实际上是在接收到第一个数据后才创建连接。tcpdeferaccept选项通常在服务端使用,它会影响套接字的SYN和ACCEPT队列。如果未设置,默认情况下,三次握手完成后,套接字将进入ACCEPT队列,应用层会感知并接受相关连接。当设置了tcpdefer_accept选项后,三次握手完成后,套接字不会进入ACCEPT队列,而是留在SYN队列(有长度限制,超过内核会拒绝新连接),直到真正收到数据后才放入ACCEPT队列。设置了这个选项的服务端可以直接在accept之后进行read操作,因为肯定有数据,这样可以节省一次系统调用。
有趣的是,如果客户端先绑定到一个端口和IP,然后设置tcpdeferaccept选项,然后连接服务器,就会出现rskqdeferaccept=1的情况。此时,内核会设置定时器,等待数据一起回复ACK包。我个人从未这样做过,难道只是为了减少一次发送空ACK包的性能开销?如果有同学了解,请告知,谢谢。
条件3:icsk->icsk_ack.pingpong != 0
pingpong属性实际上也是一个套接字选项,用于指示当前连接是否为交互式数据流。如果值为1,则表示为交互式数据流,会使用延迟确认机制。
好了,本文到此结束。上述函数的出现比较杂乱无章。具体的调用链可以参考这篇文章《TCP内核源码分析笔记》,但由于内核版本的不同,可能会有些许差异。毕竟,我对协议栈的研究有限,就不再多说了。