Safe Penetration Testing Labs · advanced · ~12 min

A safe localhost-only port scanner

- Build a TCP port scanner in C that probes a range of ports by attempting `connect()` to each one - Enforce a hardcoded scope guardrail so the tool can only ever target the loopback address (`127.0.0.1`) - Distinguish an *open* port (connection accepted) from a *closed* or *filtered* port (refused or timed out) and read that result correctly - Rate-limit and bound a scan so it stays polite even against your own machine - Log every scan decision (target, port range, result, timestamp) for defensible, auditable evidence - Explain, in your own words, why scope enforcement and authorization — not the scanning code itself — are what make a scanner safe

Overview

Security objective. The asset you are protecting is other people's machines and networks — and your own legal standing. The threat is a scanning tool that gets pointed, by accident or by a careless edit, at a host you are not authorized to test. In this lesson you build a port scanner whose scope is welded shut: it can only ever probe 127.0.0.1, the loopback address that always means "this same computer." You will learn to detect open ports on your own machine and, just as importantly, to prevent the tool from ever reaching beyond it.

A port scanner answers one question: which TCP ports on a host have a program listening? It does this by trying to open a connection to each port. If the connection is accepted, something is listening (the port is open); if it is refused, nothing is there (closed); if nothing answers at all, the port may be filtered by a firewall.

This builds directly on your prerequisites. From TCP client you already know how to create a socket, fill in a sockaddr_in, and call connect() — a scanner is just that same client run in a loop over many ports. From Localhost port checking you know that 127.0.0.1 is the loopback interface and how to test a single port. Here we combine the two and wrap them in a guardrail: a hardcoded scope check that refuses any target except loopback.

The headline lesson is subtle but essential: the scanning code is not what makes a tool ethical — the scope control and the authorization behind it are. A scanner that respects boundaries is a diagnostic instrument. The same code aimed at a stranger's server is an attack. The difference lives in the guardrail and in the permission you have to run it.

Why it matters

In authorized, professional work, port scanning is a routine first step — network administrators inventory their own services, developers confirm a dev server is actually listening, and penetration testers map an in-scope target's exposed surface. In every one of those jobs, staying inside the agreed scope is the difference between a paid engagement and a crime. Real penetration-testing contracts specify exact IP ranges and hosts; touching anything outside that list can void the contract and expose the tester to legal liability under laws like the U.S. Computer Fraud and Abuse Act or the UK Computer Misuse Act.

A localhost-only scanner is the safest possible place to learn these skills. Because the target can never be anything but your own machine, you can experiment freely with sockets, timeouts, and result interpretation without any risk of touching a system you do not own. The habit you build here — bake the scope limit into the code, log every decision, and never run against a host you lack written permission to test — is exactly the discipline that separates trustworthy security professionals from reckless ones. Employers and clients hire the people who can prove they stayed in bounds.

Core concepts

1. What a port scan actually is

Definition. A TCP port scan attempts to establish a connection to each port in a range and records which attempts succeed.

Plain explanation. A TCP port is a numbered doorway (0–65535) on a host. A server "listens" on a port; a client "connects" to it. Scanning just means knocking on each door in turn and noting which ones open.

How it works. For each port you create a socket, build a sockaddr_in with that port and the target address, and call connect(). A return of 0 means the port accepted the connection (open). An error like ECONNREFUSED means the port is closed. No response before a timeout usually means filtered.

When / when not. Scan when you need to inventory services on a host you own or are authorized to test. Never scan a host you lack explicit written permission for — even a "harmless" connect scan is unauthorized access in many jurisdictions.

Pitfall. A connect scan completes the full TCP handshake, so it is easily logged by the target. That is fine — even good — on your own lab, but it means a scan is never invisible and never deniable.

2. Loopback (127.0.0.1) as a safety boundary

Definition. 127.0.0.1 is the IPv4 loopback address; traffic to it never leaves the machine.

Plain explanation. Anything you send to loopback is handled by your own operating system and looped straight back. No packet reaches your network card, your router, or the internet.

How it works. The kernel routes 127.0.0.0/8 internally. A connect() to 127.0.0.1:8080 reaches only a service running on this computer.

When / when not. Use loopback for every learning scan. Do not assume localhost always resolves to 127.0.0.1 — on some systems it also maps to the IPv6 ::1, which is a different code path.

