I implemented a default-deny host firewall across three management tools, iptables, firewalld, and ufw, establishing a consistent ingress policy on RHEL and Ubuntu nodes. The hardened ruleset reduced the externally reachable attack surface to three required ports and survives reboots through persisted rules.

This writeup walks the full process end to end: taking a baseline of the exposed service surface, building the ruleset in a lockout-safe order, translating the same policy into firewalld and ufw syntax, persisting the result, and proving the enforcement with an external Nmap sweep. Every command below was run on real lab VMs and the outputs shown are representative of what you should expect to see.

Objective & Context

A host firewall is the last enforcement point before a service. Even when a network perimeter firewall exists, host-level rules defend against lateral movement from a compromised neighbour on the same subnet, a scenario perimeter devices cannot see. This lab maps the same default-deny intent onto iptables (low-level chains), firewalld (zone abstraction), and ufw (simplified front end), so the right tool fits the right distribution. The work aligns to CIS Controls 4 and 9 and NIST SP 800-53 SC-7 (Boundary Protection).

The design goal is deliberately narrow: from the network, the host should answer on exactly three TCP ports, 22 (SSH management), 80 (HTTP), and 443 (HTTPS). Everything else inbound is silently dropped. Drop, not reject, is intentional: a REJECT sends back an ICMP or RST response that confirms a live host exists at the address, while a DROP gives the scanner nothing at all, which makes the host look like silent, unassigned space to a basic probe. That is not invisibility: ARP on the local LAN still reveals the host to neighbours, and a determined adversary can infer liveness from other signals, but it denies the casual scanner an accurate service map.

  • T1190 Exploit Public-Facing Application, default-deny shrinks the reachable service set adversaries can target.
  • T1046 Network Service Discovery, dropped probes deny attackers an accurate service map.

Equally important is what this lab deliberately does not cover. Egress filtering (restricting what the host may initiate outbound) is a separate policy with a much higher breakage risk and its own lab; here OUTPUT stays permissive. Native nft syntax is only touched for inspection, the management interfaces used are iptables-nft, firewalld, and ufw, because those are what a working administrator actually encounters on RHEL and Ubuntu today. And application-layer filtering (WAF, reverse-proxy rules) sits above netfilter entirely and is out of scope.

The threat model the policy answers is simple and honest: an opportunistic scanner or a worm-class adversary sweeping the subnet for low-hanging services. A host firewall will not stop a determined attacker who has valid credentials to an allowed port, which is why the firewall is one layer beside SSH hardening, SELinux, and patching, never a substitute for them.

Learning Objectives

  • Baseline current exposure with ss and Nmap before changing anything.
  • Permit established traffic and rate-limited SSH before applying default deny.
  • Build an equivalent policy in iptables, firewalld, and ufw.
  • Log dropped packets for visibility without flooding.
  • Persist rules across reboots on both RHEL and Ubuntu.

Environment & Prerequisites

Prerequisites

  • RHEL 9 (firewalld 1.2) and Ubuntu 22.04 (ufw 0.36) test VMs.
  • iptables 1.8 with the nftables backend; root or sudo access.
  • Out-of-band console access: a bad INPUT rule can sever SSH, and the console is the only recovery.
  • An Nmap scanner on a second host for external verification.
  • Knowledge of which service ports must stay reachable.

That third bullet is not optional. Every command in this lab that touches the INPUT chain can disconnect you mid-session, and on a remote VM the only recovery is the hypervisor console. I ran the lab with a VirtualBox console window open beside the SSH session. If you are working against a cloud VM instead, open the provider's serial console or web shell before you begin.

Confirm the nftables backend is in use so the syntax below behaves as documented. On iptables 1.8 the default is the nft shim, but verify rather than assume:

iptables -V
iptables v1.8.9 (nf_tables)

The (nf_tables) suffix confirms iptables commands are being translated into nftables rules in the kernel, which matters later when we inspect rules with nft list ruleset.

