nftables firewall#
Why#
A public NTP server is an obvious target: the NTP protocol has
historically been exploited for amplification attacks, and an exposed
server receives continuous scan traffic as soon as it goes live. The
firewall must filter this noise before it reaches application
services, with two distinct goals: rate-limiting per IP (anti-abuse) and
preventing connection tracking (conntrack) from saturating under a load
of stateless UDP packets.
This guide does not aim for exhaustiveness on security. The rules below cover this specific project’s needs, not a universal firewall template. They should be adapted to each deployment’s own network topology, and complemented with an audit (Lynis chapter) rather than treated as sufficient as-is.
How#
Rules#
#!/usr/sbin/nft -f
flush ruleset
# Admin network allowed for SSH: documentation prefix (RFC 5737),
# replace with the real private LAN before activation
define ADMIN_NET_V4 = 192.0.2.0/24
table inet filter {
counter cnt_drop_ntp { }
counter cnt_reject_nts { }
counter cnt_state_invalid { }
set ntp_meter { type ipv4_addr ; flags dynamic ; timeout 5s ; }
set ntp_meter_v6 { type ipv6_addr ; flags dynamic ; timeout 5s ; }
set nts_meter { type ipv4_addr ; flags dynamic ; timeout 60s ; }
set nts_meter_v6 { type ipv6_addr ; flags dynamic ; timeout 60s ; }
# `timeout` sets how long a per-IP dynamic entry lives in the set: it
# must cover the window over which the rate is evaluated, otherwise
# the entry would expire before the limit means anything. 5s is
# enough for a per-second threshold (NTP), 60s is needed for a
# per-minute threshold (NTS-KE).
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid counter name "cnt_state_invalid" drop
# NTP (UDP/123): abusive > 10/s/IP -> silent drop + counter
# Deliberately generous threshold: this network-level filter is
# blind to the protocol (unlike chrony's own `ratelimit`, dedicated
# chapter, which is far more precise), its job is to contain gross
# abuse before the packet even reaches chronyd, not to replace
# application-level throttling
udp dport 123 meta nfproto ipv4 add @ntp_meter { ip saddr limit rate over 10/second } counter name "cnt_drop_ntp" drop
udp dport 123 meta nfproto ipv6 add @ntp_meter_v6 { ip6 saddr limit rate over 10/second } counter name "cnt_drop_ntp" drop
udp dport 123 accept
# NTS-KE (TCP/4460): abusive > 4/min/IP -> TCP reset + counter
tcp dport 4460 meta nfproto ipv4 ct state new add @nts_meter { ip saddr limit rate over 4/minute } counter name "cnt_reject_nts" reject with tcp reset
tcp dport 4460 meta nfproto ipv6 ct state new add @nts_meter_v6 { ip6 saddr limit rate over 4/minute } counter name "cnt_reject_nts" reject with tcp reset
tcp dport 4460 accept
# SSH restricted to the admin network
ip saddr $ADMIN_NET_V4 tcp dport 22 ct state new accept
# Public web
tcp dport { 80, 443 } accept
# ICMP / ICMPv6 (required for PMTU and IPv6 to work)
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
udp sport 547 udp dport 546 accept
}
chain forward { type filter hook forward priority filter; policy drop; }
chain output { type filter hook output priority filter; policy accept; }
}
# Bypass connection tracking for NTP (performance + DDoS resilience)
table inet raw {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
udp dport 123 notrack
}
chain output {
type filter hook output priority raw; policy accept;
udp sport 123 notrack
}
}nft -c -f /etc/nftables.conf # syntax validation before activation
systemctl enable --now nftablesThe pipeline, diagrammed#
Incoming UDP/123 packet
│
▼
┌───────────────────┐
│ table inet raw │ udp dport 123 → notrack
│ hook prerouting │ ───────────────────────────────► packet marked "untracked"
└───────────────────┘
│
▼
┌────────────────────┐
│ CONNTRACK │ ← bypassed: no entry allocated in RAM
│ (RAM allocation) │ (anti memory-exhaustion defense)
└────────────────────┘
│
▼
┌─────────────────────┐
│ table inet filter │
│ chain input │
│ rate > 10/s/IP ? │
└─────────────────────┘
│
┌─────────┴─────────┐
yes no
▼ ▼
silent drop ┌──────────┐
│ accept │ ────────────────────► chronyd socket
└──────────┘Pitfalls to avoid#
notrackon NTP traffic isn’t just a performance optimization: it’s a DDoS defense. Without it, every UDP/123 packet (including from attackers) allocates an entry in the kernel’sconntracktable. This table has finite capacity; a sufficient flood of packets can exhaust it, which then affects every connection on the server, not just NTP. Since the NTP protocol is inherently stateless server-side (each request is independent), connection tracking brings no benefit here, only risk.
ADMIN_NET_V4forgotten or wrongly set. Theinputchain’s default policy isdrop: without the SSH exception rule correctly configured for the real admin network, access cuts itself off. This value must be verified before activating the rules, not after.- Skipping syntax validation before activation.
nft -c -fvalidates the file without applying it, a syntax error in a directly applied file can leave the firewall in an inconsistent state, potentially blocking all remote access including SSH.
Verify#
nft list ruleset
# Re-reads the active configuration, should match the file
# Test from a third-party machine (not from the server itself):
nc -zv time.example.org 123The real test of the rate limit requires artificially generating traffic
exceeding 10 requests/second from a single IP, and checking that the
cnt_drop_ntp counter climbs:
watch -n1 'nft list counters table inet filter'This network-level rate-limiting acts outside the NTP protocol: it doesn’t distinguish a legitimate client from an abusive one, it just cuts off past a raw threshold. NTP also has its own protocol-level retaliation mechanism, Kiss-o’-Death (
RATE/DENYcodes returned in the Reference ID field whenStratum = 0). See the Anatomy of the NTP packet chapter. The two mechanisms complement each other: KoD regulates cooperative clients, nftables contains the ones that aren’t.
📚 Going further
NTP amplification attacks historically exploited the
monlistmonitoring command in thentpddaemon (long since removed from modern implementations, including chrony): a request of a few bytes could generate a response hundreds of times larger, directed at a spoofed victim. The per-IP rate-limiting set up here, combined with the absence of anymonlist-equivalent feature in chrony, closes this attack vector at the source.