SYN Flood 防御实战:半连接队列打满、SYN cookie 原理与内核 TCP 参数加固全流程

一次「网站打不开但服务器负载很低」的排查

先讲一个很典型的现象。某天你的个人站突然打不开,浏览器一直转圈最后超时。你 SSH 上服务器,发现 CPU 占用个位数、内存充足、磁盘也正常,top、free、df 三件套看下来一切安好。但网站就是不响应。这时候你很可能遇到的是TCP 半连接队列被 SYN Flood 打满,而不是服务器资源耗尽。

这类问题的迷惑性在于:传统监控只看 CPU、内存、磁盘、网络带宽,而 SYN Flood 消耗的是内核里极其有限的队列资源。带宽可能只跑了几 Mbps,机器看起来「很闲」,但内核已经无法接受任何新的 TCP 连接了。这篇文章就把 SYN Flood 的机制、内核里两个关键队列、以及防御配置讲清楚。

TCP 三次握手与「半连接」是怎么来的

要理解 SYN Flood,先要理解正常的 TCP 握手。客户端发起连接时发一个 SYN 包,服务器收到后回一个 SYN+ACK,同时把这个「还没完成握手」的连接放进一个队列里等待客户端最后确认。客户端回 ACK,连接才算真正建立,这时内核把它从半连接队列移到全连接队列,交给应用 accept。

正常流程下,这个中间状态只持续一个网络往返时间(RTT),转瞬即逝。但攻击者可以只发 SYN 包、永不回复最后的 ACK。于是服务器上堆积了大量「半开」的连接,每一个都要占用内核内存来记录状态,并且要等待超时(默认通常几十秒到一两分钟)才会被清理。

半连接队列(也叫 SYN 队列)的容量是有上限的。一旦被打满,内核就会丢弃后续所有新的 SYN 包。这就是问题的核心:正常用户的连接请求和攻击者的伪造请求混在一起被一起丢弃,网站对所有人都不响应了。这就是所谓的 SYN Flood(SYN 洪水攻击)。

内核里的两个队列:SYN 队列与 Accept 队列

很多人把这两个队列搞混,但它们是不同的东西,对应不同的参数,搞错了就调不对。

SYN 队列(半连接队列)。存放已收到 SYN、已发出 SYN+ACK、但还没收到最终 ACK 的连接。它们的状态是 SYN_RECV。这个队列的长度由内核根据全连接队列大小和一个系统参数推导,通常不直接配置,而是通过 tcp_max_syn_backlog 影响其最大容量。

Accept 队列(全连接队列)。存放已经完成三次握手、等待被应用 accept() 取走的连接。它的长度由应用调用 listen(fd, backlog) 时传入的 backlog 参数和内核参数 somaxconn 共同决定,实际取二者的较小值。如果应用 accept 太慢、队列满了,内核的行为由 tcp_abort_on_overflow 决定:默认是丢掉最后的 ACK,让客户端以为连接建立了但其实没生效(这会造成非常诡异的现象)。

# 查看当前各种状态的 TCP 连接数
ss -s

# 查看处于 SYN_RECV(半连接)状态的连接
ss -tan state syn-recv | wc -l

# 查看监听端口的队列溢出情况
# 倒数第二列 Recv-Q = 当前 accept 队列积压
# 最后一列 Send-Q = accept 队列上限(即 backlog)
ss -lnt

# 查看内核丢弃统计(包括因队列满丢弃的 SYN)
netstat -s | grep -iE "listen|syn"
# 或者用 nstat 看更细
nstat -az | grep -iE "ListenDrops|TCPReqQFullDrop|TCPBacklogDrop"

其中 ListenDrops 和 TCPReqQFullDrop 这两个计数器非常关键。如果它们的数值在持续增长,就说明有连接因为队列满被丢掉了 —— 要么是攻击,要么是你的应用 accept 太慢(比如 PHP-FPM 进程池打满,accept 不及时)。

syn cookies:内核的优雅防御机制

