Modern Linux Firewalling, nftables vs. firewalld_

🇺🇦 Resources to help support the people of Ukraine. 🇺🇦
April 12, 2022 @14:20

systat(1) pfstat on one of my OpenBSD firewalls I believe in a zero trust defense in depth approach to security. Every network segment has a firewall (mostly OpenBSD) that controls ingress and egress traffic and every machine that can also runs a local firewall that further limits ingress (and in some cases egress) traffic. This makes everything for an attacker harder. It limits lateral movement, complicates data exfiltration, and in some cases foils entire classes of attacks.

For my Linux servers I have been using a custom wrapper around iptables for more than 15 years now. It leverages run-parts(8) to allow for complex rule sets to be constructed out of pieces put in place by disparate sources. This was done specifically to allow multiple Puppet modules or Ansible roles to configure their own firewall rules. Linux is starting to move away from iptables and is moving to a new toolset for interacting with the kernel firewall called nftables and my preferred distribution (Debian) started the process of migrating last release so I figured it was time I looked into stepping into the present.

firewalld

The Debian nftables wiki article explicitly suggests firewalld as the recommended solution so that is where I started. Initial setup was reasonably easy, I installed the firewalld package and dropped some XML files in /etc/firewalld and had a simple host firewall up in a few short minutes. The problems started when trying to translate that into something I could manage centrally. I just don't see a good way to provide to build up a complex rule set without mixing XML fragments and command line invocations and I am not sure how well behaved that would be with bi-hourly Puppet agent runs. Also, the XML format and rule syntax feels very clunky and verbose. It seems like this is really designed for a mobile workstation where you can bind different zones (groups of rules) to different interfaces and change them on the fly (say 'public' when you are out on public WiFi and 'private' when you are at home. Similar to the Windows Firewall zone settings). I imagine if you're using NetworkManager and all the other Linux desktop pieces this makes a ton of sense. It would be nice to be able to change firewall profiles based on WiFi SSID or WiFi versus Ethernet in that case but I am trying to manage a server firewall here.

nftables

Underpinning firewalld is the nftables interface into the Linux firewall. The userspace command nft(8) compiles rule sets provided either directly on the command line, via stdin, or via a set of files into the byte code for the firewall and ships it into the kernel. This feels very much like the iptables system with a slightly more flexible rule grammar. Debian ships nftables in a way that you can just plop a rule set in /etc/nftables.conf and it will load at boot. I did a little experimenting and it looks like by shipping fragments and using a globbed include statement or two in the main config I can allow Puppet and Ansible to build up complex configurations based on the roles of the machine.

Example Rulesets

I chose an internal Icecast streaming server as a testbed for all of this as it is pretty simple. It only needs one additional port outside of the 'standard' ports open and so here is what I came up with.

iptables

As a baseline, here is a simple server running my iptables ruleset.