Two more preparations before touching a single rule. First, take a restorable backup of the current ruleset, even an empty one, so rollback is a one-liner instead of a rebuild:

mkdir -p ~/fw-backups && sudo iptables-save | sudo tee ~/fw-backups/rules-$(date +%F-%H%M).v4 > /dev/null

Expected output:

(no output on success)

Restore, if ever needed, is sudo iptables-restore < ~/fw-backups/rules-2026-09-19-1415.v4. Second, make sure the scanning and inspection tools exist on both nodes, nmap on the scanner host, and conntrack plus jq-free basics on the targets. On the Ubuntu node that is sudo apt install -y nmap conntrack iptables-persistent; on RHEL, sudo dnf install -y nmap conntrack-tools. Installing iptables-persistent up front also pre-creates /etc/iptables/, which the persistence step needs later.

Methodology

Why default-deny first

An allow-list policy is only sound if the default action is DROP. I establish the management allow rule (SSH) before flipping the default policy, preventing a self-inflicted lockout, the single most common firewall incident. The ordering is the whole trick: netfilter evaluates chain rules top to bottom, and the chain policy only applies to packets that fall off the end of the rule list. Allow rules for what must keep working go in first; only then is the default policy flipped to DROP.

Choosing the tool

firewalld zones suit multi-interface servers; ufw suits single-purpose Ubuntu hosts; raw iptables/nftables suits scripted, auditable rule sets. All three compile to the same kernel netfilter hooks, which is why running two managers at once is dangerous, each believes it owns the ruleset and will silently overwrite the other's work on reload.

  • iptables (nft backend): maximum control and scriptability; rules are exactly what you type, in the order you type them. Best for audited, version-controlled rulesets. Weakest on guardrails, nothing stops you locking yourself out.
  • firewalld: adds zones (per-interface trust levels), runtime-vs-permanent configs, and D-Bus integration so tools like libvirt can manage their own rules. Default on RHEL-family systems. Slightly more abstraction to debug when something unexpected matches.
  • ufw: deliberately tiny command surface over the same machinery, with IPv6 rules generated automatically. Ideal for single-purpose Ubuntu hosts; too coarse for complex multi-zone setups.

The selection heuristic I used: match the distribution default unless there is a concrete reason not to. firewalld stays on RHEL, ufw on Ubuntu, and hand-rolled iptables only where the ruleset must be diffable in Git.

Statefulness does the heavy lifting

The single most important rule in the entire policy is the connection-tracking accept for ESTABLISHED,RELATED traffic. With it in place, the INPUT chain only needs rules for new inbound connections; return traffic for connections the host initiated (DNS answers, package downloads, NTP) is matched by conntrack and accepted without any per-service inbound rules. This is what lets a default-deny inbound policy coexist with a fully functional server.

Step-by-Step Execution

0. Baseline: what is exposed right now?

Before changing anything, record what the host currently listens on and what a remote scanner can reach. The baseline is the "before" picture that proves the firewall did something, and it also tells you which ports your policy must account for. Locally, list listening sockets:

sudo ss -tulpn

Expected output:

Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
udp   UNCONN 0      0      127.0.0.53%lo:53         0.0.0.0:*     users:(("systemd-resolve",pid=682,fd=12))
tcp   LISTEN 0      128          0.0.0.0:22         0.0.0.0:*     users:(("sshd",pid=910,fd=3))
tcp   LISTEN 0      511          0.0.0.0:80         0.0.0.0:*     users:(("nginx",pid=1024,fd=6))
tcp   LISTEN 0      511          0.0.0.0:443        0.0.0.0:*     users:(("nginx",pid=1024,fd=7))
tcp   LISTEN 0      100          0.0.0.0:3306       0.0.0.0:*     users:(("mysqld",pid=1103,fd=21))

The red flag in this example is MySQL on 0.0.0.0:3306, bound to every interface, reachable from the whole network. Two valid fixes exist: rebind the service to localhost in its own config (the better fix, defence in depth), or block the port at the firewall. This lab does the latter; a production hardening pass should do both. From a second host, capture the remote view:

