前置内容:全连接与半连接队列SYN泛洪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
//server
int main()
{
int sock_listen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
int optval = 1;
socklen_t optlen = sizeof(optval);
setsockopt(sock_listen, SOL_SOCKET, SO_REUSEADDR, (void*)&optval, optlen);
struct sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(12345);
addr.sin_addr.s_addr = inet_addr("192.168.248.128");
Bind(sock_listen, (struct sockaddr*)&addr, sizeof(addr));
Listen(sock_listen, 3);
int sock_conn = Accept(sock_listen, NULL, NULL);
while(1);
}
//client
int main() {
int sock;
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
struct sockaddr_in addr_serv;
bzero(&addr_serv, sizeof(addr_serv));
addr_serv.sin_addr.s_addr = inet_addr("192.168.248.128");
addr_serv.sin_port = htons(12345);
addr_serv.sin_family = AF_INET;
Connect(sock, (struct sockaddr *) &addr_serv, sizeof(addr_serv));
while (1);
}
//包裹函数
void Bind(int fd, const struct sockaddr* addr, socklen_t len)
{
if(-1 == bind(fd, addr, len))
perror("bind");
}

void Listen(int fd, int backlog)
{
if(-1 == listen(fd, backlog))
perror("listen");
}

int Accept(int fd, struct sockaddr* addr, socklen_t* len)
{
if(-1 == accept(fd, addr, len))
perror("accept");
}

void Connect(int fd, struct sockaddr* addr, socklen_t len)
{
if(-1 == connect(fd, addr, len))
perror("connect");
}

第一次握手异常

第一次握手异常一般有三种情况:
1) 目标主机不可达,响应一个 “destination unreachable” 的 ICMP 报文。

这个情况很好模拟,在客户端 connect 一个任意 IP 的地址结构即可。

2)目标主机的指定端口上没有套接字处于监听状态,connect 返回 “Connection refused” 。

3)接收方丢弃 SYN 报文。

下面重点说说第三种情况。

什么情况下会丢弃SYN报文?

有两种情况是可以确定的:

  1. 半连接队列已满,且没有开启 SYN-Cookie
  2. 全连接队列已满

情况一验证:

在笔者环境下(Ubuntu 16.04),半连接队列的长度等于全连接长度,即为 min(somaxconn,backlog)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//server
int main()
{
int sock_lsn = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
struct sockaddr_in addr_lsn;
memset(&addr_lsn, 0, sizeof(addr_lsn));
addr_lsn.sin_addr.s_addr = htonl(INADDR_ANY);
addr_lsn.sin_port = htons(12345);
addr_lsn.sin_family = AF_INET;
Bind(sock_lsn, (struct sockaddr*)&addr_lsn, sizeof(addr_lsn));
Listen(sock_lsn, 5); //backlog为5,则全连接队列容量为6,半连接队列为5
while(1){};
}
//====================================
//client
int main()
{
struct sockaddr_in addr_clnt;
memset(&addr_clnt, 0 , sizeof(addr_clnt));
addr_clnt.sin_port = htons(12345);
addr_clnt.sin_addr.s_addr = inet_addr("127.0.0.1");
addr_clnt.sin_family = AF_INET;
for(int i = 0; i < 10; i++)
{
if(0 == fork())
{
int sock_clnt = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
Connect(sock_clnt, (struct sockaddr*)&addr_clnt, sizeof(addr_clnt));
while(1);
}
}
}
  1. 将 SYN-Cookie 设置为 0(默认为 1):

    1
    2
    //须在管理员身份下运行
    $ echo 0 > /proc/sys/net/ipv4/tcp_syncookies

    0 值,表示关闭该功能;
    1 值,表示仅当 SYN 半连接队列放不下时,再启用它;
    2 值,表示无条件开启功能;

  2. 运行 server 端。

  3. 运行 hping3,发起 SYN 洪水攻击

    1
    2
    3
    4
    5
    6
    7
    8
    9
    hping3 -c 1000 -d 120 -S -w 64 -p 12345 --flood --rand-source 127.0.0.1
    //-c 1000 = 发送的数据包的数量
    //-d 120 = 发送到目标机器的每个数据包的大小,单位是字节
    //-S = 只发送 SYN 数据包
    //-w 64 = TCP 窗口大小
    //-p 12345 = 目的地端口为12345
    //–flood = flood攻击模式
    //--rand-source 源IP随机,即伪造
    //目标IP为主机127.0.0.1
  4. 查看 SYN_RECV 状态的个数:

    1
    2
    netstat -nat | grep :12345 | grep SYN_RECV  | wc -l 
    5

