ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SYN Flood 的防御链条:从半连接队列到 syncookies 的边界与盲区

SYN Flood 的防御链条:从半连接队列到 syncookies 的边界与盲区

写后端的人迟早会撞上这么一个场景:压测时 QPS 还远没到瓶颈,客户端却开始大面积连接超时。服务进程活着,CPU 个位数,内存富余,带宽空着一大截,可连接就是建不起来。这时候登上机器翻一下 dmesg,大概率会看到一行刷屏的日志:

TCP: request_sock_TCP: Possible SYN flooding on port 80. Sending cookies.

机器很闲,连接却失败,这个矛盾组合指向的不是业务代码慢,而是内核里一条容量有限的队列被打满了。这类问题不在任何一段 Java 或 Go 代码里,但从 listen(fd, backlog) 那一行开始,它就是后端开发的责任范围。这篇文章把这条链路完整拆一遍:三次握手和两条队列的关系、SYN Flood 打的到底是哪个位置、怎么用几条命令定位病因、syncookies 的工作方式和它的代价,最后用 hping3 亲手复现一次。

1 半连接从哪里来

1.1 握手是一次不对等的交易

TCP 三次握手本身没有缺陷,问题出在它的实现方式上。客户端发 SYN,服务端回 SYN+ACK,这一来一回之后服务端必须记住这条 “还没谈成” 的连接——记下四元组、协商好的 MSS、窗口缩放因子,等着客户端的第三个包。这段等待期通常只有一个网络往返时间,转瞬即逝。

服务端之所以要记,是因为协议规定了重传:万一 SYN+ACK 在路上丢了,服务端得重发,所以状态必须留着。留着就得有地方放,这个存放处就是半连接队列。等客户端的 ACK 到达,连接从半连接队列摘出,挪进全连接队列,等应用调用 accept() 取走。

两条队列的分界线很干净:半连接队列里是握手没走完的连接,全连接队列里是握手走完了、应用还没领走的连接。前者出问题,说明大量连接卡在半路;后者出问题,说明握手都成了、应用自己接不过来。这两种病的排查方向完全不同,混在一起谈就会越调越乱。

1.2 队列的容量由谁决定

半连接队列的长度不归单一参数管。现代内核里它的实际上限是 tcp_max_syn_backlog 与 listen 时传入的 backlog 等几个值取小后的结果,换算公式随内核版本变过,细节不必死记。需要记住的是一条工程规律:调大半连接队列需要应用和内核两侧同时动,只把 tcp_max_syn_backlog 改成 65535,而应用的 backlog 还是默认的 128,实际生效的仍然是那个 128。

全连接队列的规则更直接:实际长度等于 min(listen 的 backlog,net.core.somaxconn)。somaxconn 在不少发行版上默认只有 128,这就是 “我明明在代码里写了 listen(fd, 1024) 却没有效果” 的根源——被内核参数悄悄截断了。写 Netty 时 SO_BACKLOG 的值、写 Go 时 net.ListenConfig 里的控制项,最终都会汇入这条规则,只是框架帮你包了一层。


2 SYN Flood 打的是队列,不是带宽

2.1 攻击的构造方式

攻击者要填满半连接队列,做法简单得近乎朴素:用伪造的源 IP 发送海量 SYN 包,然后什么都不做。服务端忠实地为每个请求分配内存、回复 SYN+ACK、把连接放进半连接队列,然后开始等——等到超时才清理。由于源 IP 是假的,SYN+ACK 发到一个根本不存在的地址,第三个 ACK 永远不会来。

成本结构极度不对称:攻击包每个只有几十字节,服务端为它付出的内存和等待却是它的数倍。攻击者只需一台普通机器每秒几百个包,就能在几十秒内塞满一条容量上千的队列,因为半连接的默认超时周期长达一分多钟——协议为网络抖动准备的宽容,在这里反过来成了帮凶。

队列满后,后续所有 SYN 被直接丢弃。正常用户的连接请求和伪造请求混在一起被一起扔掉,服务对所有人不可用。此时半连接队列的占用信息不出现在任何常规监控里,这就是 “机器很闲但连接失败” 的解释。

