Security · 50 min

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:

Prerequisites

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:

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

  1. 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 telnet or a raw socket
    one-liner (skip if telnet isn'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.

  2. Write the sequential scanner

    Create scanner.py with 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_ex instead of connect. connect_ex returns 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 1024
    

    Checkpoint

    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.py that correctly
    reports ports 8000 and 9000 as open, plus the elapsed time of a
    1–1024 scan.

  3. 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. A workers
    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 1024
    

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

  4. 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 ready banner (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).

  5. 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
    argparse interface 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.md next to scanner.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 --banners
    

    Confirm: it refuses a non-local host (test with
    python scanner.py 8.8.8.8 and 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:

    1. scanner.py implementing all three stages: sequential
      (scan_port/scan_range), threaded (scan_range_threaded), and
      banner-aware (grab_banner/scan_with_banners).
    2. The local-host allowlist check present and unmodified in
      __main__, confirmed to refuse a non-loopback host.
    3. A timed comparison note (from Steps 2 and 3) showing the threaded
      speedup on your machine.
    4. A README.md stating the tool's authorized-use boundary in your
      own words.