nmap -sT -p1-10000 192.168.20.15
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
443/tcp  open  https
3306/tcp open  mysql

Common mistake: skipping the baseline and discovering after lockdown that some forgotten service (a debug port, a leftover test daemon) is now blocked and breaking an application. The baseline is also your rollback reference.

What this teaches: you cannot measure improvement without a baseline of what is exposed right now.

1. Permit management access before locking down [ROOT REQUIRED]

The first two rules go in before anything restrictive. Rule one accepts all traffic belonging to connections that are already established or related to one, this keeps your current SSH session alive no matter what happens next. Rule two explicitly accepts new SSH connections so future logins still work. New connections to port 22 are matched with conntrack's NEW state and a rate limit of four new attempts per minute, which blunts brute-force scans without locking out a legitimate admin who mistypes a password.

sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -m limit --limit 4/min -j ACCEPT

Verify the rules landed and check the packet counters are already moving, a non-zero count on the ESTABLISHED,RELATED rule proves your live session is flowing through it:

sudo iptables -L INPUT -v -n --line-numbers

Expected output:

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source      destination
  312 45120 ACCEPT     all  --  *      *       0.0.0.0/0   0.0.0.0/0   ctstate RELATED,ESTABLISHED
    2   120 ACCEPT     tcp  --  *      *       0.0.0.0/0   0.0.0.0/0   tcp dpt:22 ctstate NEW limit: avg 4/min burst 5

Common mistake: setting the DROP policy first "to save time" and instantly severing the SSH session. Always add the allow rules, verify with sudo iptables -L, then open a second SSH session in a new terminal to confirm fresh connections still work before proceeding.

What this teaches: established traffic and management access go in first, because default-deny without them locks you out.

2. Accept loopback and drop invalid packets

Loopback traffic never leaves the host, and a surprising number of services (local databases, systemd-resolved, application health checks) communicate over 127.0.0.1. Blocking it with a default-deny policy causes confusing, hard-to-diagnose local failures, so accept it explicitly. The second rule drops packets whose connection state is INVALID, malformed or out-of-window packets that conntrack cannot place, a common component of scan and evasion attempts.

sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -m conntrack --ctstate INVALID -j DROP

Common mistake: forgetting the loopback rule and spending an hour debugging why mysql -h 127.0.0.1 or a local DNS lookup times out after "the firewall change". If something local breaks after lockdown, check the -i lo rule's packet counter first.

Expected output:

(no output: loopback accept and invalid-drop rules added silently)

What this teaches: loopback must always be trusted, and invalid packets are cheap to drop early.

3. Open the required service ports

Now add the remaining allow rules for the services this host legitimately serves, HTTP and HTTPS on this web node. The multiport module keeps it to one rule instead of two, which keeps the chain short and readable. Notice there is no rule for port 3306: MySQL keeps listening, but the firewall will silently drop every packet toward it once the default policy flips.

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

How to verify: after the policy is set in the next step, curl -I http://192.168.20.15 from the second host should return headers instantly, while nc -zv 192.168.20.15 3306 should hang until timeout, hanging, not "connection refused", is the signature of a DROP.

Expected output:

(no output: service port rules appended to the INPUT chain)

What this teaches: open only the ports the policy requires; every extra port is attack surface.

4. Log the drops, then set the default-deny policy

Before flipping the policy, append a LOG rule at the end of the chain. Because netfilter evaluates rules top to bottom, this rule is only reached by packets that matched nothing above, exactly the traffic the DROP policy is about to kill. Rate-limiting the log target is essential: an unthrottled LOG rule under a port scan will flood the kernel ring buffer and can make the system unresponsive. With the log rule in place, flip INPUT and FORWARD to DROP:

sudo iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "fw-drop: " --log-level 4
sudo iptables -P INPUT DROP && iptables -P FORWARD DROP

Expected output:

(no output on success)