2.2 全连接队列也会被殃及

SYN Flood 之外还有一条容易被忽视的路径。假设攻击流量被 syncookies 挡在半连接阶段之外,攻击者换一种打法:真实完成握手,然后立刻发 RST 或者干脆不发数据。这些连接会顺利进入全连接队列,如果应用 accept 的速度跟不上,队列照样打满。

全连接队列满之后的行为由 tcp_abort_on_overflow 决定,两档行为对应的客户端症状差别巨大:

tcp_abort_on_overflow内核行为客户端表现
0(默认)丢弃最后的 ACK,装作没收到连接 “成功” 但发数据无响应,表现为卡慢
1直接回 RST连接被拒绝,表现为快速失败

排查连接问题时,先分清是 “握手根本没完成” 还是 “握手完成了没人接”,这两种症状对应的病根不同,治法也不同。后一种的病根多半在业务代码——accept 循环里有慢操作、单线程 accept 跟不上建连速度,这才是开发侧真正的战场。


3 几条命令定位病因

3.1 半连接队列是否打满

先看 dmesg。内核在半连接队列溢出时会打日志,出现 Possible SYN flooding 字样说明队列已经满了,并且 syncookies 在工作。再看丢弃计数:

netstat -s | grep -i "SYNs to LISTEN sockets dropped" # 或读 /proc/net/netstat 里的 ListenDrops 字段

这个数字持续增长就是实锤:要么是攻击,要么是突发流量超过了队列容量。

3.2 全连接队列是否积压

用 ss 看:

ss -lnt sport = :80 # State Recv-Q Send-Q Local Address:Port # LISTEN 0 128 0.0.0.0:80

监听套接字的 Recv-Q 是全连接队列当前长度,Send-Q 是它的上限。Recv-Q 长期逼近 Send-Q,说明应用 accept 的速度跟不上建连速度。注意这个字段与 ESTABLISHED 状态下含义不同——后者表示应用没读走的字节数,别混淆。

3.3 是攻击还是自己接得慢

判定方法看来源分布。用 tcpdump 只抓 SYN 包:如果源 IP 高度分散、只发 SYN 不回 ACK,多半是伪造源的洪水;如果源 IP 集中在正常用户网段,更可能是真实并发压力或者应用 accept 太慢。前者要动防御,后者要修业务代码里的 accept 逻辑。


4 syncookies:内核的兜底机制

4.1 工作方式

syncookies 的思路是把 “记住状态” 变成 “无状态应答”。队列满时,内核不再为新的 SYN 分配内存,而是把四元组、MSS、时间戳等信息经加密哈希编码成一个序列号,作为 SYN+ACK 的初始序列号发给客户端,自己什么都不存。

客户端如果是真人,会按协议回一个 ACK,确认号正好是收到的序列号加一。服务端收到这个 ACK,反推出原始信息验证合法性,验证通过才真正建立连接、分配资源。伪造源的攻击包因为收不到 SYN+ACK,永远回不了正确的 ACK,也就永远占不到服务器的任何内存。

4.2 三档语义

net.ipv4.tcp_syncookies 有三个取值,行为差异需要分清:

  • 0:关闭。队列满时新 SYN 直接丢弃;
  • 1:默认值。仅在半连接队列满时启用,平时不影响正常握手;
  • 2:无条件启用。所有连接都走 cookie 编码路径。

推荐保持 1。设成 0 意味着完全靠队列容量硬扛,遇到攻击风险极高;设成 2 防护最强,但会让每个连接都付出额外的编码开销,正常业务也跟着受影响。

4.3 代价与盲区

syncookies 不是免费的午餐,它有三条明确的代价:

编码进序列号的空间有限,MSS 只能取少数几档,窗口缩放、SACK 等 TCP 扩展选项无法完整保存。高带宽高延迟的长肥网络里,这些选项缺席会直接拖累吞吐。内核 v2.6.26 之后可以借助 TCP Timestamps 的低位比特部分携带选项信息,但需要两端都支持时间戳。

