Skip to content
Menu

Networking9 min read

Firewalls

Packet filtering — stateless, stateful, and Linux iptables/nftables

Stateless Firewalls

A stateless firewall evaluates each packet independently against a static rule set. It has no memory of previous packets — every packet is matched against the ACL from scratch.

What a Stateless Rule Inspects

FieldExampleNotes
Source IP10.0.0.0/8Who sent this packet
Destination IP192.168.1.10Where it's going
ProtocolTCP, UDP, ICMPL4 protocol number
Source Port1024-65535Ephemeral port range
Destination Port443Service port (HTTPS)
Source IP Checked
Dest IP Checked
Protocol Checked
Src Port Checked
Dst Port Checked
Payload NOT inspected

Warning

The return-traffic problem: Stateless firewalls must have explicit rules for return traffic. If you allow outbound TCP to port 443, you must also allow inbound traffic from port 443 with ephemeral source ports. This makes rule sets complex and error-prone — which is exactly why stateful firewalls exist.

Note

Where you still see stateless filtering: AWS Security Groups are stateful, but NACLs (Network ACLs) are stateless — you must write both inbound and outbound rules. Cloud-native engineers hit this distinction constantly.

Stateful Firewalls

A stateful firewall tracks connection state. When an outbound connection is allowed, return traffic is automatically permitted because the firewall remembers the connection.

Stateless
  • Each packet evaluated independently
  • Must write rules for both directions
  • No connection memory
  • Fast but dumb — just pattern matching
  • Example: AWS NACLs, basic router ACLs
Stateful
  • Tracks connection state in conntrack table
  • Return traffic allowed automatically
  • Understands NEW, ESTABLISHED, RELATED
  • Simpler rule sets, fewer mistakes
  • Example: iptables, AWS Security Groups, pf
NEW
-->
ESTABLISHED
-->
RELATED
-->
INVALID

Connection tracking states — packets progress through these as the connection evolves

iptables Architecture — In Depth

iptables is the userspace tool that configures the Linux kernel's Netfilter framework. It organizes rules into tables, each containing chains. Understanding the table/chain architecture is essential for debugging packet flow on any Linux host — which includes every container, every Kubernetes node, and most load balancers.

Tables and Their Chains

TablePurposeBuilt-in Chains
filter Default table — accept/drop/reject packets INPUT, OUTPUT, FORWARD
nat Network Address Translation PREROUTING, OUTPUT, POSTROUTING
mangle Modify packet headers (TTL, TOS, MARK) PREROUTING, INPUT, FORWARD, OUTPUT, POSTROUTING
raw Skip connection tracking (NOTRACK) PREROUTING, OUTPUT

Packet Flow Through iptables Chains

This is the critical mental model. Every packet entering a Linux box follows this path:

  1. PREROUTING

    raw → mangle → nat
    (DNAT happens here)

  2. Routing Decision

    Local delivery? Or forward?

  3. INPUT

    mangle → filter
    (to local process)

  1. Routing Decision

    (packet is for another host)

  2. FORWARD

    mangle → filter
    (transit traffic)

  3. POSTROUTING

    mangle → nat
    (SNAT/MASQUERADE here)

  1. Local Process

    Generates outbound packet

  2. OUTPUT

    raw → mangle → nat → filter

  3. POSTROUTING

    mangle → nat
    (SNAT/MASQUERADE here)

Tip

K8s relevance: kube-proxy uses the nat table's PREROUTING and OUTPUT chains to implement ClusterIP services (DNAT to pod IPs). When kubectl get svc shows a ClusterIP, the actual packet redirection is iptables DNAT rules. This is why iptables -t nat -L is your first debugging tool for Service connectivity issues.

Rule Matching: Top-to-Bottom, First Match Wins

Rule Evaluation Logic

  1. Packet enters a chain (e.g., INPUT)
  2. Rules are evaluated sequentially from rule #1 to rule #N
  3. First rule that matches the packet determines the action (target)
  4. If no rule matches, the chain's default policy applies

Warning

Order matters enormously. If rule #3 is ACCEPT all from 10.0.0.0/8 and rule #7 is DROP from 10.0.0.5, the DROP rule will never fire — 10.0.0.5 already matched rule #3. Always put more specific rules before broader ones.

Targets (Actions)

