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 nftables

The 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#

notrack on 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’s conntrack table. 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_V4 forgotten or wrongly set. The input chain’s default policy is drop: 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 -f validates 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 123

The 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/DENY codes returned in the Reference ID field when Stratum = 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 monlist monitoring command in the ntpd daemon (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 any monlist-equivalent feature in chrony, closes this attack vector at the source.