Pitfall. Binding the scope check to a hostname string like "localhost" is weaker than binding it to the numeric 127.0.0.1, because hostname resolution can be reconfigured. Prefer the literal loopback address and validate it explicitly.

3. The hardcoded scope guardrail

Definition. A guardrail is a scope limit built into the program so the tool physically cannot act outside its intended boundary.

Plain explanation. Before any scanning happens, one check compares the requested target against the single allowed address. Anything else is refused and logged.

How it works. if (!scan_allowed(host)) return -1; sits at the very top of the scan function. scan_allowed returns 1 only for the loopback spellings you approve. No fallthrough, no "just this once."

When / when not. Always keep the guardrail. The only time you widen scope is when a written authorization names new targets — and then you change scope deliberately, in code review, not by deleting the check.

Pitfall. Reading the host from user input or an environment variable without re-validating it is the classic hole. The guardrail must run on the actual value used, every time.

4. Open vs. closed vs. filtered

Definition. Three outcomes of a connect attempt: accepted (open), actively refused (closed), or no answer (filtered/timeout).

Plain explanation. Open means a program is listening. Closed means the OS answered "nobody home." Filtered means something silently dropped your probe.

How it works. connect() returns 0 for open; sets errno to ECONNREFUSED for closed; and — with a non-blocking socket and a timeout — simply never completes for filtered.

When / when not. Interpreting these correctly matters when you write results to a report. Do not label a timeout as "open"; that is a false finding.

Pitfall. A blocking connect() on a filtered port can hang for a minute or more. Use a bounded timeout so a single unresponsive port cannot stall the whole scan.

Threat model

            +-------------------------------------------------+
            |                YOUR COMPUTER                    |
            |                                                 |
  operator  |   +-----------------+      loopback boundary    |
  runs  --->|   | localhost-only  |     (127.0.0.1 only)      |
  scanner   |   |    scanner      |---+                       |
            |   +-----------------+   |                       |
            |          |              v                       |
            |  scope guardrail   +----------------+           |
            |  scan_allowed()    | local services |           |
            |  (TRUST BOUNDARY)  | :22 :80 :8080  |           |
            |          |         +----------------+           |
            |          X refuses everything not 127.0.0.1     |
            +----------|--------------------------------------+
                       X  <-- packets to any external host
                          are NEVER generated
   ============================================================
   OUT OF SCOPE (protected asset): other hosts, LAN, internet
  • Asset protected: every host that is not your machine — your LAN, the internet, and your own legal standing.
  • Trust boundary: the scan_allowed() check. Inside it, targets are trusted (loopback only); outside it, all input is untrusted and refused.
  • Entry point: the host argument passed into the scan function — the one value an attacker or a careless edit could try to abuse.

Knowledge check.

  1. What asset is the guardrail actually protecting — the scanner, or something else?
  2. Where exactly is the trust boundary in this program, and what value crosses it?
  3. If a maintainer added host = getenv("TARGET"); just above the scan loop, what insecure assumption would that introduce, and how would you detect it in a log?

Syntax notes

The key structure is a single scope gate followed by a bounded connect loop. Here is the shape, annotated (lab-safe, loopback only):

/* Returns 1 only for approved loopback spellings, else 0. */
int scan_allowed(const char *host) {
    return strcmp(host, "127.0.0.1") == 0;   /* the ONLY trusted target */
}

int scan(const char *host, int lo, int hi) {
    if (!scan_allowed(host)) return -1;      /* GUARDRAIL: refuse & stop */
    if (lo < 0 || hi > 65535 || hi < lo) return -1;  /* bound the range */

    for (int port = lo; port <= hi; port++) {
        int fd = socket(AF_INET, SOCK_STREAM, 0);   /* new socket per port */
        struct sockaddr_in addr = {0};
        addr.sin_family = AF_INET;
        addr.sin_port   = htons((uint16_t)port);    /* host->network order */
        inet_pton(AF_INET, host, &addr.sin_addr);   /* text ip -> binary  */
        if (connect(fd, (struct sockaddr *)&addr, sizeof addr) == 0) {
            /* port is OPEN */
        }
        close(fd);                                   /* always clean up    */
    }
    return 0;
}

