高防CDN边缘节点抗SYN Flood:nftables synproxy 的三个静默失效点(timestamp 不匹配、notrack 位置写错、MSS 写死踩 GRE 降级)与 XDP 前置替换实测
一、现场:15Mpps 的 SYN 打进来,nginx 一个请求都接不到
边缘节点,单机 20Gbps 上联,跑 nginx 做 L7 反代,正常业务 8k QPS。攻击开始后带宽只涨了 1.2Gbps,但 pps 直接冲到 9Mpps,全是 60 字节 SYN,源 IP 全随机。现象:
ss -lnti 'sport = :443'看到Recv-Q顶在 65535 不动,ListenOverflows每秒涨 3 万;- nginx error.log 里
worker_connections are not enough,但worker_connections已经开到 65535,根本没用; - 网卡
/proc/net/softnet_stat第 2 列每秒涨 5 位数,softirq 单核打满。
关键判断:这不是带宽问题,是半连接队列 + accept 队列被 SYN 撑爆的问题。带宽清洗设备在这种小包高 pps 攻击下基本抓瞎,因为流量才 1.2Gbps,远没到清洗阈值,但 pps 已经能把内核协议栈打死。这种场景只能靠在边缘节点本地做 SYN 代理。
二、先钉死判断口径,别凭感觉调参
上线前先看清到底卡在哪一层,三条命令固定动作:
# 1. 是不是 SYN 队列/accept 队列溢出
nstat -az | grep -E 'TcpExt(SyncookiesSent|SyncookiesRecv|ListenOverflows|ListenDrops|TCPReqQFullDrop)|TcpInErrs|TcpExtPAWSEstab'
# 2. conntrack 是不是先于带宽爆掉
conntrack -S
# 关注 insert_failed / drop / early_drop 这三列
# 3. softirq 是不是单核吃满(第2列 = dropped,第3列 = time_squeeze)
awk '{drop+=$2; squeeze+=$3} END{print "dropped:",drop," squeeze:",squeeze}' /proc/net/softnet_stat
实测里 TcpExtListenOverflows 每秒 3 万+、conntrack -S 的 insert_failed 同步起飞,说明是「半连接队列 + conntrack 表」双爆,而不是单纯带宽。
三、为什么 tcp_syncookies 救不了,必须上 synproxy
很多人第一反应是 net.ipv4.tcp_syncookies=1。它能防住 SYN 队列溢出,但有两个硬伤:
- syncookie 是在 SYN 队列已经溢出之后 才触发,攻击前 1~2 秒的溢出已经把
ListenOverflows打上去了,真实用户已经在丢; - cookie 握手回包里塞不下完整的 window scale / SACK permitted,真正常用户在攻击期间握手 RTT 明显变长,TLS 首包超时大幅上升。
synproxy 的思路完全不同:由内核代替源站完成三次握手,只有握手真正完成、且客户端能正确响应 cookie 的,才把连接放进来。攻击流量在握手阶段就被消化了,压根不会占用 accept 队列。
四、nftables synproxy 完整规则(这份是可以直接抄的)
# 0. 前置:模块与内核参数(注意 tcp_timestamps 与下面规则里的 timestamp 必须一致)
modprobe nf_synproxy_core
sysctl -w net.ipv4.tcp_timestamps=1
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_syn_recv=10
# 1. 规则本体
nft -f - <<'EOF'
table inet edge_ddos {
set src_allow {
type ipv4_addr
flags interval
elements = { 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10,
192.0.2.0/24 } # 回源段 / 内部探活 / 监控拨测
}
# raw 优先级 -300,必须在 conntrack(-200) 之前
chain raw_prerouting {
type filter hook prerouting priority raw; policy accept;
iifname != "eth0" return
ip saddr @src_allow return
tcp dport { 80, 443 } \
tcp flags & (fin|syn|rst|ack) == syn notrack
}
chain l4_in {
type filter hook input priority filter - 10; policy accept;
ip saddr @src_allow accept
ct state established,related accept
ct state invalid drop
tcp flags & (fin|syn|rst|ack) == syn \
synproxy mss 1400 wscale 7 timestamp sack-perm accept
tcp dport { 80, 443 } ct state new accept
}
}
EOF
nft list ruleset | sed -n '/edge_ddos/,$p'
几个必须掰开讲清的点:
- notrack 只吃握手首包。这里的 flags 掩码
tcp flags & (fin|syn|rst|ack) == syn只匹配纯 SYN(不含 ACK)。握手完成后的数据包正常进 conntrack,走ct state established快路径。如果你照抄某些博客写成tcp dport 443 notrack(不限标志位),所有数据包都会被摘掉跟踪,synproxy 建完的 conntrack 条目等于白建,后续数据包全成 untracked,防火墙形同虚设。 - 白名单要在 raw 链里 return 而不是 accept。raw 链里的
accept只终止当前链,包还是会掉进 conntrack;但return语义更明确,回源 / 拨测流量不进 synproxy 路径,省 CPU。 ct state invalid drop必须排在 synproxy 规则之前。untracked 包的状态是untracked不是invalid,不会被误杀;但如果顺序写反,被 synproxy 拒绝的畸形 ACK 会继续往下走,直接进 nginx。
五、坑一:timestamp 参数和 per-netns 的 tcp_timestamps 不一致 —— 握手永远完不成
我们第一次上线,规则带了 timestamp,但镜像里的 /etc/sysctl.d/99-tuning.conf 有一条历史遗留的 net.ipv4.tcp_timestamps=0(老运维为了绕 PAWS 加的)。结果:
- 客户端能收到 SYN-ACK(synproxy 合成的),但回 ACK 之后没有下文,三次握手卡在最后一步;
TcpExtListenOverflows不涨了,看起来「防护生效」,但业务成功率从 8k QPS 掉到 0;nstat -az | grep PAWS里TcpExtPAWSEstab和TcpInErrs稳步上涨 —— 这就是 synproxy 校验 cookie 时时间戳选项对不上,把包全丢了。
而 net.ipv4.tcp_timestamps 是 per-netns 的。如果你的边缘进程跑在容器 / 独立 netns 里,宿主上 sysctl 改成 1 是无效的,必须在同一个 netns 里改:
# 确认 synproxy 与业务是否同一 netns
nsenter -t $(pgrep -f 'nginx: master') -n sysctl net.ipv4.tcp_timestamps
# 要么统一开,要么规则里去掉 timestamp 参数,两者的组合必须一致:
# sysctl tcp_timestamps = 1 -> 规则带 timestamp
# sysctl tcp_timestamps = 0 -> 规则去掉 timestamp
# 任何混搭都会导致 cookie 校验失败,且是静默的,日志里一个字都不打
验证方法:改完不要直接看业务,先在前置一台测试机上 curl -v --max-time 3 https://边缘IP -k,看到 TLS handshake 卡住就是这个问题;再配合 nstat -n1 -az | grep PAWS 看增量,涨就是铁证。
六、坑二:回源走 GRE,synproxy 写死 mss 1400 变成 PMTU 黑洞
这个坑最阴。我们边缘到源站的链路走的是隧道封装,ip link 显示 gre0 MTU 1400。但 synproxy 的 mss 1400 是写死在规则里的固定值 —— 它给客户端回的 SYN-ACK 里 MSS 就是 1400,客户端按 1400 发包,进隧道后加上 24 字节 GRE 头 = 1424,超过 1400 的隧道 MTU,ICMP 又在一层清洗设备上被丢,结果就是:
- 握手正常,短请求正常,
curl一次能通; - 大响应(比如 2MB 的 JS bundle)回源时卡死在 20% 左右,客户端报
SSL_ERROR_SYSCALL; - 日志里什么都没有,因为包是被中间设备静默丢的。
我们在轻云互联的高防节点上就是踩的这个 —— 回源链路自带隧道封装,MSS 必须按 实际出接口 MTU - 40 算:
# 1. 先看回源真实出接口的 MTU
ip route get 源站IP | head -1
ip link show $(ip route get 源站IP | sed -n 's/.*dev \([^ ]*\).*/\1/p') | grep mtu
# 2. 隧道口 MTU 1400 -> 载荷 MTU 1360,MSS 设 1360 而不是 1400
nft delete rule inet edge_ddos l4_in handle $(nft -a list chain inet edge_ddos l4_in | \
awk '/synproxy/{print $NF}')
nft add rule inet edge_ddos l4_in tcp flags \& \(fin\|syn\|rst\|ack\) == syn \
synproxy mss 1360 wscale 7 timestamp sack-perm accept
# 3. 兜底:允许 PMTUD 探测,别让内核死守 536
sysctl -w net.ipv4.tcp_mtu_probing=1
sysctl -w net.ipv4.tcp_base_mss=1280
顺便说一句,wscale 7 也要看业务。回源 RTT 超过 100ms 的链路,wscale 7(128 倍窗口)在 64KB 初始窗口下对吞吐是刚需;但如果你是纯内网回源,设 wscale 0 反而能省一点头部开销。
七、坑三:conntrack 打满时 synproxy 静默失效
synproxy 是 stateful 的,握手阶段要为每个在途半连接分配一条 conntrack template。当 nf_conntrack_max 被打满时,synproxy 分配不到 template,直接丢包,而且:
conntrack -S里insert_failed暴涨,drop同步涨;- 业务表现和「完全没防护」一模一样,但
nft list counters看着 synproxy 规则命中数在涨 —— 很容易误判成规则生效了。
三处一起调,缺一不可:
# template 生命周期默认 60s,攻击下这是纯浪费,压到 10s
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_syn_recv=10
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_syn_sent=10
# 表大小按「并发长连接数 x 3 + 在途半连接数」估,别拍脑袋
sysctl -w net.netfilter.nf_conntrack_max=2097152
sysctl -w net.netfilter.nf_conntrack_buckets=524288 # 通常是 max/4
# 确认真的吃下去了
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max
conntrack -S | awk '{print $1, $11, $12}' # insert_failed / drop
八、实测对比:三组数据
攻击模型:9Mpps 随机源 SYN 打 443,持续 60s,正常业务探针 100 QPS。
方案 业务成功率 ListenOverflows/s softirq 峰值 备注
-------------------- ------------ ------------------ -------------- ------
裸奔 12% 31,000 1 核 100% accept 队列溢出
tcp_syncookies=1 61% 0 1 核 100% 握手 RTT +180ms
nftables synproxy 99.2% 0 1 核 45% MSS 坑修正后
XDP synproxy 99.8% 0 1 核 12% 见下节
注意 synproxy 那行的 softirq 只降到 45%,因为每个 SYN 仍然走完整的 netfilter 钩子链。想要再压一档,得上 XDP。
九、把 SYN 处理彻底挪出协议栈:XDP synproxy
内核 5.9+ 的 xdp-tools 里带了 xdp-synproxy,直接在驱动层处理 SYN,不经过 conntrack、不经过 netfilter、不进 softirq 队列,单核可以扛 10Mpps 量级。
# 1. 确认网卡支持 native XDP
ethtool -i eth0 | grep driver
ip -d link show eth0 | grep -o 'xdp' || echo "no xdp attached"
# 2. 挂载(注意:与 nftables synproxy 不能同时开,先卸规则)
nft delete table inet edge_ddos
xdp-synproxy --iface eth0 \
--ports 80,443 \
--mss 1360 \
--tcp-window-scale 7 \
--tcp-ttl 63 \
--tcp-options 'mss,sack,timestamp'
# 3. 确认挂载成功,看 xdp 统计
ip -s link show eth0 | sed -n '/xdp/,+4p'
# 4. 回滚就一条命令
ip link set dev eth0 xdp off
XDP 版的 cookie 是自己维护的,跟内核的 tcp_syncookies 完全无关,所以第五节那个 tcp_timestamps 的坑在这里不存在 —— 但 MSS 的坑还在,--mss 1360 照样得按隧道 MTU 算。
有一个必须注意的点:--ports 只写业务端口,别图省事写全端口。XDP 程序在网卡收包第一站执行,写全端口会把 SSH、监控拨测、回源流量全部拖进来,出问题时你连机器都登不上去。
十、上线顺序与回滚预案
- 先在备用节点灰度:拿一台不承接业务的边缘机,用
hping3 -S -p 443 --flood 源IP造 1Mpps(别用--rand-source,回程走不通没法验证握手); - 验证握手完整性:
curl -v --max-time 3 -k https://本机IP,必须看到完整的 TLS 握手; - 验证大响应:
curl -o /dev/null -w '%{speed_download}\n' 一个 50MB 文件,速度不低于正常值 80%,否则就是 MSS 算错了; - 核对计数器:
nstat -az | grep -E 'PAWSEstab|TcpInErrs'增量为 0,conntrack -S的insert_failed不涨; - 切流量:DNS 权重逐步上调,每 5 分钟看一次
ListenOverflows和业务成功率; - 回滚:nftables 侧
nft flush ruleset(前提是你没用它做别的);XDP 侧ip link set dev eth0 xdp off,秒级生效,不用重启。
十一、几条血泪经验
- synproxy 失效全是静默的。规则命中数在涨 ≠ 防护生效,必须用
nstat的 PAWS / InErrs 和真实的 curl 握手做双重验证。 - MSS 不要抄别人的 1460。你的回源链路 MTU 是多少自己算,隧道封装、VXLAN、IPv6 双栈都会让这个值变小,写错就是大响应 100% 超时。
- conntrack 永远比带宽先爆。防护上线前先把
nf_conntrack_max和几个timeout_syn_*调好,否则 synproxy 建了等于没建。 - L7 层的
limit_conn、limit_req在 L4 被击穿时毫无意义,因为请求根本进不来。L4 先止血,再谈 L7 限速。 - 如果 pps 长期在 5Mpps 以上,别再折腾内核参数了,直接上 XDP 或者换带硬件 SYN 卸载的网卡,这是量级差异,不是调优能追回来的。