Firewalls
Topic 51

Firewalls

FirewallNetfilter

A firewall on Linux is packet-filtering policy enforced inside the kernel by the netfilter framework. The rules decide which packets the kernel accepts, drops, or rejects, keyed on interface, source and destination address, port, protocol, and connection state. Everything you type — iptables, nft, ufw, firewall-cmd — is a userspace front end that compiles human-readable rules into netfilter's in-kernel tables.

A host firewall is default-deny inbound with a short list of explicit allows, and the ruleset has to survive a reboot. The operational consequence is sharp: one wrong default-policy line severs your own SSH session on a remote box with no console, and the difference between DROP and REJECT decides whether clients fail in milliseconds or hang for tens of seconds.

netfilter and the Packet Path

netfilter exposes five hooks along a packet's path through the kernel: PREROUTING right after a packet arrives, INPUT for packets destined to this host, FORWARD for packets routed through to somewhere else, OUTPUT for packets generated locally, and POSTROUTING just before a packet leaves. A packet bound for a local service traverses PREROUTING then INPUT; a packet this host routes for others takes PREROUTING, FORWARD, then POSTROUTING. Knowing which hook a packet hits tells you which chain your rule has to live in.

Rules are grouped into tables by purpose. The filter table accepts or drops; the nat table rewrites addresses and ports for masquerading and port forwarding; the mangle table alters packet fields such as the TTL. Within a hook, chains are walked in priority order, and the first rule that issues a terminal verdict wins. The chains and tables are the same concepts whether you drive them through the legacy iptables interface or through nftables — only the syntax differs.

nftables versus iptables

nftables is a single binary, nft, with one ruleset and atomic reloads: you load a whole ruleset transactionally, so a syntax error leaves the running rules intact instead of half-applying. It adds named sets, maps, and verdict maps, so "allow these 40 ports" becomes one set lookup rather than 40 separate rules. On Debian 10+ and Ubuntu 20.04+, the iptables command is the iptables-nft shim — it accepts the old syntax but writes nft rules into the kernel underneath.

# Confirm which backend the iptables command really uses
$ iptables -V
iptables v1.8.10 (nf_tables)

# A minimal stateful ruleset in native nft syntax
$ nft list ruleset
table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    iif "lo" accept
    ct state established,related accept
    tcp dport 22 accept
  }
}

The older iptables-legacy backend still ships and writes to a separate, independently evaluated ruleset. Mixing it with nft rules leaves two rulesets the kernel walks separately, which is a reliable source of "my rule is right there but has no effect" confusion. On a new host, pick nftables or the iptables-nft shim and stay on it.

ufw and firewalld

ufw (Uncomplicated Firewall) is the standard front end on Ubuntu, where it is installed but inactive by default, and a one-line install away on Debian. It compiles a short, readable rule language down to nft rules and persists them across reboots with no extra step. It targets a single host with a handful of services, not a fleet — there you want a configuration-management tool emitting nft rulesets directly.

# Allow SSH first, THEN set the default-deny policy
$ ufw allow 22/tcp
$ ufw default deny incoming
$ ufw default allow outgoing

# Rate-limit new SSH connections (6 hits in 30s from a source = block)
$ ufw limit ssh
$ ufw enable

ufw limit caps how fast a single source can open new connections, which blunts brute-force attempts without a separate tool. Red Hat, Fedora, and their derivatives ship firewalld instead, driven by the firewall-cmd client and organized into zones such as public and trusted; it also compiles to nftables, so the concepts carry over even though the commands differ.

Connection Tracking and Stateful Rules

netfilter's conntrack subsystem records every flow the host has seen and labels each packet with a state: new for the first packet of a flow, established for packets belonging to a flow already accepted, related for a connected secondary flow such as an ICMP error or an FTP data channel, and invalid for packets that match no known state. Stateful rules let you accept reply traffic for outbound connections without opening any inbound port for it.

The standard shape accepts established,related near the top of the input chain, then matches only new packets against your allow rules. Drop the established-accept rule and the return packets for your own outbound connections get dropped, and the host feels broken in ways unrelated to whatever service you were testing. The same conntrack table drives NAT: a masquerade rule in POSTROUTING rewrites the source address of outbound packets and uses conntrack to reverse the translation on the replies.

DROP, REJECT, and Lockout Safety

DROP silently discards a packet and sends nothing back, so the client waits out its TCP timeout — typically tens of seconds — before giving up. REJECT sends back an ICMP rejection or a TCP RST, so the client fails in milliseconds with "connection refused." Use REJECT for internal services where fast failure speeds debugging, and reserve DROP for the internet edge where you would rather not confirm the host exists at all.

