Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Subscribe and receive upto $1000 discount on checkout. Learn more
Audit Open Ports and Services on Linux Servers

Why port and service audits become urgent over time

In the beginning, a Linux server is usually simple: SSH for administration, maybe a web service, and a firewall rule or two. Then reality happens. A monitoring agent gets installed “temporarily.” A database is exposed for a migration window. A developer enables a debug listener to unblock a release. A container runtime pulls in a new network stack. Months later, nobody remembers what changed, but the server is now listening on more ports than anyone can confidently explain.

That is where port and service auditing stops being a checkbox and becomes operational hygiene. Open ports are not automatically “bad,” but unknown open ports are always a risk. The goal is internal validation: we prove, from inside the host and from inside the network, exactly what is listening, what process owns it, whether it is reachable, and whether it is intentionally allowed by policy.

Scope and approach

We are going to audit open ports and services on Linux servers using host-native tooling and internal validation methods. We will avoid anything that behaves like an external scanner. The workflow is designed for production environments: it is repeatable, minimally disruptive, and it produces evidence we can hand to security, operations, and compliance teams.

  • We will enumerate listening sockets and map them to processes.
  • We will validate which services are actually enabled and expected to be running.
  • We will confirm firewall policy and reconcile it with what is listening.
  • We will validate reachability from inside the network without scanning.
  • We will add lightweight, persistent reporting so audits do not rely on memory.

Prerequisites and system assumptions

Before we touch commands, we need to be explicit about the environment assumptions so the output is trustworthy and the steps are safe.

  • Platform: Linux. The commands below are written for modern distributions using systemd (common in Ubuntu Server 20.04/22.04/24.04, Debian 11/12, RHEL 8/9, Rocky/Alma 8/9, SUSE). If a host does not use systemd, we should adapt the service checks accordingly.
  • Access level: We need root privileges for process-to-port mapping and firewall inspection. We will use sudo for commands that require elevation. If sudo is not configured, we must use a root shell with care.
  • Change control: Most steps are read-only. Where we add persistence (a scheduled report), we will do it in a controlled, reversible way and we will verify the resulting files and timers.
  • Network context: “Internal validation” means we validate from the host itself and optionally from a trusted internal jump host in the same network segment. We will not perform broad network probing.
  • Baseline expectation: We should know the intended roles of the server (web, database, bastion, monitoring, etc.). The audit is about reconciling reality with intent.

Step 1: Establish the server identity and network context

We will first capture basic identity and network details. This matters because port exposure is not just “a port is open,” it is “a port is open on which interface, with which IPs, and in which routing context.” These commands are read-only and safe in production.

set -eu

hostnamectl
uname -a
ip -br addr
ip route

We have now captured the hostname, kernel, interface/IP summary, and routing table. This gives us the context to interpret later results, especially when services bind to specific addresses or when multiple interfaces exist (public, private, management, container bridges).

Step 2: Enumerate listening ports and map them to processes

Next we will list all listening TCP and UDP sockets and include the owning process. This is the single most important internal validation step because it answers: “What is actually listening right now?” We will use ss because it is standard, fast, and accurate on modern Linux.

sudo ss -tulpen

This output shows listening sockets for TCP and UDP, the local address/port, and the process details (PID/program). We should pay attention to:

  • 0.0.0.0 or :: bindings, which indicate the service is listening on all interfaces.
  • 127.0.0.1 or ::1 bindings, which indicate local-only exposure (often desirable for internal components).
  • Unexpected high ports that may indicate debug listeners, ephemeral admin endpoints, or misconfigured services.

Produce a clean, audit-friendly table

We will now generate a stable, readable snapshot that is easy to store in tickets and change records. This does not change the system; it only formats the data.

sudo ss -tulpen | awk 'NR==1 || /LISTEN|UNCONN/ {print}'

We now have a concise view of listening sockets. If we need to compare across time, we can redirect this output into a dated file later in the persistence step.

Step 3: Confirm which systemd services are running and enabled

A port can be open because a service is running, but we also need to know whether it is meant to come back after a reboot. That is the difference between a one-off process and a persistent service. We will list running services and then check enablement for anything that looks suspicious.

sudo systemctl list-units --type=service --state=running --no-pager

We now have the set of services currently running under systemd. This is not yet a port map, but it is the authoritative “what systemd believes is active.”

When we identify a suspicious service name, we should check whether it is enabled to start at boot and review its unit definition. The following commands are safe and read-only.

sudo systemctl is-enabled ssh.service || true
sudo systemctl status ssh.service --no-pager
sudo systemctl cat ssh.service

We have now confirmed whether the service is enabled, its current status, and the exact unit configuration systemd is using. This is critical when a service is started by a drop-in override or a vendor unit that differs from expectations.