TargetBehaviorUse Case
ACCEPT Allow the packet through Whitelisted traffic
DROP Silently discard — no response sent Default for untrusted traffic (attacker gets timeout)
REJECT Discard + send ICMP unreachable or TCP RST Internal networks (fail fast for legitimate clients)
LOG Log to kernel log, then continue to next rule Debugging — non-terminating target
SNAT Rewrite source IP (nat table only) Outbound NAT with static IP
DNAT Rewrite destination IP/port (nat table only) Port forwarding, load balancing
MASQUERADE SNAT that auto-detects outbound interface IP Outbound NAT with DHCP (dynamic IP)
REDIRECT DNAT to localhost (same host, different port) Transparent proxies, Istio sidecar interception

Note

Istio/Envoy connection: The REDIRECT target is how Istio's init container (istio-init) intercepts all pod traffic. It adds iptables rules that redirect inbound/outbound TCP to Envoy's ports (15001/15006). Run iptables -t nat -L inside an Istio pod to see it.

Example iptables Rules

console
# Set default policies — DROP everything by default
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT ACCEPT

# Allow loopback (always needed — many services talk to localhost)
iptables -A INPUT -i lo -j ACCEPT

# Allow established and related connections (stateful rule)
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Allow SSH from management subnet only
iptables -A INPUT -p tcp -s 10.0.1.0/24 --dport 22 -j ACCEPT

# Allow HTTP/HTTPS from anywhere
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# Allow ICMP (ping) — rate limited
iptables -A INPUT -p icmp --icmp-type echo-request -m limit --limit 1/s -j ACCEPT

# Log dropped packets (for debugging — watch with dmesg or journalctl)
iptables -A INPUT -j LOG --log-prefix "IPT-DROP: "

# Everything else hits the default DROP policy

Tip

The golden rule: The conntrack ESTABLISHED,RELATED rule should always be near the top of your INPUT chain. It's the most-hit rule (all return traffic matches it), so putting it first is both correct and a performance optimization.

nftables: The iptables Successor

nftables replaces iptables, ip6tables, arptables, and ebtables with a single unified framework. It's the default on modern distros (Debian 10+, RHEL 8+, Ubuntu 20.04+). Under the hood, both iptables and nftables use Netfilter — but nftables has a cleaner kernel interface (nf_tables) vs iptables' per-protocol modules.

Key Improvements Over iptables

  • Unified framework — one tool for IPv4, IPv6, ARP, bridge filtering
  • User-defined tables and chains — no fixed table names required
  • Sets and maps — efficient matching against large lists (IPs, ports) without per-rule overhead
  • Atomic rule replacement — load entire ruleset at once, no flicker
  • Better syntax — more readable, less flag soup
  • No built-in chains — you create exactly what you need

Syntax Comparison

iptables
console
# Allow SSH
iptables -A INPUT \
  -p tcp --dport 22 \
  -j ACCEPT

# DNAT port forward
iptables -t nat -A PREROUTING \
  -p tcp --dport 80 \
  -j DNAT \
  --to-destination 10.0.0.5:8080

# Drop a list of IPs (one rule each)
iptables -A INPUT -s 1.2.3.4 -j DROP
iptables -A INPUT -s 5.6.7.8 -j DROP
iptables -A INPUT -s 9.10.11.12 -j DROP
nftables
console
# Allow SSH
nft add rule inet filter input \
  tcp dport 22 accept

# DNAT port forward
nft add rule ip nat prerouting \
  tcp dport 80 \
  dnat to 10.0.0.5:8080

# Drop a list of IPs (single set)
nft add set inet filter blocklist \
  { type ipv4_addr\; }
nft add element inet filter blocklist \
  { 1.2.3.4, 5.6.7.8, 9.10.11.12 }
nft add rule inet filter input \
  ip saddr @blocklist drop

nftables Sets and Maps

Sets are nftables' killer feature for operational use. Instead of O(N) rule-by-rule matching, sets use hash tables or interval trees internally — O(1) or O(log N) lookups regardless of size.

console
# Define a named set of allowed ports
nft add set inet filter allowed_ports { type inet_service\; }
nft add element inet filter allowed_ports { 22, 80, 443, 8080 }

# Use the set in a rule
nft add rule inet filter input tcp dport @allowed_ports accept

# Maps: verdict maps (key → action)
nft add map inet filter port_policy { type inet_service : verdict\; }
nft add element inet filter port_policy { \
  22 : accept, \
  80 : accept, \
  3306 : drop \
}
nft add rule inet filter input tcp dport vmap @port_policy