Chain INPUT (policy ACCEPT)
target     prot opt source               destination
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpt:6666
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpt:6789
ACCEPT     udp  --  192.168.42.0/28      0.0.0.0/0            udp dpt:7004
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpt:7080
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpts:7442:7443
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpts:7445:7447
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpt:8080
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpt:8443
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpt:8880
ACCEPT     tcp  --  192.168.42.0/28      0.0.0.0/0            tcp dpt:8843
ACCEPT     udp  --  192.168.42.0/28      0.0.0.0/0            udp dpt:3478
ACCEPT     udp  --  192.168.42.0/28      0.0.0.0/0            udp spt:10001
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpt:6666
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpt:6789
ACCEPT     udp  --  192.168.196.0/23     0.0.0.0/0            udp dpt:7004
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpt:7080
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpts:7442:7443
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpts:7445:7447
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpt:8080
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpt:8443
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpt:8880
ACCEPT     tcp  --  192.168.196.0/23     0.0.0.0/0            tcp dpt:8843
ACCEPT     udp  --  192.168.196.0/23     0.0.0.0/0            udp dpt:3478
ACCEPT     udp  --  192.168.196.0/23     0.0.0.0/0            udp spt:10001
ACCEPT     all  --  192.168.196.2        0.0.0.0/0
ACCEPT     udp  --  0.0.0.0/0            0.0.0.0/0            udp spt:53
ACCEPT     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp spt:53
ACCEPT     udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:5353
ACCEPT     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:5353
ACCEPT     udp  --  0.0.0.0/0            0.0.0.0/0            udp spt:123
ACCEPT     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:22
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0            state RELATED,ESTABLISHED
ACCEPT     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpts:1024:65535 flags:!0x17/0x02
ACCEPT     all  --  127.0.0.0/8          127.0.0.0/8
ACCEPT     all  -f  0.0.0.0/0            0.0.0.0/0
ACCEPT     icmp --  0.0.0.0/0            0.0.0.0/0
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:5678
DROP       udp  --  0.0.0.0/0            255.255.255.255      udp dpt:10001
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:57621
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpts:161:162
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:113
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:110
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp spt:67 dpt:68
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp spt:68 dpt:67
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:8763
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:8763
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:2967
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:5900
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:2222
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpts:137:139
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpts:137:139
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:445
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:1433
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:1434
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:1026
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:17500
DROP       all  --  0.0.0.0/0            224.0.0.1
DROP       59   --  0.0.0.0/0            0.0.0.0/0
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:631
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:1900
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:6969
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpts:27020:27050
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpts:27020:27050
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:1228
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:5060
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:585
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:50010
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:50020
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:50070
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:50075
DROP       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:50090
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:51729
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:1534
DROP       udp  --  0.0.0.0/0            0.0.0.0/0            udp dpt:34196
LOG_AND_REJECT  all  --  0.0.0.0/0            0.0.0.0/0

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination

Chain LOG_AND_REJECT (1 references)
target     prot opt source               destination
LOG        all  --  0.0.0.0/0            0.0.0.0/0            limit: avg 1/sec burst 5 LOG flags 0 level 4 prefix "Packet log: "
REJECT     all  --  0.0.0.0/0            0.0.0.0/0            reject-with icmp-port-unreachable

firewalld

This is much simpler, it lacks the DROP rules that provide cleaner logs but is essentially the minimum viable.

<?xml version="1.0" encoding="utf-8"?>
<zone target="%%REJECT%%">
    <short>ub3rgeek</short>
    <description>Standard server configuration for ub3rgeek.net</description>
    <!-- Some people (who are wrong) think that not responding to ICMP
    is 'safer'.  I am not one of those people. -->
    <rule family="ipv4">
        <protocol value="icmp"/>
        <accept/>
    </rule>
    <rule family="ipv6">
        <protocol value="ipv6-icmp"/>
        <accept/>
    </rule>

    <!-- You don't seem to be able to use prefix lists in ipsets so
    we have to duplicate each rule for each prefix.  You also cannot
    have more than one service in a rule.
    -->
    <rule family="ipv4">
        <source address="192.168.42.0/28"/>
        <service name="icecast2"/>
        <accept/>
    </rule>

    <rule family="ipv4">
        <source address="192.168.42.0/28"/>
        <service name="ssh"/>
        <accept/>
    </rule>

    <rule family="ipv4">
        <source address="192.168.196.0/23"/>
        <service name="icecast2"/>
        <accept/>
    </rule>

    <rule family="ipv4">
        <source address="192.168.196.0/23"/>
        <service name="ssh"/>
        <accept/>
    </rule>

    <rule family="ipv6">
        <source address="2606:c380:c:1::fffc/128"/>
        <service name="icecast2"/>
        <accept/>
    </rule>

    <rule family="ipv6">
        <source address="2606:c380:c:1::fffc/128"/>
        <service name="ssh"/>
        <accept/>
    </rule>

    <rule family="ipv6">
        <source address="2606:c380:c001:3::/64"/>
        <service name="icecast2"/>
        <accept/>
    </rule>

    <rule family="ipv6">
        <source address="2606:c380:c001:3::/64"/>
        <service name="ssh"/>
        <accept/>
    </rule>