Step 4: Trace a listening port back to the owning binary and package

When we find an unexpected listener, we need to answer two questions quickly: “What binary is this?” and “Where did it come from?” We will do this by extracting the PID from ss output and then inspecting the process and its executable path.

We will first print listeners again in a way that makes it easy to spot a PID.

sudo ss -tulpen

From the output, we can pick a PID shown in the users:(("name",pid=1234,fd=...)) field. We will then inspect that PID. The commands below are safe and do not modify the system.

PID=1
sudo ps -p "$PID" -o pid,ppid,user,group,cmd --no-headers
sudo readlink -f "/proc/$PID/exe"
sudo tr '' ' ' < "/proc/$PID/cmdline" ; echo

We have now identified the exact executable path and the full command line. If the PID we set is not the one we intended, the output will make that obvious. In production, we should replace PID=1 with the actual PID we observed.

Next, we will determine which package owns that binary. This differs by distribution family, so we will detect the OS and then run the appropriate command.

. /etc/os-release
echo "Detected: $ID $VERSION_ID"

We now have the distribution identifier. Use the matching package query below.

Debian/Ubuntu family: map binary to package

We will use dpkg -S to identify the owning package. This is read-only.

BIN_PATH="/usr/sbin/sshd"
dpkg -S "$BIN_PATH" || true

If the binary is managed by the package manager, we will see the package name. If nothing is returned, the binary may be manually installed, extracted, or generated by a build pipeline, which should trigger a deeper review.

RHEL/Rocky/Alma family: map binary to package

We will use rpm -qf to identify the owning package. This is read-only.

BIN_PATH="/usr/sbin/sshd"
rpm -qf "$BIN_PATH" || true

As with Debian-based systems, a “not owned” result is a strong signal that the binary did not come from standard repositories or has been replaced.

Step 5: Reconcile listening ports with firewall policy

Knowing what is listening is only half the story. We also need to know what is allowed in. In production, we often see “secure services” that are accidentally reachable because firewall policy drifted, or “blocked services” that are still listening and therefore still a local attack surface. We will inspect firewall state using common Linux firewall stacks.

Check nftables ruleset

Many modern distributions use nftables directly or via a frontend. We will print the full ruleset. This is read-only.

sudo nft list ruleset

We now have the active nftables policy. We should look for default policies (accept/drop), explicit allow rules for service ports, and interface-specific rules.

Check iptables ruleset

Some environments still use iptables. We will list rules with counters and line numbers for easier review. This is read-only.

sudo iptables -S
sudo iptables -L -n -v --line-numbers

We now have both the canonical rule specification and a human-readable table with counters. Counters help confirm whether a rule is actively being hit.

Check UFW status if present

On Ubuntu and some Debian setups, UFW may be the operational interface. We will check status verbosely. This is read-only.

sudo ufw status verbose || true

If UFW is installed and active, we now see which ports are allowed and from where. If the command fails, UFW is likely not installed, which is fine in environments using nftables/iptables directly.

Step 6: Validate reachability internally without scanning

Now we validate reachability in a controlled way. Instead of probing ranges of ports, we will test only the ports we already know are listening. This keeps the activity aligned with internal validation and avoids noisy behavior.

We will first capture the server’s primary IP address used for outbound routing. This is often the most relevant address for internal reachability tests.

PRIMARY_IP=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
echo "$PRIMARY_IP"

We now have a best-effort primary source IP. On multi-homed systems, we may need to choose a specific interface/IP based on the service exposure model.

Next, we will test a known listening TCP port locally using bash’s /dev/tcp. This is a direct connection attempt, not a scan. We will pick a port we observed in ss. The example below uses 22 (SSH) because it is common; we should replace it with the port we are validating.

PORT=22
timeout 2 bash -c "cat < /dev/null > /dev/tcp/127.0.0.1/$PORT" && echo "TCP $PORT reachable on localhost" || echo "TCP $PORT not reachable on localhost"

If the port is listening on localhost, we will see a success message. If it fails, either nothing is listening on that address/port, the service is bound to a different address, or local firewall policy is blocking it.

We will now test the same port against the server’s primary IP. This helps catch cases where a service is bound only to loopback (expected for internal-only components) versus bound to a routable interface.

PORT=22
timeout 2 bash -c "cat < /dev/null > /dev/tcp/$PRIMARY_IP/$PORT" && echo "TCP $PORT reachable on $PRIMARY_IP" || echo "TCP $PORT not reachable on $PRIMARY_IP"

This confirms whether the service is reachable via the primary IP from the host itself. If it fails here but succeeds on localhost, the service is likely bound to loopback only, which is often a good security posture.

Step 7: Check for common “silent exposure” sources