Because a wrong rule severs your own session, apply risky changes with a built-in escape hatch. Queue an automatic rollback before you commit the change, keep a second SSH session open as a canary, and only persist the rules once a fresh connection still works.

# Self-healing change: revert in 1 minute unless you cancel the job
$ echo "ufw --force reset" | at now + 1 minute

# ...make your change, then test SSH in a SECOND session...
$ atq            # find the job id
$ atrm <job-id>   # cancel the rollback ONLY after the new session works
iptables vs nftables vs ufw/firewalld

iptables — the long-time front end whose syntax you still meet in old configs and shell scripts. Learn it to read and migrate legacy rulesets, but on a new host let it be the nft shim, not the legacy backend.

nftables — the modern engine with one unified syntax, atomic reloads, and named sets. Reach for it directly when the ruleset is large or generated by automation and you want set lookups instead of hundreds of linear rules.

ufw / firewalld — high-level wrappers for everyday single-host rules. Pick ufw on a Debian or Ubuntu server, firewalld on Red Hat; both compile to nftables and persist across reboots without extra work.

Common Mistakes
  • Setting iptables -P INPUT DROP or ufw default deny incoming before adding the SSH allow rule — the policy applies to your live connection and locks you out instantly.
  • Forgetting the loopback accept (iif "lo" accept / -i lo -j ACCEPT), which breaks local services that talk over 127.0.0.1 such as a database client or a metrics agent.
  • Omitting the established,related accept rule, so return traffic for outbound connections is dropped and the host appears broken everywhere at once.
  • Editing rules live on a remote host with no timed rollback and no second session — one typo and recovery needs console or IPMI access.
  • Mixing iptables-legacy rules with the default nft-backed iptables command, leaving two independent rulesets the kernel evaluates separately while one looks ignored.
  • Building a working ruleset and never persisting it with netfilter-persistent save or nft list ruleset > /etc/nftables.conf, so every rule vanishes on the next reboot.
  • Using DROP on internal services and then chasing the multi-second client timeouts that the black-holing itself caused.
Best Practices
  • Set default-deny inbound and default-allow outbound, then explicitly allow only the ports each service needs.
  • Add the loopback accept and the established,related accept rules first, ahead of any service-specific allow.
  • Allow SSH before tightening the default policy, and rate-limit new SSH connections with ufw limit ssh or an equivalent nft limit rule.
  • Apply risky changes behind a timed auto-rollback (at, a sleep-and-revert script, or a held-open second session) so a mistake self-heals.
  • Use nftables or the iptables-nft shim on new hosts and keep iptables-legacy rules out of the mix entirely.
  • Persist rules explicitly with netfilter-persistent save, or rely on ufw/firewalld, which persist by default, so the policy survives a reboot.
  • Prefer REJECT for internal services so clients fail fast, and reserve DROP for the internet edge where silence is the point.
Comparable toolsWindows Defender Firewall / netsh advfirewallBSD pf (Packet Filter), also on macOSCloud security groups — network firewall, not a host firewall

Knowledge Check

You SSH into a remote host and run ufw default deny incoming then ufw enable as your first two commands. What happens?

  • Your live SSH session is cut and you cannot reconnect — no allow rule for port 22 exists
  • Nothing changes until the next reboot, so the session is safe
  • ufw detects the active SSH session and auto-allows port 22
  • The deny policy applies only to new outbound connections you initiate, leaving inbound sessions like your SSH connection untouched

A host makes outbound connections fine, but every reply seems to vanish and applications hang. The input policy is drop. Which missing rule is the likely cause?

  • An accept rule for ct state established,related so reply traffic is allowed back in
  • An allow rule opening the entire 32768-60999 ephemeral source-port range the host might pick for outbound flows
  • A second default policy of accept on the OUTPUT chain
  • A masquerade rule in the POSTROUTING chain

For an internal service, you want blocked clients to fail instantly with "connection refused" rather than wait through a long timeout. Which verdict fits?

  • REJECT, because it sends back an ICMP rejection or TCP RST so the client fails in milliseconds
  • DROP, because silently discarding the packet returns control to the client immediately and lets its socket layer give up at once
  • ACCEPT paired with a zero-length response
  • LOG, which both blocks the packet and notifies the client

On Ubuntu 22.04 you add some rules with iptables-legacy and others with the default iptables command. The legacy rules appear to have no effect. Why?

  • The default iptables is the nft shim, so the two backends keep separate rulesets the kernel evaluates independently
  • The legacy backend needs a reboot before any of its rules take effect
  • iptables-legacy rules apply only to IPv6 traffic, so they never match the IPv4 packets your default command filters
  • Legacy rules always rank below any ufw rule in priority

You got correct