Skip to main content
Networking · lesson 8 of 8
Networking intermediate Lesson 8 of 8

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

networking nmap port-scanning security firewall docker

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:

StateReasonEarlier lessonWhat it proves
opensyn-ackthe [S.] in the handshake capturesomething is listening and reachable
closedresetthe [R.], and Connection refusedthe host is up, and nothing listens on this port
filteredno-responsethe drop rule, and timed outnothing 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

  1. Scan without raw sockets. Drop NET_RAW again and make the scan work anyway, with a connect scan. What changes in the REASON column?
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.

  1. 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 with reject 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.

  1. 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.

Frequently Asked Questions

Is port scanning legal?
Scanning systems you own, or that you have written permission to test, is a normal administration and security task. Scanning systems without permission may break computer misuse laws or your provider's terms of service, depending on where you are, and it is treated as hostile by the people who run them. Keep scans inside a lab you control, like the private Docker network in this lesson.
What is the difference between closed and filtered in nmap?
Closed means the target host answered the probe with a TCP reset, so the host is reachable but nothing is listening on that port. Filtered means nmap got no useful answer, usually because a firewall dropped the probe, or it got an ICMP unreachable message, so it cannot tell whether a service is listening. Run nmap with --reason to see the reply behind each state.
What does nmap -sV do?
-sV turns on service and version detection. After finding open ports, nmap connects to each one and compares the service's responses and banners with its database of probes, then reports the product and version, such as OpenSSH 10.0p2 or Python's SimpleHTTPServer. On Alpine, the nmap-scripts package must be installed or -sV fails with 'could not locate nse_main.lua'.
What is the difference between a SYN scan and a connect scan?
A SYN scan (-sS, the default for root) sends raw SYN packets and reads the replies without completing the handshake, which needs the NET_RAW capability. A connect scan (-sT) asks the operating system to make normal TCP connections, so it works without raw sockets. Both report open, closed and filtered; the connect scan reports a closed port's reason as conn-refused instead of reset.
Why does the same port show open from one host and filtered from another?
A firewall rule can allow a port only from certain source addresses. From an allowed address the probe reaches the service and nmap reports open; from any other address the probe is dropped and nmap reports filtered. A scan result is always a view from one particular place in the network, so say where you scanned from when you report it.