关于FIN_WAIT1

关于FIN_WAIT1

前些天,一堆人在 TCPCopy 社区里闲扯蛋,有人提了一个问题:FIN_WAIT1 能持续多久?引发了一场讨论,期间我得到斌哥和多位朋友的点化,受益良多。

让我们热热身,通过一张旧图来回忆一下 TCP 关闭连接时的情况:

TCP CloseTCP Close

看图可知,主动关闭的一方发出 FIN,同时进入 FIN_WAIT1 状态,被动关闭的一方响应 ACK,从而使主动关闭的一方迁移至 FIN_WAIT2 状态,接着被动关闭的一方同样会发出 FIN,主动关闭的一方响应 ACK,同时迁移至 TIME_WAIT 状态。

回到开头的问题:FIN_WAIT1 能持续多久?一般情况下,服务器间的 ACK 确认是非常快的,以至于我们凭肉眼往往观察不到 FIN_WAIT1 的存在,不过网上也有很多案例表明在某些情况下 FIN_WAIT1 会持续很长时间,从而诱发问题。

最常见的误解是认为 tcp_fin_timeout 控制 FIN_WAIT1 的过期,从名字上看也很像,但实际上它控制的是 FIN_WAIT2 的过期时间, 官方文档 是这样说的:

The length of time an orphaned (no longer referenced by any application) connection will remain in the FIN_WAIT_2 state before it is aborted at the local end. While a perfectly valid “receive only” state for an un-orphaned connection, an orphaned connection in FIN_WAIT_2 state could otherwise wait forever for the remote to close its end of the connection.

Cf. tcp_max_orphans

Default: 60 seconds

让我们通过一个实验来说明问题(服务端:10.16.15.107;客户端:10.16.15.109):

  1. 在服务端监听 1234 端口:「nc -l 1234」
  2. 在客户端连接服务端:「nc 10.16.15.107 1234」
    此时客户端连接进入 ESTABLISHED 状态
  3. 在服务端拦截响应:「iptables -A OUTPUT -d 10.16.15.109 -j DROP」
  4. 在客户端开启抓包:「tcpdump -nn -i any port 1234」
  5. 在客户端通过「ctrl + c」断开连接
    此时客户端连接进入 FIN_WAIT1 状态

随时可以通过「netstat -ant | grep :1234」来观察状态,最终抓包结果如下:

TCP FinTCP Fin

第一个 FIN 是我们按「ctrl + c」断开连接时触发的,因为我们在服务端通过 iptables 拦截了发送给客户端的响应,所以对应的 ACK 被丢弃,随后执行了若干次重试。

此外,通过观察时间我们还能发现,第一次重试在 200ms 左右;第二次是在 400ms 左右;第三次是在 800ms 左右;以此类推,每次的时间延长一倍。

实际上,控制这一行为的关键参数是 tcp_orphan_retries, 官方文档 是这样说的:

This value influences the timeout of a locally closed TCP connection, when RTO retransmissions remain unacknowledged. See tcp_retries2 for more details.The default value is 8. If your machine is a loaded WEB server, you should think about lowering this value, such sockets may consume significant resources. Cf. tcp_max_orphans.

不过我通过 sysctl 查询 tcp_orphan_retries 的结果是 0,不是上面说的 8,看 代码

/* Calculate maximal number or retries on an orphaned socket. */
static int tcp_orphan_retries(struct sock *sk, int alive)
{
  int retries = sysctl_tcp_orphan_retries; /* May be zero. */

  /* We know from an ICMP that something is wrong. */
  if (sk->sk_err_soft && !alive)
    retries = 0;

  /* However, if socket sent something recently, select some safe
   * number of retries. 8 corresponds to >100 seconds with
   * minimal RTO of 200msec. */
  if (retries == 0 && alive)
    retries = 8;
  return retries;
}

于是乎我们可以得出结论,如果你的服务器负载较重,有很多 FIN_WAIT1,那么可以考虑通过降低 tcp_orphan_retries 来解决问题,设置为 1 应该是个不错的选择。

问题分析到这里原本可以完美谢幕,但是因为内核有缺陷,导致 FIN_WAIT1 可能被用来发起 DoS 攻击,所以我们就再唠十块钱儿的,看看到底是怎么回事儿:

假设服务端上有一个大文件,攻击者连接服务端发起请求,但是却不接收数据,于是乎就造成一种现象:客户端接收队列满,导致服务端不得不通过「zero window probes」来循环检测客户端是否有可用空间,以至于 tcp_orphan_retries 也没有用,因为服务端活活被憋死了,发不出 FIN 来,从而永远卡在 FIN_WAIT1。演示代码如下:

#!/usr/bin/env python

import socket
import time

host = 'www.domain.com'
port = 80
path = '/a/big/file'

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, port))
sock.send("GET %s HTTP/1.0\r\nHost: %s\r\n\r\n" % (path, host))

time.sleep(1000)

说明:通常文件大小以 100K 为佳,具体取决于 tcp_rmem / tcp_wmem 的大小。

怎么办?病急乱投医,重启服务!可惜没用,因为 FIN_WAIT1 属于 orphan,已经脱离的服务的管辖范围,所以重启服务是没有用的,如果一定要重启,你只能重启服务器!

理论上讲,这属于内核缺陷,在官方给出正式的解决方案前,没有特别完美的办法,但是我们也不能坐以待毙,实际上如果你搜索「 how to kill a tcp connection 」的话,能找到一些可以选择的工具,比如: tcpkill 和  killcx

如果你要杀掉一个 TCP 连接,那么需要知道相应的 ACK 和 SEQ,然后才可以 RESET 连接。为了获取 ACK 和 SEQ,tcpkill 采用的是被动机制,它通过监听匹配的数据包来获取需要的数据;killcx 采用的是主动机制,它通过伪造 SYN 包来获取需要的数据。

从前面的案例我们知道,一旦攻击者让服务端卡在 FIN_WAIT1,就不再有数据交互,所以采取被动机制的 tcpkill 在本案例中没有效果,而采取主动机制的 killcx 管用。

安装 killcx 的时候,注意 Perl 的版本不能低于 5.10.0,再搭配几个依赖的模块就能用了:

shell> perl -MCPAN -e shell

cpan> install Net::RawIP
cpan> install Net::Pcap
cpan> install NetPacket::Ethernet

使用 killcx 的时候,需要注意的是因为我们要杀的是 FIN_WAIT1 连接,所以需要使用 loopback 接口,这点再官方文档里有提及:

Note that in many cases, you will get much better results by using ‘lo’ (loopback interface), specially if the connection is not yet or no longer in the ESTABLISHED state, for instance SYN_RECV or TIME_WAIT.

最后,再次感谢 TCPCopy 社区!如果你从本文学到些许知识,那么这份荣幸属于 TCPCopy社区,如果你在本文发现谬误之处,那么全因本人笨拙,还望不吝赐教。

Comments are closed.