In real environments, ports often appear because of components that are easy to forget: containers, local resolvers, and transient dev tooling. We will do quick, internal checks that do not change the system.

Containers and port publishing

If Docker is present, published ports can expose services even when the service itself is inside a container. We will list running containers and their published ports. This is read-only.

command -v docker >/dev/null 2>&1 && sudo docker ps --format 'table {{.Names}}t{{.Image}}t{{.Ports}}' || true

If Docker is installed, we now see which containers are running and which ports are published to the host. Published ports should be reconciled with firewall policy and intended exposure.

If Podman is used, we will do the equivalent check. This is read-only.

command -v podman >/dev/null 2>&1 && sudo podman ps --format 'table {{.Names}}t{{.Image}}t{{.Ports}}' || true

If Podman is installed, we now have the same visibility for Podman-managed containers.

System resolvers and local listeners

Local DNS resolvers (like systemd-resolved) often listen on loopback. That is usually fine, but we should confirm it is loopback-only. We will check for common DNS ports and their bind addresses.

sudo ss -tulpen | awk '$5 ~ /:53$/ {print}'

If we see port 53 bound to 127.0.0.1 or ::1, that is typically expected. If it is bound to 0.0.0.0 or a routable IP, we should confirm that is intentional and protected.

Step 8: Create a persistent, internal audit snapshot (optional but production-friendly)

Audits fail when they depend on memory. We will add a small, controlled mechanism that writes a daily snapshot of listening ports and firewall state to a local directory. This is internal-only, does not transmit data, and is easy to remove. We will use a root-owned script and a systemd timer for persistence across reboots.

Create a root-owned audit directory

We will create a directory under /var/log with strict permissions so only root can read it. This prevents leaking service topology to unprivileged users.

sudo install -d -m 0700 -o root -g root /var/log/port-audit

The directory now exists with permissions 0700, owned by root. This is appropriate for sensitive operational data.

Create the snapshot script

We will write a script that captures: listening sockets, running services, and firewall state (best-effort across nftables/iptables/UFW). The script is designed to succeed even if some tools are missing.

sudo tee /usr/local/sbin/port-audit-snapshot.sh >/dev/null <<'EOF'
#!/bin/sh
set -eu

OUT_DIR="/var/log/port-audit"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
HOST="$(hostname -f 2>/dev/null || hostname)"

umask 077

{
  echo "timestamp_utc=$TS"
  echo "host=$HOST"
  echo
  echo "## identity"
  uname -a || true
  echo
  echo "## addresses"
  ip -br addr || true
  echo
  echo "## routes"
  ip route || true
  echo
  echo "## listening_sockets"
  ss -tulpen || true
  echo
  echo "## running_services"
  systemctl list-units --type=service --state=running --no-pager || true
  echo
  echo "## firewall_nft"
  nft list ruleset 2>/dev/null || echo "nft_not_available_or_no_permission"
  echo
  echo "## firewall_iptables"
  iptables -S 2>/dev/null || echo "iptables_not_available_or_no_permission"
  echo
  echo "## firewall_ufw"
  ufw status verbose 2>/dev/null || echo "ufw_not_available_or_no_permission"
} > "$OUT_DIR/snapshot_${HOST}_${TS}.txt"
EOF

sudo chmod 0750 /usr/local/sbin/port-audit-snapshot.sh
sudo chown root:root /usr/local/sbin/port-audit-snapshot.sh

We have created a root-owned script that writes a timestamped snapshot file under /var/log/port-audit. Permissions are restricted, and the script is safe to run repeatedly.

Run the snapshot once and verify output

Before scheduling anything, we will run it once to confirm it works and produces a file.

sudo /usr/local/sbin/port-audit-snapshot.sh
sudo ls -l /var/log/port-audit | tail -n 5
sudo tail -n 50 /var/log/port-audit/$(ls -1 /var/log/port-audit | tail -n 1)

We have now verified that the snapshot runs successfully, creates a file, and contains the expected sections. If any firewall tool is missing, the snapshot will record that fact without failing.

Schedule daily snapshots with a systemd timer

We will now create a systemd service and timer so the snapshot runs daily and persists across reboots. This is controlled, auditable, and easy to disable.

sudo tee /etc/systemd/system/port-audit-snapshot.service >/dev/null <<'EOF'
[Unit]
Description=Port and service audit snapshot (internal)
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/port-audit-snapshot.sh
User=root
Group=root
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF

sudo tee /etc/systemd/system/port-audit-snapshot.timer >/dev/null <<'EOF'
[Unit]
Description=Daily port and service audit snapshot (internal)

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=15m

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now port-audit-snapshot.timer
sudo systemctl status port-audit-snapshot.timer --no-pager
sudo systemctl list-timers --all | grep -F port-audit-snapshot || true

