Skip to content

Firewall Rules and iptables

A firewall enforces a policy on network traffic - deciding what packets are allowed through and what’s dropped. This note covers the theory of packet filtering, the Linux iptables/nftables tool suite, and practical rule writing for both host and network firewall scenarios.


A packet filter inspects each packet’s headers (IP source/destination, protocol, port, TCP flags) and matches them against an ordered list of rules. The first matching rule wins.

Packet arrives
Rule 1: src=10.0.0.0/24, dst=any, dport=22 → ACCEPT (matched → execute)
Rule 2: src=any, dst=any, dport=22 → DROP
Rule 3: src=any, dst=any, proto=icmp → ACCEPT
...
Default policy: → DROP (implicit deny)
TypeHow it worksLimitation
StatelessMatches each packet individually on header fields onlyCan’t distinguish request from response; must explicitly allow return traffic
StatefulTracks connection state (NEW, ESTABLISHED, RELATED, INVALID); allows return traffic automaticallyRequires more memory; state table can be exhausted (DoS)
Stateful rule for HTTP:
ALLOW outbound TCP to port 80 (NEW connection)
ALLOW inbound TCP from port 80 that is ESTABLISHED,RELATED
(return traffic automatically allowed without a separate inbound rule)

Modern firewalls are always stateful. Use conntrack module in iptables.


iptables organises rules into tableschainsrules:

Tables:
filter → packet accept/drop decisions (default; most common)
nat → source/destination address translation
mangle → packet header modification (TTL, QoS marks)
raw → bypass conntrack (specialised)
Chains (filter table):
INPUT → packets destined for THIS host
FORWARD → packets passing THROUGH this host (routing/NAT gateway)
OUTPUT → packets generated BY this host
Packet flow:
Incoming: PREROUTING (nat) → INPUT (filter) → local process
Forwarded: PREROUTING (nat) → FORWARD (filter) → POSTROUTING (nat) → egress
Outgoing: local process → OUTPUT (filter) → POSTROUTING (nat)
Terminal window
iptables -A INPUT -s 192.168.1.0/24 -p tcp --dport 22 -j ACCEPT
tool chain source IP protocol dest port target
# Targets:
# ACCEPT - allow the packet
# DROP - silently discard the packet
# REJECT - discard and send ICMP/TCP RST to sender
# LOG - log the packet (continue processing - not a terminating target)
# RETURN - return to calling chain

