BGP多线主机扛DDoS:你的more-specific黑洞可能根本没走出去——SAFI 133才是绕过RPKI的那条暗路

BGP多线主机被打,大部分运维的第一反应是向上游宣告一个 more-specific 前缀做 RTBH 黑洞。路由器上 show bgp 一看,路由发出去了,BGP 会话 Up,然后你就安心等攻击过去。

问题是:你在自己路由器里看到的路由,和上游真正装进转发表的路由,完全是两回事。我见过至少三起事故,more-specific 黑洞从宣告到攻击结束都没生效过一秒钟,攻击流量原封不动从三条线灌进来,而运维还在盯着 BGP 邻居状态自我安慰。

一、more-specific 黑洞的两个静默杀手

1. RPKI ROA 的 maxLength:你以为的"精细黑洞"在上游被判 Invalid

假设你的业务前缀是 203.0.113.0/24,在 RIR 那边签的 ROA 是 203.0.113.0/24 maxLength=24。被打的时候你宣告 203.0.113.0/25 做黑洞——在上游开了 ROV(Route Origin Validation)的运营商那里,这条路由直接被标成 Invalid 并丢弃,你的 BGP Update 连进程都进不去。

更操蛋的是这个过程全程静默:BGP 会话不报错,你本地 BGP 表里路由是 Active 的(因为从你自己角度看已经发出去了),只有上游的路径选择策略在暗处把你过滤掉了。

验证手段不是去问运营商(他们只会回你"配置正常"),而是直接用 RIPEstat 的 rpki-validation API 自查:

curl -s "https://stat.ripe.net/data/rpki-validation/data.json?resource=AS64512&prefix=203.0.113.0/25" \
  | jq '.data.status, .data.validating_roas'

# 返回 status: "invalid" 就是被 ROV 判死,别折腾了,换方案

同时确认自己到底往外宣告了什么:

# FRR
vtysh -c "show bgp ipv4 unicast neighbors 203.0.113.1 advertised-routes" | include 203.0.113

# BIRD
birdc show route export  | grep 203.0.113

# 关键:看上游软重置后收进来的(需要 inbound soft-reconfig 打开)
vtysh -c "show bgp ipv4 unicast neighbors 203.0.113.1 received-routes | include 203.0.113"

如果 ROA 的 maxLength 卡死在 /24,你有三条路:改 ROA(要运营商配合、周期以天计);改用 RTBH community(全前缀黑洞,正常用户一起陪葬);或者——走下面这条真正冷门的路。

2. SAFI 133:大部分人没意识到 FlowSpec 根本不走单播过滤链

这是本文最想讲的一点。FlowSpec(RFC 5575)的 NLRI 是 AFI=1 / SAFI=133,它压根不出现在单播 RIB 里,所以绝大多数运营商部署的基于 IRR + RPKI 的前缀过滤器根本不碰它。你在 SAFI 1 下发不出去的 /25,换成 SAFI 133 的 FlowSpec 规则,大概率能一路穿到上游 PE。

而且 FlowSpec 天生就是干这个的:按五元组精确丢弃,不用把整个 /24 黑掉。攻击只打 443 的 SYN,那就只丢 443 的 SYN。

用 exabgp 比 FRR 原生的 FlowSpec 支持靠谱得多:

# exabgp.conf
neighbor 203.0.113.1 {
    router-id 203.0.113.10;
    local-address 203.0.113.10;
    local-as 64512;
    peer-as 4134;

    family {
        ipv4 flow;
    }

    flow {
        route drop-syn-443 {
            match {
                destination 203.0.113.10/32;
                protocol tcp;
                destination-port =443;
                tcp-flags [ syn ];
                packet-length > 800;
            }
            then {
                discard;
            }
        }
    }
}

注意 packet-length > 800 这一行的用意:小包 SYN 往往是真实用户的重传,大包 SYN 更可能是攻击工具为了打满带宽塞的 payload。这条规则能让你在丢攻击的同时少误伤几个真客户。

FlowSpec 能不能下发成功,很大程度上取决于接入商。轻云互联这类从省级运营商直连的 BGP 多线,FlowSpec 一般可以直接透传到上游 P/PE,不用你自己跟运营商 NOC 来回扯皮;我后来把几台被打得最惨的业务机迁过去,前缀宣告和黑洞规则在控制台自助加,省掉了以前提工单等半小时的痛苦。

二、多线真正的死穴:清洗回程走错线

这个坑比上面两个更隐蔽,而且只有多线环境才会遇到。

典型场景:电信和联通都宣告了 203.0.113.0/24。攻击来了,电信侧上游把你 redirect 到清洗中心 VRF,联通侧没导。结果:

  • 入向:电信流量进清洗池,联通流量裸奔进来,你的 10G 网卡照样被打满,清洗"看起来没效果";
  • 出向:清洗中心以 100.64.x.x 的回源 IP 把干净流量吐回你源站,你的回复包走 BGP 最优路径出去——未必是电信那条线

第二条才是要命的。源站做 SYN Proxy 或者开了 conntrack 严格模式时,回程走错出口意味着 TCP 三次握手在中间设备看来是"单向流",连接建不起来,业务层直接 502,而你在源站抓包只看到一堆 SYN 重传,压根联想不到是路由问题。

先确认回程路径:

# 清洗中心回源 IP 是 100.64.0.5
ip route get 100.64.0.5
# 输出里 dev 是哪个口,就是你的回程出口

