Every team we have worked with has a document that says which ports are open on which hosts. Almost none of those documents are correct. Security groups get a temporary rule during an incident, a container publishes a port on all interfaces, someone installs a monitoring agent that listens on 0.0.0.0. The only reliable way to know what a host exposes is to ask the network, and nmap is the tool we use for that.
This post is the routine we run against our own infrastructure: how we scope it, the scans we actually use, how we read the results, and how we turn a one-off scan into a weekly diff that pages someone when exposure changes. It is written for engineers who own servers, not for testing other people's.
Get permission and write down the scope first
Scanning hosts you do not own or are not explicitly authorised to test can be illegal and will at minimum get your source IP reported and blocked. Before any scan we confirm three things in writing, even inside our own company:
- The exact targets: IP ranges and hostnames we own, listed in a file, not typed ad hoc.
- Who approved it and the time window.
- Provider rules. Major cloud providers allow customers to scan their own resources without prior approval for most services, but they publish policies with exceptions. Read yours before scanning anything hosted.
We keep the scope in a plain text file and always pass it with -iL, so a typo on the command line cannot widen the scan:
# scope-prod.txt - approved 2025-09-01, owner: platform team
203.0.113.10
203.0.113.11
203.0.113.0/28
api.example.com
We also scan from two vantage points: a small VM outside our network (what the internet sees) and a host inside the VPC (what a compromised neighbour sees). Both views matter, and they rarely match.
Host discovery without fooling yourself
Cloud hosts usually drop ICMP echo, so nmap's default ping step marks them as down and skips them. That is the first false sense of safety. For a known list of our own addresses we skip discovery entirely:
# Treat every target as up; list what nmap resolved before scanning anything
nmap -sL -iL scope-prod.txt
nmap -Pn -iL scope-prod.txt --top-ports 100 -oA scans/quick-$(date +%F)
The -sL list scan sends no probes; it only shows the targets nmap will use after DNS resolution. We run it first to confirm a hostname has not started resolving somewhere unexpected.
The scans we actually run
Full TCP port sweep
The top 1,000 ports are a sample, not an audit. Services we have found exposed in the past lived on 2375, 6443, 9200, 10250 and 27017, and odd ones on 8xxx ports chosen by whoever deployed them. We scan all 65,535 TCP ports:
sudo nmap -sS -Pn -p- --min-rate 500 --max-retries 2 \
-iL scope-prod.txt -oA scans/tcp-full-$(date +%F)
-sS is a SYN scan and needs root. Without root, nmap falls back to -sT (full connect), which works but leaves more noise in the target's logs. --min-rate keeps the run inside our maintenance window; we keep it modest so we do not trip our own rate limiting or IDS and spend the morning chasing ourselves.
Service and version detection on what is open
Once we know the open ports, we ask what is behind them, only on those ports:
sudo nmap -sS -sV --version-intensity 5 -Pn -p 22,80,443,6379,9100 \
203.0.113.10 -oA scans/svc-203.0.113.10-$(date +%F)
A port number tells you very little. Version detection is what reveals that 443 on one host is nginx and on another is an old admin console someone forgot about.
UDP, the part everyone skips
UDP scanning is slow because closed ports often do not answer and the kernel rate-limits ICMP unreachable replies. We do not scan all UDP ports; we scan the ones that cause real incidents:
sudo nmap -sU -Pn -p 53,123,161,500,1900,4500,5353,11211 \
-iL scope-prod.txt -oA scans/udp-$(date +%F)
An open|filtered result means nmap got no answer, which is ambiguous. Combine it with -sV on that port to get a definite answer when it matters, for example for SNMP on 161 or memcached on 11211.
IPv6
If your hosts have IPv6 addresses, scan them too. Several times we found a firewall that was strict on IPv4 and completely open on IPv6 because the rules were written for one family only:
sudo nmap -6 -sS -Pn -p- 2001:db8::10 -oA scans/tcp6-$(date +%F)
Safe NSE scripts for configuration checks
The Nmap Scripting Engine has categories. For audits we restrict ourselves to scripts in the safe category, and name them explicitly:
nmap -Pn -p 443 --script ssl-enum-ciphers,ssl-cert,http-security-headers api.example.com
nmap -Pn -p 22 --script ssh2-enum-algos,ssh-hostkey 203.0.113.10
ssl-enum-ciphers grades the TLS versions and cipher suites the server offers, ssh2-enum-algos shows whether old key exchange and MAC algorithms are still offered, ssh-hostkey records host key fingerprints so we notice when one changes unexpectedly, and http-security-headers gives a quick view of HSTS and related headers. We never run the intrusive, brute or exploit categories as part of routine auditing.
Reading results: the finding that justified the whole exercise
On one audit, a host that ufw said only allowed 22 and 443 showed this:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.13
443/tcp open ssl/http nginx 1.24.0 (Ubuntu)
6379/tcp open redis Redis key-value store 7.2.4
ufw was configured correctly. The problem was a Compose file with ports: - "6379:6379". Docker publishes container ports by writing its own iptables rules in the DOCKER chain, which are evaluated before the ufw chains for forwarded traffic. The result is a port that is open to the world while ufw status insists it is not. The fix was to bind to loopback, or drop the published port and use the Compose network:
services:
redis:
image: redis:7.2
ports:
- "127.0.0.1:6379:6379"
If you need filtering for published container ports, Docker documents the DOCKER-USER chain as the place for your own rules. Then rescan: the only proof that matters is the port showing closed or filtered from outside.
How we triage each open port:
| State | What it means | What we do |
|---|---|---|
| open, expected | Matches the inventory | Check version against patch level |
| open, unexpected | Something listens and the path is allowed | Owner identified within a day, closed or justified |
| filtered | A firewall drops the probe | Usually fine; confirm it is the firewall we expect |
| closed | Host answered with RST, nothing listening | Fine, but the host is reachable on that port if something starts |
| open, filtered (UDP) | No reply | Follow up with version detection on that port |
Turning it into a weekly diff
A single scan is a snapshot. Exposure problems come from change, so we diff. -oA writes normal, grepable and XML output; the XML is what ndiff (shipped with nmap) compares:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/exposure-audit
today=scans/tcp-full-$(date +%F)
sudo nmap -sS -sV -Pn -p- --min-rate 500 --max-retries 2 \
-iL scope-prod.txt -oA "$today"
prev=$(ls -1 scans/tcp-full-*.xml | sort | tail -n 2 | head -n 1)
if [ "$prev" != "$today.xml" ]; then
ndiff "$prev" "$today.xml" > diff-latest.txt || true
if grep -qE '^\+.*open' diff-latest.txt; then
./notify.sh "New open port detected" diff-latest.txt
fi
fi
ndiff exits non-zero when it finds differences, which is why the || true is there. Lines starting with + are new state; we alert only on newly open ports, and review the rest in the weekly ticket. The schedule lives in a systemd timer on the external scanning VM, and scan output is kept for 90 days so we can answer "since when was this open?" during an incident.
Common mistakes
| Mistake | Why it hurts | Do this instead |
|---|---|---|
| Relying on the default ping | Cloud hosts drop ICMP, so they are skipped and look clean | Use -Pn for known targets |
| Scanning only the top ports | Misconfigured services love high ports | Run -p- at least weekly |
| Only scanning from inside | Internal view hides what the internet sees | Scan from an external vantage point too |
Trusting ufw status with Docker |
Published ports bypass ufw rules | Bind to 127.0.0.1 or filter in DOCKER-USER, then rescan |
| Forgetting IPv6 | Rules often exist for IPv4 only | Scan with -6 for every dual-stack host |
| Running intrusive scripts in production | Can crash fragile services or lock accounts | Stick to named safe scripts |
Key takeaways
- Only scan systems you own or are explicitly authorised to test, with a written scope file passed via
-iL. - Use
-Pnand a full-p-sweep; default discovery and top-ports sampling both hide real exposure. - Scan from outside and inside, and over IPv6 as well as IPv4.
- Treat every unexpected open port as a ticket with an owner, then verify the fix with a rescan.
- Automate with
-oAandndiffso a new open port triggers an alert instead of waiting for the next audit.
References
- Nmap Reference Guide: https://nmap.org/book/man.html
- Nmap port scanning techniques: https://nmap.org/book/man-port-scanning-techniques.html
- NSE script categories: https://nmap.org/book/nse-usage.html
- Ndiff documentation: https://nmap.org/ndiff/
- Docker packet filtering and firewalls: https://docs.docker.com/engine/network/packet-filtering-firewalls/
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.