TCP的正常情况
TCP的正常连接情况,本文不再赘述。三次握手与四次挥手有大把八股可以看。这里会放一个TCP状态机的图片,用于在下方出现错误时参考。
一些前置知识
TCP状态由内核维护
TCP 协议栈是由操作系统内核负责实现和维护的。内核负责处理连接的建立与关闭、数据包的发送与接收、重传、流量控制、拥塞控制及错误处理等底层逻辑。用户态的应用程序并不直接操作这些细节,而是通过如 socket()、connect()、send()、recv() 等系统调用,借助内核提供的接口与 TCP 协议栈进行交互。这样,用户程序可以专注于业务逻辑,而由内核透明地处理复杂的网络传输细节。
什么是RST报文
TCP 中的 RST(Reset)报文 是一种 强制中断连接 的方式,用于立即终止一个连接或通知对方出现异常。由于RST报文会立刻中断TCP连接,为了防止不在路由上的恶意攻击者随意伪造RST导致连接中断,只有满足一定条件的RST报文才能被处理,此处姑且称之为“有效的RST报文”。
什么是有效的RST报文
-
IP 层要匹配
- 源地址和目标地址必须和原连接一致。
- 源端口和目标端口必须正确(即 TCP 四元组匹配)。
-
序列号必须合法
- 对于一个已建立的连接,RST 报文的
seq(序列号)必须落在接收方的接收窗口范围内,否则接收方会丢弃该报文。 - 有些情况下(如未建立连接时收到 RST),只要匹配 TCP 四元组即可。
- 对于一个已建立的连接,RST 报文的
-
报文标志位
- 必须设置
RST标志。 - 一般不能同时带有
SYN或FIN标志。
- 必须设置
TCP的设计思想
-
TCP使用RST报文表示连接异常,需要立刻关闭,且任何RST报文均无需回应。所以,一旦RST报文被发送或接收,TCP状态机就会转移到CLOSED状态。相应地内核会销毁相关资源,不再处理后续任何报文。也就是说,对一条TCP连接而言,RST 报文最多只会被处理一次,即使可能收到了很多RST报文。
-
TCP设计中,错误并非一种状态,而是一个事件。所以在几乎所有主流的(Windows、Linux、类Unix及Unix)TCP协议栈实现中,TCP的错误都是读清 (read and clear) 的。读清表示错误一旦被读取就会立刻被清除,直到下一次错误发生。下面的
get_error函数就是读清的一种典型实现。static int error; // 0 代表没有错误 int get_error() { int err = error; error = 0; return err; } int set_error(int err) { error = err; }这里的读取是广义的,不仅包含使用
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len)明确读出的错误,也包含read/recv write/send后返回的错误。当这些错误到达用户态时,错误就被消费了,在新的错误到来之前,无法再读取到错误。
但不同场景下收到RST报文,返回的错误及现象会有不同,通过这些不同的现象,我们可以更进一步定位引起异常的原因。
握手阶段的 RST
SYNC-SENT 状态
这是TCP RST报文中最典型的一种情况,代表对方并没有监听这个端口,或路径上的任一防火墙拒绝了连接请求。此时客户端会返回错误“连接被拒绝”(Linux)或“由于目标计算机积极拒绝,无法连接”(Windows)。
SYNC-RCVD 状态
正常的TCP客户端中,这种情况几乎不可能出现,因为客户端没理由在尝试连接后又不接受服务端的返回报文。如果出现,可能是因为被防火墙错误拦截。若客户端在发送SYN握手包后立刻退出,由于此时TCP还处于SYN-SENT状态,并不会触发优雅关闭,内核会以RST报文响应服务端发送的SYN-ACK报文。
非正常的TCP客户端(如scapy、hping3)可能会有此类行为,它们通常是一些网络调试或渗透工具,这类工具并不使用TCP上层接口,而是直接使用 raw socket 完整控制网卡发出任何消息(可能受驱动或硬件限制)。由于此操作绕过了内核的TCP接口,所以内核没有为它们创建TCP协议栈。当该类工具发出 TCP SYN 报文后,通常只会检查是否收到了 SYN-ACK 报文,而不会返回ACK。由于它们未在内核中创建TCP连接,内核会自动对非预期的 SYN-ACK 报文返回RST。
这种方式可以通过SYN-ACK报文检测对应端口是否开放,但没有任何有效数据传输,且因为它发生在accept前,服务端进程无法记录日志,是一种常见的隐蔽端口扫描行为。
对服务器来说,这种问题影响很小,内核在收到RST报文后,便会将其从半连接队列中删除,没有可见的现象。
双工阶段的 RST
在TCP处于ESTABLISHED状态时,收到RST后,内核中相关的资源就被销毁了,并且会将错误设置为“连接被重置”。由于TCP是全双工协议,所以此时任何一方收到RST,行为都是一样的。
当收到有效的RST时,读或写都会产生连接被重置(connection reset by peer) 的错误。然而由于之前提到的两个规则,同一条连接下,这个错误只会出现一次,再之后无论读或写,都不可能产生连接被重置的错误了。其原因如下。
- RST报文只会被处理一次,之后即使再收到报文,也会因为socket资源被销毁而不会再处理。
- 错误是读清的,一旦获得了该错误,在新错误出现前,就不会再有错误了。
所以,程序在感知到连接被重置后,再去读就会返回0代表EOF,不会出现错误。而写则会产生EPIPE错误,代表TCP写管道已经损坏,无法再写入。如果尝试用getsockopt函数获取错误,也是没有错误的。
当收到RST报文后,即使内核的读取缓冲区仍有数据,
recv/read也无法读出。但如果正在读时收到RST,已有的数据还是能读出的。(Linux内核行为,不代表其他内核) 类似地,即使内核的写入缓冲区仍有数据,这些数据也不会被发送,只会被丢弃。
进程退出
进程因任何原因退出(也包括kill -9后,系统会自动关闭进程的文件描述符。默认情况下,若没有使用setsocketopt配置SO_LINGER,close就会优雅关闭连接,走完挥手流程。
挥手阶段的RST
一端发送FIN包后,另一端就会进入CLOSE-WAIT状态。关闭可以由任何一端触发,其效果相同,故本文假设客户端触发关闭,首次发送FIN包。
CLOSE-WAIT
在TCP的设计中,FIN报文表示发送者不会再写入,TCP开始进入关闭流程。但是,没有任何一种报文可以准确表示不会再读取。服务端收到FIN报文只能确定客户端不会再发送,但不确定客户端是否也不会再接收。如果此时服务端继续发送数据,若客户端还能继续读取,则会返回ACK,若客户端进程完全关闭了连接,内核会通过发送RST报文的方式告知服务端不会再读取。所以当服务端处于CLOSE-WAIT状态时,收到RST报文也不会出现连接被重置的错误,而是会出现Broken Pipe的错误。
这种错误也是一种相对比较常见的情况,常见于下载过程中客户端意外退出时。服务端正传输大量数据,但客户端不再能接收,则服务端会发生Broken Pipe错误。
FIN-WIAT
FIN-WAIT-1 与 FIN-WAIT-2 状态并无不同,区别只在是否收到了 FIN 包的 ACK。
此时进程仍然可以读,由于没有收到对方的 FIN 报文,所以客户端完全可以假设仍然可读,若读取过程中收到了 RST 报文,也会报告连接被重置错误。
ICMP 报文
ICMP 报文也可能会导致 TCP 连接出现错误。与 TCP RST 报文不同,ICMP 有多种错误,包括主机不可达、网络不可达、端口不可达等等。
ICMP错误分为软错误和硬错误,端口不可达、协议不可达和需要分段但设置了DF位被视为硬错误。对于硬错误,标准(RFC 9293 3.9.9.2)要求应该中断连接,但Linux并没有这样做,因为过去很多网络设备滥用ICMP报文,导致这类消息并不可靠。对于软错误,则要求必须不能中断连接。
事实上,曾经的标准(RFC 1122 3.2.2.1 发布于1989年)的要求是这样的。
即使某个传输协议已经有自己的方式来通知发送方某个端口不可达(例如,TCP 发送 RST 段),它仍然必须(MUST)接受 ICMP 的“端口不可达(Port Unreachable)”消息,并用于相同目的。
可能也正是由于ICMP报文的滥用,标准之后也更新了。所以,当前针对 TCP 的 ICMP 报文,在操作系统中通通被视为软错误,只有通过 getsockopt 才能获取该错误,使用recv / send是无法获取的,若真的出现不可达的情况,只会卡住。
内部错误
除了收到特定的报文,内核在处理TCP连接时也可能会发生错误。
Keepalive 超时
当配置了 Keepalive 检测时,若达到超时限制,则会返回超时错误。此时内核会尝试向对端发送 RST 报文,并关闭 TCP 连接。故超时错误发生后,连接是无法再使用的。
本地防火墙关闭
一些本机防火墙工具可能会绕过用户态程序,直接通知内核关闭TCP连接。此时对端会收到 RST 报文,但是本机程序无法感知到 TCP 连接状态的变化,再尝试使用连接就会看到 Software caused connection aborted。即连接被强制终止。