Skip to content
Go back

SYN Flood攻击与syncookies防御——半连接队列打满的原理

SYN Flood 与 syncookies:DDoS 最经典的攻防

一句话结论(30s)

SYN Flood 是利用三次握手的不对称性:攻击者只需发一个 40 字节的 SYN 包,服务端却要为每个 SYN 分配资源、回 SYN-ACK 并等待,海量伪造源 IP 的 SYN 会把半连接队列打满,让合法连接被拒。syncookies 的防御思路是”不维护半连接状态”——把连接信息哈希成 cookie 编码进 SYN-ACK 的初始序列号(ISN),收到 ACK 时重新算哈希验证,把攻击从”打满队列”退化为”消耗 CPU 算哈希”,存储开销转为计算开销。

核心原理(2min)

底层深入(5-10min)

攻击原理

攻击者: 发送海量 SYN 包(源 IP 伪造、不可达)
  → 服务端: 每个 SYN → 回复 SYN-ACK → 进入 SYN_RCVD 状态 → 放入半连接队列
  → 攻击者永远不回 ACK(源 IP 伪造,SYN-ACK 发到不存在的主机)
  → 半连接队列被打满 → 合法用户的 SYN 被丢弃 → 服务不可用

攻击成本极低(发送一个 40 字节的 SYN 包),防御成本极高(服务端为每个 SYN 分配连接资源、回复 SYN-ACK、等待超时)。

💭 思考:为什么攻击者“只发一个 40 字节的 SYN”,服务端就要付出巨大代价?——因为三次握手是不对称的:客户端发 SYN 这一步几乎零成本(伪造源 IP、连 ACK 都不用回),服务端却要为每个 SYN 分配 request_sock 内存、回 SYN-ACK、还要占着半连接队列等几十秒超时。攻击者用海量“只发不问”的 SYN,就能把这套“有状态等待”的机制灌满。理解了这份不对称,就理解了防御为什么必须从“让服务端少为每个 SYN 付出”入手。

半连接队列 vs 全连接队列

SYN 到达 → 放入半连接队列 (syn queue)
  → 完成三次握手 → 放入全连接队列 (accept queue)
    → accept() 从全连接队列取出

半连接队列大小由 tcp_max_syn_backlogsomaxconn 共同决定。全连接队列大小由 listen(fd, backlog) 中的 backlog 决定。ss -lnt 可以查看两个队列的当前状态(Recv-Q / Send-Q)。

syncookies 防御

核心思想:不维护半连接状态。 将连接信息编码进 SYN-ACK 的序列号中,收到 ACK 时验证合法性:

# 开启 syncookies
sysctl -w net.ipv4.tcp_syncookies=1

syncookie 的编码过程:

cookie = hash(client_ip, client_port, server_ip, server_port, timestamp_secret)
初始序列号 = cookie(编码在 ISN 中)

服务端发送 SYN-ACK 后不分配任何连接资源——不将连接放入半连接队列。收到 ACK 时:

  1. 从 ACK 的确认号(= ISN+1)恢复出 cookie
  2. 重新计算 hash 验证 cookie 是否合法
  3. 合法 → 分配连接资源 → 连接建立
  4. 不合法 → 丢弃(攻击流量被过滤)

syncookies 让 SYN Flood 从”打满队列”退化为”消耗 CPU 计算 hash”——攻击效率大幅降低。

为什么 SYN Cookie 能做到”无状态”? 关键在”把状态藏进对端一定会原样回传的东西里”。三次握手的第三步,客户端 ACK 会把 ISN+1 作为确认号发回来。所以只要把 cookie 编码进 ISN,服务端回完 SYN-ACK 后就可以”彻底失忆”——不占队列、不占内存——等 ACK 回来时再从确认号 ack_seq - 1 里把状态抠出来验证。攻击者伪造的 SYN 永远不回 ACK,也就永远占不到服务端一分钱资源。

为什么说这是”存储换计算”? 传统半连接队列里,每个 SYN 都要分配一块 request_sock 内存并占住直到超时(默认可达几十秒),攻击者用海量 SYN 就能线性耗尽内存。cookie 方案反过来:发 SYN-ACK 只做一次哈希,验证 ACK 再做一次哈希——用每连接几次哈希计算,换掉了每连接几十秒的内存占用。攻击者的资源消耗从”耗尽你的内存”变成”耗尽你的 CPU 算哈希”,而算哈希恰恰是便宜且能横向扩展的。

内核源码:cookie 是怎么生成和验证的

net/ipv4/syncookies.c 里,哈希用的是 SipHash,密钥是系统启动时随机生成的 syncookie_secret

static siphash_aligned_key_t syncookie_secret[2];