Key points:

  • htons() converts the port to network byte order — required, or you scan the wrong port.
  • inet_pton() parses the dotted-quad string into binary; it also validates the address format.
  • One socket per port, always close()d, or you leak file descriptors across 65k iterations.
  • The guardrail and the range bound both run before any socket is created.

Lesson

What a localhost-only scanner does

A port scanner checks which network ports on a host are open. Each open port usually means a program is listening there (for example, a web server on port 80).

A localhost-only scanner is deliberately limited. It hardcodes its target as 127.0.0.1 (the special address that always means "this same computer") and refuses any other host. This keeps the tool safe for learning: it can never be pointed at someone else's machine.

The defensive structure

The core idea is a single check at the top of the scan function:

int scan(const char *host, int lo, int hi) {
    if (strcmp(host, "127.0.0.1") != 0) return -1;  // refuse anything else
    /* ...try connect() on each port... */
}

Here is what happens:

  • strcmp(host, "127.0.0.1") compares the requested host against the only allowed target. It returns 0 when the strings are equal.
  • If the host is anything other than 127.0.0.1, the function returns -1 and does no scanning.
  • Only when the host matches does the code move on to test each port with connect().

Why the hardcoded check matters

The hardcoded check is a guardrail — a safety limit built into the code itself.

Suppose a future maintainer changes the program to read the host from user input. Even then, the scanner still cannot be redirected at a third party, because the check rejects any host that is not 127.0.0.1.

Never soften or remove this check on a production tool. It is what keeps the scanner from becoming a weapon against other systems.

Code examples

Below: an insecure version (do not deploy), a secure rewrite, and a verification harness.

(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

This version takes the target from the command line with no scope check. It will happily scan any host — which is exactly the mistake this lesson exists to prevent.

/* WARNING: intentionally vulnerable — use only in a local, isolated,
   authorized lab. Do not deploy. Reads target from argv with NO guardrail. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>

int main(int argc, char **argv) {
    if (argc != 4) { fprintf(stderr, "usage: %s host lo hi\n", argv[0]); return 1; }
    const char *host = argv[1];          /* UNTRUSTED, and never checked! */
    int lo = atoi(argv[2]), hi = atoi(argv[3]);
    for (int port = lo; port <= hi; port++) {
        int fd = socket(AF_INET, SOCK_STREAM, 0);
        struct sockaddr_in a = {0};
        a.sin_family = AF_INET;
        a.sin_port = htons((unsigned short)port);
        inet_pton(AF_INET, host, &a.sin_addr);
        if (connect(fd, (struct sockaddr *)&a, sizeof a) == 0)
            printf("%s:%d open\n", host, port);   /* could be ANYONE's host */
        close(fd);
    }
    return 0;
}

Why it is dangerous: host flows straight from argv into connect(). A typo, a copy-pasted command, or a malicious script can aim it at a third party. That is unauthorized access — a legal and ethical failure, not just a bug.

(2) SECURE fix — hardcoded loopback scope + bounded, timed scan

/* Safe localhost-only scanner. Compile: cc -std=c11 -Wall -Wextra scan.c -o scan
   Run only on your own machine. Scope is welded to 127.0.0.1. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <time.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <unistd.h>

/* GUARDRAIL: the trust boundary. 1 = approved loopback target, 0 = refuse. */
int scan_allowed(const char *host) {
    return host != NULL && strcmp(host, "127.0.0.1") == 0;
}

/* Timestamped audit line for every scan decision. */
static void audit(const char *event, const char *host, int port, const char *result) {
    char ts[32];
    time_t now = time(NULL);
    struct tm tmv;
    localtime_r(&now, &tmv);
    strftime(ts, sizeof ts, "%Y-%m-%dT%H:%M:%S", &tmv);
    fprintf(stderr, "[%s] event=%s target=%s port=%d result=%s\n",
            ts, event, host ? host : "(null)", port, result);
}

