← All cheat sheets

LINUX FIREWALL AUDIT (NFTABLES AND IPTABLES)

Plain-text reference · 17 KB. Read it, search it (Ctrl-F) or print it.

A read-only audit of a Linux host firewall: which tool really filters
traffic, what the full ruleset allows, whether IPv6 matches IPv4, whether
the saved config matches the running one, and signs that someone changed
it. Every command in sections 1-13 only reads. Section 14 lists the few
changes that are safe, each clearly marked. Commands assume root and bash;
paths are given for Debian/Ubuntu and RHEL/Rocky/Alma.

Before you start: on a remote host, never change the firewall without a
console or out-of-band access. Save a copy of what is running first (see
section 14, step 1) so you have evidence and a way back.

1. WHICH BACKEND IS ACTIVE (ATT&CK T1686)#

Modern iptables is a front end to nftables ("iptables-nft"). The old
kernel backend ("iptables-legacy") can still load rules beside it, and
nft never shows legacy rules.
Inspect:
  iptables -V                         # "(nf_tables)" = nft backend,
                                      # "(legacy)" = old backend
  update-alternatives --display iptables      # Debian/Ubuntu
  nft list tables                     # every nft table, any front end
  iptables-legacy-save 2>/dev/null | grep -c '^-A'   # legacy rule count
  systemctl is-active nftables firewalld ufw netfilter-persistent \
    iptables ip6tables 2>/dev/null
Flags: "# Warning: iptables-legacy tables present" in any iptables output
(rules exist that nft list ruleset does not show); more than one manager
active (ufw and firewalld, or firewalld and a hand-written nftables.conf);
a table nobody can name. "table ip filter is managed by iptables-nft, do
not touch!" in nft output is normal: read those tables with iptables.
On RHEL 9 the iptables commands come from iptables-nft; Debian ships both.

2. FULL RULESET AND COUNTERS (ATT&CK T1686)#

Inspect (all tables, all families, including NAT and raw):
  nft list ruleset                    # everything nft knows
  nft -a list ruleset                 # same, with rule handles
  iptables-save -c                    # iptables view, with counters
  ip6tables-save -c
  iptables -L -n -v -x --line-numbers           # filter table, readable
  iptables -t nat -L -n -v -x --line-numbers
  iptables -t mangle -S
  iptables -t raw -S
Counters: -v -x shows packets/bytes per rule and per chain policy. A
rule with 0 packets after weeks of uptime is dead or shadowed by an
earlier rule; a large count on a DROP policy shows what is being blocked.
Flags: ACCEPT rules with no source limit on admin ports (22, 3389, 5900,
database ports); a broad ACCEPT placed before the deny rules; rules in
nat, mangle or raw that nobody explains; rules matching one odd source
address (for example 198.51.100.7) or one high port (for example 4444).
Listing does not create or change tables (checked with iptables 1.8.11).

3. DEFAULT POLICIES AND IPv6 PARITY (ATT&CK T1686)#

Inspect:
  iptables -S | grep '^-P'            # -P INPUT DROP etc.
  ip6tables -S | grep '^-P'
  nft list ruleset | grep -E 'table|hook|policy'
Read the base chains: "type filter hook input ... policy drop;" is a
default deny. A base chain with no "policy" word accepts by default.
nft tables are per family: "ip" is IPv4 only, "ip6" is IPv6 only, "inet"
covers both.
Flags: IPv4 INPUT is DROP but ip6tables shows policy ACCEPT; only "table
ip" exists while the host has a global IPv6 address (check ip -6 addr);
FORWARD ACCEPT on a host that is not a router; OUTPUT locked down on
IPv4 only.
Not a flag: on a firewalld host (nftables backend) iptables -S shows
ACCEPT on every chain, because firewalld filters in its own table inet
firewalld. Read that table, not the empty iptables view.

4. UFW (ATT&CK T1686)#