static u32 cookie_hash(__be32 saddr, __be32 daddr, __be16 sport, __be16 dport,
		       u32 count, int c)
{
	net_get_random_once(syncookie_secret, sizeof(syncookie_secret));
	return siphash_4u32((__force u32)saddr, (__force u32)daddr,
			    (__force u32)sport << 16 | (__force u32)dport,
			    count, &syncookie_secret[c]);
}

分析:cookie_hash 把四元组(源/目的 IP、源/目的端口)和 count(随时间递增的时间桶)一起喂进 SipHash。注意 syncookie_secretnet_get_random_once 只随机一次的全局密钥——密钥不随连接变化,攻击者无从伪造,这是无状态防御的安全根基。

真正的 cookie 合成在 secure_tcp_syn_cookie

static __u32 secure_tcp_syn_cookie(__be32 saddr, __be32 daddr, __be16 sport,
				   __be16 dport, __u32 sseq, __u32 data)
{
	/*
	 * Compute the secure sequence number.
	 * The output should be:
	 *   HASH(sec1,saddr,sport,daddr,dport,sec1) + sseq + (count * 2^24)
	 *      + (HASH(sec2,saddr,sport,daddr,dport,count,sec2) % 2^24).
	 * Where sseq is their sequence number and count increases every
	 * minute by 1.
	 * As an extra hack, we add a small "data" value that encodes the
	 * MSS into the second hash value.
	 */
	u32 count = tcp_cookie_time();
	return (cookie_hash(saddr, daddr, sport, dport, 0, 0) +
		sseq + (count << COOKIEBITS) +
		((cookie_hash(saddr, daddr, sport, dport, count, 1) + data)
		 & COOKIEMASK));
}

分析:这个函数把 ISN 拆成三层——固定的四元组哈希(防伪造)+ 客户端的 sseq(把对端的序列号也绑进来)+ count << 24(时间桶,COOKIEBITS=24,即高 8 位存时间)。低 24 位还塞了一个 data,它编码的是 MSS(见下文)。一个 32 位的序列号里同时藏了”连接身份 + 时间戳 + MSS”,这就是 cookie 的精巧之处。

💭 思考:为什么要把“连接身份 + 时间戳 + MSS”全塞进一个 32 位的 ISN 里?——因为 ISN 是三次握手里“对端一定会原样回传”的唯一载体:第三步的 ACK 会把 ISN+1 作为确认号发回来。把 cookie 编进 ISN,等于让客户端替服务端免费保管了连接状态,服务端回完 SYN-ACK 就能失忆。于是 32 位被精打细算地拆成几段:四元组哈希防伪造、sseq 绑对端、count<<24 存时间、低 24 位再塞 MSS 档位——一位都舍不得浪费。

MSS 怎么塞进去的?看 __cookie_v4_init_sequence

u32 __cookie_v4_init_sequence(const struct iphdr *iph, const struct tcphdr *th,
			      u16 *mssp)
{
	int mssind;
	const __u16 mss = *mssp;

	for (mssind = ARRAY_SIZE(msstab) - 1; mssind ; mssind--)
		if (mss >= msstab[mssind])
			break;
	*mssp = msstab[mssind];

	return secure_tcp_syn_cookie(iph->saddr, iph->daddr,
				     th->source, th->dest, ntohl(th->seq),
				     mssind);
}

分析:MSS 不是直接塞进去的——它先被量化成 msstab[] = {536, 1300, 1440, 1460} 这 4 档的下标 mssind(只有 2 个 bit),再作为 data 编进 cookie 低 24 位。原因是 cookie 的位空间宝贵,放不下完整的 16 位 MSS,只放一个”档位索引”,重建连接时再查表还原。这就是”用精度换空间”的典型做法。

💭 思考:为什么 MSS 不直接放进 cookie,而要压成 4 档下标?——因为 cookie 能用的位空间极其有限(就一个 32 位 ISN,还要分给身份、时间戳),放不下完整的 16 位 MSS。于是做一次“量化”:把 MSS 归到 536/1300/1440/1460 四档,只存 2 bit 的档位下标,重建连接时再查表还原。代价是 MSS 精度损失(真实 1400 会被归到 1300 那档),换来的是“挤进宝贵的序列号空间”。这正是 cookie 方案“功能降级换无状态”的一个具体缩影。

收到 ACK 后,check_tcp_syn_cookie 逆向还原 cookie:

static __u32 check_tcp_syn_cookie(__u32 cookie, __be32 saddr, __be32 daddr,
				  __be16 sport, __be16 dport, __u32 sseq)
{
	u32 diff, count = tcp_cookie_time();

	/* Strip away the layers from the cookie */
	cookie -= cookie_hash(saddr, daddr, sport, dport, 0, 0) + sseq;

	/* Cookie is now reduced to (count * 2^24) ^ (hash % 2^24) */
	diff = (count - (cookie >> COOKIEBITS)) & ((__u32) -1 >> COOKIEBITS);
	if (diff >= MAX_SYNCOOKIE_AGE)
		return (__u32)-1;

	return (cookie -
		cookie_hash(saddr, daddr, sport, dport, count - diff, 1))
		& COOKIEMASK;	/* Leaving the data behind */
}

