高防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、监控拨测、回源流量全部拖进来,出问题时你连机器都登不上去。

十、上线顺序与回滚预案

  1. 先在备用节点灰度:拿一台不承接业务的边缘机,用 hping3 -S -p 443 --flood 源IP 造 1Mpps(别用 --rand-source,回程走不通没法验证握手);
  2. 验证握手完整性:curl -v --max-time 3 -k https://本机IP,必须看到完整的 TLS 握手;
  3. 验证大响应:curl -o /dev/null -w '%{speed_download}\n' 一个 50MB 文件,速度不低于正常值 80%,否则就是 MSS 算错了;
  4. 核对计数器:nstat -az | grep -E 'PAWSEstab|TcpInErrs' 增量为 0,conntrack -S 的 insert_failed 不涨;
  5. 切流量:DNS 权重逐步上调,每 5 分钟看一次 ListenOverflows 和业务成功率;
  6. 回滚: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 卸载的网卡,这是量级差异,不是调优能追回来的。