The timer is now enabled and running. With Persistent=true, if the server was down during the scheduled time, systemd will run the job after boot. We also added a small randomized delay to avoid synchronized load across fleets.

Verification checklist for a clean audit

At this point, we should be able to answer, with evidence, what is exposed and why. We will run a final set of verification commands to confirm our view is consistent.

sudo ss -tulpen
sudo systemctl list-units --type=service --state=running --no-pager
sudo systemctl status port-audit-snapshot.timer --no-pager || true

We have re-confirmed the live listening sockets, the running services, and (if enabled) the audit timer status. This is the minimum set of outputs that should align with our intended server role.

Troubleshooting

Symptom: ss -tulpen shows a port, but we cannot identify the process

  • Likely cause: Insufficient permissions or a restricted environment (containers, hardened kernels) preventing process details.
  • Fix: Run with sudo and confirm we are on the host namespace.
sudo ss -tulpen
sudo lsns -t net | head

If network namespaces are in use, we may need to audit within the relevant namespace (for example, container networking). In enterprise environments, we should document namespace ownership and reconcile published ports at the host level.

Symptom: A service is listening on 0.0.0.0 but should be internal-only

  • Likely cause: The service is configured to bind to all interfaces by default.
  • Fix: Change the service bind address to loopback or a private interface, then restart the service under change control.
sudo ss -tulpen | awk '$5 ~ /:([0-9]+)$/ {print}' | head

This confirms the bind addresses. The actual configuration change depends on the service (for example, a web server Listen directive or an application --bind flag). After changing configuration, we should re-run ss -tulpen and confirm the bind address is now correct.

Symptom: Firewall appears to allow a port, but connections still fail internally

  • Likely cause: The service is bound to localhost only, or the server is listening on IPv6 while we are testing IPv4 (or vice versa).
  • Fix: Confirm bind address family and test accordingly.
sudo ss -tulpen | grep -E 'LISTEN|UNCONN'
PRIMARY_IP=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
echo "$PRIMARY_IP"
PORT=22
timeout 2 bash -c "cat < /dev/null > /dev/tcp/$PRIMARY_IP/$PORT" && echo ok || echo fail

We have validated whether the service is reachable via the primary IPv4 address. If the service is IPv6-only, we should validate using an IPv6 address and ensure firewall policy covers IPv6 as well.

Symptom: The snapshot timer is enabled but no files appear in /var/log/port-audit

  • Likely cause: The timer has not fired yet, or the service failed due to missing tools/permissions.
  • Fix: Trigger the service manually and inspect logs.
sudo systemctl start port-audit-snapshot.service
sudo systemctl status port-audit-snapshot.service --no-pager
sudo journalctl -u port-audit-snapshot.service --no-pager -n 200
sudo ls -l /var/log/port-audit | tail -n 10

We have forced a run, checked service status, reviewed logs, and confirmed whether output files are being created. If the directory permissions were changed, we should restore them to root-only.

Common mistakes

Mistake: Treating “listening” as “reachable”

  • Symptom: ss -tulpen shows a service listening, but internal clients cannot connect.
  • Cause: Firewall policy blocks it, routing is different than assumed, or the service is bound to loopback.
  • Fix: Validate bind address and test a single known port using controlled connection attempts, then reconcile firewall rules.

Mistake: Ignoring IPv6 exposure

  • Symptom: IPv4 looks locked down, but ss shows listeners on ::.
  • Cause: Services bind to IPv6 by default, and firewall policy may not mirror IPv4 controls.
  • Fix: Review IPv6 listeners and ensure nftables/iptables policies cover IPv6 paths as intended.

Mistake: Assuming “disabled” means “not running”

  • Symptom: A service is running, but systemctl is-enabled reports disabled.
  • Cause: The service may be socket-activated, started by another unit, or launched outside systemd.
  • Fix: Use systemctl status, check unit dependencies, and confirm the owning process via ss and /proc.

Mistake: Leaving audit artifacts world-readable

  • Symptom: Non-root users can read port/service snapshots.
  • Cause: Logs stored with permissive permissions or in shared directories.
  • Fix: Keep audit snapshots under root-only directories and enforce 0700 on directories and 0750 on scripts.

How do we at NIILAA look at this

This setup is not impressive because it is complex. It is impressive because it is controlled. Every component is intentional. Every configuration has a reason. This is how infrastructure should scale — quietly, predictably, and without drama.

At NIILAA, we help organizations design, deploy, secure, and maintain production Linux environments where exposure is measurable, changes are traceable, and audits are repeatable. That includes building internal validation workflows, hardening service configurations, aligning firewall policy with real service intent, and operationalizing evidence collection so security and operations stay in sync as systems grow.

Leave A Comment

All fields marked with an asterisk (*) are required