分析:验证逻辑和生成完全对称——先减掉固定的四元组哈希和客户端 sseq,剩下的高 8 位就是时间桶 countdiff = 当前时间桶 - 编码时的时间桶,如果 diff >= MAX_SYNCOOKIE_AGE(值为 2,见 include/net/tcp.h)就判定 cookie 过期返回 -1这个时间窗检查同时实现了两个目的:防御重放(老 cookie 无效)+ 让密钥可以周期性轮换而不用精确同步。

时间桶的定义(include/net/tcp.h):

#define MAX_SYNCOOKIE_AGE	2
#define TCP_SYNCOOKIE_PERIOD	(60 * HZ)
#define TCP_SYNCOOKIE_VALID	(MAX_SYNCOOKIE_AGE * TCP_SYNCOOKIE_PERIOD)
static inline u32 tcp_cookie_time(void)
{
	u64 val = get_jiffies_64();

	do_div(val, TCP_SYNCOOKIE_PERIOD);
	return val;
}

分析:tcp_cookie_time() 把 jiffies 除以 60 * HZ(60 秒),得到”每 60 秒 +1”的时间桶。MAX_SYNCOOKIE_AGE = 2 意味着一个 cookie 最多有效 2 分钟——客户端 SYN 后必须 2 分钟内完成握手,否则 cookie 过期。这个设计天然限制了一个 cookie 被重放/爆破的时间窗。

最终入口 __cookie_v4_check 完成”取 cookie → 验证 → 还原 MSS”:

int __cookie_v4_check(const struct iphdr *iph, const struct tcphdr *th)
{
	__u32 cookie = ntohl(th->ack_seq) - 1;
	__u32 seq = ntohl(th->seq) - 1;
	__u32 mssind;

	mssind = check_tcp_syn_cookie(cookie, iph->saddr, iph->daddr,
				      th->source, th->dest, seq);

	return mssind < ARRAY_SIZE(msstab) ? msstab[mssind] : 0;
}

分析:注意 cookie = ntohl(th->ack_seq) - 1——确认号是 ISN+1,所以减 1 才是当时的 ISN。check_tcp_syn_cookie 返回的 mssind 若超出 msstab 范围(说明 cookie 非法),函数返回 0 表示验证失败;否则查表还原出真实 MSS。到这里,一条连接”消失”期间的所有状态(MSS、时间戳、窗口缩放等)都被无损地找回来了。

net/ipv4/tcp_input.ctcp_conn_request 处理每个 SYN,先判断队列是否满了:

	if (!isn) {
		syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies);

		if (syncookies == 2 || inet_csk_reqsk_queue_is_full(sk)) {
			want_cookie = tcp_syn_flood_action(sk,
							   rsk_ops->slab_name);
			if (!want_cookie)
				goto drop;
		}
	}
	...
	if (want_cookie) {
		isn = cookie_init_sequence(af_ops, skb, &req->mss);
		tcp_rsk(req)->txhash = isn;
	}

分析:syncookies == 2 表示”总是启用 cookie”(值 2),而 syncookies == 1 是默认的”只在队列满时才启用”。注意关键点:syncookies 默认不是无条件开启的——正常流量下依然走传统的半连接队列(保留完整 TCP 选项),只有队列被压满、疑似被 Flood 时才切换到 cookie 模式。这是”平时完整、战时降级”的优雅设计。

💭 思考:既然 cookie 这么优雅,为什么默认不“总是开启”,而要等到队列满才切换?——因为 cookie 有代价:MSS 被量化、部分 TCP 选项(大窗口缩放、SACK 等)会被降级,正常连接的性能会打折。所以内核默认“平时走完整的半连接队列,保留全部选项;只有队列被压满、疑似被 Flood 时才切 cookie”。这是典型的“平时全功能、战时降级保命”策略——把优雅留给正常流量,把防线留给攻击时刻。

队列满时是否发 cookie 由 tcp_syn_flood_action 决定:

static bool tcp_syn_flood_action(struct sock *sk, const char *proto)
{
	...
	syncookies = READ_ONCE(net->ipv4.sysctl_tcp_syncookies);

#ifdef CONFIG_SYN_COOKIES
	if (syncookies) {
		msg = "Sending cookies";
		want_cookie = true;
		__NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPREQQFULLDOCOOKIES);
	} else
#endif
		__NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPREQQFULLDROP);
	...
	return want_cookie;
}