发送了 1000 个虚假 SYN 报文,而半连接队列中只有 5 个连接(满),说明其他 SYN 确实是被服务端丢弃了。那么如果开启了 SYN-Cookie 会怎么样呢?继续实验:

  1. 将 SYN-Cookie 设置为 1:

    1
    2
    $cat /proc/sys/net/ipv4/tcp_syncookies 
    1
  2. 运行 server 端和 hping3

  3. 查看 SYN_RECV 状态的个数,仍然为 5:

    1
    2
    netstat -nat | grep :12345 | grep SYN_RECV  | wc -l 
    5
  4. 查看 ESTABLISHED 状态的个数,为 0:

    1
    2
    netstat -nat | grep :12345 | grep ESTABLISH  | wc -l 
    0
  5. 运行客户端,并查看 ESTABLISHED 状态的个数:

    1
    2
    netstat -nat | grep :12345 | grep ESTABLISH  | wc -l
    12

    这里显示为 12,实际上为 6,这是因为服务器端和客户端都在一个主机上,netstat 命令分别以服务器和客户端的角度进行了输出,所以算重复了一次。
    已连接状态数为 6 的原因请参见前置文章,这里不再赘述。

所以,若半连接队列已满,且没有开启 SYN-Cookie ,则丢弃 SYN 报文。开启 SYN Cookies 后,就可以在不经过半连接队列的情况下成功建立连接:

情况二验证

  1. 先后运行服务器端和客户端

  2. 分别查看 ESTABLISH 和 SYN_RECV 的个数:

    1
    2
    3
    4
    netstat -nat | grep :12345 | grep ESTABLISH  | wc -l
    12
    netstat -nat | grep :12345 | grep SYN_RECV | wc -l
    0

    可见,当全连接队列满了后,半连接队列也不再接受 SYN

追问:如果全连接队列仅还有一个空位,那么半连接队列也只会接收一个 SYN 吗?继续实验:

  1. 修改 client 的第 23 行代码,将 10 改为 5,即发起 5 次连接(全队列容量为 6,这样就能余下一个空位)

  2. 先后运行 server 和 client

  3. 查看 ESTABLISHED 个数:

    1
    2
    netstat -nat | grep :12345 | grep ESTABLISH  | wc -l
    10

    即,有 5 个连接已经完成建立,全队列余下一个空位。

  4. 运行 hping3,并查看 SYN_RECV 个数:

    1
    2
    netstat -nat | grep :12345 | grep SYN_RECV  | wc -l 
    5

    发现半连接队列也已经满员。

综合以上两种情况,得到下面的流程图:

SYN报文丢失了会怎样?
很简单,重传即可。值得一提的是,SYN 报文最大重传次数由 tcp_syn_retries 内核参数控制 。通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后,即,每次超时的时间是上一次的 2 倍 。在笔者环境下,重传次数为 6:

1
2
cat /proc/sys/net/ipv4/tcp_syn_retries 
6

为了更清晰地看到这个过程,我们先关闭 SYN-Cookie,然后发起泛洪,使半连接队列满员,进而只能丢弃新到的 SYN 报文;接着开启一个客户端,此时使用 wireshark 进行抓包,结果如下:
image-20230407213529813
一共发送 7 次 SYN 报文,其中重传 6 次。看左边的数字小数点的前两位:50 -> 51 -> 53 -> 57 -> 65 -> 81 ,间隔时间分别为 1、2、4、8、16,和我们前面的结论相同。只是最后差了 34s,接近 32s,可能是误差吧,不太清楚这里是怎么回事。


第二次握手异常

有了第一次异常的分析,第二次握手异常就很容易分析出来了。首先能够确定的是,第一次握手的 SYN 报文仍然会重传,因为 client 压根没有收到 server 的 SYN+ACK 报文。那么 server 会怎么样呢?也容易猜到,由于迟迟没有收到第三次握手的 ACK 报文,server 也一定会重传,且重传次数由 tcp_synack_retries 决定( 在笔者环境下,该值为 5)。实验如下:
1)使用 iptables 屏蔽第二次握手:

1
iptables -i lo -I INPUT -p tcp  --tcp-flags SYN,ACK SYN,ACK -j DROP

2)运行 server 和 client,使用 wireshark 抓包,结果如下:

可见,第一次和第二次握手都在重传。

第三次握手异常

第三次握手就有意思了。看下面的三次握手的过程图:

可见当第三次握手的 ACK 发出后,客户端已经处于建立连接的状态,而服务器此时还没收到客户端的 ACK 报文,仍处于 SYN_RECV 状态。那么问题来了,如果 client 给 server 发送数据报文,会出现什么情况呢?

