LINUX FIREWALL AUDIT (NFTABLES AND IPTABLES)
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).
LINUX FIREWALL AUDIT (NFTABLES AND IPTABLES)
============================================
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.