Inspect:
  ufw status verbose                  # defaults, logging, rules
  ufw status numbered
  ufw show added                      # the user rules, as commands
  ufw show raw                        # the real iptables rules, counters
  ufw show listening                  # listening ports and their rules
  grep -E '^(IPV6|DEFAULT_)' /etc/default/ufw
  ls -la /etc/ufw/*.rules
Files: user.rules and user6.rules hold "ufw allow" rules; before.rules,
after.rules, before6.rules and after6.rules hold the built-in rules.
Flags: ufw status does NOT show rules from the /etc/ufw files or rules
added by hand with iptables. In testing, a hand-added "iptables -I INPUT
-p tcp --dport 4444 -j ACCEPT" was absent from ufw status but visible in
ufw show raw, ahead of all ufw chains. Also flag IPV6=no in
/etc/default/ufw, "deny (routed)" changed to allow, and edits in
before.rules (the stock file accepts some ICMP, DHCP client, mDNS and
UPnP traffic; anything beyond that was added). On Debian, ufw and
iptables-persistent cannot be installed together (the packages conflict).

5. FIREWALLD (ATT&CK T1686)#

Inspect:
  firewall-cmd --state
  firewall-cmd --get-default-zone
  firewall-cmd --get-active-zones     # empty = no interface/source bound
  firewall-cmd --list-all-zones       # runtime: services, ports, rules
  firewall-cmd --list-all-policies
  firewall-cmd --direct --get-all-rules       # deprecated direct rules
  firewall-cmd --get-log-denied
  firewall-cmd --check-config
  grep -E '^(FirewallBackend|DefaultZone|LogDenied)' \
    /etc/firewalld/firewalld.conf
  nft list table inet firewalld       # what firewalld really loaded
Zones apply only to bound interfaces or sources; check which zone the
public interface is in with firewall-cmd --get-zone-of-interface=<if>.
"no zone" means the default zone applies.
Flags: extra services or ports in the zone of the public interface; rich
rules with "accept" and a wide source; masquerade or forward-ports in a
zone; any direct rule (the direct interface is deprecated, superseded by
policies); a source bound to the "trusted" zone. Runtime vs permanent
drift is in section 8.

6. DOCKER AND PUBLISHED PORTS (ATT&CK T1686)#

Docker writes its own rules. Published ports (-p 8080:80) are rewritten
in the nat table before ufw's INPUT chains see them, so "ufw deny 8080"
does not block a published container port.
Inspect:
  iptables -t nat -S DOCKER           # published ports (DNAT)
  iptables -S FORWARD                 # jumps to DOCKER-USER etc.
  iptables -L DOCKER-USER -n -v       # admin rules, checked first
  grep -E 'iptables|ip6tables|firewall-backend|ip-forward' \
    /etc/docker/daemon.json 2>/dev/null
  docker ps --format '{{.Names}} {{.Ports}}'
Filter chains Docker creates: DOCKER-USER, DOCKER-FORWARD, DOCKER,
DOCKER-BRIDGE, DOCKER-CT, DOCKER-INTERNAL, DOCKER-INGRESS. Packets reach
DOCKER-USER after DNAT, so rules there must match the original port with
-m conntrack --ctorigdstport. When Docker turns on IP forwarding it also
sets the FORWARD policy to DROP. Docker 29 adds an experimental nftables
backend ("firewall-backend": "nftables", tables ip/ip6 docker-bridges)
that has no DOCKER-USER chain.
Flags: 0.0.0.0:<port> in docker ps for an admin or database port; an
empty DOCKER-USER on a host that relies on ufw; "iptables": false in
daemon.json with no replacement rules (per the Docker docs, all ports of
bridge-network containers are then reachable from the local network).

7. LISTENING PORTS VS RULES (ATT&CK T1686)#

Inspect:
  ss -tulpn                           # TCP/UDP listeners with process
  ss -Htln | awk '{print $4}' | sort -u
  nft list ruleset | grep -E 'dport|accept'
Compare every listener on 0.0.0.0, [::] or a public address with the
rule that allows it. A listener on 127.0.0.1 or ::1 is not reachable
from outside, whatever the firewall says.
Flags: a public listener with no rule you can explain (then the
firewall is the only thing protecting it -- check that a deny really
covers it); an ACCEPT rule for a port nothing listens on (stale rule, or
a hole waiting for a service); a listener owned by an unexpected process
or user. ufw show listening does this comparison for ufw (it skips
loopback).

8. SAVED VS RUNNING CONFIG (ATT&CK T1686)#

Rules changed at run time are lost at reboot, and saved files can be
edited so that a change appears only at the next boot. Compare both.
Boot-time files:
  Debian nftables: /etc/nftables.conf (nftables.service, not enabled by
    default)
  Debian iptables-persistent: /etc/iptables/rules.v4, rules.v6
    (netfilter-persistent.service)
  RHEL nftables: /etc/sysconfig/nftables.conf (+ /etc/nftables/*.nft)
  RHEL iptables-services: /etc/sysconfig/iptables, ip6tables
  firewalld: /etc/firewalld/ (zones/, policies/, direct.xml)
  ufw: /etc/ufw/*.rules, /etc/default/ufw
Compare iptables (strips comments and counters):
  diff <(grep -v '^#' /etc/iptables/rules.v4 | sed 's/\[[0-9]*:[0-9]*\]//') \
       <(iptables-save | grep -v '^#' | sed 's/\[[0-9]*:[0-9]*\]//')
Compare nftables (loads the file in a throwaway network namespace, so the
live ruleset is never touched):
  nft -c -f /etc/nftables.conf && echo "syntax OK"
  ip netns add fwcheck
  ip netns exec fwcheck nft -f /etc/nftables.conf
  diff <(ip netns exec fwcheck nft list ruleset) <(nft list ruleset)
  ip netns del fwcheck
Use /etc/sysconfig/nftables.conf on RHEL. Order and handles can differ;
read the lines that differ, not just the exit code.
Compare firewalld (runtime vs permanent):
  diff <(firewall-cmd --list-all-zones) \
       <(firewall-cmd --permanent --list-all-zones)
Flags: a rule or port only in the running set (added by hand, gone at
reboot); a rule only in the saved file (waits for the next boot); a
policy that differs (DROP saved, ACCEPT running); file times on these
files that match the incident window (ls -la --time=ctime).

9. KERNEL SETTINGS (ATT&CK T1090)#

Inspect:
  sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
  sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.default.rp_filter
  sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
  grep -rE 'forward|rp_filter|conntrack' /etc/sysctl.conf \
    /etc/sysctl.d/ /usr/lib/sysctl.d/ 2>/dev/null
ip_forward=1 lets the host route traffic between interfaces; with NAT
rules it can relay an attacker's traffic into other networks. Docker,
libvirt and VPN software turn it on legitimately. rp_filter: 0 = off,
1 = strict, 2 = loose; the higher of conf/all and conf/<if> is used.
Flags: forwarding on a host that is not a router, VM host or container
host; forwarding set at run time but in no sysctl file (or the reverse);
nf_conntrack_count close to nf_conntrack_max (new connections are then
dropped -- check dmesg for "table full").

10. LOGGING (ATT&CK T1686)#

Inspect:
  nft list ruleset | grep -n 'log'
  iptables -S | grep -E 'LOG|NFLOG'
  ufw status verbose | grep Logging
  firewall-cmd --get-log-denied
  journalctl -k --since today | grep -iE 'ufw|drop|reject' | tail -20
Flags: no log rule before the final drop or reject (blocked traffic is
then invisible); Logging: off in ufw; LogDenied=off in firewalld while
policy says denies are logged; log rules removed recently (compare with
the saved files in section 8). Log rules without a rate limit can flood
the journal; that is a tuning issue, not a sign of attack.

11. REDIRECTS AND PORT KNOCKING (ATT&CK T1090, T1205.001)#

Attackers use NAT to send a port to a hidden service, or rules that open
a port only after a secret packet sequence.
Inspect:
  nft list ruleset | grep -nE 'redirect|dnat|snat|masquerade|tproxy'
  iptables-save -t nat
  ip6tables-save -t nat
  nft list ruleset | grep -nE ' meter |update @|add @'  # dynamic sets
  iptables-save | grep -nE 'recent|string|u32'
Flags: a redirect or DNAT nobody explains (for example tcp dport 80
redirect to :8080 on a web server, sending visitors to a different
process); "-m recent" or a dynamic nft set feeding an ACCEPT rule
(port-knock pattern); "-m string" matching payload on an INPUT ACCEPT.
fail2ban and sshguard also use sets and recent lists: check the owner.

12. TAMPERING SIGNS (ATT&CK T1686, T1574.006)#

Inspect:
  ausearch -m NETFILTER_CFG -ts today -i      # auditd: ruleset changes
  journalctl -u nftables -u firewalld -u ufw -u netfilter-persistent \
    --since -7d
  ls -la --time=ctime /etc/nftables.conf /etc/iptables /etc/sysconfig \
    /etc/firewalld /etc/ufw 2>/dev/null
  grep -rlE 'iptables|nft |ufw |firewall-cmd' /etc/cron* \
    /var/spool/cron /etc/systemd/system 2>/dev/null
  cat /etc/ld.so.preload 2>/dev/null
Watch live (stop with Ctrl-C):
  nft monitor                         # every table/chain/rule change
  xtables-monitor -e                  # same, for iptables-nft rules
With auditd running, the kernel writes a NETFILTER_CFG record for every
table, chain or rule change, without any audit rule. nft monitor prints
"# new generation N by process PID (name)" after each change, so the
program is named.
Flags: rule changes outside a deploy or package update; a cron job or
unit that runs iptables or nft (rules re-added after you remove them);
a firewall service stopped or masked; any entry in /etc/ld.so.preload
(a library hook can hide rules from iptables and nft output -- confirm
from a known-good boot medium when it matters).

13. FAIL2BAN CHAINS (ATT&CK T1686)#

Inspect:
  fail2ban-client status              # list of jails
  fail2ban-client status sshd         # counts and banned IPs per jail
  fail2ban-client banned              # all bans, all jails
  grep -rE '^banaction' /etc/fail2ban/jail.conf /etc/fail2ban/jail.d/ \
    /etc/fail2ban/jail.local 2>/dev/null
  nft list table inet f2b-table 2>/dev/null   # nftables ban action
  iptables -S | grep f2b-                     # iptables ban actions
Debian 13 defaults to banaction = nftables (table inet f2b-table, one
set per jail, addr-set-<jail>); upstream jail.conf uses
iptables-multiport (chains f2b-<jail>).
Flags: a jail that is enabled but has no chain or set (bans do nothing);
f2b rules placed after an ACCEPT for the same port; ignoreip ranges wider
than your admin network; a known-bad address absent from the bans while
the log shows failures.

14. SAFE CHANGES (CHANGES THE HOST -- read before running)#

Only these steps change something, and none of them opens or closes a
port. Anything else (new rules, new policies) goes through change
control, with console access ready.
1. Save evidence of the running rules (writes files only):
  mkdir -p /root/fw-audit
  nft list ruleset > /root/fw-audit/nft-$(date +%F-%H%M).txt
  iptables-save -c > /root/fw-audit/ipt4-$(date +%F-%H%M).txt
  ip6tables-save -c > /root/fw-audit/ipt6-$(date +%F-%H%M).txt
2. Check a config file's syntax without loading it:
  nft -c -f /etc/nftables.conf && echo "syntax OK"
  firewall-cmd --check-config
3. Turn on logging of denied packets (adds log rules only):
  ufw logging low
  firewall-cmd --set-log-denied=unicast
ufw logging kept hand-added iptables rules in testing. firewall-cmd
--set-log-denied RELOADS firewalld: every runtime-only rule is lost (in
testing a runtime port vanished). Do step 1 and the section 8 diff first.
Do NOT run firewall-cmd --runtime-to-permanent during an investigation:
it overwrites the saved config with whatever is running, including an
attacker's runtime rules. Read section 8 first.

QUICK AUDIT ORDER#

1. Backend: iptables -V, nft list tables, the legacy warning, which
   manager is active (section 1).
2. Rules: nft list ruleset and iptables-save -c, all tables, counters.
3. Defaults: INPUT and FORWARD policies, IPv4 and IPv6 both.
4. Manager view: ufw show raw or firewall-cmd --list-all-zones, plus the
   Docker chains if Docker runs.
5. Exposure: ss -tulpn against the rules.
6. Drift: saved vs running (section 8).
7. Kernel and logs: forwarding, rp_filter, log rules.
8. Tampering: NAT redirects, knock rules, NETFILTER_CFG audit records,
   cron/units that run firewall tools, /etc/ld.so.preload.
9. Save the evidence (section 14, step 1) and compare with the incident
   window (see the Auditd, Fail2ban and Linux Persistence Detection
   sheets).

Commands and options checked against the nft, iptables, iptables-save,
iptables-extensions, xtables-nft, xtables-monitor and ufw manual pages,
the firewalld documentation (firewall-cmd, firewalld.conf,
firewalld.direct), the Linux kernel ip-sysctl, nf_conntrack-sysctl and
audit sources, the Docker packet filtering and firewall docs, and the
Fail2ban configuration shipped with Debian 13; technique IDs per MITRE
ATT&CK Enterprise v20. Commands tested on Debian 13 and Rocky Linux 9
(Docker commands from the Docker documentation only).

Defensive reference on CyberRamen. Offensive / red-team sheets live on OffensiveRamen.com.