4、三次握手(上):SYN_SENT与SYN_RCVD状态、SYN Flood攻击原理

4.1 从CLOSED到SYN_SENT:客户端的第一步

三次握手的第一步,说白了就是客户端主动发起连接。客户端调用 connect() 系统调用后,内核会干两件事:

  • 生成一个初始序列号(ISN),这个号不是随便来的,有严格的安全规则
  • 构造一个 SYN 标志位置 1 的 TCP 段,发出去

然后客户端的 TCP 状态就变成了 SYN_SENT。我刚开始学 TCP 时总觉得这个状态一闪而过,没什么好关注的。直到有一次线上排查连接超时,才发现 SYN_SENT 状态其实藏着很多信息。

SYN_SENT 状态的核心特征:

  • 客户端已经发出 SYN,等待服务端的 SYN+ACK
  • 此时客户端还没有分配接收缓冲区,只有发送缓冲区
  • 如果网络延迟大,这个状态会持续较长时间
  • 默认超时时间通常是 20 秒到 120 秒(取决于内核参数)

你想想看,如果 connect() 调用后一直卡在 SYN_SENT,说明什么?要么是服务端没开机,要么是中间防火墙把 SYN 包丢了。我曾经遇到过一个问题:某台服务器上的应用频繁报连接超时,ss -t 一看,大量连接卡在 SYN_SENT。最后发现是客户端的 iptables 规则误杀了出方向的 SYN 包。

4.2 服务端视角:SYN_RCVD 与半连接队列

服务端收到 SYN 后,状态变为 SYN_RCVD。这时候服务端做了三件事:

  1. 分配传输控制块(TCB),但还没完全初始化
  2. 把这条连接放入 半连接队列(也叫 SYN Queue)
  3. 回复 SYN+ACK,等待客户端的 ACK

这里有个关键点:SYN_RCVD 状态下的连接,服务端已经消耗了内存资源。虽然比全连接状态少,但架不住数量多啊。我记得有一次帮客户排查问题,一台 8G 内存的服务器,半连接队列里挂了 6 万多个 SYN_RCVD 连接,直接把内存吃光了。

对比项 半连接队列(SYN Queue) 全连接队列(Accept Queue)
状态 SYN_RCVD ESTABLISHED
资源消耗 较少(仅 TCB 头部) 较多(完整 TCB + 缓冲区)
最大长度 net.ipv4.tcp_max_syn_backlog listen(fd, backlog) 参数
超时时间 默认 20 秒(SYN-ACK 重传) 取决于应用处理速度

嗯,这里要注意:半连接队列满了之后,新来的 SYN 包会被直接丢弃。Linux 内核会尝试回复一个 RST 吗?不会。它只是静默丢弃,让客户端重试。这个行为其实挺坑的,我曾经见过一个场景:应用层配置了 3 秒超时,但内核的 SYN 重传间隔是 1 秒、3 秒、6 秒……结果应用层先超时了,内核还在傻等。

4.3 SYN Flood 攻击:原理与危害

讲到这里,就不得不提 TCP 协议最经典的攻击方式——SYN Flood。说白了,攻击者就是利用 SYN_RCVD 状态的资源消耗特性,搞死服务端。

攻击流程其实很简单:

  • 攻击者伪造大量源 IP,发送 SYN 包给目标服务器
  • 服务器回复 SYN+ACK 到伪造的 IP(这些 IP 不存在或不可达)
  • 服务器等待 ACK,连接一直卡在 SYN_RCVD
  • 半连接队列很快被填满,正常用户的 SYN 包被丢弃

为什么会这么有效?因为 服务端在 SYN_RCVD 状态下的资源消耗是不对称的。攻击者发一个几十字节的 SYN 包,服务器就要分配几百字节的 TCB,还要维护定时器。你想想看,如果攻击者用 1Gbps 的带宽发小包,服务器得用多少内存来扛?

我曾经踩过的坑:

有一次线上业务突然中断,登录服务器一看,dmesg 里全是 "TCP: request_sock_TCP: Possible SYN flooding on port 80"。当时我第一反应是加 tcp_max_syn_backlog,从 1024 加到 4096。结果呢?半连接队列是大了,但服务器内存消耗也暴涨,最后 OOM Killer 把 Nginx 给杀了。教训是:不要只调大队列,要配合 SYN Cookie 一起用。

4.4 三次握手流程全景图

下面这张图把三次握手的状态变化和队列关系画清楚了。我建议你仔细看看半连接队列和全连接队列的位置,很多调优问题都出在这两个队列的配合上。

三次握手状态机与队列关系图 三次握手状态机与队列关系 客户端 CLOSED SYN_SENT ESTABLISHED 服务端 LISTEN SYN_RCVD ESTABLISHED 半连接队列 全连接队列 ① SYN (seq=x) ② SYN+ACK (seq=y, ack=x+1) ③ ACK (seq=x+1, ack=y+1) ⚠ 关键说明: • 半连接队列存储 SYN_RCVD 状态的连接,受 tcp_max_syn_backlog 控制 • 全连接队列存储已完成三次握手的连接,受 listen backlog 控制

4.5 实战:如何观察 SYN_SENT 和 SYN_RCVD

我个人习惯用 ss 命令而不是 netstat,因为 ss 直接从内核的套接字哈希表读取数据,速度快得多。来看几个常用命令:

# 查看所有 SYN_SENT 状态的连接
ss -t state syn-sent

# 查看所有 SYN_RCVD 状态的连接
ss -t state syn-rcvd

# 统计各状态连接数
ss -t state all | awk '{print $1}' | sort | uniq -c

# 查看半连接队列当前使用情况
ss -t state syn-rcvd | wc -l

# 查看全连接队列溢出情况
ss -t state established | grep -c "ESTAB"

我曾经在排查一个 Web 服务性能问题时,发现 ss -t state syn-rcvd 的输出一直在 500 左右波动,但 ss -lnt 看到的 Recv-Q 却是 0。这说明半连接队列没满,但全连接队列也没积压。那问题在哪?后来用 tcpdump 抓包才发现,是客户端的 ACK 包被中间防火墙丢了,服务端一直在重传 SYN+ACK。

避坑指南:

我曾经在调整 tcp_max_syn_backlog 时犯过一个错误:只改了内核参数,没改应用层的 listen() 的 backlog 参数。结果半连接队列是大了,但全连接队列还是原来的 128,导致全连接队列频繁溢出。记住:两个队列要一起调。

4.6 内核参数速查表

下面这几个参数是跟 SYN_SENT 和 SYN_RCVD 最相关的,我整理成表格方便你参考:

内核参数 默认值 作用 建议调整场景
net.ipv4.tcp_syn_retries 6 客户端 SYN 重传次数 内网低延迟环境可改为 3
net.ipv4.tcp_synack_retries 5 服务端 SYN+ACK 重传次数 公网环境建议保持默认
net.ipv4.tcp_max_syn_backlog 1024 半连接队列最大长度 高并发服务器可调至 4096~8192
net.ipv4.tcp_syncookies 1 是否启用 SYN Cookie 建议保持 1,防 SYN Flood
net.ipv4.tcp_abort_on_overflow 0 全连接队列满时是否发 RST 建议保持 0,避免客户端困惑

嗯,这里要特别说一下 tcp_syncookies。这个参数开启后,当半连接队列满时,内核不再把连接放入队列,而是通过 SYN Cookie 技术把连接信息编码到 SYN+ACK 的序列号里。客户端回复 ACK 时,内核解码验证,直接建立连接。说白了,就是用 CPU 计算换内存空间。我个人建议永远不要关掉它。