大带宽服务器的加固死角全在出向:`-m owner` 白名单栽在 SCM_RIGHTS,nftables `cgroupv2 level` 写错直接静默失效——三层出向围栏落地实录
先说结论:入向加固在 10G 口上基本是心理安慰
我们手里的机器一旦上了 10G 甚至 25G 上行,你 `iptables -A INPUT -j DROP` 写得再花,被打的时候流量早就灌进上游交换机了,你 DROP 掉的只是你自己网卡的 softirq 预算。入向那套东西的正确归属是上游清洗,不是主机。
真正属于你自己的加固面是出向。一台 10G 机器被人打穿,攻击者要的从来不是你的数据,是你这条免费的高速上行——拿去做 DNS/NTP/memcached 反射的放大器、做 Socks5 代理池、做 C2 回连和数据集外发的中转。这三件事的共同点:源 IP 是你自己的、端口是合法的、协议是干净的 443,任何基于"IP + 端口 + 协议"的边界规则都拦不住。
所以下面讲的全是出向。而且我会把三层方案的绕过面一起讲清楚,因为前两层都有一个致命的共同点,绝大多数教程不会告诉你。
第零层:`iptables -m owner --uid-owner` 为什么是个假门
最常见的写法长这样:
iptables -A OUTPUT -m owner --uid-owner www-data -p tcp --dport 443 -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner www-data -j REJECT
这套东西至少有三个绕过面,而且都不需要 root:
- fd 传递(SCM_RIGHTS)。 一个跑在允许 uid 下的进程,`socket()` + `connect()` 建好连接,然后把 fd 通过 unix socket 传给任意别的进程。接收方 `send()` 时,包走的是创建者的 uid,`-m owner` 一路放行。这在 PHP-FPM + 常驻 worker、或者 supervisord 里 root fork 再 setuid 的场景下几乎是默认行为。
- uid namespace 映射。 容器里 uid 0 在 host 侧是 100000,你写 `--uid-owner 0` 白名单,宿主机上任何 root 进程都命中,容器里的 root 反而不命中,两边都错。
- 合法 root agent 被改造成通道。 日志上报、监控采集、配置下发,这些进程必须 root 跑、必须出网。你没法禁,但它读的是本地配置文件——改一行 `server=` 就是一个任意出向通道。
根因就一句话:uid 是"人"的身份,不是"程序执行路径"的身份。你要控的是后者。
第一层:nftables 的 `socket cgroupv2`,零代码但有两个静默坑
内核 5.13 之后 netfilter 提供了按 cgroup v2 路径匹配 socket 的表达式,比 eBPF 轻得多,适合先落地:
nft add table inet egress
nft add chain inet egress out '{ type filter hook output priority 0; policy accept; }'
# 允许 nginx 这个 cgroup 里的 socket 出 80/443
nft add rule inet egress out \
socket cgroupv2 level 2 "system.slice/nginx.service" \
meta l4proto tcp tcp dport '{ 80, 443 }' counter accept
# 同一 cgroup 其余出向全拒
nft add rule inet egress out \
socket cgroupv2 level 2 "system.slice/nginx.service" \
counter log prefix "egress-block " drop
坑一:`level` 写错不报错,规则永远不命中。 `level N` 里的 N 是从 cgroup root 往下数的层数,必须等于你后面写的相对路径的段数。`system.slice/nginx.service` 是两段,`level` 必须是 2。写成 3,`nft` 加载成功,`nft list ruleset` 看着一切正常,`counter` 永远是 0。排查时只看 `nft -a list ruleset` 的包计数,别信"语法过了就是对的"。
坑二:这个匹配只存在于 `output` hook。 挪到 `postrouting` 就完全失效,因为那里已经丢了 socket 上下文。而 `output` hook 对 forwarded 流量不生效——你要加固的如果是转发的机器(NAT / 反代),这层用不上,直接上第二层。
坑三(最要命的):它匹配的是 socket 创建时绑定的 cgroup,不是发包时进程所在的 cgroup。 也就是说,上面的 fd 传递绕过在这里依然成立:在 `nginx.service` 这个 cgroup 里建好 socket,把 fd 传给一个 `untrusted.slice` 下的进程,包照样放行,因为内核看的是 `sk->sk_cgrp_data`,不是 `current`。
所以第一层只是个"能挡住脚本小子和大部分自动化挖矿回连"的量级,别当终局。
第二层:`BPF_CGROUP_INET_EGRESS` 做 L3/L4 白名单
目的地址级白名单,nftables 也能写,但你会炸在规则集规模上;而且大带宽机器上每条出向包都过 `xt_` 表的开销不是闹着玩的。BPF 这边是 LPM trie 一次查表,几千条前缀也是 O(log n)。
// egress_guard.bpf.c
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <linux/udp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
struct lpm_key { __u32 prefixlen; __u32 addr; }; /* addr 用网络字节序 */
struct allow_val { __u16 port_lo; __u16 port_hi; };// 主机字节序,0 表示不限端口
struct {
__uint(type, BPF_MAP_TYPE_LPM_TRIE);
__uint(max_entries, 4096);
__uint(map_flags, BPF_F_NO_PREALLOC);
__type(key, struct lpm_key);
__type(value, struct allow_val);
} allow4 SEC(".maps");
enum { ST_PASS, ST_DROP_DST, ST_DROP_PORT, ST_SKIP };
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 4);
__type(key, __u32);
__type(value, __u64);
} stats SEC(".maps");
static __always_inline int verdict(__u32 st, int pass)
{
__u64 *c = bpf_map_lookup_elem(&stats, &st);
if (c) (*c)++; /* PERCPU,不需要原子操作 */
return pass;
}
SEC("cgroup_skb/egress")
int egress_guard(struct __sk_buff *skb)
{
struct ethhdr eth;
struct iphdr ip;
struct lpm_key key;
struct allow_val *av;
__u32 ihl;
__u16 dport;
/* 注意:cgroup_skb 里不能直接摸 skb->data,必须走 bpf_skb_load_bytes */
if (bpf_skb_load_bytes(skb, 0, ð, sizeof(eth)) < 0)
return verdict(ST_SKIP, 1); /* 读不到就放行,别把自己锁死 */
if (eth.h_proto != bpf_htons(ETH_P_IP))
return verdict(ST_SKIP, 1); /* IPv6 / ARP / VLAN 交给别的链 */
if (bpf_skb_load_bytes(skb, sizeof(eth), &ip, sizeof(ip)) < 0)
return verdict(ST_SKIP, 1);
key.prefixlen = 32;
key.addr = ip.daddr;
av = bpf_map_lookup_elem(&allow4, &key); /* LPM:/32 查询会命中 /24 等更长前缀 */
if (!av)
return verdict(ST_DROP_DST, 0);
ihl = ip.ihl * 4;
if (ihl < sizeof(struct iphdr))
return verdict(ST_SKIP, 1);
if (bpf_ntohs(ip.frag_off) & 0x1FFF) /* 非首片没有 L4 头 */
return verdict(ST_SKIP, 1);
if (ip.protocol == IPPROTO_TCP) {
struct tcphdr th;
if (bpf_skb_load_bytes(skb, sizeof(eth) + ihl, &th, sizeof(th)) < 0)
return verdict(ST_SKIP, 1);
dport = bpf_ntohs(th.dest);
} else if (ip.protocol == IPPROTO_UDP) {
struct udphdr uh;
if (bpf_skb_load_bytes(skb, sizeof(eth) + ihl, &uh, sizeof(uh)) < 0)
return verdict(ST_SKIP, 1);
dport = bpf_ntohs(uh.dest);
} else {
return verdict(ST_SKIP, 1);
}
if (av->port_lo && (dport < av->port_lo || dport > av->port_hi))
return verdict(ST_DROP_PORT, 0);
return verdict(ST_PASS, 1);
}
char _license[] SEC("license") = "GPL";
加载与挂载(关键是挂到 slice 上,不是挂到某个 PID 的 cgroup——进程一重启就没了):
cat > /etc/systemd/system/egress-guard.slice <<'EOF'
[Unit]
Description=Egress Guard Slice
[Slice]
EOF
systemctl daemon-reload
# 目标服务里加一行:Slice=egress-guard.slice
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c egress_guard.bpf.c -o egress_guard.bpf.o
bpftool prog loadall egress_guard.bpf.o /sys/fs/bpf/egress_guard
bpftool cgroup attach /sys/fs/cgroup/egress-guard.slice egress \
pinned /sys/fs/bpf/egress_guard/egress_guard
# 灌一条 10.0.0.5:443 的白名单(key: prefixlen 32 小端 + IP 网络序;value: port_lo/port_hi 主机序小端)
bpftool map update pinned /sys/fs/bpf/egress_guard/allow4 \
key hex 20 00 00 00 0a 00 00 05 value hex bb 01 bb 01
bpftool map dump pinned /sys/fs/bpf/egress_guard/stats
排错现场记录,三条都是真踩过的:
- 返回 0 之后应用报的是 `EPERM`,不是网络错误。 nginx 会打 `connect() failed (1: Operation not permitted)`,和 SELinux 拒绝的报错一字不差。别去翻 `ausearch`,直接 dump `stats` map 看 `ST_DROP_DST` 有没有涨。
- loopback 没有以太网头。 本机回环上的出向包,起始就是 IP 头,你按 14 字节偏移读 IP 头会读到 garbage,然后整条 `127.0.0.1` 链路莫名其妙断掉。VLAN 环境则要再 +4 字节。稳妥做法是用 `skb->protocol` 和 `skb->network_header` 定位,别硬编码常量。
- GSO 大包截断。 TSO 场景下拿到的是接近 64KB 的分段,`bpf_skb_load_bytes` 读 L4 头没问题,但如果你试图读 payload 做特征匹配就会读不全,得不偿失。
第三层:这三层里唯一真正区分"进程"的,是 BPF LSM
前面两层,包括这一层之前的 cgroup_skb,绕不过同一个洞:它们看到的都是 socket 所属的 cgroup,不是当前发包的进程。fd 一路传出去,围栏形同虚设。
要真的按"现在是哪个进程在发包"判断,只有 LSM hook 给的信息是可靠的:
SEC("lsm/socket_sendmsg")
int BPF_PROG(sendmsg_guard, struct socket *sock, struct msghdr *msg, int size)
{
__u64 cgid = bpf_get_current_cgroup_id(); /* 当前进程的 cgroup,与 socket 无关 */
__u8 *ok = bpf_map_lookup_elem(&allow_cg, &cgid);
if (!ok)
return -1; /* -EPERM */
return 0;
}
先确认内核给不给面子,别写完才发现加载不了:
cat /sys/kernel/security/lsm # 输出里必须有 bpf
grep CONFIG_BPF_LSM /boot/config-$(uname -r)
没有 `bpf` 的话,得在启动参数里加 `lsm=lockdown,capability,landlock,yama,selinux,bpf`——注意顺序保留现有 LSM,只在末尾追加 bpf,顺序写错会导致 SELinux 不生效,这个代价比你想的大。线上改这个要安排重启窗口。
另外补一句运维上的经验:我们把出口反代和日志上报那批机器换到轻云互联的大带宽机器之后,第一件做的事就是给出向加围栏。原因很实际——他们那边上行是独享的,被打满就是真的打满,没有邻居的突发额度给你当缓冲,出向滥用在这里的破坏半径比共享带宽的机器大得多,越早卡死越好。
没有 eBPF 也想立刻干活:用 `tcpi_bytes_acked` 差分找出内鬼
围栏是"防",这一节是"查"。判断某台机器是不是已经被当成中转,最便宜的指标是每个已建立 socket 的出向累计字节数的变化率。`ss -i` 会把 `tcpi_bytes_acked` 打出来,采两次做差就行:
#!/usr/bin/env bash
# egress-top.sh [间隔秒] —— 找出"合法端口、合法 IP"上偷跑的大流量连接
INTERVAL=${1:-5}
snapshot() {
ss -tinH 2>/dev/null | awk '
$1 ~ /^(ESTAB|SYN-SENT|CLOSE-WAIT|FIN-WAIT-1|FIN-WAIT-2|LAST-ACK|CLOSING)$/ {
match($0, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+ +[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+/)
key = substr($0, RSTART, RLENGTH); gsub(/ +/, "->", key); cur = key; next
}
/bytes_acked:/ {
for (i = 1; i <= NF; i++)
if ($i ~ /^bytes_acked:/) { split($i, a, ":"); print cur "\t" a[2] }
}'
}
declare -A PREV
while :; do
while IFS=$'\t' read -r key bytes; do
[ -z "$key" ] && continue
if [ -n "${PREV[$key]}" ]; then
d=$(( bytes - PREV[$key] ))
[ "$d" -gt 0 ] && \
printf '%10.2f Mbps\t%s\n' \
"$(awk -v d="$d" -v i="$INTERVAL" 'BEGIN{printf "%.2f", d*8/i/1000000}')" "$key"
fi
PREV[$key]=$bytes
done < <(snapshot)
sleep "$INTERVAL"
done | sort -rn | head -n 20
几个读数要点,都是踩出来的:
tcpi_bytes_acked是被对端确认的字节数,所以测的是有效吞吐,不是本地重传的自嗨。对端长期不 ACK 的"假连接"不会排到前面,正好符合你要找的目标。- 一定要过滤掉 `dport 22`——备份、rsync over ssh、发布系统全部走 22,不排掉的话榜单前三永远是它们。
- 单条连接长期稳定在 200Mbps 以上、对端是境外 VPS 归属、本地进程是 `php-fpm` 或 `node`——这三条同时成立,基本可以直接上 `ss -tie` 看 `rtt` 和重传,再 `ss -tinp` 拿 pid 现场取证了。
最后的取舍
三层不是叠加用,是按风险面分治:
- 低危业务 cgroup(只出固定几家对象存储、固定几个 API):用第二层的 LPM 白名单,直接 fail-closed,新目标走工单流程加表。这是性价比最高的一档。
- 必须 root 跑、必须出网的 agent:只上第三层 LSM,按 cgroup id 放行,不去管它连哪里——反正你也管不住它连哪里,但至少能保证它不是从别人的进程里被借道发出去的。
- 用户可达的一切(PHP-FPM、node、任何能吃外部输入的):永不给白名单,只能 fail-closed 加极小放行,因为这一层出问题的概率不是"会不会",是"什么时候"。
至于"要不要上 eBPF"这件事,我的态度是:先用 nftables 那层把能挡的挡掉,把 `level` 和计数看对;只有当你的绕过面分析里明确出现了 fd 传递或者 root agent 的时候,LSM 那层的复杂度才值得付。