Build a Port Scanner in Python
Write a TCP port scanner from scratch using Python's `socket` module, speed it up with threading, and add basic banner-grabbing service detection — all against your own local machine, learning how the reconnaissance tools you'll defend against actually work.
Problem
Before you can secure a network, you need to understand how it gets mapped. A **port scanner** is the first tool in almost every security assessment (and every attack): it tells you which TCP ports on a host are **open** (something is listening and accepting connections), **closed** (the host responds but nothing is listening), or **filtered** (no response at all — usually a firewall silently dropping the packet). Security tools like `nmap` do this at massive scale with dozens of scanning techniques. But the core mechanism behind the simplest and most common one — a **TCP connect scan** — is something you can build yourself in under an hour with nothing but Python's standard library. Building it yourself is worth more than reading about it: once you've written the `connect()` loop and watched it succeed, timeout, and get refused, you'll understand exactly what firewall logs and IDS alerts about "port scan detected" are actually reacting to. In this lab you will write a scanner in three stages: first a simple, correct, single-threaded version; then a much faster threaded version; then a version that grabs service banners from open ports so it can guess *what* is listening, not just *that* something is. Every stage runs only against `127.0.0.1` (your own machine) — this is the one and only target you are authorized to scan without separate written permission. > **Legal and ethical boundary — read this before writing any code.** > Port scanning a host you do not own or do not have explicit written > authorization to test is illegal in most jurisdictions (in the US, it > can fall under the Computer Fraud and Abuse Act; most countries have an > equivalent). It is also a violation of nearly every cloud provider's and > ISP's acceptable-use policy, and will typically get an account > suspended even before any legal action. Every exercise in this lab > targets `127.0.0.1` / `localhost` — your own machine. Do not point the > scanner you build here at any host you do not own or administer.
Objectives
By the end of this lab you will be able to:
- Explain the difference between an open, closed, and filtered TCP port,
and what a client-sideconnect()call observes for each. - Write a TCP connect scanner using Python's
socketmodule, including
correct timeout and error handling. - Explain why a naive sequential scanner is slow, and speed it up with a
thread pool, using aThreadPoolExecutoror manualthreading.Threads. - Implement basic banner grabbing to read the first bytes a service sends
after connecting, and map common ports to expected services. - State, in your own words, the legal and ethical boundary around port
scanning, and justify why every test in this lab targets127.0.0.1only.
Prerequisites
- Python 3.9+ installed, with no third-party packages required (the
standard library'ssocket,threading/concurrent.futures, and
timemodules are enough). - A terminal and a plain-text editor or IDE.
- Basic familiarity with Python functions, loops, and exceptions.
- The ability to start a small local TCP listener for testing (this lab
shows you how, using Python's built-inhttp.server).
What you will build
A command-line Python port scanner, scanner.py, that grows across three
stages: correct-but-slow, fast-with-threads, and service-aware. By the end
you'll run it against your own machine and get a report like:
Scanning 127.0.0.1 (ports 1-1024)...
Port 22 OPEN (banner: SSH-2.0-OpenSSH_9.6)
Port 80 OPEN (banner: unknown/no banner)
Port 8000 OPEN (banner: unknown/no banner)
Scan complete: 3 open, 1021 closed/filtered, 4.2s elapsed
How a TCP connect scan actually works
TCP connections start with a three-way handshake: SYN → SYN-ACK →
ACK. A connect scan is the simplest possible scan technique because
it just asks your operating system's networking stack to complete a
normal connection — no raw sockets, no special privileges needed. Python's
socket.connect() does exactly this:
| Outcome | What happened on the wire | What Python sees |
|---|---|---|
| Open | Full handshake completes |
connect() returns successfully |
| Closed | Target sends back RST (reset) |
connect() raises ConnectionRefusedError
|
| Filtered | Nothing comes back at all |
connect() blocks until your timeout expires |
This table is the entire algorithm. Everything else in this lab is about
doing that lookup correctly, quickly, and with enough information to be
useful.
Why this matters defensively, not just offensively
Understanding scanning is a defensive skill:
- It's exactly what triggers "port scan detected" alerts in an IDS/IPS —
now you know precisely what pattern of connection attempts causes that. - It's the first thing an attacker runs against any host they can reach —
knowing how fast and how noisy it is tells you what to expect in your
own logs during a real incident. - Building your own rate-limited, well-behaved scanner (which you will
do in Step 3) versus a reckless one is the same design tension as
building rate limiting into your own APIs — you'll recognize the pattern
later from the other side.
How to work through this lab
Work through the five steps in order — each stage of the scanner builds on
the previous file. Test everything against 127.0.0.1 only.
Steps
-
Start local test targets
You need real open ports to scan. Start two harmless local listeners
in separate terminals so your scanner has something to find.# Terminal A — a simple HTTP server on port 8000 python -m http.server 8000 # Terminal B — a minimal raw TCP echo listener on port 9000 python -c " import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('127.0.0.1', 9000)) s.listen(5) print('listening on 9000') while True: conn, addr = s.accept() conn.send(b'ECHO-SERVER-1.0 ready\n') conn.close() "Leave both running for the rest of the lab. You now have two known
open ports (8000, 9000) plus whatever else is already open on your
machine (commonly nothing else in the low ports, unless you have SSH,
a database, or a dev server running).Checkpoint
Confirm each listener responds using
telnetor a raw socket
one-liner (skip iftelnetisn't installed — the Python one-liner
works everywhere):python -c " import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(2) s.connect(('127.0.0.1', 9000)) print(s.recv(100)) "You should see
b'ECHO-SERVER-1.0 ready\n'printed. If you get a
ConnectionRefusedError, the listener from Terminal B isn't running —
go back and start it.Deliverable for this step: two listeners running locally on ports
8000 and 9000, confirmed reachable by the checkpoint script. -
Write the sequential scanner
Create
scanner.pywith a single function that scans one port and
reports its state, then loop it over a range.# scanner.py import socket import sys import time DEFAULT_TIMEOUT = 0.5 # seconds def scan_port(host: str, port: int, timeout: float = DEFAULT_TIMEOUT) -> str: """Return 'open', 'closed', or 'filtered' for a single TCP port.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: result = sock.connect_ex((host, port)) # connect_ex returns 0 on success instead of raising return "open" if result == 0 else "closed" except socket.timeout: return "filtered" except OSError: return "closed" finally: sock.close() def scan_range(host: str, start_port: int, end_port: int): open_ports = [] start = time.time() for port in range(start_port, end_port + 1): state = scan_port(host, port) if state == "open": print(f"Port {port:<6} OPEN") open_ports.append(port) elapsed = time.time() - start print(f"Scan complete: {len(open_ports)} open, " f"{end_port - start_port + 1 - len(open_ports)} closed/filtered, " f"{elapsed:.1f}s elapsed") return open_ports if __name__ == "__main__": host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" if host not in ("127.0.0.1", "localhost", "::1"): print("Refusing to scan a non-local host. This tool is for " "authorized local testing only.") sys.exit(1) start_port = int(sys.argv[2]) if len(sys.argv) > 2 else 1 end_port = int(sys.argv[3]) if len(sys.argv) > 3 else 1024 print(f"Scanning {host} (ports {start_port}-{end_port})...") scan_range(host, start_port, end_port)Notice two deliberate design choices:
-
connect_exinstead ofconnect.connect_exreturns an error
code instead of raising an exception on refusal, which keeps the
hot loop free of exception-handling overhead for the extremely
common "closed" case. -
A host allowlist in
__main__. The script refuses to run
against anything other than loopback addresses. This is not
decoration — it is the single most important line of code in this
lab. Keep it in every version you write from here on.
Run it
python scanner.py 127.0.0.1 1 1024Checkpoint
You should see ports 8000 and 9000 (from Step 1) reported as
OPEN,
plus possibly a few others already running on your machine (SSH on 22
is common on macOS/Linux). Time the run — on most machines, 1,024
sequential ports with a 0.5s timeout takes several seconds for open
ports and can take much longer if any ports are filtered (each
filtered port burns the full timeout). Write down the elapsed time
printed at the end — you'll compare it against the threaded version
in Step 3.Deliverable for this step: a working
scanner.pythat correctly
reports ports 8000 and 9000 as open, plus the elapsed time of a
1–1024 scan. -
-
Speed it up with a thread pool
A sequential scan is dominated by waiting: each
connect()either
returns almost instantly (open/closed) or blocks for the full timeout
(filtered). Network I/O like this is exactly what threads are good
for in Python — the GIL is released during blocking socket calls, so
threads genuinely run concurrently here.Add a threaded scan function using
concurrent.futures.ThreadPoolExecutor:# add to scanner.py from concurrent.futures import ThreadPoolExecutor, as_completed DEFAULT_WORKERS = 100 def scan_range_threaded(host: str, start_port: int, end_port: int, workers: int = DEFAULT_WORKERS): open_ports = [] ports = range(start_port, end_port + 1) start = time.time() with ThreadPoolExecutor(max_workers=workers) as pool: future_to_port = { pool.submit(scan_port, host, port): port for port in ports } for future in as_completed(future_to_port): port = future_to_port[future] state = future.result() if state == "open": print(f"Port {port:<6} OPEN") open_ports.append(port) elapsed = time.time() - start total = end_port - start_port + 1 print(f"Scan complete: {len(open_ports)} open, " f"{total - len(open_ports)} closed/filtered, " f"{elapsed:.1f}s elapsed ({workers} workers)") return sorted(open_ports)Wire it up as an option:
# replace the __main__ block if __name__ == "__main__": host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" if host not in ("127.0.0.1", "localhost", "::1"): print("Refusing to scan a non-local host. This tool is for " "authorized local testing only.") sys.exit(1) start_port = int(sys.argv[2]) if len(sys.argv) > 2 else 1 end_port = int(sys.argv[3]) if len(sys.argv) > 3 else 1024 print(f"Scanning {host} (ports {start_port}-{end_port})...") scan_range_threaded(host, start_port, end_port)Why not thousands of workers?
More threads is not free. Each open socket consumes a file descriptor
(operating systems cap these — often 1024 by default), and connection
attempts still cost real syscall and kernel overhead. Aworkers
value in the 50–200 range is typically the sweet spot for a scan
against a single host: fast enough to be dramatically better than
sequential, without exhausting file descriptors or making your own
machine's network stack the bottleneck.There is also a courtesy dimension: on a real (authorized) target,
an extremely aggressive scan can look like — and functionally behave
like — a denial-of-service. A well-behaved scanner picks a
concurrency level appropriate to the target and the authorization it
has, not just "as fast as my hardware allows."Checkpoint
Run the threaded version and compare its elapsed time directly against
your Step 2 measurement:python scanner.py 127.0.0.1 1 1024You should see the same set of open ports (8000, 9000, plus whatever
else was already open) but a dramatically lower elapsed time —
typically 5–20x faster, since the filtered-port timeouts now overlap
instead of stacking sequentially.Deliverable for this step: a threaded scan that reports the same
open ports as Step 2, with the elapsed-time improvement written down. -
Add banner grabbing for service detection
Knowing a port is open is useful; knowing what's probably listening
is more useful. Many services announce themselves the moment a
connection is made — SSH, FTP, and SMTP all send a greeting banner
unprompted. HTTP servers, by contrast, wait for a request first. A
good scanner tries both: read passively for a moment, and if nothing
arrives, send a minimal HTTP probe.# add to scanner.py COMMON_PORTS = { 21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 80: "HTTP", 443: "HTTPS", 3306: "MySQL", 5432: "PostgreSQL", 6379: "Redis", 8000: "HTTP-alt", 8080: "HTTP-alt", 9000: "Unknown/custom", } def grab_banner(host: str, port: int, timeout: float = 1.0) -> str: """Best-effort read of a service banner. Returns 'unknown' if none.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect((host, port)) # Step 1: many services (SSH, FTP, SMTP) greet immediately. sock.settimeout(0.8) try: data = sock.recv(256) if data: return data.decode(errors="replace").strip() except socket.timeout: pass # no unsolicited banner — try prompting it instead # Step 2: HTTP-like services need a request before they'll talk. try: sock.sendall(b"HEAD / HTTP/1.0\r\n\r\n") sock.settimeout(0.8) data = sock.recv(256) if data: return data.decode(errors="replace").splitlines()[0] except (socket.timeout, OSError): pass return "unknown/no banner" except OSError: return "unreachable" finally: sock.close() def scan_with_banners(host: str, start_port: int, end_port: int, workers: int = DEFAULT_WORKERS): open_ports = scan_range_threaded(host, start_port, end_port, workers) print("\nService detection:") for port in open_ports: banner = grab_banner(host, port) guess = COMMON_PORTS.get(port, "unknown") print(f"Port {port:<6} OPEN guess={guess:<12} banner={banner!r}")Why this two-step probe order matters
If you send the HTTP probe first to every port, you risk confusing
or misbehaving with non-HTTP services (some will log a garbage
request, some will just close the connection). Passive-read-first,
active-probe-second is the same order real tools use, and it's
gentler on whatever is actually listening.Test it against your two known listeners
# quick manual check print(grab_banner("127.0.0.1", 9000)) # -> "ECHO-SERVER-1.0 ready" print(grab_banner("127.0.0.1", 8000)) # -> an HTTP status line, e.g. "HTTP/1.0 501 ..."Checkpoint
Run the full scan with banners:
python -c " from scanner import scan_with_banners scan_with_banners('127.0.0.1', 1, 1024) "Confirm port 9000 shows the
ECHO-SERVER-1.0 readybanner (grabbed
passively) and port 8000 shows an HTTP response line (grabbed via the
active probe). If SSH is running locally, confirm it shows an
SSH-2.0-...banner too — that is the single most useful line a
banner grab can produce, since SSH banners usually include the exact
server software and version.Deliverable for this step: a scan report where each open port
shows a banner or an explicit "unknown/no banner", correctly
distinguishing the passively-greeted service (9000) from the
actively-probed one (8000). -
Harden, document boundaries, and submit
Finish by tightening the safety rails and writing down, explicitly,
the rules this tool must follow.Add a rate limit and a clear CLI
A scanner with no pacing at all is indistinguishable from an
aggressive attack tool. Add a small, optional delay knob and a proper
argparseinterface so the tool is self-documenting:# replace the __main__ block one more time import argparse if __name__ == "__main__": parser = argparse.ArgumentParser( description="Educational TCP port scanner. LOCAL HOSTS ONLY." ) parser.add_argument("host", nargs="?", default="127.0.0.1") parser.add_argument("start_port", nargs="?", type=int, default=1) parser.add_argument("end_port", nargs="?", type=int, default=1024) parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS) parser.add_argument("--banners", action="store_true", help="attempt service/banner detection on open ports") args = parser.parse_args() if args.host not in ("127.0.0.1", "localhost", "::1"): print("Refusing to scan a non-local host. This tool is for " "authorized local testing only. See the README.") sys.exit(1) print(f"Scanning {args.host} (ports {args.start_port}-{args.end_port})...") if args.banners: scan_with_banners(args.host, args.start_port, args.end_port, args.workers) else: scan_range_threaded(args.host, args.start_port, args.end_port, args.workers)Write the boundary down where anyone using this tool will see it
Create a short
README.mdnext toscanner.py:# scanner.py — educational TCP port scanner Built for a DARE security lab. Demonstrates how a TCP connect scan, threaded concurrency, and banner grabbing work. ## Authorized use only This tool refuses to run against anything other than 127.0.0.1 / localhost / ::1 by design (see the check in `__main__`). Do not remove that check to point this at a host you do not own or do not have explicit written authorization to test. Unauthorized port scanning can violate computer-crime law and acceptable-use policies. ## Usage python scanner.py [host] [start_port] [end_port] [--workers N] [--banners]Checkpoint
Run the finished tool end to end with the full feature set:
python scanner.py 127.0.0.1 1 1024 --workers 150 --bannersConfirm: it refuses a non-local host (test with
python scanner.py 8.8.8.8and confirm it prints the refusal and
exits with a non-zero code instead of scanning), it finds your known
open ports, and it reports banners for at least the two listeners
from Step 1.Submission criteria
Submit when all of the following hold:
-
scanner.pyimplementing all three stages: sequential
(scan_port/scan_range), threaded (scan_range_threaded), and
banner-aware (grab_banner/scan_with_banners). - The local-host allowlist check present and unmodified in
__main__, confirmed to refuse a non-loopback host. - A timed comparison note (from Steps 2 and 3) showing the threaded
speedup on your machine. - A
README.mdstating the tool's authorized-use boundary in your
own words.
-