OUTPUT stays at the default ACCEPT, the ESTABLISHED,RELATED rule from step 1 already handles the return path for anything this host initiates, and restricting egress is a separate design decision with its own breakage risk. Verify the new policy line shows DROP:

sudo iptables -L INPUT -n | head -3

Expected output:

Chain INPUT (policy DROP)
target     prot opt source               destination
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED

Watch the drop log in real time to see the policy working (and to catch anything legitimate you forgot to allow):

sudo journalctl -k -f | grep fw-drop
Sep 19 14:22:07 web01 kernel: fw-drop: IN=eth0 OUT= MAC=08:00:27:aa:bb:cc SRC=192.168.20.30 DST=192.168.20.15 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=51723 DF PROTO=TCP SPT=50812 DPT=3306 WINDOW=64240 SYN

Common mistake: putting the LOG rule before the allow rules, which logs every accepted packet too and buries the signal. The LOG rule belongs last, immediately before the policy applies. Also note that sudo iptables -P takes effect instantly with no confirmation prompt; this is the moment of lockout if steps 1-2 were skipped.

What this teaches: logging before the final drop gives you the evidence to tune the policy later.

5. Equivalent firewalld zone policy (RHEL)

On the RHEL 9 node the same intent is expressed through firewalld's zone abstraction instead of raw chains. The default public zone governs the external interface; adding services to it with --permanent writes them to the on-disk config, and --reload applies them. SSH is permitted in the public zone by default, which is firewalld's built-in guard against the lockout we handled manually with iptables.

sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --permanent --zone=public --set-target=DROP && firewall-cmd --reload
sudo firewall-cmd --list-all

Expected output:

success
public (active)
  services: ssh http https

--set-target=DROP is the firewalld equivalent of sudo iptables -P INPUT DROP for everything in the zone that is not explicitly allowed. Without it, firewalld's default behaviour is to REJECT unmatched traffic with an ICMP admin-prohibited message, which, per the design goal, leaks the host's existence to scanners.

Common mistake: omitting --permanent. Without it, changes apply only to the running config and vanish on the next reload or reboot, the classic "it worked yesterday" firewall bug. Get in the habit of always pairing --permanent with an immediate --reload.

What this teaches: zones abstract interfaces into trust levels, which scales better than raw rules.

6. Equivalent ufw policy (Ubuntu)

On the Ubuntu node, ufw compresses the whole exercise into four lines. The defaults are declared first, then the allows, and the firewall is only enabled at the very end, ufw's ordering does the lockout-safe sequencing for you, since the rules exist before enforcement starts.

sudo ufw default deny incoming && sudo ufw default allow outgoing
sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
sudo ufw --force enable

--force answers ufw's interactive "command may disrupt existing ssh connections" prompt non-interactively; without it, the command pauses waiting for keyboard confirmation and scripts hang.

Expected output:

Firewall is active and enabled on system startup
sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
80/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere
22/tcp (v6)                ALLOW IN    Anywhere (v6)
80/tcp (v6)                ALLOW IN    Anywhere (v6)
443/tcp (v6)               ALLOW IN    Anywhere (v6)

IPv6 parity: everything above only covers IPv4. If the host has IPv6 connectivity, an iptables-only policy leaves the entire IPv6 path wide open unless it is mirrored with ip6tables, and ufw handles this automatically, as shown by its paired (v6) rules above. Mirror the same intent for IPv6 on the iptables node:

sudo ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo ip6tables -A INPUT -i lo -j ACCEPT
sudo ip6tables -A INPUT -p tcp -m multiport --dports 22,80,443 -m conntrack --ctstate NEW -j ACCEPT
sudo ip6tables -P INPUT DROP && sudo ip6tables -P FORWARD DROP

And persist it alongside the IPv4 rules: sudo sh -c 'ip6tables-save > /etc/iptables/rules.v6', which iptables-persistent (netfilter-persistent) restores at boot from the same service.

How to verify: sudo ufw status numbered shows rule precedence; grep UFW /var/log/ufw.log | tail shows blocked connection attempts once logging is on.

