Safe Penetration Testing Labs · advanced · ~12 min
- 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
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.
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.
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.
127.0.0.1) as a safety boundaryDefinition. 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.
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.
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.
+-------------------------------------------------+
| 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
scan_allowed() check. Inside it, targets are trusted (loopback only); outside it, all input is untrusted and refused.host argument passed into the scan function — the one value an attacker or a careless edit could try to abuse.Knowledge check.
host = getenv("TARGET"); just above the scan loop, what insecure assumption would that introduce, and how would you detect it in a log?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.close()d, or you leak file descriptors across 65k iterations.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 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.127.0.0.1, the function returns -1 and does no scanning.connect().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.
Below: an insecure version (do not deploy), a secure rewrite, and a verification harness.
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.
/* 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.
/* 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.
Walking the secure version's hot path — a single call scan("127.0.0.1", 1, 1024):
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.lo=1, hi=1024 are inside [0, 65535] and hi >= lo, so we continue. A garbage range like hi=99999 would be refused here.audit("scan_start", ...) writes one timestamped line to stderr, creating the audit trail before work begins.port from 1 to 1024. For each, probe_port is called.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.connect() on a non-blocking socket usually returns -1 with errno == EINPROGRESS. That is normal — the handshake is still in flight.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.getsockopt(SO_ERROR) reveals the real result: 0 means the connection succeeded (open), non-zero means it was refused.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.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.
Mistake 1 — Taking the target from user input without re-checking it.
const char *host = argv[1]; then straight into connect().host = "127.0.0.1", or route every externally-supplied host through scan_allowed() before it reaches a socket.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.
addr.sin_port = port;addr.sin_port = htons((uint16_t)port);Mistake 3 — Blocking connect() with no timeout.
select() with a bounded timeout (300 ms here).Mistake 4 — Leaking file descriptors.
socket() fails with EMFILE and the rest of the scan silently breaks.close(fd) on every path out of probe_port.lsof -p <pid> or count descriptors.Mistake 5 — Labeling a timeout as "open."
SO_ERROR == 0; report filtered separately or not at all.nc -vz 127.0.0.1 <port>.Every port reports closed.
ss -tlnp (or netstat -an | grep LISTEN) shows local listening ports.htons()?inet_pton's return value — if it is not 1, the address never parsed.The scan hangs.
connect(). Switch to the non-blocking + select() pattern.timeout_ms split into tv_sec and tv_usec must be correct (tv_usec is microseconds).socket() starts failing partway through.
errno / strerror(errno). EMFILE means descriptor leak — audit every exit path for a matching close().Guardrail seems to let a bad host through.
host — a trailing space or newline (common from fgets) makes strcmp fail to match while looking identical.Results disagree with a known tool.
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.
connect() return EINPROGRESS (expected) or a hard error (closed)?fd closed on every path?SO_ERROR before deciding open vs. closed?sockaddr_in. Use struct sockaddr_in addr = {0}; so padding and unused fields are defined. Passing partially-initialized structs to connect() is undefined behavior.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.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.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.What to log for every scan (the audit trail):
scan_start, port_result, scan_refused, scan_done)open, out_of_scope, bad_range)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):
scan_refused / out_of_scope lines — someone or something is trying to point the tool off-machine.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.
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:
argv, getenv, or config files blindly.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):
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.
scan_allowed reject look-alikes."127.0.0.1"; reject "127.0.0.1 " (trailing space), "127.0.0.10", "127.1", and NULL.1/0.strcmp requires an exact match; there is no partial matching to worry about, but watch whitespace.int port_count(int lo, int hi) returning the number of ports in inclusive [lo, hi], or 0 if hi < lo.[0, 65535].port_count(20, 25) -> 6; port_count(80, 79) -> 0.hi - lo + 1 when valid.probe_port that returns 1 open, 0 closed/filtered, -1 error, using a non-blocking socket and a caller-supplied timeout.EINPROGRESS is expected after a non-blocking connect; confirm with getsockopt(SO_ERROR).open lines, and a done line.audit() helper; keep fields key=value for easy grepping.scan_refused / out_of_scope events, simulating how a defender would notice attempted scope drift.10.0.0.5 only as refused inputs.out_of_scope; your detector just aggregates those lines.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.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.