/* Non-blocking connect with a timeout. Returns 1 open, 0 closed/filtered, -1 error. */
static int probe_port(const char *host, int port, int timeout_ms) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) return -1;

    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { close(fd); return -1; }

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_port   = htons((uint16_t)port);
    if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) { close(fd); return -1; }

    int rc = connect(fd, (struct sockaddr *)&addr, sizeof addr);
    if (rc == 0) { close(fd); return 1; }          /* connected immediately */
    if (errno != EINPROGRESS) { close(fd); return 0; } /* refused -> closed  */

    fd_set wset;
    FD_ZERO(&wset);
    FD_SET(fd, &wset);
    struct timeval tv = { timeout_ms / 1000, (timeout_ms % 1000) * 1000 };
    int sel = select(fd + 1, NULL, &wset, NULL, &tv);
    if (sel <= 0) { close(fd); return 0; }         /* timeout -> filtered    */

    int soerr = 0; socklen_t len = sizeof soerr;
    if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &len) < 0) { close(fd); return -1; }
    close(fd);
    return soerr == 0 ? 1 : 0;
}

int scan(const char *host, int lo, int hi) {
    if (!scan_allowed(host)) { audit("scan_refused", host, 0, "out_of_scope"); return -1; }
    if (lo < 0 || hi > 65535 || hi < lo) { audit("scan_refused", host, 0, "bad_range"); return -1; }

    audit("scan_start", host, lo, "begin");
    for (int port = lo; port <= hi; port++) {
        int st = probe_port(host, port, 300);      /* 300 ms per port cap */
        if (st == 1) {
            printf("%s:%d open\n", host, port);
            audit("port_result", host, port, "open");
        }
        struct timespec pause = {0, 5 * 1000 * 1000}; /* 5 ms: gentle rate limit */
        nanosleep(&pause, NULL);
    }
    audit("scan_done", host, hi, "complete");
    return 0;
}

int main(int argc, char **argv) {
    /* Target is NOT taken from argv. Scope is fixed in code. */
    const char *host = "127.0.0.1";
    int lo = 1, hi = 1024;
    if (argc == 3) { lo = atoi(argv[1]); hi = atoi(argv[2]); }
    if (scan(host, lo, hi) != 0) {
        fprintf(stderr, "scan refused or failed\n");
        return 1;
    }
    return 0;
}

Expected output. On a machine running, say, a local web server on 8080 and SSH on 22, a run like ./scan 1 1024 prints lines such as 127.0.0.1:22 open to stdout, while every decision (start, each open port, done, and any refusal) is written to stderr as timestamped audit lines. Ports with nothing listening produce no stdout line.

(3) VERIFY — prove the guardrail rejects bad input and accepts good input

/* Verification harness. Compile with scan_allowed() available.
   Proves the trust boundary: only 127.0.0.1 is accepted. */
#include <stdio.h>
#include <string.h>
int scan_allowed(const char *host);   /* from scan.c */

static int check(const char *host, int expected) {
    int got = scan_allowed(host);
    printf("scan_allowed(%-18s) = %d  (want %d)  %s\n",
           host ? host : "NULL", got, expected, got == expected ? "PASS" : "FAIL");
    return got == expected;
}

int main(void) {
    int ok = 1;
    ok &= check("127.0.0.1", 1);        /* good input  -> accepted */
    ok &= check("10.0.0.5",  0);        /* LAN host    -> refused  */
    ok &= check("192.168.1.1", 0);      /* router      -> refused  */
    ok &= check("example.com", 0);      /* remote name -> refused  */
    ok &= check("127.0.0.1 ", 0);       /* trailing space -> refused */
    ok &= check(NULL, 0);               /* null        -> refused  */
    printf("%s\n", ok ? "ALL PASS" : "SOME FAILED");
    return ok ? 0 : 1;
}

Expected output. Every line reports PASS, ending in ALL PASS. The two refusal cases that matter most are 10.0.0.5 and example.com: they prove the tool cannot be redirected off your machine even when handed a plausible target.

Line by line

