第一次注意到系统日志里的“TCP: Possible SYN flooding on port 80. Sending cookies.”,是在一次半夜的压测复盘。当时我以为服务马上要挂,结果除了几个长连接被重置,业务吞吐基本没受影响。后来我把这个机制完整读了一遍内核代码,才发现这行日志背后的设计远比“丢包 + 限速”高明:它让 Linux 内核在 SYN 队列接近饱和的时候,不再为每个半连接分配内存,而是用一种“可验证的无状态握手”方式把新建连接救回来。这就是标题里的 SYN Cookies,一个在内核 TCP 协议栈里藏了很多年的机制。
这篇文章不打算只讲概念。我会从 SYN Flood 的杀伤链路讲起,拆解 cookie 的构造和校验过程,再进到内核实现层面,看哪些信息被压缩了、哪些功能被牺牲了,最后给出生产环境里监控和调整的实操建议。无论你是运维、内核开发者,还是写 TCP 协议栈相关代码的同学,这套逻辑都比较值得过一遍。
1. 半连接队列被塞满之后:SYN Flood 的杀伤链与内核出路
1.1 握手过程的服务端“账本”
TCP 三次握手在服务端要维护的中间状态,远比表面看到的“三次报文”多。收到第一个 SYN 时,内核需要为这个未来的连接准备一个request_sock结构体,记录:来源 IP、来源端口、我发出去的初始序列号、对端声明的接收窗口、双方协商的 MSS、是否允许 SACK、时间戳等。这个结构体会挂在监听 socket 的半连接队列上,等待客户端最后的 ACK 回来之后,再转为完整的 socket 进入 accept 队列。
在高并发正常业务里,这个“账本”机制没有太大问题,因为每个连接都会很快走完流程,资源随即释放。真正怕的是攻击者制造大量“只来 SYN、永不回 ACK”的连接:每收到一个 SYN,内核就要记账一次,账本上的资源一直占着不释放,直到超时才被回收。半连接队列是有限长度的,满了之后,后面的 SYN 无论是否合法都会被拒之门外。经典 SYN Flood 攻击就是把源 IP 伪造得不可达,让服务端发出的 SYN-ACK 永远没有回应,从而在队列里堆满半开连接。
1.2 队列满后的两条路:丢弃和发 cookie
当半连接队列到达上限时,Linux 内核的tcp_conn_request()会面临选择。最朴素的做法是把新来的 SYN 直接丢掉,等客户端超时后重传。客户端重传依然进不了队列,服务端至少不会被拖垮,但这种模式在高强度洪水下体验很差:接收路径的 CPU 开销一点没少,合法用户却连一次完成握手的机会都没有。
另一种做法就是打开 SYN Cookie:收到 SYN 时照常回复 SYN-ACK,但不创建任何半连接记录,把连接状态压缩进 SYN-ACK 的序列号里。客户端正常回 ACK 后,内核通过解算这个序列号来确认“这个握手确实发生过”,然后直接走完建连流程。半连接队列里需要维护的状态,被换成了每次计算出的无状态校验值。
1.3 为什么“不记账”反而更安全
把这两种策略放到攻击者视角下对比,会发现发 cookie 最大的优势是:攻击者发出的所有伪造 SYN,真正消耗的服务端资源只有一次哈希计算和一次 SYN-ACK 发送。不再有排队的内存对象,不再有等待超时的定时器,也不再有半连接累积导致的溢出连锁反应。合法用户在队列满的瞬间依然能拿到 cookie,只需要正常完成第三次握手就能建连。
这里要澄清一个常见误区:开启 SYN Cookie 并不等于“不验证来源”。cookie 里嵌套了由源/目的地址、端口、随机密钥和时间窗计算的哈希,伪造方如果不能猜中密钥,就无法构造出能通过校验的 ACK。所以它不是开放入口,而是把资源消耗从“内存堆积”转移到了“实时计算”,同时保留了完整性校验。
2. SYN Cookie 的构造与校验:一次无状态握手是如何完成的
2.1 把状态写进 32 位序列号
先理解 TCP 的一个基本事实:SYN-ACK 报文里,服务端要发送一个初始序列号 ISN,而客户端发回最终 ACK 时,带回来的确认号必须恰好等于这个 ISN + 1。这就是天然的信息回传通道。SYN Cookie 的思路是:正常握手时 ISN 是随机生成的;cookie 模式下,ISN 不再随机,而是用一个不可逆的单向函数算出来的“压缩状态”。
这个单向函数的输入,一般包含五类信息:通信双方的 IP 和端口,即四元组;客户端发来的 SYN 序列号;一个内核内置且定期轮换的随机密钥;以及时间戳的高位。把这些内容做一次哈希运算后,取结果填充到 ISN 上。含义是:任何人想要伪造一个有效 cookie,必须先知道当前内核使用的密钥,否则算不出来。
2.2 校验过程:ACK 到达的那一瞬间
当客户端的 ACK 到达时,内核不需要去半连接记录里翻“我当时发过什么序号”,因为记录根本不存在。它只需要做三件事:
- 从 ACK 的确认号里减 1,还原出当初发出的 cookie;
- 取出报文里的四元组、客户端自带的 SYN 序号,以及当前时间窗口和密钥,重新计算一遍哈希;
- 比较算出的值和 cookie 是否一致,一致就说明这个 ACK 的确对应一次合法的握手交互,立即为它创建真实连接。
用伪代码表达大概是这样的逻辑:
# 概念性伪代码,真实内核实现会融合位域和时间窗 def cookie_make(src_ip, dst_ip, src_port, dst_port, client_seq, key, ts): data = f"{src_ip}-{dst_ip}-{src_port}-{dst_port}-{client_seq}-{ts}-{key}" return hash(data) & 0xFFFFFFFF def cookie_check(ack_seq, src_ip, dst_ip, src_port, dst_port, client_seq, current_keys, ts_now): cookie = ack_seq - 1 for ts_win in [ts_now, ts_now - 1, ts_now - 2]: for key in current_keys: if cookie_make(src_ip, dst_ip, src_port, dst_port, client_seq, key, ts_win) == cookie: return True return False这个流程的本质有点类似验证码票据:服务端不记录每一张票据,只在客户端归还时验证票据内容是否有效。区别在于 cookie 的校验还必须对时间敏感,过期窗口内的值会被拒绝,防止抓包拿到的 cookie 被无限重放。
2.3 时间窗口和密钥轮换
为了让 cookie 在“一段时间”内有效,又要防止无限重放,内核把时间维度切成了多个滑动窗口,每个窗口对应一个秒级时间桶,同时维护最近若干个窗口的密钥版本。即使客户端因为网络慢,ACK 到达时已经跨了窗口,只要时间差仍在保留范围内,依然可以校验成功;超出范围的 ACK 会被视为无效,客户端重传一次即可恢复。
密钥轮换的作用是防离线爆破。即使攻击者抓到了一批 cookie 样本,也无法在密钥更换后继续复用。实现上,密钥通常每隔几十秒到几分钟轮换一次,时间窗口则决定 cookie 的最大可用生命周期。这样的设计保证了:短暂的网络抖动不会导致建连失败,但长期重放攻击会被时间因素挡住。
2.4 为什么选哈希而不是加密
哈希和加密都能做校验,但选择哈希有一个很实际的原因:校验方向只需要单向运算,不需要解密还原出完整连接状态;同时哈希运算在 CPU 上足够轻量,洪水场景下每包只多出很短的运算时间。更关键的是,我们没有在报文里保存“完整状态”,只保存“状态摘要”,即使攻击者通过某种方式拿到 cookie,也无法直接反推出 MSS、窗口等参数以外的信息。
3. Linux 内核实现细节:序列号位域、代码路径和 sysctl
3.1 触发入口不是“收到 SYN”,而是“SYN 队列满”
Linux 内核处理 SYN Cookie,并不是一收到 SYN 就启用。默认的net.ipv4.tcp_syncookies=1意思是“SYN 队列溢出时才考虑用 cookie 兜底”。真正的决策点位于tcp_conn_request():当监听 socket 的半连接队列已满、accept 队列也即将或已经溢出时,协议栈会调用tcp_syn_flood_action(),判断是否切换到 cookie 模式。
如果决策是“发 cookie”,内核会调用cookie_v4_init_sequence()或对应版本里的新接口,计算出 cookie 序号并直接放进 SYN-ACK,过程中不创建request_sock,所以在半连接队列里找不到它。等客户端回 ACK 时,tcp_check_req()发现没有对应的半连接记录,就会走cookie_v4_check()校验路径。这是一条与普通握手完全平行的代码分支,平时不会被触发。
3.2 32 位序列号里的位域分配
32 位的 ISN 要装下“时间窗口 + 校验 + MSS 协商”,必须做位划分。内核实现大体把序列号分成三块:
- 高位时间桶:标记当前时间窗口,使 ACK 到达时能判断 cookie 是否过期;
- MSS 索引:通常用 3 位编码 8 个常见 MSS 代表值,实际建立的连接采用其中最接近的一个;
- 校验哈希位:用剩余空间存放来源信息及密钥的哈希结果,负责校验合法性并防止伪造。
| 字段 | 位数 | 作用 |
|---|---|---|
| 时间桶 | 若干高位 | 判断 cookie 生命周期 |
| MSS 索引 | 约 3 位 | 在 8 个常用 MSS 值中做近似协商 |
| 校验哈希 | 剩余位 | 验证四元组、SYN 序号和密钥 |
这里最容易被业务感知到的妥协就是 MSS。正常握手中 MSS 可以通过双方协商得到精确值;cookie 模式下只能在 8 个预设值里取近似值。这组值通常覆盖 536 到 1500 的常见区间,比如 536、1024、1440、1460、1480、1492、1500 这些典型数字。如果双方网卡 MTU 是 9000,没有 cookie 时可能协商出很大的 MSS;有 cookie 时内核只能取表中最大的普适值,大包传输效率就会受影响。
3.3 TCP 选项的损失与恢复
除了 MSS,还有几个 TCP 选项在 cookie 模式下会被压缩:Window Scale、SACK、时间戳等。正常握手时这些选项可以让高带宽长连接的窗口放大到 64KB 以上;cookie 模式下,如果这些信息没有被编码进有限的序列号空间,连接建立后内核可能不启用窗口缩放或 SACK,结果就是连接能建立,但吞吐上限下降。
为了缓解这种损失,部分实现会通过附带的 TCP 时间戳选项来携带额外信息,把一部分状态放到时间戳字段里,尽量保住关键选项。但凡是“尽量”,就意味着不是 100% 保留。生产环境中如果长期处于 cookie 模式,用户会观察到长连接大流量传输被“拖慢”,这正是本机制对精细协商的损失。
3.4 sysctl 的三档语义
内核对外暴露的配置项net.ipv4.tcp_syncookies,是控制这个机制的主要开关:
| 取值 | 含义 | 适用情况 |
|---|---|---|
| 0 | 完全关闭 | 只用于特定故障排查,不推荐长期使用 |
| 1 | 半连接队列满时才启用 | 生产默认,绝大多数场景的合理选择 |
| 2 | 强制所有新建连接都走 cookie | 常用于测试 cookie 逻辑,或短暂防御 |
取值 2 虽然看起来安全级别更高,但它意味着所有正常连接都走“压缩状态”的协商方式,常见高性能 TCP 选项会被无差别丢弃。我实测过把 2 打开后,长肥网络的吞吐会有可测量的下降,这也是内核默认保守取 1 而不是取 2 的原因。
3.5 防御性设计的隐藏成本
cookie 模式并非零成本。每个 SYN 都要做一次哈希计算;在高 PPS 洪水下,协议栈原本的收包、查 socket、回包路径依然会走,只是少了request_sock分配。所以它的意义不是“免疫洪水”,而是“让洪水不再以状态累积的方式压爆内存,同时保住合法连接建立的机会”。对真正的超大规模攻击,依旧需要外层设备、代理、防火墙等多层配合。
4. 生产观测与调优:日志、计数器、实验和边界
4.1 从 dmesg 看到的那行日志
当内核决定发 cookie 时,它会打印一条日志,常见格式如下:
TCP: Possible SYN flooding on port 80. Sending cookies. Check SNMP counters.这条日志对判断“半连接队列曾经或正在溢出的瞬间”很有用,但它有速率限制,不会每包打印一次。如果整夜只出现一次,说明流量洪峰只在短时间内触顶;如果反复刷屏,说明攻击或异常流量已经持续一段时间。日志里的端口是监听端口,不一定是业务故障的直接原因,很多场景下业务端口本身还是可用的,只是内核把新建连接切换到了 cookie 模式。
4.2 看 ListenOverflows 和 ListenDrops 的组合
内核会在网络统计计数里记录半连接队列的处理情况。常用命令:
nstat -az | grep -i listen cat /proc/net/netstat | grep -A2 TcpExt重点关注两类指标:ListenOverflows表示半连接队列溢出进入丢弃或 cookie 流程的次数,ListenDrops表示实际被丢弃的 SYN 数量。如果两者同时快速增长,说明队列已经多次顶满;如果只有 Overflows 增长而 Drops 没有明显变化,说明 cookie 机制正在发挥作用,被丢掉的包不多,合法连接基本都被保住了。
4.3 传统调优更倾向于“把队列调大”?
很多入门资料遇到洪水第一反应就是调大tcp_max_syn_backlog和 listen 的 backlog。这个方法对“合法连接瞬时暴涨”有一定帮助,因为在流量真的超过容量之前,更大的队列能装下更多半开连接。但对 SYN Flood 效果有限:攻击流量制造半连接的速度,永远可以大于你扩容的速度。队列再大,也只是让内存被占用的时间更长,反而可能给攻击者更多消耗资源的机会。
更稳妥的思路是:根据正常业务峰值的短连接并发量设置一个合理队列,然后保持tcp_syncookies=1,让 cookie 成为溢出后的兜底。同时对tcp_synack_retries做必要缩减,加快无效半连接回收,避免 SYN-ACK 重试长时间占据队列位置。典型配置可以这样给:
net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn_backlog = 4096 net.ipv4.tcp_synack_retries = 2注意,tcp_max_syn_backlog和 listen 的 backlog 不是同一个东西。前者影响半连接队列上限,后者更多决定已完成连接的等待对列上限。两者在不同内核版本中的交互细节有差异,调优后必须用计数器验证效果,而不是只改完参数就觉得完事了。
4.4 一个可控的本地观测实验
如果你想亲眼看到 cookie 生效,可以在隔离的网络环境里做一次简单验证。目标机开启tcp_syncookies=1,监听一个测试端口;请求端在授权范围内向目标机持续打 SYN 包,同时从另一个正常客户端尝试建立真实连接。观察三处变化:
- 目标机
dmesg出现“Possible SYN flooding ...”; cat /proc/net/netstat里ListenOverflows和ListenDrops上升;- 正常客户端依然能在合理延时内完成连接。
如果正常连接在洪水下也能建立,说明 cookie 确实是有效兜底。需要提醒的是,这类实验只应在拥有明确授权的隔离网络中进行,不要在公网或无法控制的机器上做,避免干扰他人业务。
4.5 重传与高延迟链路下的边界
cookie 模式在时间上是“短视”的。如果客户端的 ACK 延迟超过保留窗口,校验就会失败,客户端不得不重传最后一个 ACK。对网络质量极差的链路,连接建立耗时可能拉长。另一个边界是 SYN-ACK 本身丢失时,客户端不会重传 SYN,而是继续等待。此时服务端因为没有半连接记录,不会主动重发 SYN-ACK,连接会卡在客户端侧,直到客户端超时重传 SYN 后再走一轮。在高丢包环境中,这是容易被忽略的行为差异。
5. 踩坑记录与经验沉淀
5.1 不要把 tcp_syncookies=2 当安全锁
前文已经说过,2 会强制所有新连接都走“信息压缩”握手。由于 Window Scale、SACK 等选项可能丢失,正常流量的吞吐和延迟都会受到影响。真实生产环境遇到高频 SYN 淹没时,应该先确认流量是不是真实攻击,再把tcp_syncookies从 1 临时调到 2 做兜底,等攻击结束尽快切回 1。长期保持 2 运行,相当于为罕见的洪水牺牲日常性能,并不划算。
5.2 窗口缩放丢失的隐性影响
一次内部大流量测试里,我把tcp_syncookies临时设为 2,之后再压测长连接下载,吞吐大约掉了一截。起初怀疑是网络设备限速,后来把新建连接的 TCP 选项打开看了才知道,很多连接没有启用 Window Scale。原因正是 cookie 的序列号空间装不下完整的窗口缩放系数,连接按默认初始窗口建立,高带宽长肥网络立刻体现差距。这个案例值得每一个临时把 cookie 调成 2 的人记住:建连成功的背后,可能藏着吞吐退化。
5.3 队列指标应该先于服务指标告警
与其等业务端口已经“连接超时”再告警,不如把ListenOverflows和ListenDrops纳入监控。这两个指标能提前暴露半连接队列压力,给处理留出时间。如果你的监控系统只看连接数和系统负载,那么洪水开始后你看到的往往只有 CPU 升高和连接异常,排查方向容易被带偏。把内核网络统计拿进来之后,问题会清晰很多。
5.4 SYN Cookie 与外层防护怎么配合
内核的 SYN Cookie 作为本地算法,处理的是“内核队列已经满了”的场景;外层的 SYN Proxy 或代理是在流量进入内核之前就把握手挡在业务服务器之外。配合方式通常是:外网入口做 SYN Proxy 和限速,业务内核保留tcp_syncookies=1作为最后一道防线。把防御压力全部压在内核这一层,既不经济也不可靠。
5.5 调优不是改完参数就结束
tcp_max_syn_backlog和 backlog 对队列的影响,在不同内核版本里存在细节差异。建议每改一个参数,持续观察一两天ListenOverflows、ListenDrops、连接成功率等指标,确认它确实缓解了问题而不是掩盖了问题。生产环境里,保持最小改动、可回退的记录,比“一步到位”更可靠。我自己的习惯是每次只改一个 sysctl,记录改前改后的指标差值,再决定是否保留。这个过程虽然慢,但能帮你真正理解每项配置在自己业务负载下的实际效果。