内核应对 SYN Flood 最经典的武器叫 SYN cookies。它的思路非常巧妙:既然半连接队列被占满是因为要「记住」每个半开连接的状态,那干脆不记了 —— 把连接的关键信息(源 IP、源端口、目标 IP、目标端口、以及一个递增的时间戳)通过一个加密哈希函数编码进服务器返回的 SYN+ACK 序列号里。这样服务器不需要为半开连接分配任何存储空间。

当客户端最后发来 ACK 时,服务器从 ACK 的确认号里反推出原始信息,验证合法性,验证通过才真正建立连接、分配资源。攻击者伪造源 IP 发来的 SYN,因为收不到 SYN+ACK,永远不会发回正确的 ACK,也就永远不会占用服务器的真实资源。这一招基本上把纯 SYN Flood 的杀伤力降低到了接近于零。

代价是什么?SYN cookie 会丢失一些 TCP 选项信息(比如窗口缩放因子、SACK 等),在开启的那一瞬间,某些连接的协商会简化。所以内核的策略是「按需开启」:

# 查看 SYN cookie 开关(1 = 仅在 SYN 队列满时才启用)
cat /proc/sys/net/ipv4/tcp_syncookies
# 默认值为 1,这是推荐配置:
#   0 = 关闭(不推荐,除非明确知道自己需要)
#   1 = 仅当 SYN 队列满时启用(最佳实践)
#   2 = 无条件无条件启用(会牺牲部分 TCP 协商能力,一般不用)

# 查看 SYN 队列的最大长度(半连接队列容量)
cat /proc/sys/net/ipv4/tcp_max_syn_backlog

# 查看全连接队列的系统上限
cat /proc/sys/net/core/somaxconn

对于现代内核(尤其是 4.x 以后),tcp_syncookies=1 基本都是默认值,也就是说防御其实一直在默默工作。如果你发现自己的服务器被 SYN Flood 打瘫,第一件事就是确认这个值没有被错误地改成 0。

加固配置:一份务实的内核参数清单

光有 SYN cookie 还不够,合理调大队列容量、缩短半开连接的超时,能在攻击流量超过防御能力前争取更多缓冲。下面这份清单适合个人站服务器,可以直接参考。

# 半连接队列上限,调大以容纳突发流量
# 注意:实际生效值还受 somaxconn 影响,内核会取较大者做参考
net.ipv4.tcp_max_syn_backlog = 4096

# 全连接队列系统上限
net.core.somaxconn = 4096

# 加快半开连接的重试与回收:降低 SYN+ACK 重传次数,
# 半开连接更快超时释放(默认 5 次重试,缩短到 2)
# 这能显著减小被占满的窗口
net.ipv4.tcp_synack_retries = 2

# SYN 重传次数(主动发起方视角,一般保持默认)
net.ipv4.tcp_syn_retries = 5

# 开启 SYN cookie
net.ipv4.tcp_syncookies = 1

# 全连接队列满时:默认 0 会静默丢弃并让客户端误以为已连接,
# 若应用 accept 慢导致问题,可考虑设为 1 直接 reset(更快暴露问题)
net.ipv4.tcp_abort_on_overflow = 0

# 加快 TIME_WAIT 回收(大量短连接场景有帮助)
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 30

# 连接跟踪表上限(避免 conntrack 被打满,见下文)
net.netfilter.nf_conntrack_max = 262144

把这些写进 /etc/sysctl.d/99-tcp-hardening.conf,然后 sysctl --system 生效,比直接改 /etc/sysctl.conf 更规范,也便于版本管理。改完务必用 sysctl net.ipv4.tcp_max_syn_backlog 逐条复核,不要以为写进去就一定生效了(有些参数受其他参数联动限制)。

别忘了 conntrack:另一个会被打满的表

如果你的服务器开了防火墙的 连接跟踪(iptables/netfilter 的 conntrack,用于 NAT 和有状态规则),那么每一个半开连接都会在 conntrack 表里占一个条目。SYN Flood 攻击不仅打满 SYN 队列,也会打满 conntrack 表。表一满,内核会打印 nf_conntrack: table full, dropping packet,然后静默丢弃所有包,网站彻底不可达。

# 查看 conntrack 表当前用量 / 上限
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max

# 查看是否出现表满告警
dmesg -T | grep -i conntrack