Walking the secure version's hot path — a single call scan("127.0.0.1", 1, 1024):

  1. scan_allowed(host) runs first. host is "127.0.0.1", so strcmp returns 0 and the function returns 1 (truthy). The guardrail passes; had host been anything else, we would log scan_refused and return -1 before any socket exists.
  2. Range bound. lo=1, hi=1024 are inside [0, 65535] and hi >= lo, so we continue. A garbage range like hi=99999 would be refused here.
  3. audit("scan_start", ...) writes one timestamped line to stderr, creating the audit trail before work begins.
  4. The loop iterates port from 1 to 1024. For each, probe_port is called.
  5. Inside probe_port: a fresh socket is made non-blocking so connect() cannot hang. inet_pton parses "127.0.0.1" into addr.sin_addr; a return of 1 confirms a valid address.
  6. connect() on a non-blocking socket usually returns -1 with errno == EINPROGRESS. That is normal — the handshake is still in flight.
  7. select() waits up to 300 ms for the socket to become writable. If it times out (sel <= 0), the port is treated as closed/filtered and we return 0.
  8. If it becomes writable, getsockopt(SO_ERROR) reveals the real result: 0 means the connection succeeded (open), non-zero means it was refused.
  9. Back in scan, an open port prints one stdout line and logs port_result ... open. Then nanosleep pauses 5 ms — a gentle rate limit so even a full 1024-port sweep does not hammer the loopback stack.
  10. After the loop, audit("scan_done", ...) records completion.
port connect() result select() interpreted as stdout
22 (sshd up) EINPROGRESS writable, SO_ERROR=0 open 127.0.0.1:22 open
23 (nothing) ECONNREFUSED n/a closed (none)
5000 (dropped) EINPROGRESS timeout filtered (none)

The key value that changes across ports is SO_ERROR after the socket is writable — that single integer is what separates a true "open" finding from a false one.

Common mistakes

Mistake 1 — Taking the target from user input without re-checking it.

  • Wrong: const char *host = argv[1]; then straight into connect().
  • Why wrong: the untrusted value crosses the trust boundary unchecked, so the tool can be aimed at anyone. This is the exact hole in the insecure example.
  • Corrected: hardcode host = "127.0.0.1", or route every externally-supplied host through scan_allowed() before it reaches a socket.
  • Recognise/prevent: grep for argv, getenv, scanf feeding into connect; require the guardrail call to be the first statement in the scan function.

Mistake 2 — Forgetting htons() on the port.

  • Wrong: addr.sin_port = port;
  • Why wrong: on little-endian machines the byte order is swapped, so you probe the wrong port and get nonsense results.
  • Corrected: addr.sin_port = htons((uint16_t)port);
  • Recognise/prevent: results look random or all-closed; always wrap ports/addresses in the byte-order helpers.

Mistake 3 — Blocking connect() with no timeout.

  • Wrong: a plain blocking socket scanning a filtered port.
  • Why wrong: the OS default connect timeout can be over a minute per port; a 1024-port scan could take hours and appear hung.
  • Corrected: non-blocking socket + select() with a bounded timeout (300 ms here).
  • Recognise/prevent: the scan stalls on one port; add per-port timeouts from the start.

Mistake 4 — Leaking file descriptors.

  • Wrong: creating a socket each iteration but only closing it on the open branch.
  • Why wrong: after ~1024 open descriptors, socket() fails with EMFILE and the rest of the scan silently breaks.
  • Corrected: close(fd) on every path out of probe_port.
  • Recognise/prevent: later ports all report closed; check with lsof -p <pid> or count descriptors.

Mistake 5 — Labeling a timeout as "open."

  • Wrong: treating any non-refused result as open.
  • Why wrong: a filtered/timed-out port is not confirmed open; reporting it as open is a false finding that erodes trust in your report.
  • Corrected: only report open when SO_ERROR == 0; report filtered separately or not at all.
  • Recognise/prevent: cross-check a claimed-open port with nc -vz 127.0.0.1 <port>.

Debugging tips

Every port reports closed.

  • Confirm something is actually listening: ss -tlnp (or netstat -an | grep LISTEN) shows local listening ports.
  • Check byte order: is the port wrapped in htons()?
  • Check inet_pton's return value — if it is not 1, the address never parsed.

The scan hangs.

  • You are almost certainly using a blocking connect(). Switch to the non-blocking + select() pattern.
  • Verify your timeout math: timeout_ms split into tv_sec and tv_usec must be correct (tv_usec is microseconds).

socket() starts failing partway through.

  • Print errno / strerror(errno). EMFILE means descriptor leak — audit every exit path for a matching close().

Guardrail seems to let a bad host through.

  • Print the exact bytes of host — a trailing space or newline (common from fgets) makes strcmp fail to match while looking identical.
  • Run the verification harness; it exists precisely to catch this.

