ESTABLISHED 状态的连接收到 SYN 会回复什么?

5,153 阅读12分钟

最初这个问题是读者在我的 TCP 掘金小册的《TCP RST 攻击与如何杀掉一条 TCP 连接》小节中的一个留言提出的:「处于 ESTABLISHED 的连接,为什么还要响应 SYN 包?」,这篇文章就来聊聊这一部分的内容。

通过阅读这篇文章,你会了解到这些知识

  • ESTABLISHED 状态的连接收到乱序包会回复什么
  • Challenge ACK 的概念
  • ACK 报文限速是什么鬼
  • SystemTap 工具在 linux 内核追踪中的使用
  • 包注入神器 scapy 的使用
  • RST 攻击的原理
  • killcx 等工具利用 RST 攻击的方式来杀掉连接的原理

接下来开始文章的内容。

scapy 实验复现现象

实验步骤如下:

在机器 A(10.211.55.10) 使用 nc 启动一个服务程序,监听 9090 端口,如下所示。

nc -4 -l 9090

机器 A 上同步使用 tcpdump 抓包,其中 -S 表示显示绝对序列号。

sudo tcpdump -i any port 9090 -nn  -S

在机器 B 使用 nc 命令连接机器 A 的 nc 服务器,输入 "hello" 。

nc 10.211.55.10 9090

使用 netstat 可以看到此次连接的信息。

Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 10.211.55.10:9090       10.211.55.20:50718      ESTABLISHED 9029/nc

在机器 B 上使用 scapy,模拟发送 SYN 包,scapy 脚本如下所示。

send(IP(dst="10.211.55.10")/TCP(sport=50718, dport=9090, seq=10, flags='S'))

源端口号 sport 使用此次连接的临时端口号 50718,序列号随便写一个,这里 seq 为 10。

执行 scapy 执行上面的代码,tcpdump 中显示的包结果如下。

// nc 终端中 hello 请求包
18:41:51.956735 IP 10.211.55.20.50718 > 10.211.55.10.9090: Flags [P.], seq 3219267420:3219267426, ack 2848436085, win 229, options [nop,nop,TS val 1094540820 ecr 12823113], length 6
18:41:51.956787 IP 10.211.55.10.9090 > 10.211.55.20.50718: Flags [.], ack 3219267426, win 227, options [nop,nop,TS val 12827910 ecr 1094540820], length 0

// scapy 的 SYN 包
18:44:32.373331 IP 10.211.55.20.50718 > 10.211.55.10.9090: Flags [S], seq 10, win 8192, length 0
18:44:32.373366 IP 10.211.55.10.9090 > 10.211.55.20.50718: Flags [.], ack 3219267426, win 227, options [nop,nop,TS val 12988327 ecr 1094540820], length 0

可以看到,对于一个 SEQ 为随意的 SYN 包,TCP 回复了正确的 ACK 包,其确认号为 3219267426。

从 rfc793 文档中也可以看到:

Linux 内核对于收到的乱序 SYN 报文,会回复一个携带了正确序列号和确认号的 ACK 报文。

这个 ACK 被称之为 Challenge ACK。

我们后面要介绍的杀掉连接工具 killcx 的原理,正是是基于这一点。

原因分析

为了方便说明,我们记发送 SYN 报文的一端为 A,处于 ESTABLISHED 状态接收 SYN 报文的一端为 B,B 对收到的 SYN 包回复 ACK 的原因是想让对端 A 确认之前的连接是否已经失效,以便做出一些处理。

对于 A 而已,如果之前的连接还在,对于收到的 ACK 包,正常处理即可,不再讨论。

如果 A 之前的此条连接已经不在了,此次 SYN 包是想发起新的连接,对于收到的 ACK 包,会立即回复一个 RST,且 RST 包的序列号就等于 ACK 包的序列号,B 收到这个合法的 RST 包以后,就会将连接释放。A 此时若想继续与 B 创建连接,则可以选择再次发送 SYN 包,重新建连,如下图所示。

estab_syn

接下来我们来看内核源码的处理,

内核源码分析

