TCP异常的几种情况

581 阅读9分钟

TCP的正常情况

TCP的正常连接情况,本文不再赘述。三次握手与四次挥手有大把八股可以看。这里会放一个TCP状态机的图片,用于在下方出现错误时参考。

image.png

一些前置知识

TCP状态由内核维护

TCP 协议栈是由操作系统内核负责实现和维护的。内核负责处理连接的建立与关闭、数据包的发送与接收、重传、流量控制、拥塞控制及错误处理等底层逻辑。用户态的应用程序并不直接操作这些细节,而是通过如 socket()connect()send()recv() 等系统调用,借助内核提供的接口与 TCP 协议栈进行交互。这样,用户程序可以专注于业务逻辑,而由内核透明地处理复杂的网络传输细节。

什么是RST报文

TCP 中的 RST(Reset)报文 是一种 强制中断连接 的方式,用于立即终止一个连接或通知对方出现异常。由于RST报文会立刻中断TCP连接,为了防止不在路由上的恶意攻击者随意伪造RST导致连接中断,只有满足一定条件的RST报文才能被处理,此处姑且称之为“有效的RST报文”。

什么是有效的RST报文

  1. IP 层要匹配

    • 源地址和目标地址必须和原连接一致。
    • 源端口和目标端口必须正确(即 TCP 四元组匹配)。
  2. 序列号必须合法

    • 对于一个已建立的连接,RST 报文的 seq(序列号)必须落在接收方的接收窗口范围内,否则接收方会丢弃该报文。
    • 有些情况下(如未建立连接时收到 RST),只要匹配 TCP 四元组即可。
  3. 报文标志位

    • 必须设置 RST 标志。
    • 一般不能同时带有 SYNFIN 标志。

TCP的设计思想

  1. TCP使用RST报文表示连接异常,需要立刻关闭,且任何RST报文均无需回应。所以,一旦RST报文被发送或接收,TCP状态机就会转移到CLOSED状态。相应地内核会销毁相关资源,不再处理后续任何报文。也就是说,对一条TCP连接而言,RST 报文最多只会被处理一次,即使可能收到了很多RST报文。

  2. 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) 的错误。然而由于之前提到的两个规则,同一条连接下,这个错误只会出现一次,再之后无论读或写,都不可能产生连接被重置的错误了。其原因如下。

  1. RST报文只会被处理一次,之后即使再收到报文,也会因为socket资源被销毁而不会再处理。
  2. 错误是读清的,一旦获得了该错误,在新错误出现前,就不会再有错误了。

所以,程序在感知到连接被重置后,再去读就会返回0代表EOF,不会出现错误。而写则会产生EPIPE错误,代表TCP写管道已经损坏,无法再写入。如果尝试用getsockopt函数获取错误,也是没有错误的。

当收到RST报文后,即使内核的读取缓冲区仍有数据,recv/read也无法读出。但如果正在读时收到RST,已有的数据还是能读出的。(Linux内核行为,不代表其他内核) 类似地,即使内核的写入缓冲区仍有数据,这些数据也不会被发送,只会被丢弃。

进程退出

进程因任何原因退出(也包括kill -9后,系统会自动关闭进程的文件描述符。默认情况下,若没有使用setsocketopt配置SO_LINGERclose就会优雅关闭连接,走完挥手流程。

挥手阶段的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。即连接被强制终止。