Results disagree with a known tool.

  • Sanity-check one port with nc -vz 127.0.0.1 <port> or curl http://127.0.0.1:<port>. If nc says open and you say closed, the bug is in your interpretation of SO_ERROR.

Questions to ask when it fails.

  1. Is the value I passed to the guardrail byte-for-byte what I think it is?
  2. Did connect() return EINPROGRESS (expected) or a hard error (closed)?
  3. Is every fd closed on every path?
  4. Am I reading SO_ERROR before deciding open vs. closed?

Memory safety

C memory & UB safety for this scanner

  • Zero-initialize sockaddr_in. Use struct sockaddr_in addr = {0}; so padding and unused fields are defined. Passing partially-initialized structs to connect() is undefined behavior.
  • Validate inet_pton's return. It returns 1 on success, 0 on a malformed address, -1 on error. Ignoring it can leave sin_addr unset and lead you to scan 0.0.0.0.
  • Bound the port to uint16_t. Cast to uint16_t and reject ranges outside [0, 65535] before the loop; an out-of-range int fed to htons silently truncates.
  • Close every descriptor. Each socket() must be paired with a close() on all exit paths, or you leak and eventually hit EMFILE.
  • strcmp needs a non-NULL, NUL-terminated string. The guardrail checks host != NULL first; a NULL to strcmp is undefined behavior and could crash or, worse, appear to pass.

Security & safety: detection and logging

What to log for every scan (the audit trail):

  • Timestamp (ISO-8601, as in the code)
  • Event type (scan_start, port_result, scan_refused, scan_done)
  • Target host and port
  • Result / security decision (open, out_of_scope, bad_range)
  • A correlation id if you run many scans, so one run's lines can be grouped
  • The operator/user identity in a multi-user setting

What to NEVER log: passwords, API tokens, session cookies, private keys, full payment card numbers, or any PII you do not strictly need. A scanner has no reason to touch secrets — if one appears in your logs, something upstream is wrong.

Which events signal abuse (on a real host, not this lab):

  • A burst of scan_refused / out_of_scope lines — someone or something is trying to point the tool off-machine.
  • Scans against unusual port ranges or at high frequency.
  • Connect attempts from a source that has no business scanning.

How false positives arise: a developer legitimately probing their own dev ports looks identical, in raw logs, to a reconnaissance sweep. Health-check tools and monitoring agents connect to many ports routinely. That is why context — who ran it, whether it was authorized, and whether scope stayed on loopback — matters as much as the raw connection events. Never conclude "attack" from connection counts alone.

Real-world uses

Authorized real-world use case. A backend developer finishing a microservice runs a localhost scan to confirm exactly which ports their stack exposes — verifying that a debug port (say 9229) is not accidentally listening before they push to staging. The scan touches only 127.0.0.1, so it is unambiguously in scope and needs no special permission.

Professional best-practice habits:

  • Validation: re-check every host value at the trust boundary; never trust argv, getenv, or config files blindly.
  • Least privilege: a scanner needs no root and no write access to anything but its own log — run it as an unprivileged user.
  • Secure defaults: default target is loopback, default range is bounded, default behavior is refuse on anything unexpected.
  • Logging: timestamped, structured audit lines for every decision, kept for later review.
  • Error handling: check the return of socket, connect, inet_pton, and getsockopt; fail closed.
Beginner Advanced
Scope Hardcoded 127.0.0.1 only Explicit allowlist reviewed against a signed authorization document
Timing Fixed 5 ms pause Adaptive rate limiting, jitter, and concurrency caps
Results open / not-open open / closed / filtered, with service banners where authorized
Logging stderr audit lines structured JSON to a SIEM with correlation ids and operator identity
Authorization "it's my machine" written scope, dates, contacts, and out-of-scope exclusions on file

Authorization checklist (before any scan beyond your own loopback):

  • I have written permission naming the exact hosts/IP ranges.
  • The engagement dates and time windows are current.
  • I know which hosts are explicitly out of scope.
  • I have an emergency contact if something breaks.
  • My tool's scope control matches the authorized scope — verified by test, not assumption.

Misconceptions to keep straight: passing a scan (finding few open ports) does not prove a system is secure — it only reflects what was reachable at that moment. And nothing you build is ever "completely secure"; scope control reduces risk, it does not eliminate responsibility.

Practice tasks