Note

Performance at scale: If you're blocking 10,000 IPs, iptables evaluates up to 10,000 rules per packet. nftables with a set does a single hash lookup. This matters on high-throughput gateways and DDoS mitigation.

Default Policies: DROP vs ACCEPT

Default DROP (Recommended)

Principle of least privilege.

  • Everything is denied unless explicitly allowed
  • New services are blocked until you add a rule
  • Accidental exposure is impossible
  • You must explicitly allow: loopback, established connections, SSH, needed services
bash
iptables -P INPUT DROP
iptables -P FORWARD DROP
Default ACCEPT (Dangerous)

Open by default.

  • Everything allowed unless explicitly denied
  • New services are immediately exposed
  • Must maintain a deny-list (always incomplete)
  • One forgotten rule = security hole
console
iptables -P INPUT ACCEPT
# You're trusting that your deny
# rules are exhaustive. They aren't.

Warning

Production rule: Always -P INPUT DROP and -P FORWARD DROP. OUTPUT can be ACCEPT in most cases (you trust your own machine's outbound traffic, usually). If you're hardening a bastion host or a high-security environment, consider DROP on OUTPUT too.

Connection Tracking (conntrack)

Conntrack is the kernel subsystem that makes stateful firewalling possible. It tracks every connection (flow) through the host in a hash table, and both iptables and nftables query it to determine whether a packet belongs to a known connection.

Connection States

StateMeaningExample
NEW First packet of a connection (SYN for TCP, first UDP packet) Client sends SYN to port 443
ESTABLISHED Packets belonging to a connection that has seen traffic in both directions After the SYN-ACK exchange completes
RELATED New connection that is related to an existing one (via protocol helpers) ICMP "port unreachable" in response to a blocked UDP packet; FTP data connections
INVALID Packet doesn't match any known connection and isn't a valid start of one Malformed packets, ACK without SYN, corrupted state

Inspecting the Conntrack Table

console
console

    $ conntrack -L

    tcp   6 431985 ESTABLISHED src=10.0.0.5 dst=93.184.216.34 sport=44820 dport=443 src=93.184.216.34 dst=10.0.0.5 sport=443 dport=44820 [ASSURED] mark=0 use=1

    udp  17 29 src=10.0.0.5 dst=8.8.8.8 sport=53214 dport=53 src=8.8.8.8 dst=10.0.0.5 sport=53 dport=53214 mark=0 use=1


    $ conntrack -C    # Count entries

    2847


    $ sysctl net.netfilter.nf_conntrack_max

    net.netfilter.nf_conntrack_max = 262144
  

Why Conntrack Matters

Conntrack Powers Everything Stateful in Linux Networking

  • Stateful firewalling-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT is the single most important firewall rule
  • NAT — SNAT/DNAT/MASQUERADE all store their mappings in conntrack; return packets use conntrack to reverse the translation
  • Kubernetes Services — kube-proxy's iptables mode creates DNAT rules that rely on conntrack for return-path translation

Conntrack Table Exhaustion

Warning

A real production failure mode. The conntrack table has a finite size (nf_conntrack_max, default ~262144). Under heavy load or during a SYN flood, the table fills up. When it's full:
  • New connections are silently dropped
  • dmesg shows: nf_conntrack: table full, dropping packet
  • Symptoms: intermittent connection failures, timeouts, "connection refused" with no corresponding firewall rule

Diagnosing & Fixing Conntrack Exhaustion

console
console

      # Check current usage vs max

      $ conntrack -C

      261890  # Dangerously close to max!


      $ sysctl net.netfilter.nf_conntrack_max

      262144


      # Increase the limit (immediate)

      $ sysctl -w net.netfilter.nf_conntrack_max=524288


      # Persistent: add to /etc/sysctl.d/99-conntrack.conf

      net.netfilter.nf_conntrack_max = 524288

      net.netfilter.nf_conntrack_buckets = 131072


      # Reduce timeouts for faster cleanup

      $ sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=3600

      $ sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
    

Tip

K8s-specific: On a busy Kubernetes node running many Services, conntrack exhaustion is a well-known issue. Each Service connection (ClusterIP DNAT) creates a conntrack entry. Nodes with high pod density and many short-lived connections (like gRPC or HTTP/1.1 without keep-alive) are most vulnerable. Monitor node_nf_conntrack_entries in Prometheus and alert at 80% of max.

Solidnines — solidnines.com