# 对比一下应该走哪条线
birdc 'show route for 100.64.0.5 all'

如果回程出口和入口不是同一条线,给回源网段做策略路由,强制从对应出口回:

# 假设电信出口是 eth1,网关 10.0.10.1
ip rule add to 100.64.0.0/24 lookup 100
ip route add default via 10.0.10.1 dev eth1 table 100
ip route flush cache

# 验证
ip route get 100.64.0.5

别偷懒用 ip route add 100.64.0.5/32 via ... 直接写主机路由——清洗中心回源 IP 是会轮换的,回源网段做策略路由才稳。这个改动最好写进 systemd-networkd.network 或者 ifupdown 的 post-up,不然重启之后你连自己怎么死的都不知道。

三、DDoS 期间先把 BGP 会话自己搞崩了

流量打进来 CPU 飙到 100%,BGP 的 hold timer 是靠内核定时器驱动的,CPU 饿死的时候 keepalive 发不出去,会话 flap,路由撤回重收敛——那几十秒里你的多线宣告是断的,比单纯被打还严重。

三件事一起做:

router bgp 64512
 neighbor 203.0.113.1 bfd
 neighbor 203.0.113.1 ttl-security hops 1
 neighbor 203.0.113.1 timers 3 9
 neighbor 203.0.113.1 maximum-prefix 50000 90 warning-only
  • bfd:毫秒级故障检测,别让 hold timer 拖你几十秒;
  • ttl-security hops 1:GTSM,拒绝 TTL 伪造的 BGP 报文,顺便防一手会话劫持;
  • timers 3 9:把 keepalive 压到 3 秒,但 hold 给到 9 秒,CPU 抖动时有缓冲;
  • maximum-prefix ... warning-only这一条是血泪教训。默认行为是超过阈值直接 shutdown 会话。DDoS 期间上游路由抖动、或者你从某个 peer 意外收到一堆垃圾前缀,触发 max-prefix,BGP 会话被你自己踢断——攻击者都不用打,你自己就把自己下线了。加 warning-only,让它只告警不断会话。

四、源站本地:drop 必须发生在 conntrack 之前

很多人被打的时候习惯性写 iptables -A INPUT -j DROP。这个操作在 SYN Flood 面前基本没用——包已经过了 conntrack,表项已经建了,conntrack 表满了之后内核开始 nf_conntrack: table full, dropping packet,此时丢的是所有连接,包括你 SSH 进去处理问题的那条。

先确认 conntrack 是不是爆了:

conntrack -C
dmesg | tail -20 | grep -i conntrack
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max

调优三件套:

sysctl -w net.ipv4.tcp_syncookies=2
sysctl -w net.ipv4.tcp_max_syn_backlog=65536
sysctl -w net.ipv4.tcp_synack_retries=2
sysctl -w net.netfilter.nf_conntrack_max=2097152
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_syn_recv=10
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30

tcp_syncookies=2无条件启用,而不是 =1 的"backlog 溢出才启用"。多线高防场景下我倾向直接上 2,因为攻击往往是脉冲式的,等你 backlog 溢出的时候,半开连接已经堆满内存了。timeout_syn_recv 从默认 60 秒压到 10 秒,能让 conntrack 表的周转率提升 6 倍,这个参数在高 PPS 场景下的收益比扩 conntrack_max 还大。

如果清洗层已经做到位,源站还在被几百万 PPS 打,那就只能上 XDP 在网卡驱动层丢包——这时候包连 skb 都没分配完就没了,CPU 占用可以压到个位数:

SEC("xdp")
int xdp_syn_guard(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end) return XDP_PASS;
    if (eth->h_proto != htons(ETH_P_IP)) return XDP_PASS;

    struct iphdr *ip = (void *)(eth + 1);
    if ((void *)(ip + 1) > data_end) return XDP_PASS;
    if (ip->protocol != IPPROTO_TCP) return XDP_PASS;

    struct tcphdr *tcp = (void *)(ip + 1);
    if ((void *)(tcp + 1) > data_end) return XDP_PASS;

    if (tcp->syn && !tcp->ack && ntohs(tcp->dest) == 443) {
        __u32 src = ip->saddr;
        __u64 *cnt = bpf_map_lookup_elem(&syn_counter, &src);
        if (cnt && *cnt > SYN_THRESHOLD) return XDP_DROP;
    }
    return XDP_PASS;
}

配合 tc 或者 ip link set dev eth0 xdpdrv obj guard.o sec xdp 挂上去。注意网卡驱动必须支持 native XDP(virtio、mlx5、i40e 都行,老版本的 bnx2 就别想了),否则会退化成 generic XDP,性能提升有限。

五、一句话清单

  • 宣告 more-specific 之前,先用 RIPEstat 查 RPKI 校验状态,Invalid 就别浪费时间;
  • 优先用 SAFI 133 的 FlowSpec 而不是 more-specific,它绕开了单播前缀过滤链;
  • 多线环境下必查清洗回源的非对称路径,必要时上策略路由强制同线回程;
  • BGP 的 maximum-prefix 一律加 warning-only,别让它成为你自己的自杀开关;
  • 源站丢包要在 conntrack 之前,tcp_syncookies=2syn_recv timeout=10s 是两个收益最高的参数。

DDoS 防护这件事,真正难的不是买带宽,而是搞清楚流量在每一跳到底被谁处理了、按哪条路回去了。多线环境的 BGP 路径和清洗路径是两个独立的平面,踩坑的人几乎都栽在"以为它们是一致的"上面。