You pushed a firewall rule update at 11 PM. The nftables reload completed without errors. The syntax was valid, the config looked right, and you called it done. Three days later, a monitoring alert flags unexpected traffic to a port you were certain was blocked. The ruleset said one thing. The network said another. That gap is exactly what a proper audit is built to catch, and most workflows never close it.
This article covers a complete, repeatable port auditing workflow for hardened Linux hosts. It starts with local socket inspection using ss and lsof, cross-references those findings against the active nftables ruleset, and then confirms external reachability with a scan from outside the host. Each step catches failures the others cannot see.
Audit Reality Check
A ruleset that reads correctly on paper is not the same as a host that is actually secure: confirming the two match requires checking from both sides of the firewall.
ssandlsofshow what the kernel says is listening right now, with full process attribution.- Comparing socket output against your nftables ruleset exposes the mismatch between intent and actual rule evaluation order.
- An external port scan confirms what a remote host would see, regardless of what local tools report.
The Gap Between Rules and Reality
nftables is precise and expressive. A well-written ruleset handles stateful connection tracking, strict input chain policies, and fine-grained port allowlisting. But a ruleset enforces what it says at the moment traffic arrives. It knows nothing about services that started after the ruleset was loaded, daemons that bind to interfaces your rules do not cover, or container runtimes that modify the networking stack underneath your config.
The gap between stated policy and actual exposure is usually small on well-maintained hosts. It can still matter enormously. A service bound to all interfaces instead of loopback only, a missing rule for IPv6, a routing change that bypasses an interface-specific drop, any of these can leave a port reachable when it should not be. Local tooling shows you the host’s perspective. External scanning shows you the internet’s perspective. You need both views before you can call an audit complete.
Starting Local: What ss Tells You
The ss utility reads directly from kernel socket tables. It does not rely on parsing /proc output the way old netstat did, which makes it faster and more accurate on loaded systems. For a port audit, you want every listening socket, the address it is bound to, the protocol it uses, and the process that owns it.
This command gives you TCP listeners with full process detail:
ss -tlnp
Breaking down the flags: -t for TCP, -l to show only listening sockets, -n to suppress name resolution, -p to include process information. For UDP services, substitute -u for -t. Run both commands. UDP services are easy to miss when you only check TCP.
Making ss Output Readable at Scale
On a host running many services, the raw output becomes dense quickly. Pipe it through column -t for better alignment, or filter by specific ports when you know what you are looking for:
ss -tlnp | grep -E ':(22|80|443|3306|5432)\s'
The column that matters most is Local Address. A socket bound to 0.0.0.0:8080 is listening on every interface. A socket bound to 127.0.0.1:8080 is loopback-only. A socket bound to a specific public IP is reachable only via that address. Every listener bound to a non-loopback address needs a corresponding rule in your nftables input chain. If it lacks one and your chain’s default policy is accept, that port is exposed to the network.
Adding Process Context with lsof
lsof overlaps with ss -p in some ways, but it gives you richer detail. Beyond the process name and PID, you get the full binary path via the file descriptor reference, the user account running the process, and a clear mapping between each socket and its owner. This matters when a PID appears in your socket list that you do not immediately recognize.
lsof -i -P -n | grep LISTEN
The -P flag skips port-to-service-name resolution, and -n skips hostname resolution. The result maps every listening socket to a named process and user account. If something appears that you cannot place, resolve the binary path:
ls -la /proc/<PID>/exe
That symlink points to the actual executable on disk. A path that resolves with a (deleted) suffix means the binary was replaced or removed after the process started. On any production host, that warrants immediate investigation before anything else.
How Each Inspection Tool Contributes to the Audit
| Tool | What It Shows | Best Used For |
|---|---|---|
ss |
Listening sockets by protocol, port, and bound address | First-pass inventory across all listening sockets |
lsof |
Sockets mapped to process binaries and user accounts | Tracing an unknown listener to its executable and owner |
nft list ruleset |
Active rules, chain ordering, and match conditions | Verifying that rules cover each non-loopback listener |
| External port scanner | Ports reachable from outside the network perimeter | Confirming that firewall policy matches external reality |
Mapping Sockets Against Your nftables Ruleset
With your socket list in hand, pull the full active ruleset:
nft list ruleset
Work through each non-loopback listening port and check it against the input chain. For each one, these are the questions to ask:
- Is there an explicit accept rule for this port, or does traffic hit a default-drop chain policy without one?
- Does the rule match the correct protocol? A TCP drop rule does not cover UDP on the same port number.
- Is the rule scoped to a specific interface with
iif? If the service is reachable on other interfaces, the scoped rule may not apply there. - Are there rules above it in the chain that could match first and produce a different verdict?
- Does the ruleset cover both IPv4 and IPv6? An inet table handles both, but separate ip and ip6 tables require duplicate rules for complete coverage.
nftables evaluates rules in the order they appear in the chain. Rule ordering bugs are common after manual edits. Add packet counters to rules you are uncertain about:
nft add rule inet filter input tcp dport 8080 counter drop
If that counter stays at zero while the port is clearly active in your socket list, something earlier in the chain is matching first. Work backwards from the top until you find it.
Unfamiliar port numbers in your socket output can be cross-referenced using the IANA port number registry, which maps registered service names to their assigned transport-layer ports and helps confirm whether a listener is a known service or something that should not be there.
Confirming What’s Reachable from the Outside
Local inspection tells you what the host knows about itself. It cannot tell you what the network infrastructure between your host and the outside world does to traffic. NAT rules, upstream filtering, routing policy, and container networking layers can all produce results that diverge from what local nftables output suggests.
The verification step that closes the loop is running an open port scanner against the host’s public IP from an external vantage point. The results show exactly what a remote client would see: which ports respond with a SYN-ACK, which return RST, and which are silently dropped somewhere in the path.
Cross-reference those external results against your local socket list and your expected ruleset state. A port that shows as open externally but should be blocked means your ruleset has a gap, a rule applies to the wrong interface or address family, or a network-layer artifact is passing traffic through unexpectedly. A port that shows as filtered or closed when you intended it to be reachable points to an accept rule that is rejecting legitimate traffic somewhere in the chain.
This external view is the step that separates a real audit from one that only looks inward. You can have a perfectly formatted, logically consistent nftables ruleset and still have a port exposed that you did not intend. The scan tells you the truth.
A Repeatable Workflow for Every Rule Change
The value of this process comes from running it consistently, not perfectly. After any nftables modification, work through these steps in order:
- Apply the change with
nft -f /etc/nftables.confand confirm no parse errors are reported. - Run
ss -tlnpandss -ulnpto capture the current listening socket inventory for both TCP and UDP. - Run
nft list rulesetand compare each non-loopback listener against the input chain rules. - Flag any listener bound to a public-facing address that lacks a clear drop, reject, or documented accept rule.
- Trigger an external port scan against the host’s public IP, covering at minimum the ports you modified and any high-risk service range.
- Compare the external scan output against the previous run’s baseline to identify any newly exposed or newly blocked ports.
- Record the verified state in a structured change log: timestamp, specific rule changed, ports verified, and a summary of scan results.
The documentation step is the one most frequently skipped. It is also the one that makes incident reconstruction possible. When an on-call engineer asks what the firewall state looked like at 23:00 on a specific date, the change log is where that answer lives. Tooling output is ephemeral. A timestamped record is not.
Common Mismatches and Their Root Causes
When your local audit and external scan results tell different stories, the cause usually fits one of a few patterns. Knowing them speeds up diagnosis considerably.
Interface scope gaps: A drop rule specifying iif eth0 only covers traffic arriving on eth0. Traffic arriving via a secondary NIC, a container bridge device, or a libvirt virtual interface bypasses it entirely. Drop rules that must apply universally should omit the interface qualifier so they match on all ingress paths.
Chain ordering issues: If your input base chain carries a default accept policy and your restrictive rules live in a separate chain, the jump to that chain must appear before any unconditional accept in the base chain. A jump placed too late means your drop rules never get evaluated for certain traffic classes. This is a silent failure: nothing errors, packets just pass.
Address family coverage: An inet table covers IPv4 and IPv6 in a single ruleset. Hosts still using separate ip and ip6 tables need every drop rule present in both. A port blocked only in the ip table is fully reachable over IPv6 unless the ip6 table also carries the same rule. This is a common oversight on older configs that predate the inet table.
Container networking side effects: Docker with rootful containers and libvirt can inject rules into the netfilter stack at runtime. On hosts running both hand-written nftables configs and container runtimes, the output of nft list ruleset will include runtime-injected rules. Confirm those additions are not opening ports you intended to block or disrupting the chain ordering you rely on.
When the Audit Becomes Your Firewall’s Ground Truth
None of the tools in this workflow are new. ss, lsof, and nft list ruleset have been standard kit for years. The external scan step is not technically demanding. What makes this workflow worth formalizing is that it checks the actual state of the system, not the expected state. Configuration management tools report what they believe they applied. Deployment logs report that a task completed. This workflow reports what is actually reachable right now, from both inside and outside the host.
Run it after every firewall change. Run it on a schedule on hosts where changes happen infrequently, because services, containers, and kernel updates can alter socket state without any firewall rule being touched. Build the external scan into your change control process if your organization has one. The firewall that catches the incident you never heard about is always the one that was verified before the incident happened, not the one that looked correct in the config file.