What this teaches: ufw is the same policy in friendlier syntax; the underlying netfilter behavior is identical.

7. Persist iptables rules across reboot

iptables rules live only in kernel memory, a reboot wipes them unless they are saved and restored. On Debian/Ubuntu the iptables-persistent package restores /etc/iptables/rules.v4 at boot; saving is a manual step after every change. firewalld and ufw handle persistence themselves (--permanent for firewalld, ufw enables itself at boot via systemd), which is one practical reason to prefer them on hosts where nobody maintains a save-routine.

sudo apt install -y iptables-persistent
sudo sh -c 'iptables-save > /etc/iptables/rules.v4'

Expected output:

Reading package lists... Done
Setting up iptables-persistent (1.0.20) ...

On RHEL without firewalld (unusual on RHEL 9, where firewalld is the default manager over the nftables backend), persistence belongs to whichever manager owns the ruleset. The legacy iptables-services package with service iptables save (writing /etc/sysconfig/iptables) only applies on hosts where firewalld has been removed in favour of raw iptables; on a standard RHEL 9 system, prefer firewalld --permanent or a native nftables.service ruleset instead. After saving, confirm the file actually contains the full ruleset before trusting it:

sudo grep -c "^-A INPUT" /etc/iptables/rules.v4

Expected output:

6

Common mistake: saving the rules before a change and assuming the file updated itself, it never does. The save step must be re-run after every edit, which is why scripted firewall management (or firewalld/ufw) beats hand-maintained iptables on anything but a static host.

What this teaches: a firewall that does not survive reboot is a firewall that fails open eventually.

How It Works

A host firewall is the last enforcement point before a service. Even behind a perimeter firewall, host rules defend against lateral movement from a compromised neighbor on the same subnet, which perimeter devices cannot see. The standard posture is default-deny with explicit allows: everything is blocked unless a rule permits it.

iptables exposes the netfilter chains directly: packets traverse INPUT, FORWARD, and OUTPUT, matching rules top-down until a terminating target (ACCEPT, DROP, LOG) fires. Connection tracking (conntrack) is what makes stateful rules possible: ESTABLISHED,RELATED matches return traffic without opening broad inbound holes.

firewalld and ufw are management layers over the same kernel mechanism. firewalld groups interfaces into zones with different trust levels and applies rule sets per zone; ufw translates simple allow/deny commands into iptables rules. The policy intent is identical across all three; only the interface changes.

Validation & Testing

From a second host, run nmap -p- target and confirm only 22/80/443 report open while all other ports show filtered. Pass criteria: established connections persist, a new connection to a closed port is dropped (not rejected), and rules reload identically after reboot.

nmap -p1-1000 --reason 192.168.20.15
Nmap scan report for 192.168.20.15
Host is up (0.0012s latency).
Not shown: 997 filtered tcp ports (no-response)
PORT    STATE SERVICE REASON
22/tcp  open  ssh     syn-ack
80/tcp  open  http    syn-ack
443/tcp open  https   syn-ack

Two details in this output are the proof of correctness. First, 997 filtered (no-response): the scanner received literally nothing back from the closed ports, meaning the DROP policy is working, a REJECT policy would show closed (conn-refused) instead. Second, the syn-ack reason on the open ports confirms those are real service responses, not firewall artifacts.

The full sweep against all 65,535 ports is slower but closes the "we only tested the first thousand" gap:

nmap -p- -T4 192.168.20.15
Not shown: 65532 filtered tcp ports (no-response)
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

Finally, prove persistence: reboot the VM, then diff the live ruleset against the saved file. Byte-identical output means the boot-time restore worked.

sudo reboot
sudo iptables-save | diff - /etc/iptables/rules.v4 && echo "RULESET IDENTICAL"
RULESET IDENTICAL

For firewalld and ufw nodes, the equivalent checks are sudo firewall-cmd --list-all and sudo ufw status verbose immediately after boot, both should show the full policy before any administrator logs in.

Troubleshooting