在这之前,我们需要先了解 SystemTap 工具的使用。SystemTap 是 Linux 中非常强大的调试探针工具,类似于 java 中的 javaagent instrument,可以获取一个内核函数运行时的入参变量、返回值、调用堆栈,甚至可以直接修改变量的值。这个工具详细的使用这里不展开,感兴趣的同学可以自行 Google。

接下来我们来使用 SystemTap 这个工具来给内核插入 probe 探针,以 3.10.0 内核为例,内核中回复的 ack 的函数在 net/ipv4/tcp_output.c 的 tcp_send_ack 中实现。我们给这个函数插入调用探针,在端口号为 9090 时打印调用堆栈。新建一个 ack_test.stp 文件,部分代码如下所示。

%{
#include <net/sock.h>
#include <linux/tcp.h>
#include <linux/skbuff.h>
#include <net/route.h>
%}

function tcp_src_port:long(sk:long)
{
	return __tcp_sock_sport(sk)
}
function tcp_dst_port:long(sk:long)
{
	return __tcp_sock_dport(sk)
}
function tcp_src_addr:long(sk:long)
{
	return ntohl(__ip_sock_saddr(sk))
}
function tcp_dst_addr:long(sk:long)
{
	return ntohl(__ip_sock_daddr(sk))
}
function str_addr:string(addr, port) {
        return sprintf("%d.%d.%d.%d:%d",
                       (addr & 0xff000000) >> 24,
                       (addr & 0x00ff0000) >> 16,
                       (addr & 0x0000ff00) >> 8,
                       (addr & 0x000000ff),
                       port
                )
}

probe kernel.function("tcp_send_ack@net/ipv4/tcp_output.c")
{
       src_addr = tcp_src_addr($sk);
       src_port = tcp_src_port($sk);
       dst_addr = tcp_dst_addr($sk);
       dst_port = tcp_dst_port($sk);
       if (dst_port == 9090 || src_port == 9090)
       {
              printf("send ack : %s:->%s\n",
                     str_addr(src_addr, src_port),
                     str_addr(dst_addr, dst_port));
              print_backtrace();
       }
}

使用 stap 命令执行上面的脚本

sudo stap -g ack_test.stp

再次使用 scapy 发送一个 syn 包,内核同样会回复 ACK,此时 stap 输出结果如下。

send ack : 10.211.55.10:9090:->10.211.55.20:50718
 0xffffffff815d0940 : tcp_send_ack+0x0/0x170 [kernel]
 0xffffffff815cb1d2 : tcp_validate_incoming+0x212/0x2d0 [kernel]
 0xffffffff815cb44d : tcp_rcv_established+0x1bd/0x760 [kernel]
 0xffffffff815d5f8a : tcp_v4_do_rcv+0x10a/0x340 [kernel]
 0xffffffff815d76d9 : tcp_v4_rcv+0x799/0x9a0 [kernel]
 0xffffffff815b1094 : ip_local_deliver_finish+0xb4/0x1f0 [kernel]
 0xffffffff815b1379 : ip_local_deliver+0x59/0xd0 [kernel]
 0xffffffff815b0d1a : ip_rcv_finish+0x8a/0x350 [kernel]
 0xffffffff815b16a6 : ip_rcv+0x2b6/0x410 [kernel]

可以看到这个 ACK 经过了下面这些函数调用。

tcp_v4_rcv
  -> tcp_v4_do_rcv
    -> tcp_rcv_established
      -> tcp_validate_incoming
        -> tcp_send_ack

tcp_validate_incoming 函数精简后的部分代码如下所示。