Beginner 1 — Strengthen the guardrail

  • Objective: make scan_allowed reject look-alikes.
  • Requirements: accept only "127.0.0.1"; reject "127.0.0.1 " (trailing space), "127.0.0.10", "127.1", and NULL.
  • Input/Output: for each test string, print the host and 1/0.
  • Constraints: no dynamic allocation; guard against NULL first.
  • Hints: strcmp requires an exact match; there is no partial matching to worry about, but watch whitespace.
  • Concepts: trust boundary, exact-match validation.

Beginner 2 — Count ports in a range

  • Objective: implement int port_count(int lo, int hi) returning the number of ports in inclusive [lo, hi], or 0 if hi < lo.
  • Requirements: also return 0 for any range straying outside [0, 65535].
  • Input/Output: port_count(20, 25) -> 6; port_count(80, 79) -> 0.
  • Constraints: integer arithmetic only; no overflow.
  • Hints: the count of an inclusive range is hi - lo + 1 when valid.
  • Concepts: bounding a scan, off-by-one care.

Intermediate 1 — Timed probe with clear outcomes

  • Objective: write probe_port that returns 1 open, 0 closed/filtered, -1 error, using a non-blocking socket and a caller-supplied timeout.
  • Requirements: never block longer than the timeout; close the fd on every path.
  • Input/Output: against a known-open local port, returns 1; against a closed one, returns 0.
  • Constraints: loopback only; timeout <= 500 ms.
  • Hints: EINPROGRESS is expected after a non-blocking connect; confirm with getsockopt(SO_ERROR).
  • Concepts: open vs. closed vs. filtered, timeouts, descriptor hygiene.

Intermediate 2 — Structured audit log

  • Objective: extend the scanner to emit one structured audit line per decision (start, each open port, each refusal, done).
  • Requirements: include ISO-8601 timestamp, event, target, port, result; write to stderr so stdout stays machine-parseable.
  • Input/Output: a scan of a small range produces a start line, zero or more open lines, and a done line.
  • Constraints: never log secrets; loopback only.
  • Hints: reuse the audit() helper; keep fields key=value for easy grepping.
  • Concepts: detection & logging, what to log vs. never log.

Challenge — Scope-drift detector (lab-only, defensive)

  • Objective: add a self-check that scans the scanner's own audit log after a run and flags any scan_refused / out_of_scope events, simulating how a defender would notice attempted scope drift.
  • Requirements: read the log, count out-of-scope refusals, and if any exist, print a warning summarizing count and the attempted (refused) targets. Do NOT perform any scan of a non-loopback host to test this — instead, feed the guardrail crafted bad inputs in a unit test so the refusals are generated safely.
  • Constraints: everything runs on localhost; no external hosts are ever contacted; use placeholder targets like 10.0.0.5 only as refused inputs.
  • Hints: the guardrail already logs out_of_scope; your detector just aggregates those lines.
  • Concepts: detection, false positives (a developer's own refused typo vs. real abuse).
  • Defensive conclusion — remediate + verify: after building the detector, confirm the remediation works by (1) running the verification harness to prove no non-loopback target is ever scanned, and (2) checking that every crafted bad input produced exactly one scan_refused audit line and zero connection attempts. That closes the loop: you have not just prevented scope drift, you have proven the prevention with evidence.

Summary

A port scanner probes each port in a range with connect() and records which accept — but the code that scans is not what makes it safe. Safety comes from the scope guardrail and the authorization behind it. This lesson welds the target to the loopback address 127.0.0.1 so the tool can never reach another machine.

Key structure/commands: scan_allowed() as the trust boundary (checked first, refuses everything but loopback); socket -> htons(port) -> inet_pton -> non-blocking connect + select timeout -> read SO_ERROR -> close(fd); a bounded port range; a timestamped audit() line for every decision.

Common mistakes: trusting argv/getenv without re-validating, forgetting htons, blocking connect with no timeout, leaking descriptors, and mislabeling a timeout as "open."

What to remember: hardcode and validate scope; log every decision (never secrets); interpret open/closed/filtered honestly; and only ever run against a host you own or have written permission to test. Finding few open ports does not prove a system is secure, and nothing is ever "completely secure" — scope control lowers risk, it never removes responsibility.

Practice with these exercises