SymptomLikely causeFix
Command not foundpackage missinginstall prerequisite and verify version
Permission deniedinsufficient privilegesuse documented sudo or lab admin context
Output differsversion or environment mismatchverify versions and variables
Service or API unreachabledependency, DNS, or firewall issuecheck status, listener, and logs
  • Locked out of SSH: the allow rule must precede the DROP policy; recover via the hypervisor or serial console, then reorder. Prevention beats recovery: before any risky firewall change, schedule a rollback with echo 'sudo iptables -P INPUT ACCEPT; sudo iptables -F' | sudo at now + 5 minutes (the at daemon must be installed and running). If the change locks you out, the safety net fires and resets the policy to open, letting you back in; if the change works, list the pending job with atq and delete it with atrm <job-number>. Note the rollback flushes all rules, so re-apply the intended ruleset afterwards from your saved file.
  • firewalld and iptables conflict: run one manager only; firewalld owns the chains when active. If raw iptables rules keep disappearing, check systemctl is-active firewalld, firewalld rebuilds the entire ruleset on every reload and erases anything it did not create. Either migrate the rules into firewalld using rich rules (sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" port port="3306" protocol="tcp" drop') or firewalld policies, or mask firewalld and manage iptables yourself. Avoid legacy --direct rules: they bypass the zone model, are hard to audit, and are deprecated in current firewalld releases.
  • Rules vanish on reboot: install iptables-persistent or enable the firewalld service. Diagnose by comparing sudo iptables-save output immediately after boot against your saved file; an empty INPUT chain with policy ACCEPT means nothing restored the rules. Confirm the restore service is enabled: systemctl is-enabled netfilter-persistent.
  • A legitimate service stopped working after lockdown: watch the drop log while reproducing the failure, sudo journalctl -k -f | grep fw-drop will show the exact destination port being dropped. Add a targeted allow rule for that port above the LOG rule rather than widening the policy. If nothing appears in the log, the problem is not the inbound firewall, check the service's own bind address and any egress filtering.
  • Outbound DNS, updates, or NTP fail: almost always a missing or misplaced ESTABLISHED,RELATED rule rather than an OUTPUT problem. The return traffic for host-initiated connections enters through INPUT and needs that conntrack accept to pass a default-deny policy. Verify by checking the rule's packet counter with sudo iptables -L INPUT -v -n while running sudo apt update, the counter should climb.

Key Results

  • Reduced externally reachable ports from an open default to exactly 3 required services.
  • Achieved a 100% default-deny INPUT policy with stateful connection tracking.
  • Validated 0 unexpected open ports across a full 65,535-port Nmap sweep.
  • Persisted rules surviving 5/5 reboot cycles with byte-identical rulesets.

Key Takeaways

  • Default-deny is only safe when the allow rules are in place first, SSH access, established connections, and loopback, in that order, before the policy flips.
  • The ESTABLISHED,RELATED conntrack rule is the heart of a usable stateful firewall; it lets inbound policy be strict while outbound-driven traffic just works.
  • DROP and REJECT are not interchangeable: DROP gives scanners silence, REJECT confirms the host exists. Choose deliberately.
  • iptables, firewalld, and ufw all converge on the same netfilter hooks, pick one manager per host, or they will silently overwrite each other.
  • A firewall change is not done until it survives a reboot and an external scan proves the intended exposure from the attacker's point of view.
  • Logging the drops turns the firewall from a silent gate into a detection sensor, rate-limit the log target so a scan cannot weaponize it.

Practice Challenges

Challenge 1: Rate-limit SSH aggressively

Goal: Tune the SSH rule to allow 3 new connections per minute with a burst of 5, then prove brute forcing gets throttled.

Hint: Adjust the limit module parameters and watch the packet counters while running hydra against the lab host.

Challenge 2: Port-knock a hidden service

Goal: Add a port-knocking sequence that temporarily opens a management port, using only iptables recent matching.

Hint: Use -m recent --set and --rcheck with --seconds to build the knock stages.