#!/bin/bash
# Host firewall hardening script
# Run as root
# 1. Flush existing rules
iptables -F
iptables -X
iptables -Z
# 2. Default policies - drop everything inbound, allow outbound
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT
# 3. Always allow loopback
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# 4. Allow established/related return traffic (stateful)
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# 5. Drop INVALID packets (malformed or unexpected state)
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
# 6. Allow ICMP ping (optional - remove for stealth)
iptables -A INPUT -p icmp --icmp-type echo-request -m limit --limit 5/sec -j ACCEPT
# 7. Allow SSH only from management network
iptables -A INPUT -s 10.0.0.0/24 -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
# 8. Allow HTTPS from anywhere
iptables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW -j ACCEPT
# 9. Log and drop everything else
iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "DROPPED: " --log-level 4
iptables -A INPUT -j DROP
# 10. Persist rules
iptables-save > /etc/iptables/rules.v4
Terminal window
# Load rules on boot (Debian/Ubuntu)
apt install iptables-persistent
netfilter-persistent save
netfilter-persistent reload
# Load rules on boot (systemd approach)
cat << 'EOF' > /etc/systemd/system/iptables-restore.service
[Unit]
Description=Restore iptables rules
Before=network.target
[Service]
ExecStart=/sbin/iptables-restore /etc/iptables/rules.v4
Type=oneshot
[Install]
WantedBy=multi-user.target
EOF
systemctl enable --now iptables-restore
Terminal window
# --- Block a specific IP ---
iptables -I INPUT -s 203.0.113.50 -j DROP
# --- Rate limit SSH (brute force protection) ---
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
-m recent --set --name SSH_BRUTE
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW \
-m recent --update --seconds 60 --hitcount 5 --name SSH_BRUTE -j DROP
# Allows max 5 new SSH connections per 60 seconds per source IP
# --- Block outbound to suspicious IP range ---
iptables -A OUTPUT -d 185.220.0.0/16 -j DROP
# --- Allow specific port range ---
iptables -A INPUT -p tcp --dport 8000:8080 -j ACCEPT
# --- Allow multiple ports with multiport ---
iptables -A INPUT -p tcp -m multiport --dports 80,443,8080 -j ACCEPT
# --- Block a country's IP range (using ipset for efficiency) ---
apt install ipset
ipset create russia hash:net
ipset add russia 5.8.0.0/16 # add each CIDR block
# ... (or load from geolocation database)
iptables -A INPUT -m set --match-set russia src -j DROP
# --- FORWARD rules for a NAT/router host ---
# Allow forwarding from internal to external
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
# Allow return traffic
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
Terminal window
# --- Masquerade (dynamic NAT - Linux acting as a router) ---
# All traffic from 192.168.1.0/24 going out eth0 appears to come from eth0's IP
iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o eth0 -j MASQUERADE
# Enable IP forwarding (required for NAT/routing)
echo 1 > /proc/sys/net/ipv4/ip_forward
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
# --- Port forwarding (DNAT - forward external port to internal host) ---
# Forward external port 2222 on this host to 192.168.1.100:22
iptables -t nat -A PREROUTING -p tcp --dport 2222 -j DNAT --to-destination 192.168.1.100:22
# --- Static SNAT (force a specific source IP, e.g., for multi-IP servers) ---
iptables -t nat -A POSTROUTING -s 192.168.1.100 -o eth0 -j SNAT --to-source 203.0.113.5
Terminal window
# List all rules with line numbers and packet counts
iptables -L -v -n --line-numbers
iptables -t nat -L -v -n --line-numbers # NAT table
# Delete by line number (INPUT chain rule 3)
iptables -D INPUT 3
# Delete by matching the rule exactly
iptables -D INPUT -s 203.0.113.50 -j DROP
# Insert rule at position 1 (start of chain)
iptables -I INPUT 1 -s 10.0.0.0/8 -j ACCEPT
# Flush only one chain
iptables -F INPUT
# Reset entire ruleset and default policies
iptables -F && iptables -X && iptables -P INPUT ACCEPT && iptables -P FORWARD ACCEPT
# Show packet/byte counters (useful for verifying rules are being hit)
iptables -L -v -n | column -t
# Watch live rule hits
watch -n 1 "iptables -L INPUT -v -n --line-numbers"

nftables replaces iptables, ip6tables, arptables, and ebtables with a unified framework. Most distributions now default to nftables (Debian 10+, RHEL 8+).

Terminal window
# Check if nftables is the backend
iptables --version # shows "iptables v1.8.x (nf_tables)" if nftables backend
# Basic nftables ruleset equivalent to the iptables script above
nft delete table ip filter # clear previous
nft add table ip filter
nft add chain ip filter input { type filter hook input priority 0 \; policy drop \; }
nft add chain ip filter output { type filter hook output priority 0 \; policy accept \; }
# Loopback
nft add rule ip filter input iif lo accept
# Established/related
nft add rule ip filter input ct state established,related accept
nft add rule ip filter input ct state invalid drop
# SSH from management network
nft add rule ip filter input ip saddr 10.0.0.0/24 tcp dport 22 accept
# HTTPS
nft add rule ip filter input tcp dport 443 accept
# Log and drop
nft add rule ip filter input log prefix "DROPPED: "
nft add rule ip filter input drop
# Save
nft list ruleset > /etc/nftables.conf
systemctl enable --now nftables
Terminal window
# nftables syntax - view current ruleset
nft list ruleset
# Delete a specific rule (use handle)
nft list ruleset -a # shows handles
nft delete rule ip filter input handle 5
# Add to start (priority lower number = process first in nftables)
# Use separate chains with priority numbers instead of -I ordering

ufw - Uncomplicated Firewall (Ubuntu/Debian)

Section titled “ufw - Uncomplicated Firewall (Ubuntu/Debian)”

