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。这时候服务端做了三件事:
- 分配传输控制块(TCB),但还没完全初始化
- 把这条连接放入 半连接队列(也叫 SYN Queue)
- 回复 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 三次握手流程全景图
下面这张图把三次握手的状态变化和队列关系画清楚了。我建议你仔细看看半连接队列和全连接队列的位置,很多调优问题都出在这两个队列的配合上。
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 计算换内存空间。我个人建议永远不要关掉它。