它的防护范围只覆盖半连接资源耗尽。带宽被打满、UDP Flood、完成握手后的连接洪水,syncookies 一概无能为力。攻击者完全可以用真实源 IP 完成三次握手、然后立刻 RST,绕开这层防御去打全连接队列和应用层。

部分中间设备对带 cookie 序列号的 SYN+ACK 报文处理不当,可能造成兼容性问题。

正因为这些代价,内核文档把 syncookies 定位成兜底设施而不是调优手段——正确的目标是通过扩容队列和加快 accept 让 Possible SYN flooding 这条日志消失,而不是开着 cookie 硬扛。

4.4 配套参数

单靠一个开关不够,完整的内核配置长这样:

# 兜底:队列满时改用 cookie 应答 net.ipv4.tcp_syncookies = 1 # 扩容:半连接与全连接队列上限,需配合应用侧 backlog net.ipv4.tcp_max_syn_backlog = 8192 net.core.somaxconn = 8192 # 回收:缩短 SYN+ACK 重传次数,加速僵尸半连接清理 # 默认 5 次、指数退避,半条连接要挂一分多钟;调成 2 大幅缩短 net.ipv4.tcp_synack_retries = 2 # 可选:让全连接队列溢出时客户端快速失败,便于暴露问题 # net.ipv4.tcp_abort_on_overflow = 1

两点提醒。tcp_tw_recycle 这个老参数在内核 4.12 之后已被移除,网上还在流传的调优文章别再照抄。容器环境里 net.core 与 net.ipv4 的多数项属于全局命名空间,在容器内改往往报只读文件系统,需要宿主机上操作。


5 用 hping3 复现一次

道理讲完了,动手验证才算数。找两台测试机(虚拟机即可),一台起 HTTP 服务,另一台发起攻击:

# 攻击端:伪造随机源 IP,洪水模式打 80 端口 hping3 -S --flood --rand-source -p 80 192.168.1.10

-S 表示发 SYN 包,–flood 让发包速度不设上限,–rand-source 为每个包随机一个源 IP。与此同时在服务端观察:

# 终端一:看半连接状态堆积 watch -n1 'ss -tan state syn-recv | wc -l' # 终端二:看内核告警与丢弃计数 dmesg -T | grep -i "SYN flooding" netstat -s | grep -i "SYNs to LISTEN sockets dropped" # 终端三:验证正常连接是否还能建立 curl -m 3 http://192.168.1.10/

把 tcp_syncookies 临时关掉(sysctl -w net.ipv4.tcp_syncookies=0)再重复一遍实验,对比效果会更直观:关闭时队列打满、curl 超时;开启后内核日志出现 Sending cookies、半连接队列不再堆积、正常连接恢复。整个实验五分钟能做完,一次就能看到队列打满和 cookie 兜底两种状态的切换,比背参数表管用得多。复现之后还能顺带观察自己服务的 accept 表现——把业务代码里的 accept 循环故意加个 sleep,看看全连接队列的 Recv-Q 怎么涨上去,对两条队列的分工会有更踏实的体感。

实验做完记得收尾:测试机上的 sysctl 改动如果没有写进 /etc/sysctl.d/ 下的配置文件,重启即失效,正好当作一次清理。


6 防御是有边界的

把这套机制从头捋一遍,会发现 SYN Flood 的防御是一条多层链条,每一层管一段:syncookies 兜住半连接队列的溢出,tcp_max_syn_backlog 和 somaxconn 提供缓冲余量,应用的 accept 速度决定全连接队列的健康,而在所有这些之上,真正的第一道防线其实在网络边界——上游限速、流量清洗、源地址过滤,这些不是内核参数能替代的。

对后端开发来说,这条链条上属于自己的环节是两个:框架配置里 backlog 的取值,以及业务代码 accept 与建连之后的处理速度。内核参数更像安全气囊——撞击发生时保住可用性,但它的存在不是为了让你放心去撞。理解 syncookies 牺牲了什么(TCP 扩展选项、编码开销、防护范围),才能判断什么场景该开、什么场景该配合什么手段。把三档语义、两条队列、几个参数各自的管辖范围记清楚,下次压测再遇到低负载下连接超时,五分钟内就能定位问题出在哪一层。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进