# 调大上限并在内存允许的范围内留足余量
net.netfilter.nf_conntrack_max = 262144

# 缩短已建立连接的超时(默认 5 天,太长了)
net.netfilter.nf_conntrack_tcp_timeout_established = 3600

# 缩短 TIME_WAIT 超时
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30

这里有个实操经验:调大 conntrack_max 只是「扩容」,真正治本的是缩短超时。因为大量半开或长连接会一直占着表项不释放,光扩容治标不治本,迟早还得满。缩短 established 超时可以把那些实际已经断掉的僵尸连接快速清理掉。

攻击真的来了怎么办:从内核到应用的分层处置

调参数是「准备」,真正的攻击打过来时还需要一套处置流程。按侵入性从低到高排列:

第一层:确认与止血。用 ss -tan state syn-recv | wc -l 看半开连接数量,用 ss -tan | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head 统计来源 IP 分布。如果是少数几个 IP 在猛发 SYN,直接 iptables -I INPUT -s 攻击IP -j DROP 或加 nftables 规则封掉即可,这是最省事的。

第二层:Nginx 层限流。如果攻击是应用层的大量半开连接(不完全是 SYN Flood,而是慢速连接攻击),Nginx 的 limit_conn 可以限制单 IP 的并发连接数。不过要注意,SYN 阶段的连接 Nginx 是看不到的,它能管的只是已经完成握手的连接。

第三层:把流量导到 CDN / 高防后面。对于个人站来说,这往往是最现实的选择。带宽几百 Mbps 以下的 SYN Flood,自身硬件根本扛不住,与其在源站内死磕,不如把域名解析到 Cloudflare 这类有强大清洗能力的服务后面,源站 IP 隐藏起来。这是成本最低、效果最好的方案。

第四层:内核层丢弃。极端情况下可以用 iptables 的 -m limit --limit 10/s --limit-burst 20 对 SYN 包做速率限制,或者更进一步用 XDP/eBPF 在内核网络栈最前面丢弃恶意包 —— 但这属于重武器,个人站一般用不上。

# 按来源 IP 统计连接数,找出攻击源
ss -tan | awk 'NR>1 {print $5}' | cut -d: -f1 \
  | sort | uniq -c | sort -rn | head -20

# 对单个恶意 IP 限速(每秒最多 20 个新连接包)
iptables -A INPUT -p tcp --syn -m limit \
  --limit 20/s --limit-burst 40 -j ACCEPT
iptables -A INPUT -p tcp --syn -j DROP

# 查看当前丢包统计
nstat -az | grep -iE "Drop|Listen"

常见问题

问:我的服务器常年开着 tcp_syncookies=1,是不是就万事大吉了?SYN cookie 能防住纯 SYN Flood,但防不住应用层攻击和慢速连接攻击。它只是防御体系里的一环。真正的防护应该是「内核参数 + 应用限流 + CDN 高防」的组合拳。

问:调大 tcp_max_syn_backlog 会不会有副作用?会占用更多内核内存来维护队列条目,但相对现代服务器几十上百 GB 的内存来说微不足道。真正的风险是「调大了反而给了攻击者更多可占用的资源」,所以配合缩短 tcp_synack_retries 一起做才合理 —— 既要接得住正常突发,又要快速释放被恶意占用的条目。

问:怎么看服务器到底有没有被攻击过?定期检查 nstat -az 里的 ListenDrops、TCPReqQFullDrop,以及 dmesg 里有没有 possible SYN flooding 的字样。内核一旦检测到 SYN Flood 嫌疑,会主动在 dmesg 里打印警告,这是最直接的信号。

总结

SYN Flood 的核心在于它攻击的是内核的队列资源而非 CPU / 内存 / 带宽,所以常规监控往往发现不了。理解半连接队列和全连接队列的区别、掌握 SYN cookie 的工作原理、调好一套务实的内核参数、再配合 Nginx 限流和 CDN 兜底,个人站的抗 SYN Flood 能力就能上一个台阶。最关键的一步往往不是调参,而是把源站 IP 藏到 CDN 后面 —— 让攻击流量根本到不了你的机器,才是真正的省心。

Last modification:October 16th, 2026 at 08:29 pm

Leave a Comment