Networking · lesson 8 of 8
Beginner
4Intermediate
4Also part of Networking
11- + Linux Networking: Routes, Interfaces, and Bind Addresses from Linux
- + Bash Networking from Bash
- + Docker Networking from Docker
- + FastAPI HTTP Methods and Status Codes from FastAPI
- + FastAPI Headers and Cookies from FastAPI
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + VPC and Networking Basics from AWS
- + GCP VPC Networking Basics from Google Cloud
- + Azure Virtual Network Basics from Azure
- + The TLS Handshake, Run Locally with openssl s_server from Cryptography
- + Load Balancing: Round Robin vs Least Connections from System Design
Also part of: Ethical Hacking (methodology, lab-only)
Port Scanning Your Own Lab with nmap: Open, Closed, Filtered
Scan a container you own with nmap on a private Docker network: what open, closed and filtered mean, the packets behind each, and -sV version banners.
By SkillByExample · Updated
Scope first. Only scan systems you own or have written permission to test. Scanning anyone else’s
hosts may be illegal where you live, and it always looks like the first step of an attack to the people
who run them. Everything in this lesson targets one container you start yourself, on a private Docker
network marked --internal so no packet can leave your machine. Use plain scans only. The timing,
decoy, fragmentation and other evasion options exist to avoid being noticed, and you have nobody to
hide from in your own lab.
With that settled, a scanner is the outside view of everything in this topic. Its three port states are the three answers you’ve already met: a handshake from the tcpdump lesson, a reset from a closed port, and the silence of a firewall drop. Tested with Docker Engine 29.8.2, nmap 7.97 on Alpine 3.22.6, OpenSSH 10.0p2, Python 3.12.15 and nftables 1.1.3.
The target: two services and a guarded admin port
The target runs SSH on 22 and a web server on 80, plus an “admin” web server on 8080. A firewall lets
only 10.77.0.20 reach the admin port.
# admin.nft
table inet lab {
chain input {
type filter hook input priority 0; policy accept;
ip saddr 10.77.0.20 tcp dport 8080 accept
tcp dport 8080 drop
}
}
# start.sh
nft -f /admin.nft
/usr/sbin/sshd
python -m http.server 8080 --directory /tmp &
exec python -m http.server 80 --directory /tmp
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server nftables \
&& rm -rf /var/lib/apt/lists/* && ssh-keygen -A && mkdir -p /run/sshd
COPY admin.nft start.sh /
CMD ["sh", "/start.sh"]
The scanner is Alpine with nmap in scanner/Dockerfile. nmap-scripts is needed for version
detection later.
FROM alpine:3.22
RUN apk add --no-cache nmap nmap-scripts
docker build -t scan-target ./target
docker build -t scanner ./scanner
docker network create --internal --subnet 10.77.0.0/24 scan-lab
docker run -d --name target --network scan-lab --ip 10.77.0.10 --cap-add NET_ADMIN scan-target
The target gets NET_ADMIN so it can load its own firewall. Nothing gets --privileged, and nothing
touches the host network.
A scan needs raw packets
$ docker run --rm --network scan-lab --ip 10.77.0.30 --cap-drop NET_RAW scanner nmap -p 22,80,5432,8080 target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:24 +0000
dnet: Failed to open device eth0
QUITTING!
Running as root, nmap defaults to a SYN scan, which builds its own packets and needs a raw socket.
Without CAP_NET_RAW it can’t even open the interface. Docker grants NET_RAW to containers by
default, which is why this demo had to drop it with --cap-drop NET_RAW. The scans below pass
--cap-add NET_RAW on the scanner container only. On Docker that changes nothing: it makes the
requirement explicit, and keeps the command working where the default has been tightened.
Open, closed, filtered
Scan four named ports on the one target, from 10.77.0.30, an address the firewall doesn’t allow:
$ docker run --rm --network scan-lab --ip 10.77.0.30 --cap-add NET_RAW scanner nmap -p 22,80,5432,8080 target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:24 +0000
Nmap scan report for target (10.77.0.10)
Host is up (0.000049s latency).
rDNS record for 10.77.0.10: target.scan-lab
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
5432/tcp closed postgresql
8080/tcp filtered http-proxy
MAC Address: 6E:72:E9:A0:3A:7B (Unknown)
Nmap done: 1 IP address (1 host up) scanned in 1.48 seconds
Three states from four ports. The SERVICE column is only nmap’s guess from the port number: nothing
on this target speaks PostgreSQL, and 8080 isn’t a proxy. Treat that column as a label, not a finding.
--reason shows the evidence behind each state:
$ docker run --rm --network scan-lab --ip 10.77.0.30 --cap-add NET_RAW scanner nmap -p 22,80,5432,8080 --reason target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:24 +0000
Nmap scan report for target (10.77.0.10)
Host is up, received arp-response (0.000029s latency).
rDNS record for 10.77.0.10: target.scan-lab
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 64
80/tcp open http syn-ack ttl 64
5432/tcp closed postgresql reset ttl 64
8080/tcp filtered http-proxy no-response
MAC Address: 6E:72:E9:A0:3A:7B (Unknown)
Nmap done: 1 IP address (1 host up) scanned in 1.50 seconds
Each reason is a packet you’ve already seen:
| State | Reason | Earlier lesson | What it proves |
|---|---|---|---|
| open | syn-ack | the [S.] in the handshake capture | something is listening and reachable |
| closed | reset | the [R.], and Connection refused | the host is up, and nothing listens on this port |
| filtered | no-response | the drop rule, and timed out | nothing at all, so the port’s real state is hidden |
nmap sent a SYN and stopped there. For open ports the target’s SYN-ACK arrived at the scanner, and the scanner’s kernel answered it with a reset. nmap built that SYN on a raw socket, so no socket on the scanner owns the connection, and to the kernel the SYN-ACK is unexpected. No connection was ever completed. “Host is up, received arp-response” is the ARP exchange from the timeout capture: on a local subnet, nmap knows a host exists before it sends a single TCP probe.
Ask the services what they are
-sV connects to each open port and matches the replies against nmap’s database of service
fingerprints:
$ docker run --rm --network scan-lab --ip 10.77.0.30 --cap-add NET_RAW scanner nmap -p 22,80 -sV target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:24 +0000
Nmap scan report for target (10.77.0.10)
Host is up (0.000054s latency).
rDNS record for 10.77.0.10: target.scan-lab
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.0p2 Debian 7+deb13u4 (protocol 2.0)
80/tcp open http SimpleHTTPServer 0.6 (Python 3.12.15)
MAC Address: 6E:72:E9:A0:3A:7B (Unknown)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 6.48 seconds
Each service gave away its exact build. SSH announces its version in its first line, and Python’s
http.server puts its name and the Python version in its Server header. That’s useful to a defender
checking a patch level, and equally useful to anyone else. Note also what this scan finds: a
development web server, python -m http.server, open on port 80. It’s the kind of door
Attack surface teaches you to close. Without nmap-scripts
installed, Alpine’s nmap fails here with could not locate nse_main.lua, because version detection
runs inside nmap’s script engine.
The view depends on where you stand
Scan again from 10.77.0.20, the address the firewall allows:
$ docker run --rm --network scan-lab --ip 10.77.0.20 --cap-add NET_RAW scanner nmap -p 22,80,5432,8080 --reason target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:24 +0000
Nmap scan report for target (10.77.0.10)
Host is up, received arp-response (0.000057s latency).
rDNS record for 10.77.0.10: target.scan-lab
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 64
80/tcp open http syn-ack ttl 64
5432/tcp closed postgresql reset ttl 64
8080/tcp open http-proxy syn-ack ttl 64
MAC Address: 6E:72:E9:A0:3A:7B (Unknown)
Nmap done: 1 IP address (1 host up) scanned in 0.29 seconds
Port 8080 was open all along. “Filtered” didn’t mean “safe” or “not running”. It meant “this vantage
point can’t see it”. The scan also finished five times faster, because nmap no longer had to wait out
the timeout for a probe that would never be answered. A scan report without its source address is
incomplete, and so is a firewall test run from the wrong place. When you’re done, run
docker rm -f target and docker network rm scan-lab.
Practice
- Scan without raw sockets. Drop
NET_RAWagain and make the scan work anyway, with a connect scan. What changes in theREASONcolumn?
Solution
$ docker run --rm --network scan-lab --ip 10.77.0.30 --cap-drop NET_RAW scanner nmap --unprivileged -sT -p 22,80,5432,8080 --reason target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:25 +0000
Nmap scan report for target (10.77.0.10)
Host is up, received syn-ack (0.000036s latency).
rDNS record for 10.77.0.10: target.scan-lab
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack
80/tcp open http syn-ack
5432/tcp closed postgresql conn-refused
8080/tcp filtered http-proxy no-response
Nmap done: 1 IP address (1 host up) scanned in 1.23 seconds
-sT asks the kernel for ordinary connect() calls, the same as client.py in the sockets lesson, so a
closed port’s reason is now conn-refused, the errno, not a raw reset. -sT alone wasn’t enough as
root: nmap still tried to open the interface for raw access. --unprivileged tells it not to. Without
ARP, host discovery fell back to a TCP probe (“received syn-ack”), and the MAC Address line is gone.
- Reject instead of drop. Inside the target, replace the 8080 rules with a plain
reject(an ICMP port-unreachable reply), scan port 8080 with--reason, and then do the same withreject with tcp reset. Compare the two results with the firewall lesson’s client errors.
Solution
$ docker exec target sh -c "nft flush chain inet lab input && nft add rule inet lab input tcp dport 8080 reject"
$ docker run --rm --network scan-lab --ip 10.77.0.30 --cap-add NET_RAW scanner nmap -p 8080 --reason target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:25 +0000
Nmap scan report for target (10.77.0.10)
Host is up, received arp-response (0.00029s latency).
rDNS record for 10.77.0.10: target.scan-lab
PORT STATE SERVICE REASON
8080/tcp filtered http-proxy port-unreach ttl 64
MAC Address: 6E:72:E9:A0:3A:7B (Unknown)
Nmap done: 1 IP address (1 host up) scanned in 0.30 seconds
$ docker exec target sh -c "nft flush chain inet lab input && nft add rule inet lab input tcp dport 8080 reject with tcp reset"
$ docker run --rm --network scan-lab --ip 10.77.0.30 --cap-add NET_RAW scanner nmap -p 8080 --reason target
Starting Nmap 7.97 ( https://nmap.org ) at 2026-10-13 19:25 +0000
Nmap scan report for target (10.77.0.10)
Host is up, received arp-response (0.000070s latency).
rDNS record for 10.77.0.10: target.scan-lab
PORT STATE SERVICE REASON
8080/tcp closed http-proxy reset ttl 64
MAC Address: 6E:72:E9:A0:3A:7B (Unknown)
Nmap done: 1 IP address (1 host up) scanned in 0.30 seconds
A Python client reported both of these as Connection refused. nmap tells them apart: an ICMP
port-unreachable reply for TCP is a firewall’s fingerprint, so it reports filtered. A TCP reset is
indistinguishable from a closed port, so it reports closed, even though a server is listening behind
the rule. Each state is an inference from one reply, not a fact about the service.
- Write the finding down. From the scans above, write a three-line report on the target that a colleague could act on: what’s exposed, from where, and what you’d change.
Solution
target (10.77.0.10), scanned from 10.77.0.30 and 10.77.0.20 on scan-lab, nmap 7.97:
- 22/tcp OpenSSH 10.0p2 and 80/tcp Python SimpleHTTPServer are open to every host on the network;
the dev web server should not be running, and SSH should be limited to admin addresses.
- 8080/tcp is open from 10.77.0.20 only and dropped for everyone else, as intended.
A useful finding says where the scan ran from, uses the version evidence rather than the port-number guess, and separates “unexpected” from “as intended”. This one comes from a single command per port state, and every target in it is a container you started.