为了模拟server 无法接收 ACK 报文的情况,我们使用 iptables 在防火墙阻截这个 ACK 报文:

1
iptables -I INPUT -s 192.168.248.128 -p tcp --tcp-flag ACK,SYN  ACK -j DROP

只需知道上述命令的作用为:屏蔽含有 ACK 标记 不包含 SYN 标记的报文,显然三次握手中只有第三次符合要求,因此会被屏蔽。关于 iptables,请移步本博客另一篇文章——iptables
另外,实验完成后记得把屏蔽去掉,将 -I 改为 -D 重新执行上述命令即可。

结果如下:

可见,如果第三次握手的 ACK 报文丢失,则会引起第二次握手 ACK+SYN 重传,且重传 5 次,如 tcp_synack_retries 示,然后 clinet 重发 ACK,仍然丢失。再来看看 server 和 client 的状态:

1
2
3
netstat -a | grep 12345
tcp 0 0 192.168.248.128:59999 192.168.248.128:12345 ESTABLISHED
tcp 0 0 192.168.248.128:12345 192.168.248.128:59999 SYN_RECV

表明 client 已处于已连接状态,server 还未连接。而且重传 5 次完毕后不久,SYN_RECV 状态消失,说明 server 已经主动断开连接。如果此时 client 发送数据,这些报文当然也无法得到回复。建立连接后(是指在 ESTABLISHED 状态下)发送的报文丢失,则会重传,次数由 tcp_retries2 决定(本机为 15 次,等待大概 15min) ,没有回应则直接关闭连接。

tcp_retries1 变量是控制在系统向下级发出信号以尝试验证网络是否可用之前的重试次数,可忽略。

那么如果 client 不发送数据呢?它岂不是会一直保持 ESTABLISHED 状态。并不会,TCP 有保活机制,当一条连接上连续 两小时 没有任何动静时,本端就会发送探测报文,若连续几次探测报文都没有得到回应,则直接断开连接。保活时间,探测报文的次数、时间间隔分别由以下三个参数决定:

1
2
3
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9

即,最少需要经过 2 小时 11 分 15 秒才能断开一个死亡连接

关于心跳包和保活机制,可参见保活机制与心跳包设计


第一次挥手异常

FIN 报文丢失的原因之一是因为对方接收缓冲区满导致 FIN 无法被接收。

重传 FIN 报文,次数由 tcp_orphan_retries 控制,超过指定次数则直接关闭。这里有个细节: tcp_orphan_retries 默认为 0,但实际上重传次数为 8,源码向我们解释了原因:

为了避免 FIN_WAIT1 状态的连接过多,我们可以调小 tcp_orphan_retries 的值,也可以通过 tcp_max_orphans 限制其数量, 如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭


第二次挥手异常

即 ACK 报文丢失,这种情况下会重复第一次挥手。注意,pure ACK 报文都不会主动重传!只能被第一次挥手驱动而重传

第三次挥手异常

则重传 FIN 报文,次数仍由 tcp_orphan_retries 控制,超出指定次数则直接关闭连接。注意,此时 client(主动关闭方) 已经处于 FIN_WAIT2 状态,如果 client 是通过 close() 函数关闭连接的,则 FIN_WAIT2 状态只会持续 tcp_fin_timeout 指定的秒数(默认 60s);如果 client 是通过 shutdown() 关闭连接的,则 FIN_WAIT2 可以一直保持。

关于 close 和 shutdown,参见深入理解socket基本函数

第四次挥手异常

则重传第三次挥手的 FIN 报文,超过指定次数则直接关闭。第三次挥手成功后,主动关闭方进入了 TIME_WAIT 状态,这在期间如果收到重传的第三次挥手报文则回复 ACK:


区分连接断开的几种常见情况

进程崩溃
进程崩溃可以指是使用 kill 命令杀死进程,此时进程会进行关闭套接字等一系列动作,并向对方发出 FIN 报文,和正常四次挥手无差别。

服务器主机崩溃
服务器主机崩溃可以是断电,这种情况下进程来不及关闭套接字,因此服务器主机直接从网络中消失,那么客户向服务器重传数次后将返回 “destination unreachable” 错误。

服务器主机关机
Unix 系统关机时,init 进程会先给所有进程发送 SIGTERM 信号,等待一小段时间(以给进程清除和终止的时间)后发送 SIGKILL 信号强制终止所有进程。进程终止时会关闭所有描述符,于是进行正常挥手。

服务器主机崩溃后重启
服务器崩溃后重启,之前的 TCP 连接状态信息已经丢失,因此服务器对之前连接上发来的消息都将回复 RST。

参考:《UNP》、小林网络iptable用法详解