Peter Davies

Ramblings

An iptables ruleset, line by line

I've been asked more than once to look at a server's firewall and say whether it's any good. The first thing I do is iptables-save and read it top to bottom. This is the ruleset I start new single-purpose web servers from, with every line explained, followed by notes on reading a dump that someone else (or firewalld) wrote.

The file

Saved as /etc/iptables/rules.v4 and loaded with iptables-restore:

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:ADMIN - [0:0]

-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -p icmp --icmp-type echo-request -m limit --limit 5/second --limit-burst 10 -j ACCEPT
-A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
-A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT

-A INPUT -p tcp -m multiport --dports 80,443 -m conntrack --ctstate NEW -j ACCEPT

-A INPUT -p tcp -m multiport --dports 22,3306,10000 -m conntrack --ctstate NEW -j ADMIN

-A ADMIN -s 203.0.113.10/32 -j ACCEPT
-A ADMIN -s 198.51.100.0/28 -j ACCEPT
-A ADMIN -j DROP

-A INPUT -m limit --limit 5/min -j LOG --log-prefix "iptables drop: " --log-level 4
COMMIT

Line by line

*filter starts the filter table, the one that decides what is allowed. Everything up to COMMIT is applied as one change, so a half-loaded ruleset never exists.

:INPUT DROP [0:0] sets the policy for the INPUT chain: whatever reaches the end of the chain without matching a rule is dropped. This is the most important line in the file. A lot of rulesets I've been handed have :INPUT ACCEPT and a list of rules that send a few ports to a restricted chain. That locks those ports down, but every port not on the list, including whatever service someone installs next month, is open. Default drop turns it the right way round: only what you name is allowed. The [0:0] is the packet and byte counters, which iptables-save writes and iptables-restore reads back.

:FORWARD DROP because a web server isn't a router. If you run Docker or a VPN on it, this changes.

:OUTPUT ACCEPT lets the server talk out freely. Restricting outbound is worth doing on a high-value box, as it stops a compromised site phoning home, but it's a lot more work to get right and I don't start there.

:ADMIN - [0:0] creates a user-defined chain. User chains have no policy, hence the dash; when a packet reaches the end of one it returns to the rule after the jump.

-A INPUT -i lo -j ACCEPT accepts everything on the loopback interface. PHP talking to MySQL on 127.0.0.1, Postfix talking to itself and plenty else depend on this. It belongs at the top of INPUT, not tucked into a restricted chain where it only applies to the listed ports.

--ctstate RELATED,ESTABLISHED -j ACCEPT lets in replies to connections the server made (DNS lookups, package downloads, outgoing mail) and the rest of any connection already accepted. Without it, default drop would break everything the server does outbound. It goes near the top because it matches most packets, so most packets stop being checked here.

--ctstate INVALID -j DROP drops packets connection tracking can't place: out-of-state TCP, odd flag combinations, a lot of scan traffic.

The three ICMP lines allow ping at a limited rate, plus the two error types that matter. destination-unreachable carries the "fragmentation needed" message that path MTU discovery relies on, and blocking it gives you connections that hang on large transfers for no obvious reason. Blocking all ICMP is a common mistake.

--dports 80,443 ... -j ACCEPT is the public service. multiport lets one rule cover several ports. Matching on NEW is strictly redundant after the established rule, but it makes the intent clear.

--dports 22,3306,10000 ... -j ADMIN sends SSH, MySQL and the Webmin port to the ADMIN chain instead of accepting them.

In ADMIN, each -s line accepts one trusted address or range: an office line, a VPN endpoint, a bastion host. The final -A ADMIN -j DROP drops anyone else. Strictly the INPUT policy would drop them anyway, but an explicit drop at the end of a restricted chain means it still works if someone later changes the policy.

The LOG line logs what's about to be dropped by the policy, rate-limited so a scan doesn't fill the disk. The messages land in the kernel log (journalctl -k or /var/log/kern.log). Turn it off once you're happy.

Loading it safely

Never load a new ruleset on a remote server without a way back. iptables-apply loads a file and rolls it back if you don't confirm within a timeout:

iptables-apply -t 60 /etc/iptables/rules.v4

If you lose the session, wait a minute and it's undone. Once it's right, make it load at boot. On Debian and Ubuntu:

apt-get install iptables-persistent
netfilter-persistent save

That writes the running rules to /etc/iptables/rules.v4 and rules.v6 and loads them at boot. Older servers did the same job with an iptables-restore < /etc/iptables.up.rules line in /etc/network/interfaces.

IPv6 is a separate ruleset. If the server has an IPv6 address and you only write the v4 rules, everything is open over v6. The same structure works for ip6tables, with IPv6 addresses in the admin chain and ipv6-icmp in place of the ICMP lines; also allow neighbour-solicitation and neighbour-advertisement, or the server can't find its own gateway.

Reading a dump someone else wrote

iptables-save output from a live server has a few extra things in it.

Counters on the chain lines (:INPUT ACCEPT [52310:48871204]) show how many packets and bytes have reached that point since the counters were last reset. A user chain sitting on [0:0] on a busy server has never matched anything, which is worth asking about.

Several tables appear: nat, mangle, raw, security. On a plain web server they're normally empty apart from their chain definitions. Anything in nat on a box that isn't a router or a Docker host deserves a look.

firewalld writes its own structure: chains such as INPUT_ZONES, IN_public, IN_public_allow, with the actual open ports sitting in the _allow chains. Its rules use -g (goto) as well as -j (jump). A goto doesn't come back to the calling chain when it reaches the end; the packet falls to the policy or the rule after the original jump further up. Don't hand-edit these; change them with firewall-cmd and read them here.

fail2ban adds rules that match an ipset and reject, something like:

-A INPUT -p tcp -m multiport --dports 22 -m set --match-set f2b-sshd src -j REJECT --reject-with icmp-port-unreachable

These need to sit above your accept rules to have any effect; fail2ban inserts them at the top for that reason. If you reload your own ruleset from a file, fail2ban's rules go with it until fail2ban is restarted.

A final -j REJECT --reject-with icmp-host-prohibited at the end of INPUT, as on Red Hat systems, works much like a drop policy, but the sender is told straight away that it was refused rather than left to time out. Both are fine; drop gives a scanner less to go on.

nftables

On Debian 10 the iptables command is a front end to nftables (iptables-nft), and everything above still works with it. If you're starting from scratch, iptables-restore-translate -f rules.v4 will turn a file like this into native nft syntax.