Spoofed source addresses are still one of the cheapest ways to waste firewall CPU, poison logs, and bounce abuse complaints onto the wrong network.
Linux already has a classic defense: rp_filter in the IPv4 sysctl tree. It works. It is also blunt, per-interface, and easy to disable “just for a minute” during asymmetric routing debugging — and then never turn back on.
nftables gives you the same idea as an explicit packet-path rule: the fib expression. You ask the kernel’s forwarding information base (FIB) what it thinks about a source or destination address, then accept or drop based on that answer. You can log the rejects, scope them to one WAN interface, combine them with other edge policy, and keep the logic next to the rest of your firewall.
This post is a practical recipe for reverse-path filtering with stock nftables on Linux edge hosts and small gateways.
What you are solving
Three common edge problems:
-
Forged sources on the WAN — packets arrive on
eth0claiming to come from your LAN, loopback, or some other customer prefix. - Martian and unroutable sources — traffic whose source address has no usable route at all.
- Local-address confusion — packets aimed at addresses that are not configured on the receiving interface (useful on multi-homed servers).
Comparison of nearby tools:
| Tool | Layer | Main job |
|---|---|---|
net.ipv4.conf.*.rp_filter |
kernel routing validation | strict/loose reverse-path check for IPv4 |
nftables fib
|
Netfilter rule expression | query FIB and decide per-rule |
| nftables dynamic sets / meters | packet-path abuse control | rate-limit or temp-ban noisy sources |
| nftables SYNPROXY | TCP handshake proxy | stop unauthenticated SYNs from filling conntrack |
| VRF / policy routing | routing domains | isolate tables; does not by itself anti-spoof WAN ingress |
Use fib when you want reverse-path policy that is visible, countable, and composable inside the firewall ruleset — not only a silent sysctl.
Prerequisites
- Linux 4.10+ and nftables 0.7+ (FIB matching landed there; modern Debian/Ubuntu/Fedora are fine)
- root on the host that sees the traffic
- A routing table you understand (
ip route, optional policy rules) - A recovery path if you lock yourself out of a remote box
Packages (Debian/Ubuntu-style):
sudo apt-get update
sudo apt-get install -y nftables iproute2
sudo nft -v
Before changing anything live:
sudo nft list ruleset > "/root/nftables-before-fib-$(date +%F-%H%M).nft"
ip -4 route show table all > "/root/routes-before-fib-$(date +%F-%H%M).txt"
ip -6 route show table all >> "/root/routes-before-fib-$(date +%F-%H%M).txt"
Core ideas (short)
What fib does
From nft(8) and the nftables wiki:
fib {saddr | daddr | mark | iif | oif} [. ...] {oif | oifname | type}
You feed the FIB a key (source address, destination address, optional mark/interfaces) and read back:
-
oif/oifname— which interface the kernel would use to reach that address -
type— address type such aslocal,broadcast,unicast,blackhole, …
Reverse-path filtering in one line
The manpage’s canonical anti-spoof pattern:
fib saddr . iif oif missing drop
Read it as:
- Take the packet’s source address and input interface.
- Ask the FIB: “If I needed to reply toward this source, which output interface would I use?”
- If the answer is missing (no usable reverse path for that
saddr/iifpair), drop.
When iif is part of the lookup key, a successful reverse path means the reverse route goes out the same interface the packet arrived on (strict-style). If you omit iif and only check fib saddr oif, any valid outgoing interface counts (loose-style).
Strict vs loose (and vs sysctl)
Kernel rp_filter (from ip-sysctl docs):
| Value | Mode | Behavior |
|---|---|---|
0 |
off | no source validation |
1 |
strict (RFC 3704) | ingress interface must be the best reverse path |
2 |
loose (RFC 3704) | source must be reachable via some interface |
nftables equivalents (conceptually):
| Goal | nftables shape |
|---|---|
| Strict RPF | fib saddr . iif oif missing drop |
| Loose RPF | fib saddr oif missing drop |
| Accept only from a named reverse iface |
fib saddr . iif oif eq "eth0" accept then drop the rest |
You can run nftables fib checks even when sysctl rp_filter=0, or keep both. If both are on, either layer can drop the packet. Prefer one clear owner of RPF policy so debugging stays sane.
Recipe 1 — Strict reverse-path filter on WAN ingress
Goal: on the public interface, drop packets whose source address would not be routed back out that same interface.
sudo tee /etc/nftables.d/fib-rpfilter.nft >/dev/null <<'EOF'
table inet edge_rpf {
chain prerouting_rpf {
type filter hook prerouting priority mangle; policy accept;
# Never RPF-filter loopback
iifname "lo" accept
# Optional: skip link-local IPv6 ND noise early if you prefer
# icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert,
# nd-router-solicit, nd-router-advert } accept
# Strict RPF on WAN: reverse path must exist via the ingress iface
iifname "eth0" fib saddr . iif oif missing counter drop
# IPv6 uses the same fib expression in an inet table
# (kernel performs the family-appropriate FIB lookup)
}
}
EOF
sudo nft -c -f /etc/nftables.d/fib-rpfilter.nft
sudo nft -f /etc/nftables.d/fib-rpfilter.nft
sudo nft list table inet edge_rpf
Replace eth0 with your real WAN device name (enp1s0, ppp0, WireGuard wg0 only if you truly want RPF there, etc.).
Why prerouting + mangle priority?
- prerouting sees packets before the local-delivery vs forward decision — the right place for edge anti-spoofing on both host-destined and forwarded traffic.
-
priority mangle (-150) is a conventional early filter priority from the Netfilter hook table. You can use
filter(0) instead if you already own that slot; just keep RPF before broad accepts.
Conntrack still runs around its own priorities. RPF does not replace ct state invalid drop; it answers a different question: “Is this source plausible on this wire?”
Recipe 2 — Loose reverse-path filter
Use loose mode when reverse traffic legitimately leaves a different interface than the one that received the request (asymmetric routing, some multi-homed designs, certain VPN hairpins).
table inet edge_rpf_loose {
chain prerouting_rpf {
type filter hook prerouting priority mangle; policy accept;
iifname "lo" accept
# Source must be reachable via *some* interface
iifname "eth0" fib saddr oif missing counter drop
}
}
Loose mode still kills pure martians (sources with no route) while allowing asymmetric return paths. It will not catch “arrived on WAN but source belongs to LAN behind another NIC” if that LAN source is still globally routable in your FIB via the LAN interface.
Recipe 3 — Require reverse path exactly via the ingress device
The wiki’s positive form is useful when you want an allow-list style:
table inet edge_rpf_allow {
chain prerouting_rpf {
type filter hook prerouting priority mangle; policy accept;
iifname "lo" accept
# Accept only if FIB reverse path is eth0; drop otherwise for WAN
iifname "eth0" fib saddr . iif oif eq "eth0" accept
iifname "eth0" counter drop
}
}
Functionally this is the strict check written as accept-then-drop. Prefer oif missing drop when you only care about failure; prefer the positive form when you are composing multiple interface-specific outcomes (for example with a verdict map).
Recipe 4 — Drop packets not addressed to this host on this NIC
Multi-homed servers sometimes receive traffic for foreign destinations on the wrong interface (miswiring, cloud secondary IPs, odd anycast). The manpage pattern:
# Drop unless destination is local/broadcast/multicast on this iif
fib daddr . iif type != { local, broadcast, multicast } counter drop
Example limited to WAN:
table inet edge_local_dst {
chain prerouting_local {
type filter hook prerouting priority mangle; policy accept;
iifname "lo" accept
# Host firewall angle: WAN should deliver local addresses, not random transit
iifname "eth0" fib daddr . iif type != { local, broadcast, multicast } counter drop
}
}
Do not paste this on a router’s WAN if that box must forward transit traffic. Forwarding hosts need reverse-path checks on source addresses (Recipes 1–3), not “destination must be local.”
Recipe 5 — Wire RPF into a real server policy
Dynamic RPF is not a full firewall. Drop it in front of a normal default-deny input policy:
#!/usr/sbin/nft -f
flush ruleset
table inet firewall {
chain prerouting_rpf {
type filter hook prerouting priority mangle; policy accept;
iifname "lo" accept
iifname "eth0" fib saddr . iif oif missing counter drop
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "lo" accept
# IPv6 neighbor discovery
icmpv6 type { nd-neighbor-solicit, nd-router-advert,
nd-neighbor-advert, nd-redirect } accept
tcp dport { 22, 80, 443 } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
}
Persist the usual way:
sudo mkdir -p /etc/nftables.d
# ensure /etc/nftables.conf includes your files, or paste the table there
sudo systemctl enable --now nftables
sudo nft -c -f /etc/nftables.conf
sudo systemctl reload nftables
Recipe 6 — Gateway / forward path
On a router, keep RPF on prerouting so both forwarded and locally terminated packets are covered once:
table inet edge_forward_rpf {
chain prerouting_rpf {
type filter hook prerouting priority mangle; policy accept;
iifname "lo" accept
# WAN customers/uplink: strict RPF
iifname "eth0" fib saddr . iif oif missing counter drop
# Optional: loose on a path known to be asymmetric
# iifname "gre1" fib saddr oif missing counter drop
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
# your normal LAN egress / DNAT-related allows…
}
}
If you use nftables flowtables for established fastpath, keep RPF on prerouting for new packets. Spoof checks belong where every fresh flow still visits the classic path.
Optional: coexist with sysctl rp_filter
Check current IPv4 behavior:
sysctl net.ipv4.conf.all.rp_filter \
net.ipv4.conf.default.rp_filter \
net.ipv4.conf.eth0.rp_filter
Remember: the kernel uses the max of conf/all/rp_filter and conf/<iface>/rp_filter for that interface.
If nftables owns RPF, you can make the sysctl intentional rather than mysterious:
sudo tee /etc/sysctl.d/90-rpfilter-owner.conf >/dev/null <<'EOF'
# nftables fib rules own reverse-path policy on this host.
# Keep kernel RPF off to avoid double-drops that are hard to attribute.
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
EOF
# Per-interface files may still set rp_filter=1 on some distros — audit them:
# grep -R rp_filter /etc/sysctl.conf /etc/sysctl.d /usr/lib/sysctl.d 2>/dev/null
sudo sysctl --system
If you prefer belt-and-suspenders, leave rp_filter=1 on and still add nftables counters for visibility. Just document which layer you trust when a packet disappears.
IPv6 note: classic rp_filter sysctls are an IPv4 story. nftables fib in an inet or ip6 table is one of the clean ways to apply the same operational idea to IPv6.
Verification checklist
1. Rules are attached where you think
sudo nft list table inet edge_rpf
sudo nft list hooks 2>/dev/null || true
Confirm a prerouting chain contains fib saddr ... and a counter.
2. FIB answers match your mental model
ip route get 203.0.113.50
ip -6 route get 2001:db8::50
Pick a source you expect to arrive on WAN. ip route get should egress via that WAN interface for strict mode to accept it.
3. Counter moves on spoofed lab traffic
In a lab netns or on a packet generator you own (never third-party networks), send a packet into WAN with a source that only exists behind LAN:
# Conceptual lab check — adjust interfaces/netns to your harness
sudo nft reset counters table inet edge_rpf 2>/dev/null || true
# ... inject spoofed source toward the WAN NIC ...
sudo nft list chain inet edge_rpf prerouting_rpf
The fib ... missing counter drop rule should increment.
4. Legitimate clients still work
curl -I --max-time 10 https://your.example
ssh user@your.example true
If real clients break immediately after enabling strict mode, you almost certainly have asymmetric routing. Switch that path to loose mode or fix the routes/policy rules.
5. Compare with kernel RPF if still enabled
# Dropped by kernel rp_filter may never hit your nft counter
nstat -az | grep -i IPReversePath || true
journalctl -k -b --grep='rp_filter|martian' --no-pager | tail
If only the kernel path is dropping, your nftables counter will stay quiet. That is a common “I added fib rules and nothing happens” confusion.
Operational pitfalls
Asymmetric routing + strict mode
Multi-homed edge, BGP unequal paths, some SD-WAN overlays: reverse path ≠ ingress interface. Use loose mode on those interfaces or fix symmetry.Policy routing / multiple tables
fibfollows the kernel FIB lookup rules, including marks when you keyfib daddr . mark/fib saddr . mark. If you set marks after the RPF chain, the lookup will not see them yet. Order matters.VRF
Addresses living in a VRF are looked up in that VRF’s world. A source that is valid only in another table will fail RPF. That is desirable for isolation — and surprising if you forgot the VRF.DHCP clients / weird bootstrap
Rare boot windows can look “wrong” before routes land. Prefer applying strict RPF after networking is configured, or start in log-only mode:
iifname "eth0" fib saddr . iif oif missing log prefix "RPF " counter drop
Router vs host destination checks
fib daddr . iif type localis a host/edge filter. On a forwarder it will blackhole transit.Flowtable / offload assumptions
Established fastpaths skip a lot of classic filtering. Put anti-spoofing where new flows still pass (prerouting), and do not expect prerouting counters to tick for every offloaded packet.Double RPF with silent sysctl
Distros often shiprp_filter=1. Know which layer drops before you chase nftables.IPv4-only thinking on dual-stack edges
Spoofers use IPv6 too. Aninettable withfibcovers both families; plainiptables do not.
Rollback
sudo nft delete table inet edge_rpf 2>/dev/null || true
sudo nft delete table inet edge_rpf_loose 2>/dev/null || true
sudo nft delete table inet edge_rpf_allow 2>/dev/null || true
sudo nft delete table inet edge_local_dst 2>/dev/null || true
sudo nft delete table inet edge_forward_rpf 2>/dev/null || true
# Or restore the pre-change dump
# sudo nft -f /root/nftables-before-fib-YYYY-MM-DD-HHMM.nft
sudo systemctl reload nftables 2>/dev/null || true
Keep console/out-of-band access when testing remote edge policy.
Minimal production recipe
# 1) Backup
sudo nft list ruleset > "/root/nftables-before-fib-$(date +%F).nft"
# 2) Audit kernel RPF so you know who owns drops
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter
# 3) Install strict RPF on WAN (edit interface name first)
sudo nft -c -f /etc/nftables.d/fib-rpfilter.nft
sudo nft -f /etc/nftables.d/fib-rpfilter.nft
# 4) Verify
sudo nft list table inet edge_rpf
ip route get 1.1.1.1
# 5) Confirm real services still answer
# curl -I https://your.example
Closing
If your edge still trusts every source address that arrives on the wire, you are letting attackers choose their own return identity.
nftables fib reverse-path filtering is not a full anti-DDoS platform. It is a precise kernel feature with a clear contract:
- ask the FIB whether a source (or destination) makes sense
- enforce strict or loose reverse-path policy in the firewall itself
- count and log the rejects
- keep asymmetric paths on loose mode instead of silently disabling protection forever
Use SYNPROXY when unauthenticated handshakes are the cost center. Use dynamic sets and meters when rates and temp-bans matter. Use fib when the first question is simpler: should this source address be here at all?
Sources and references
- nftables wiki — Matching routing information (
fib,rt nexthop): https://wiki.nftables.org/wiki-nftables/index.php/Matching_routing_information - nftables wiki — Netfilter hooks and priorities: https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks
- nftables wiki — Configuring chains: https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains
- nftables wiki — Simple ruleset for a server: https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_server
- Debian
nft(8)— FIB expressions (fib saddr . iif oif missing drop): https://manpages.debian.org/bookworm/nftables/nft.8.en.html - Netfilter nftables manpage hub: https://www.netfilter.org/projects/nftables/manpage.html
- Linux kernel networking docs —
rp_filterstrict/loose (RFC 3704): https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt - RFC 3704 — Ingress Filtering for Multihomed Networks: https://www.rfc-editor.org/rfc/rfc3704
Top comments (0)