解读SYN Cookies:原理、内核实现与生产调优
第一次注意到系统日志里的“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 内核实现细节序列号位域、代码路径和 sysctl3.1 触发入口不是“收到 SYN”而是“SYN 队列满”Linux 内核处理 SYN Cookie并不是一收到 SYN 就启用。默认的net.ipv4.tcp_syncookies1意思是“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_syncookies1让 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_syncookies1监听一个测试端口请求端在授权范围内向目标机持续打 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_syncookies2 当安全锁前文已经说过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_syncookies1作为最后一道防线。把防御压力全部压在内核这一层既不经济也不可靠。5.5 调优不是改完参数就结束tcp_max_syn_backlog和 backlog 对队列的影响在不同内核版本里存在细节差异。建议每改一个参数持续观察一两天ListenOverflows、ListenDrops、连接成功率等指标确认它确实缓解了问题而不是掩盖了问题。生产环境里保持最小改动、可回退的记录比“一步到位”更可靠。我自己的习惯是每次只改一个 sysctl记录改前改后的指标差值再决定是否保留。这个过程虽然慢但能帮你真正理解每项配置在自己业务负载下的实际效果。