ufw is a friendlier wrapper around iptables, suitable for simpler host firewalls:

Terminal window
# Enable ufw (defaults: deny inbound, allow outbound)
ufw default deny incoming
ufw default allow outgoing
# Allow common services
ufw allow ssh # port 22
ufw allow 443/tcp # HTTPS
ufw allow from 10.0.0.0/24 to any port 22 # SSH from LAN only
# Rate limit SSH
ufw limit ssh # limits to 6 connections per 30 seconds
# Enable and check status
ufw enable
ufw status verbose
# Delete a rule
ufw delete allow 443/tcp
ufw delete 2 # delete rule #2 from numbered list
# Show with rule numbers
ufw status numbered

firewalld - Zone-Based Firewall (RHEL/Fedora)

Section titled “firewalld - Zone-Based Firewall (RHEL/Fedora)”

firewalld uses the concept of zones - pre-defined trust levels for network interfaces:

Terminal window
# View available zones
firewall-cmd --get-zones
# public trusted home work internal dmz drop block external
# Check active zones and interfaces
firewall-cmd --get-active-zones
# Allow a service (uses /etc/firewalld/services/ definitions)
firewall-cmd --zone=public --add-service=https --permanent
firewall-cmd --reload
# Allow a port
firewall-cmd --zone=public --add-port=8443/tcp --permanent
# Allow only from specific source
firewall-cmd --zone=internal --add-source=10.0.0.0/24 --permanent
firewall-cmd --zone=internal --add-service=ssh --permanent
# Remove a service from zone
firewall-cmd --zone=public --remove-service=dhcpv6-client --permanent
# View all rules in a zone
firewall-cmd --zone=public --list-all
# Create a rich rule (more granular control)
firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.50" reject' --permanent

Terminal window
# Check firewall profiles status
Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction
# Block inbound by default on all profiles
Set-NetFirewallProfile -Profile Domain,Public,Private -DefaultInboundAction Block
# Allow inbound SSH (OpenSSH)
New-NetFirewallRule -DisplayName "Allow SSH" -Direction Inbound `
-Protocol TCP -LocalPort 22 -Action Allow
# Allow inbound from specific IP only
New-NetFirewallRule -DisplayName "Allow SSH from LAN" -Direction Inbound `
-Protocol TCP -LocalPort 22 -RemoteAddress 10.0.0.0/24 -Action Allow
# View all inbound allow rules
Get-NetFirewallRule -Direction Inbound -Action Allow | Select-Object DisplayName, Enabled
# Delete a rule
Remove-NetFirewallRule -DisplayName "Allow SSH"
# List rules with port details
Get-NetFirewallRule | Get-NetFirewallPortFilter | Select-Object LocalPort, Protocol

1. Default deny (implicit deny)
→ Start with DROP/BLOCK all; add allow rules selectively
2. Least privilege
→ Allow exactly what's needed, from exactly whom, to exactly where
3. Stateful inspection always
→ Use conntrack; never write stateless rules for TCP services
4. Egress filtering matters too
→ Outbound filtering stops C2 callbacks, data exfiltration, lateral movement
→ Block outbound to known-bad IPs; restrict what protocols servers can initiate
5. Log before drop
→ LOG target before DROP gives visibility into what's being blocked
→ Rate-limit logging to avoid log flood (--limit 5/min)
6. Rules are evaluated in order
→ More specific rules before general ones
→ ALLOW before DROP for the same traffic
7. Segment with firewall, not just VLAN
→ VLANs separate L2 broadcast domains
→ Firewalls enforce L3/L4 access control between segments
8. Test after change
→ nmap from attacker perspective after applying rules
→ Verify blocked traffic is actually blocked; verify allowed traffic works
Terminal window
# Verify your firewall from the outside (test from a different machine)
# Test that SSH is blocked when it should be
nmap -p 22 -sS target-ip # should show "filtered" if dropped, "closed" if rejected
# Test connectivity to an allowed port
nmap -p 443 -sS target-ip # should show "open"
# Simulate an attack - check if suspicious ports are open
nmap -sV -p- target-ip # full port scan with service detection