分析:如果系统开了 tcp_syncookies,队列满时进入”发 cookie”模式(并打印 Possible SYN flooding 内核日志);如果没开,就直接 drop。这是攻击发生那一刻的”切换开关”——从有状态退化为无状态,保证服务不因队列打满而拒绝合法连接。

最后看 net/ipv4/tcp_ipv4.c 里 ACK 到达时的分发——监听套接字收到 ACK 会先走 cookie 校验:

static struct sock *tcp_v4_cookie_check(struct sock *sk, struct sk_buff *skb)
{
#ifdef CONFIG_SYN_COOKIES
	const struct tcphdr *th = tcp_hdr(skb);

	if (!th->syn)
		sk = cookie_v4_check(sk, skb);
#endif
	return sk;
}

分析:!th->syn 保证这是握手第三步的 ACK(而不是又一个新的 SYN)。cookie_v4_check 内部会重新算出 cookie 并验证,验证通过就”凭空”重建出一个 request_sock 并走正常建连流程——服务端从始至终没有为这个连接保留过任何状态,却在收到合法 ACK 时把它当成熟人接回来了。

其他防御手段

总结

SYN Flood 利用 TCP 的三次握手不对称性——客户端发 SYN 几乎零成本、服务端分配资源成本相对高。syncookies 打破了这种不对称——让服务端不维护半连接状态,将存储开销转为计算开销,是这项攻防中最优雅的解法。

章末提问

1. “SYN Cookie 的核心思路是什么?为什么它能做到无状态?”

回答思路:把连接状态(四元组、MSS、时间戳等)通过哈希编码进 SYN-ACK 的 ISN,服务端回完 SYN-ACK 就不占队列、不占内存。因为握手第三步客户端会把 ISN+1 作为确认号原样回传,服务端收到 ACK 时从 ack_seq - 1 抠出 cookie 重算验证,验证通过再建连接。一句话:利用三次握手”ACK 必然回显 ISN”的特性,把服务端状态转移到对端替自己保管

2. “Cookie 编码进 ISN,ISN 不应该是随机序列号吗?会不会有冲突或安全问题?”

回答思路:ISN 本来就是为了防序列号预测攻击而随机化的,cookie 方案本质是”用可验证的哈希值替代纯随机数”——ISN 仍然对攻击者不可预测(因为密钥 syncookie_secret 未知),只是额外携带了可验证信息。代价是 ISN 的熵/选项携带能力下降(比如丢了一些 TCP 选项),这是下面第 3 问要说的取舍。

3. “SYN Cookie 的缺点/代价是什么?”

回答思路:① 丢选项——传统 SYN-ACK 会回显客户端的窗口缩放、SACK、时间戳等选项,cookie 模式下 ISN 位空间有限,只能把选项压进时间戳低 6 位(TS_OPT_*),部分高级选项(如大窗口、MPTCP)会降级;② MSS 被量化成 4 档(msstab[]),丢失精度;③ 每次 ACK 都要算哈希,CPU 开销上升。本质是”用协议功能的降级换无状态”

4. “Cookie 有时效性吗?为什么 MAX_SYNCOOKIE_AGE = 2 分钟?”

回答思路:有。cookie 里编码了时间桶 count = jiffies / 60s,验证时算 diff = 当前桶 - 编码桶diff >= 2 就判定过期。2 分钟窗口是权衡:太小会让慢网络的合法握手经常超时失败;太大会放大重放攻击的时间窗,也拖慢密钥轮换。所以取 2 分钟是”可用性 vs 安全”的折中。

5. “攻击者自己回 ACK,SYN Cookie 还防得住吗?”

回答思路:防得住”半连接队列打满”这个核心目标。攻击者若要伪造合法 ACK,必须猜出 ISN(= 带密钥的 SipHash),而密钥未知,成本从”零成本发 SYN”暴涨为”暴力猜 32 位序列号”。所以 cookie 方案把攻击从”耗内存”转成了”耗 CPU 猜哈希”——攻击者依然能烧你的 CPU(算哈希),但烧不动你的内存(队列),而 CPU 打满是可以用横向扩容 + 限流 + CDN 缓解的。

6. “syncookies 和增大 tcp_max_syn_backlog 有什么区别?为什么说增大队列是治标不治本?”

回答思路:增大队列只是推迟打满的时刻——攻击者发包成本几乎为零,队列再大也会被线性灌满,而且队列越大单连接占的内存越多,反而更容易被内存耗尽。syncookies 是改变模型——彻底不维护半连接状态,让”队列大小”这个变量从方程里消失。一个是在旧模型里调参数,一个是换掉旧模型本身。


Share this post on:

Previous Post
TCP三次握手——为什么不是两次或四次?
Next Post
SQL注入、XSS、CSRF——Web安全的三大经典攻击