static bool tcp_validate_incoming(struct sock *sk, struct sk_buff *skb,
				  const struct tcphdr *th)
{	
	// seq 不在窗口内
	/* Step 1: check sequence number */
	if (!tcp_sequence(tp, TCP_SKB_CB(skb)->seq, TCP_SKB_CB(skb)->end_seq)) {
		// RST 标记没有设置
		if (!th->rst) {
			if (th->syn)
				goto syn_challenge;
		}
		goto discard;
	}
	
	/* step 4: Check for a SYN。 RFC 5961 4.2 : Send a challenge ack */
	if (th->syn) {
syn_challenge: // 处理 SYN Challenge 的情况
		tcp_send_challenge_ack(sk, skb); // 
		goto discard;
	}

tcp_send_challenge_ack 函数真正调用了 tcp_send_ack 函数。 这里的注释提到了 RFC 5961 4.2,说的正是 Challenge ACK 相关的内容。

如果攻击者疯狂发送假的乱序包,接收端也跟着回复 Challenge ACK,会耗费大量的 CPU 和带宽资源。于是 RFC 5961 提出了 ACK Throttling 方案,限制了每秒钟发送 Challenge ACK 报文的数量,这个值由 net.ipv4.tcp_challenge_ack_limit 系统变量决定,默认值是 1000,也就是 1s 内最多允许 1000 个 Challenge ACK 报文。

接下来使用 sysctl 将这个值改小为 1,如下所示。

sudo sysctl -w net.ipv4.tcp_challenge_ack_limit="1"

这样理论上在一秒内多次发送一个 Challenge ACK 包,接下来使用 scapy 在短时间内发送 5 次 SYN 包,看看内核是否只会回复一个 ACK 包,scapy 的脚本如下所示。

send(IP(dst="10.211.55.10")/TCP(sport=50718,dport=9090,seq=10,flags='S'), loop=0, count=5)

tcpdump 抓包结果如下。

03:40:30.970682 IP 10.211.55.20.50718 > 10.211.55.10.9090: Flags [S], seq 10, win 8192, length 0
03:40:30.970771 IP 10.211.55.10.9090 > 10.211.55.20.50718: Flags [.], ack 3219267426, win 227, options [nop,nop,TS val 45146923 ecr 1094540820], length 0
03:40:30.974889 IP 10.211.55.20.50718 > 10.211.55.10.9090: Flags [S], seq 10, win 8192, length 0
03:40:30.975004 IP 10.211.55.20.50718 > 10.211.55.10.9090: Flags [S], seq 10, win 8192, length 0
03:40:30.978643 IP 10.211.55.20.50718 > 10.211.55.10.9090: Flags [S], seq 10, win 8192, length 0
03:40:30.981987 IP 10.211.55.20.50718 > 10.211.55.10.9090: Flags [S], seq 10, win 8192, length 0

可以看到确实是只对第一个 SYN 包回复了一个 ACK 包,其它的四个 SYN 都没有回复 ACK。

RST 攻击

RST 攻击也称为伪造 TCP 重置报文攻击,通过伪造 RST 报文来关闭掉一个正常的连接。

源 IP 地址伪造非常容易,不容易被伪造的是序列号,RST 攻击最重要的一点就是构造的包的序列号要落在对方的滑动窗口内,否则这个 RST 包会被忽略掉,达不到攻击的效果。

下面我们用实验演示不在滑动窗口内的 RST 包会被忽略的情况,完整的代码见:rst_out_of_window.pkt github.com/arthur-zhan…

+0 < S 0:0(0) win 32792 <mss 1460> 
+0 > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 65535 
+0 accept(3, ..., ...) = 4

// 不在窗口内的 RST
+.010 < R. 29202:29202(0) ack 1 win 65535

// 如果上面的 RST 包落在窗口内,连接会被重置,下面的写入不会成功
+.010 write(4, ..., 1000) = 1000 

// 断言服务端会发出下面的数据包
+0 > P. 1:1001(1000) ack 1 <...>

执行上面的脚本,抓包的结果如下,完整的包见:rst_out_of_window.pcap github.com/arthur-zhan…

抓包文件中的第 5 个包可以看到,write 调用成功,1000 字节发送成功,write 调用并没有收到 RST 包的影响。

下面来介绍两个工具,利用 RST 攻击的方式来杀掉一条连接。

工具一:tcpkill 工具使用及原理介绍

Centos 下安装 tcpkill 命令步骤如下

yum install epel-release -y
yum install dsniff -y

实验步骤: 1、机器 c2(10.211.55.10) 启动 nc 命令监听 8080 端口,充当服务器端,记为 B

nc -l 8080

2、机器 c2 启动 tcpdump 抓包

sudo tcpdump -i any port 8080 -nn -U -vvv -w test.pcap

3、本地机器终端(10.211.55.2,记为 A)使用 nc 与 B 的 8080 端口建立 TCP 连接

nc c2 8080

在服务端 B 机器上可以看到这条 TCP 连接

netstat -nat | grep -i 8080
tcp        0      0 10.211.55.10:8080       10.211.55.2:60086       ESTABLISHED

4、启动 tcpkill

sudo tcpkill -i eth0 port 8080

注意这个时候 tcp 连接依旧安然无恙,并没有被杀掉。

5、在本地机器终端 nc 命令行中随便输入一点什么,这里输入hello,发现这时服务端和客户端的 nc 进程已经退出了

下面来分析抓包文件,这个文件可以从我的 github 下载 tcpkill.pcap

可以看到,tcpkill 假冒了 A 和 B 的 IP发送了 RST 包给通信的双方,那问题来了,伪造 ip 很简单,它是怎么知道当前会话的序列号的呢?

tcpkill 的原理跟 tcpdump 差不多,会通过 libpcap 库抓取符合条件的包。 因此只有有数据传输的 tcp 连接它才可以拿到当前会话的序列号,通过这个序列号伪造 IP 发送符合条件的 RST 包。

原理如下图所示

可以看到 tcpkill 对每个端发送了 3 个RST 包,这是因为在高速数据传输的连接上,根据当前抓的包计算的序列号可能已经不再 TCP 连接的窗口内了,这种情况下 RST 包会被忽略,因此默认情况下 tcpkill 未雨绸缪往后计算了几个序列号。还可以指定参数-n指定更多的 RST 包,比如tcpkill -9

根据上面的分析 tcpkill 的局限还是很明显的,无法杀掉一条僵死连接,下面我们介绍一个新的工具 killcx,看看它是如何来处理这种情况的。

工具二:killcx

killcx 是一个用 perl 写的在 linux 下可以关闭 TCP 连接的脚本,无论 TCP 连接处于什么状态。

下面来做一下实验,实验的前几步骤跟第一个例子中一模一样

1、机器 c2(10.211.55.10) 启动 nc 命令监听 8080 端口,充当服务器端,记为 B

nc -l 8080

2、机器 c2 启动 tcpdump 抓包

sudo tcpdump -i any port 8080 -nn -U -vvv -w test.pcap

3、本地机器终端(10.211.55.2,记为 A)使用 nc 与 B 的 8080 端口建立 TCP 连接

nc c2 8080

在服务端 B 机器上可以看到这条 TCP 连接

netstat -nat | grep -i 8080
tcp        0      0 10.211.55.10:8080       10.211.55.2:61632       ESTABLISHED

4、客户端 A nc 命令行随便输入什么,这一步也完全可以省略,这里输入"hello\n"

5、执行 killcx 命令,注意 killcx 是在步骤 4 之后执行的

sudo ./killcx 10.211.55.2:61632

可以看到服务端和客户端的 nc 进程已经退出了。

抓包的结果如下

前 5 个包都很正常,三次握手加上一次数据传输,有趣的事情从第 6 个包开始

  • 第 6 个包是 killcx 伪造 IP 向服务端 B 发送的一个 SYN 包
  • 第 7 个包是服务端 B 回复的 ACK 包,里面包含的 SEQ 和 ACK 号
  • 第 8 个包是 killcx 伪造 IP 向服务端 B 发送的 RST 包
  • 第 9 个包是 killcx 伪造 IP 向客户端 A 发送的 RST 包

整个过程如下图所示

小结

这篇文章介绍了为什么 ESTABLISHED 状态连接的需要对 SYN 包做出响应,Challenge ACK 是什么,使用 scapy 复现了现象,演示了 SystemTap 内核探针调试工具的使用,最后通过修改系统变量复现了 ACK 限速。

文章的最后介绍了杀掉 TCP 连接的两个工具 tcpkill 和 killcx:

  • tcpkill 采用了比较保守的方式,抓取流量等有新包到来的时候,获取 SEQ/ACK 号,这种方式只能杀掉有数据传输的连接
  • killcx 采用了更加主动的方式,主动发送 SYN 包获取 SEQ/ACK 号,这种方式活跃和非活跃的连接都可以杀掉

有问题可以扫描下面的二维码关注我的公众号到联系我。