20 KiB
Security Concepts
This file covers the theory behind Linux firewalling and the specific problems
that fwrule is built to detect. Every concept here ties back to real code in
the project or to real incidents where someone got it wrong and paid for it.
Netfilter Architecture
Every Linux firewall you have heard of (iptables, nftables, firewalld, ufw) is a frontend for the same kernel framework: netfilter. It sits inside the Linux networking stack and provides five hook points where the kernel can inspect, modify, or drop packets as they move through the system.
Packet Flow
When a packet arrives at a Linux machine, it takes one of two paths depending
on the routing table. If the destination IP belongs to this machine, the packet
goes to INPUT. If the destination is somewhere else and IP forwarding is enabled
(net.ipv4.ip_forward = 1), it goes to FORWARD. A packet never hits both.
NETWORK
|
v
+------------------+
| PREROUTING | raw, mangle, nat (DNAT)
+------------------+
|
Routing Decision
/ \
v v
+----------+ +-----------+
| INPUT | | FORWARD |
| filter, | | filter, |
| mangle, | | mangle, |
| security | | security |
+----------+ +-----------+
| |
v v
Local Process +------------------+
| | POSTROUTING | mangle, nat (SNAT/MASQ)
v +------------------+
+----------+ |
| OUTPUT | v
| raw, | NETWORK
| mangle, |
| nat, |
| filter, |
| security |
+----------+
|
v
+------------------+
| POSTROUTING |
+------------------+
|
v
NETWORK
The Five Hooks
| Hook | When It Fires | Typical Use |
|---|---|---|
| PREROUTING | Packet just arrived, before routing decision | DNAT (port forwarding), connection tracking entry |
| INPUT | Packet destined for this machine | Filtering inbound traffic to local services |
| FORWARD | Packet passing through (this box is a router) | Filtering between network segments |
| OUTPUT | Packet originated from a local process | Filtering outbound traffic |
| POSTROUTING | Packet about to leave, after routing decision | SNAT, masquerading for NAT gateways |
Tables
Netfilter organizes rules into five tables, each with a specific job:
| Table | Purpose | Available Chains |
|---|---|---|
| filter | Accept/drop/reject decisions | INPUT, FORWARD, OUTPUT |
| nat | Network Address Translation | PREROUTING, OUTPUT, POSTROUTING |
| mangle | Packet header modification (TTL, TOS, marking) | All five |
| raw | Bypass connection tracking | PREROUTING, OUTPUT |
| security | SELinux/AppArmor labeling | INPUT, FORWARD, OUTPUT |
The fwrule tool models all five in its Table enum (src/models/models.v),
but the vast majority of real-world rulesets live in filter. That is where
accept/drop decisions happen, and where misconfigurations cause breaches.
The raw table deserves a note: rules here run before conntrack, so you can
mark high-volume traffic (like DNS on a busy resolver) with NOTRACK to skip
connection tracking entirely. This matters when the conntrack table fills up
on NAT gateways handling hundreds of thousands of concurrent connections. When
that happens, the kernel drops new connections and you see
nf_conntrack: table full, dropping packet in dmesg.
iptables vs nftables
iptables: The Legacy Tool
iptables has been the standard Linux firewall CLI since the 2.4 kernel (2001).
An iptables-save dump looks like this:
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -p tcp --dport 22 -j ACCEPT
-A INPUT -p tcp --dport 80 -j ACCEPT
-A INPUT -p tcp --dport 443 -j ACCEPT
-A INPUT -j LOG --log-prefix "DROPPED: "
-A INPUT -j DROP
COMMIT
The format: *filter declares the table, :INPUT DROP [0:0] sets the chain
policy and packet/byte counters, -A INPUT appends a rule, -j ACCEPT is
the jump target, COMMIT atomically applies the table.
Limitations worth knowing:
- Separate binaries for IPv4 (
iptables), IPv6 (ip6tables), ARP (arptables), and bridge filtering (ebtables) - Rules are a flat list of conditions with match extensions (
-m conntrack,-m limit,-m multiport) - Ruleset updates replace one table at a time, not the entire ruleset atomically
nftables: The Replacement
nftables replaced iptables starting with Linux 3.13 (2014). Debian 10+,
RHEL 8+, Fedora 18+, and Ubuntu 20.10+ all default to it. The same ruleset
in nftables syntax:
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept
ct state established,related accept
tcp dport 22 accept
tcp dport 80 accept
tcp dport 443 accept
log prefix "DROPPED: " drop
}
}
Key Differences
| Feature | iptables | nftables |
|---|---|---|
| IPv4/IPv6 | Separate binaries | Unified (inet family) |
| Syntax | Flag-based (-p tcp --dport 22) |
Expression-based (tcp dport 22) |
| Atomicity | Per-table | Entire ruleset in one transaction |
| Sets | No native support | Native sets and maps ({ 22, 80, 443 }) |
| Multiple actions | One target per rule | Chain multiple statements |
| Performance | Linear rule matching | Sets use hash lookups (O(1) vs O(n)) |
The set syntax is a concrete improvement. This nftables line:
tcp dport { 22, 80, 443 } accept
replaces three separate iptables rules. The kernel evaluates the set with a hash lookup instead of walking three rules sequentially.
Why Both Still Exist
nftables ships with a compatibility layer (iptables-nft) that translates
iptables commands into nftables rules behind the scenes. Many distributions
install this by default, so running iptables actually creates nftables rules
without the user knowing. This is why you can run iptables -L on a modern
system and see rules, then run nft list ruleset and see the same rules in
nftables format.
The fwrule export command handles conversion between formats, which is useful
during migration.
Rule Evaluation Order
First-Match-Wins
This is the single most important concept in firewall configuration: the first matching rule wins. The kernel walks through each rule in order, top to bottom. The moment a packet matches a rule with a terminating target (ACCEPT, DROP, REJECT), evaluation stops. The packet never sees the remaining rules.
Two rulesets with identical rules in different order can have completely different security properties:
Ordering A (secure): Ordering B (broken):
1. -s 10.0.0.5 -p tcp --dport 22 -j DROP 1. -p tcp --dport 22 -j ACCEPT
2. -p tcp --dport 22 -j ACCEPT 2. -s 10.0.0.5 -p tcp --dport 22 -j DROP
Ordering A blocks SSH from 10.0.0.5, then allows everyone else. Ordering B allows SSH from everywhere including 10.0.0.5. Rule 2 is dead code. Same rules, opposite security outcome.
Chain Policies
Every built-in chain has a default policy that fires when no rule matches:
:INPUT DROP [0:0] <-- default deny (fail-closed)
:INPUT ACCEPT [0:0] <-- default accept (fail-open)
Default deny means anything you forgot to allow is blocked. Default accept
means anything you forgot to block gets through. The fwrule harden command
always generates DROP on INPUT and FORWARD, ACCEPT on OUTPUT.
The Shadowing Problem
Shadowing is the most common firewall misconfiguration. It happens when a broad rule early in the chain silently prevents a more specific rule later from ever matching.
Walk through this numbered ruleset from testdata/iptables_conflicts.rules:
Rule 7: -A INPUT -p tcp --dport 22 -j ACCEPT
Rule 8: -A INPUT -s 10.0.0.0/8 -p tcp --dport 22 -j ACCEPT
Rule 9: -A INPUT -p tcp --dport 80 -j ACCEPT
Rule 10: -A INPUT -p tcp --dport 80 -j ACCEPT
Rule 11: -A INPUT -s 192.168.1.0/24 -p tcp --dport 443 -j ACCEPT
Rule 12: -A INPUT -s 192.168.0.0/16 -p tcp --dport 443 -j DROP
What happens:
-
Rule 8 is shadowed by Rule 7. Rule 7 accepts SSH from any source. Rule 8 accepts SSH only from 10.0.0.0/8. Since Rule 7 already accepted all SSH traffic, Rule 8 can never fire. The
find_shadowed_rulesfunction insrc/analyzer/conflict.vcatches this by checking whether Rule 7's match criteria is a superset of Rule 8's. -
Rules 9 and 10 are duplicates. Both accept TCP port 80 with no source restriction. Rule 10 is dead weight.
-
Rules 11 and 12 contradict. 192.168.1.0/24 is inside 192.168.0.0/16. Hosts in 192.168.1.0/24 match Rule 11 (ACCEPT) first. The rest of 192.168.0.0/16 hits Rule 12 (DROP). This might be intentional, but overlapping criteria with opposite actions always deserves review. The
find_contradictionsfunction flags it.
The tool performs this analysis by running pairwise comparison across every
rule in each chain. For each pair (i, j) where i < j, it calls
match_is_superset(rules[i].criteria, rules[j].criteria). That function
checks protocol, source address, destination address, ports, interfaces,
and conntrack states. If every field of the earlier rule encompasses the
later rule, the later rule is shadowed.
Connection Tracking (conntrack)
Stateful vs Stateless
Without connection tracking, a firewall is stateless. It evaluates each packet in isolation with no memory of previous packets. If you allow inbound traffic to port 80, you also need a separate rule to allow response packets going back out on ephemeral ports (1024-65535). That is a huge attack surface.
Connection tracking solves this. The kernel maintains a table of every active
connection (stored in /proc/sys/net/netfilter/nf_conntrack_max, typically
65536 entries by default, each consuming about 300-400 bytes of kernel memory).
Each tracked flow gets classified into a state.
The Four States
| State | Meaning | Example |
|---|---|---|
| NEW | First packet of a connection | TCP SYN, first UDP datagram |
| ESTABLISHED | Part of a bidirectional flow | Anything after the SYN/SYN-ACK exchange |
| RELATED | New connection spawned by an existing one | FTP data channel, ICMP error responses |
| INVALID | Cannot be associated with any known connection | Corrupted packet, out-of-window TCP sequence |
Why ESTABLISHED,RELATED Must Be Near the Top
Look at the standard pattern from testdata/iptables_basic.rules:
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -p tcp --dport 22 -j ACCEPT
On a busy server, 90%+ of packets belong to established connections. If the conntrack rule is at position 2, those packets match immediately and skip everything else. If you bury it at position 10, every established packet walks past 9 rules before it matches. That is thousands of unnecessary rule evaluations per second under load.
The find_missing_conntrack function in src/analyzer/optimizer.v detects
two problems: chains with no ESTABLISHED/RELATED rule at all (warning), and
chains where the rule exists but is positioned past the third slot (info
suggestion to move it up).
RELATED Connections
RELATED is less obvious than ESTABLISHED but equally important. Two scenarios:
FTP data channels: FTP uses port 21 for control and a separate connection
for data transfer. The kernel's nf_conntrack_ftp helper module watches the
control channel, sees the PORT or PASV command, and marks the resulting data
connection as RELATED. Without RELATED in your conntrack rule, FTP data
transfers break even though port 21 is open.
ICMP errors: When a packet is dropped somewhere in the network, the dropping router sends back an ICMP "destination unreachable" or "time exceeded" message. These ICMP packets are RELATED to the original connection. Without RELATED, your machine never receives these error messages, which breaks path MTU discovery and makes network debugging much harder.
Default Deny vs Default Accept
This is the principle of least privilege applied to network traffic.
Default deny (the only sane production policy):
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
You build a whitelist. Every service that needs to be reachable gets an explicit ACCEPT rule. Anything you forgot stays blocked. If someone adds a new service to the machine without adding a firewall rule, the service is unreachable. That is annoying, but safe. You notice and fix it.
Default accept (dangerous):
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
You build a blacklist. You try to block everything bad and hope you did not forget anything. When someone installs MySQL on the box and it binds to 0.0.0.0:3306, it is immediately reachable from the entire internet because you never added a rule to block it. You might not notice for months.
The difference between these two comes down to what happens when something goes wrong. Default deny fails closed (the safe direction). Default accept fails open (the dangerous direction). The Palo Alto Unit 42 2023 Cloud Threat Report found 76% of organizations had publicly exposed SSH in at least one cloud environment, almost always because of default-accept equivalent configurations on security groups.
Real-World Breaches
Capital One (2019)
In March 2019, a former AWS employee exploited a Server-Side Request Forgery (SSRF) vulnerability in a misconfigured WAF protecting Capital One's AWS infrastructure. The WAF had an IAM role with excessive permissions, and the firewall rules allowed the compromised instance to reach the EC2 metadata service at 169.254.169.254. The attacker queried the metadata endpoint to obtain temporary IAM credentials, used them to list and download S3 buckets, and exfiltrated data because outbound traffic was unrestricted.
A single egress firewall rule would have stopped the exfiltration:
-A OUTPUT -d 169.254.169.254/32 -j DROP
Impact: 100 million credit applications exposed, 140,000 Social Security numbers, 80,000 bank account numbers. Capital One paid an $80 million fine to the OCC and $190 million in settlements. (United States v. Paige A. Thompson, Case No. CR19-159, W.D. Wash. 2019.)
Imperva (2019)
Imperva disclosed a security incident where an internal database instance had a misconfigured AWS security group that allowed unauthorized access. The exposed instance should have been network-isolated, but its security group rules permitted inbound connections they should not have. An attacker obtained API keys from the instance and used them to access customer data. The root cause was a security group that was too permissive on an instance that never needed external connectivity.
This is the exact pattern fwrule flags as "overly permissive": a rule
matching source 0.0.0.0/0 on a port that should be restricted to an internal
subnet.
NSA Advisory on IPsec VPN Firewalls (U/OO/179891-20)
The National Security Agency published guidance specifically about misconfigured firewall rules around VPN infrastructure. The advisory documented how adversaries exploit overly permissive rules on VPN gateways to gain initial access to a network, then use the same misconfigured segmentation to move laterally between network zones that should be isolated. The specific concern: firewall rules that allow VPN traffic to reach internal subnets without restricting which internal services are accessible, turning the VPN into a free pass past the perimeter.
Equifax (2017, CVE-2017-5638)
The root cause was an unpatched Apache Struts vulnerability, but the breach was dramatically worsened by firewall and network failures. An expired SSL certificate on a network monitoring device meant encrypted traffic inspection stopped working for 19 months without anyone noticing. Misconfigured network segmentation allowed the attacker to move laterally across systems for 76 days after initial compromise, accessing 48 databases containing records of 147 million people. The combination of no patching, no monitoring, and no segmentation turned a single web application vulnerability into one of the largest data breaches in history. The eventual cost exceeded $1.4 billion.
Docker/Kubernetes Default Networking
This is not a single breach but a widespread class of misconfiguration. Docker's default bridge network inserts iptables rules directly into the FORWARD chain and the nat table's PREROUTING chain. These rules bypass host-level firewalls like UFW and firewalld, because Docker's rules are evaluated before the host firewall's rules in the chain.
What this means in practice: you set up UFW on a Docker host and add rules to block port 3306. Docker publishes a MySQL container on port 3306. UFW reports the port as blocked. The port is actually open to the internet because Docker's iptables rules in the FORWARD chain accept the traffic before it ever reaches UFW's rules.
Packet arrives
|
v
PREROUTING (Docker DNAT rule matches, rewrites destination)
|
v
FORWARD chain
|
+-> Docker's ACCEPT rule fires here <-- UFW never sees this packet
|
+-> UFW's rules (never reached)
Kubernetes has the same problem at scale. kube-proxy generates iptables or nftables rules for every Service object. On a cluster with hundreds of services, there can be thousands of generated rules that no human wrote or reviewed. These rules interact with the host firewall in ways that are not obvious from looking at either the Kubernetes configuration or the host firewall configuration alone.
Common Firewall Mistakes
These are the specific patterns fwrule analyze and fwrule optimize detect.
Each one maps to a function in src/analyzer/conflict.v or
src/analyzer/optimizer.v:
-
Shadowed rules (
find_shadowed_rules): A broad ACCEPT before a specific DENY makes the DENY unreachable. Severity: CRITICAL. -
Missing conntrack (
find_missing_conntrack): No ESTABLISHED/RELATED rule means every packet walks the full chain. On a busy server, this is measurable in CPU. Severity: WARNING. -
No rate limiting on SSH (
find_missing_rate_limits): Port 22 open with a plain ACCEPT. An attacker runs hydra with thousands of password attempts per minute. A limit of 3/minute with burst 5 makes brute force impractical. Severity: WARNING. -
Duplicate rules (
find_duplicates): Two rules with identical match criteria and the same action. The second one is dead weight that makes auditing harder. Severity: WARNING. -
Contradictory rules (
find_contradictions): Overlapping match criteria with opposite actions (ACCEPT vs DROP). Might be intentional, but needs human review. Severity: WARNING. -
Default accept policy: The chain's policy is ACCEPT, so anything not explicitly blocked gets through. This is the single most common misconfiguration on internet-facing servers.
-
Redundant rules (
find_redundant_rules): A narrow rule with the same action as a broader rule that already covers it. Not a security risk, but clutter that obscures the actual policy. Severity: INFO. -
Missing logging (
find_missing_logging): A chain with a DROP policy but no LOG rule. Dropped traffic generates no audit trail, which makes incident response and forensics significantly harder. Severity: INFO.