</zone>

nftables

This is nearly the same as what would be done by the iptables rule set. It only lacks some of the extra logic for services like NFS that are accomplished by the fact that I have access to the full shell scripting environment.

define BLOCKED_TCP_PORTS = {
    pop3,
    auth,
    netbios-ns,
    netbios-dgm,
    netbios-ssn,
    microsoft-ds,
    ms-sql-s,
    ms-sql-m,
    585,
    2967,
    5900,
    8763,
    27020-27050,
    50010,
    50020,
    50070,
    50075,
    50090
}

define BLOCKED_UDP_PORTS = {
    netbios-ns,
    netbios-dgm,
    netbios-ssn,
    snmp,
    snmptrap,
    ipp,
    1026,
    1228,
    ms-sql-s,
    ms-sql-m,
    1534,
    1900,
    2222,
    sip,
    5678,
    6969,
    8763,
    10001,
    17500,
    20720-27050,
    34196,
    51729,
    57621
}

define MY_V4_NETWORKS = {
    192.168.42.0/28,
    192.168.196.0/23
}

define MY_V6_NETWORKS = {
    2606:c380:c001:3::/64,
    2606:c380:c:1::fffc/128
}

table inet filter {
    counter c_rejected {
        comment "Rejected packets"
    }

    counter c_dropped {
        comment "Dropped packets"
    }

    chain drop-no-log {
        counter name c_dropped
        drop
    }

    chain log-and-reject {
        counter name c_rejected
        log prefix "[nftables REJECT]: " \
            limit rate 5/second \
        reject
    }

    chain input {
        type filter hook input priority 0; policy drop;
        ct state vmap {
            established: accept,
            related: accept,
            invalid: drop
        } counter
        ip protocol icmp counter accept
        ip6 nexthdr icmpv6 counter accept
        ip6 nexthdr ipv6-nonxt counter drop
        iifname lo counter accept

        # Add any local overrides here.
        include "ruleset.input*"

        # All servers are allowed a few things.
        # ssh
        ip saddr $MY_V4_NETWORKS tcp dport { 22 } \
            counter accept comment "SSH"
        ip6 saddr $MY_V6_NETWORKS tcp dport { 22 } \
            counter accept comment "SSH"

        # dns
        tcp sport 53 counter accept comment "DNS"
        udp sport 53 counter accept comment "DNS"

        # mdns
        tcp sport 5353 counter accept comment "mDNS"
        udp sport 5353 counter accept comment "mDNS"

        # ntp
        ip saddr $MY_V4_NETWORKS tcp sport 123 \
            counter accept comment "NTP"
        ip6 saddr $MY_V6_NETWORKS tcp sport 123 \
            counter accept comment "NTP"

        # Accept SYNs on high ports
        tcp dport 1024-65535 tcp flags syn \
            counter accept

        # Drop known noisy traffic so it does not get logged.
        tcp dport $BLOCKED_TCP_PORTS goto drop-no-log
        udp dport $BLOCKED_UDP_PORTS goto drop-no-log
        ip daddr 224.0.0.1/32 goto drop-no-log
        goto log-and-reject
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        goto log-and-reject
    }

    chain output {
        type filter hook output priority 0; policy accept;

        # Add any local overrides here.
        include "rulset.output*"
    }
}

Conclusion

It looks like I'm going to move to nftables, either as a ruleset with includes or directly using run-parts like I am today. For a server use case firewalld is just too complex. You should understand your use case (and threat profile) when making a similar choice but in the case of a server you're almost always best served by simplicity. I'm going to start converting my Ansible managed systems (simple, single purposet cloud VMs and Raspberry Pis mostly) and see how that goes. If all goes well I'll eventually migrate my more complex Puppet managed environment.

Oh, and as a parting shot, pf.conf(5) still 1000% better than even the new hotness in Linux. 🤷

Subscribe via RSS. Send me a comment.