Safe Penetration Testing Labs · intermediate · ~15 min
- Read a saved `nmap -oX` XML report from disk and walk it as plain text with C string functions. - Count `<port>` elements whose nested `<state>` is exactly `open`, ignoring `filtered` and `closed`. - Write a bounded, crash-proof parser that tolerates malformed input instead of trusting it. - Separate the state look-ahead so one port's data can never leak into another port's count. - Turn the parsed result into a defender's answer: "which hosts have an unexpected port open?" backed by logging you can audit.
Security objective: the asset you are protecting is your organization's attack surface — the set of network services exposed on your hosts. The threat is an unexpected or unauthorized open port (a forgotten Telnet daemon, a debug service, a re-enabled port) that an attacker could reach. In this lesson you build a small C program that reads already-saved scan output and detects open ports, so a defender can compare them against an approved baseline. You detect exposure; you do not create it.
This is pure string parsing. Your program never opens a socket, never sends a packet, and never does DNS. It reads bytes that a previous, authorized scan wrote to a file. That distinction is the whole point: running a scanner is an active, permissioned action; reading its saved output is passive analysis a defender does constantly.
Builds on your prereqs. From C strings you reuse strstr, strchr, NUL-termination, and pointer walking — an nmap XML file is just one long NUL-terminated char buffer. From The main function you reuse int main(void), reading arguments, returning a status code, and structuring a program that reads input, computes a result, and reports it. We add one new idea on top: bounded parsing — always stopping the look-ahead at a known limit so text from a neighboring element can never be counted by mistake.
Why not a real XML library? Because the goal here is to learn defensive text-walking on a small, regular format, and to see exactly where a naive parser goes wrong. In production you would often reach for a hardened XML parser; we discuss that trade-off explicitly.
In authorized professional work, defenders read scan output far more often than they run scans. A blue team (defensive security team) receives nmap XML from a scheduled internal scan, an authorized pentest engagement, or a vulnerability-management tool, and has to answer concrete questions fast: Which hosts have port 23 (Telnet) open? Did anything change since last week? Does this match our approved baseline?
A 25-line parser is often the fastest way to answer. Feeding thousands of lines of XML to a human does not scale; a small, auditable program does. Because the parser handles security data, it must be trustworthy: it cannot crash on a truncated file, it cannot silently miscount, and it must log what it decided so an analyst can verify the result later.
These same habits — never trust input, always bound your reads, log your decisions — are exactly what keep C security tooling from becoming a vulnerability itself. A parser that reads past its buffer is not a defense tool; it is a new bug.
nmap -oX output actually isDefinition. nmap -oX report.xml tells nmap to write scan results as XML (-oX = output XML). The file is regular, mostly-nested tags describing hosts, their ports, and each port's state.
Plain explanation. To your C program it is not "XML" — it is one long char array ending in \0. Every tag, attribute, and value is just bytes you can search.
How it works. A trimmed example:
<host><address addr="10.0.0.5"/><ports>
<port portid="22"><state state="open"/></port>
<port portid="23"><state state="filtered"/></port>
<port portid="443"><state state="open"/></port>
</ports></host>
Each <port ...> element wraps a <state state="..."/> whose value is open, closed, or filtered.
When / when not. Text-walking is fine for a small, trusted-source, regular file like this. It is not fine for untrusted, deeply-nested, or attacker-controlled XML — there you use a hardened, well-maintained XML parser configured to reject external entities (to avoid XXE attacks).
Pitfall. Assuming the format is prettified with newlines and indentation. Real nmap output is often one giant line. Never rely on line breaks as delimiters.
Definition. Repeatedly find the next <port with strstr, then look within that element only for the open-state marker.
Plain explanation. You hop from one <port to the next. For each hop, you decide the element boundary is where the next <port begins. You only search for state="open" before that boundary.
How it works. For port N, limit = start of port N+1 (or end of buffer). If state="open" appears before limit, port N is open.
When / when not. Use a bounded look-ahead whenever you match a marker that could also appear later in the file. Skip the bound only if the marker can appear at most once — which is rarely guaranteed.
Pitfall. An unbounded strstr(p, "state=\"open\"") will happily reach forward into the next port and over-count. Port 23 (filtered) could be counted as open because port 443 (open) sits right after it. Bounding fixes this.
Definition. Treat every input as possibly malformed, truncated, or hostile, and degrade gracefully.
Plain explanation. Count what you can parse, skip what you cannot, and return a number rather than crashing. A defensive tool that segfaults on a truncated report is worse than useless during an incident.
How it works. Check every pointer strstr/strchr returns before dereferencing. Stop cleanly at the NUL terminator. Never write to the input.
When / when not. Always, for security tooling. The only time you "fail hard" is when a silent miscount would be more dangerous than an error — and even then you log and exit, you do not crash.
Pitfall. Using strchr(p, '>') and dereferencing the result without checking for NULL. A truncated file with no closing > returns NULL, and *NULL is undefined behavior (likely a crash).
AUTHORIZED LAB / OWNED NETWORK
+--------------------------------------------------+
| |
| [earlier authorized scan] |
| nmap -oX ---> report.xml (on disk) |
| | |
| == trust boundary: file from disk (untrusted) == |
| v |
| ENTRY POINT: your C parser reads the bytes |
| - bounds every read |
| - never opens a socket |
| - logs: file, open-port count, timestamp |
| | |
| v |
| ASSET PROTECTED: the host attack surface |
| (compare open ports vs approved baseline) |
| |
+--------------------------------------------------+
Outside the box: real targets, third-party hosts — OUT OF SCOPE.
The parser touches only a local file, never the network.
Knowledge check.
state="open" look-ahead over-count, and why is analysis like this only ever run against systems you own or are authorized to scan?The whole parser rests on three standard C string functions from <string.h>, plus careful NULL checks.
#include <string.h>
/* strstr: find first occurrence of a substring, or NULL if absent. */
const char *hit = strstr(haystack, "<port"); /* -> points at '<' of "<port", or NULL */
/* strchr: find first occurrence of a single char, or NULL. */
const char *gt = strchr(hit, '>'); /* -> the '>' ending the tag, or NULL */
/* Bounded look-ahead: only trust a match that lands BEFORE the limit. */
const char *next = strstr(hit + 1, "<port"); /* start of the NEXT port element */
const char *limit = next ? next : hit + strlen(hit); /* next port, or end of buffer */
const char *state = strstr(hit, "state=\"open\""); /* the open marker, if any */
if (state != NULL && state < limit) {
/* this port element is open */
}
Key points:
NULL. Check before you dereference or compare-order.state < limit is the guard that keeps the next port's data out of this port's decision.p past the current tag each loop so you make progress and eventually hit the terminating NULL from strstr.Defenders read scan output far more often than they run scans themselves.
When the SOC team asks "which of our hosts have port 23 open?", the answer is one tiny parser away. (A SOC is a Security Operations Center — the team that monitors and responds to threats.)
This lesson teaches the parsing side. You take the bytes that nmap saved to disk and pull out the answer your team actually needs.
The file is bundled in the harness. The function never opens a socket.
Here is a trimmed nmap -oX dump (the -oX flag tells nmap to write its results as XML):
<host><address addr="127.0.0.1"/><ports>
<port portid="22"><state state="open"/></port>
<port portid="80"><state state="filtered"/></port>
<port portid="443"><state state="open"/></port>
</ports></host>
You don't need a real XML parser. Walking from one <port to the next with strstr is enough. The format is regular and the buffer is small.
Write a function that returns the count of <port> elements whose nested <state> has state="open".
Skip everything that is filtered or closed.
int count_open_ports(const char *xml) {
int count = 0;
const char *p = xml;
while ((p = strstr(p, "<port")) != NULL) {
const char *end = strchr(p, '>');
const char *next = strstr(p + 1, "<port");
const char *limit = next ? next : p + 256;
const char *state = strstr(p, "state=\"open\"");
if (state && state < limit) count++;
if (!end) break;
p = end + 1;
}
return count;
}
state="open|filtered" as open. nmap uses that exact string — match it whole.This is not a live nmap runner.
The bytes you read came from a previous, authorised scan. The function never opens a socket and never makes a DNS query.
The task itself (counting open ports in a saved, authorized report) is defensive. To meet the security standard we show the classic INSECURE parser first (the naive one that over-counts and can crash), then the SECURE fix, then checks that prove the fix rejects bad input and accepts good input.
/* insecure_count.c
* WARNING: intentionally vulnerable — use only in a local, isolated,
* authorized lab. Do not deploy.
* Bug A: unbounded look-ahead reaches into the NEXT port -> over-counts.
* Bug B: dereferences strchr result without a NULL check -> can crash
* (undefined behavior) on a truncated file with no '>'.
*/
#include <string.h>
int count_open_ports_insecure(const char *xml) {
int count = 0;
const char *p = xml;
while ((p = strstr(p, "<port")) != NULL) {
/* Bug A: no limit — this match may belong to a LATER port. */
if (strstr(p, "state=\"open\"") != NULL) count++;
const char *end = strchr(p, '>');
/* Bug B: if end == NULL, end + 1 is undefined; the next loop
* dereferences garbage. */
p = end + 1;
}
return count;
}
Why it is wrong: for a filtered port immediately followed by an open port, the unbounded strstr sees the later state="open" and counts the filtered port too. And a truncated report with no > makes end NULL, so end + 1 is undefined behavior.
/* secure_count.c — bounded, NULL-checked, crash-resistant. */
#include <string.h>
int count_open_ports(const char *xml) {
if (xml == NULL) return 0; /* tolerate a NULL buffer */
int count = 0;
const char *p = xml;
while ((p = strstr(p, "<port")) != NULL) {
/* Boundary = start of the next port element, or end of buffer. */
const char *next = strstr(p + 1, "<port");
const char *limit = next ? next : (p + strlen(p));
/* Only count an open marker that falls INSIDE this element. */
const char *state = strstr(p, "state=\"open\"");
if (state != NULL && state < limit) count++;
/* Advance safely: to the next port if any, else stop. */
if (next == NULL) break;
p = next;
}
return count;
}
/* verify.c — build: cc -std=c11 -Wall -Wextra -fsanitize=address \
* secure_count.c verify.c -o verify && ./verify
* (ASan flags any out-of-bounds read the parser might make.)
*/
#include <stdio.h>
#include <assert.h>
int count_open_ports(const char *xml); /* from secure_count.c */
int main(void) {
/* GOOD input: 2 open, 1 filtered -> expect 2. */
const char *good =
"<host><ports>"
"<port portid=\"22\"><state state=\"open\"/></port>"
"<port portid=\"23\"><state state=\"filtered\"/></port>"
"<port portid=\"443\"><state state=\"open\"/></port>"
"</ports></host>";
/* The over-count trap: filtered port directly before an open one.
* The insecure parser reports 2 here; the secure one reports 1. */
const char *trap =
"<port portid=\"23\"><state state=\"filtered\"/></port>"
"<port portid=\"443\"><state state=\"open\"/></port>";
/* BAD input: the open marker is present but the tag is truncated
* (no closing '>' and no next port). Must count it without crashing. */
const char *truncated = "<port portid=\"22\"><state state=\"open\"/";
const char *empty = "";
int g = count_open_ports(good);
int t = count_open_ports(trap);
int r = count_open_ports(truncated);
int e = count_open_ports(empty);
int n = count_open_ports(NULL);
printf("good=%d trap=%d truncated=%d empty=%d null=%d\n", g, t, r, e, n);
assert(g == 2); /* accepts good input, correct count */
assert(t == 1); /* rejects the over-count: only the open port */
assert(r == 1); /* truncated but the open marker is present; no crash */
assert(e == 0); /* empty is fine */
assert(n == 0); /* NULL is fine */
puts("all checks passed");
return 0;
}
Expected output:
good=2 trap=1 truncated=1 empty=0 null=0
all checks passed
The assert(t == 1) line is the heart of the fix: it fails against the insecure parser (which returns 2) and passes against the secure one, proving the bounded look-ahead actually rejects the over-count. Running under AddressSanitizer (-fsanitize=address) additionally proves the truncated input causes no out-of-bounds read.
Walking the secure count_open_ports against the good fixture (2 open, 1 filtered):
if (xml == NULL) return 0; — guards the NULL case up front so the loop never dereferences a null pointer.p = xml; — start at the beginning of the buffer.while ((p = strstr(p, "<port")) != NULL) — find the next <port. First iteration lands on port 22. When no more <port exists, strstr returns NULL and the loop ends.next = strstr(p + 1, "<port") — search from just after the current < for the following port. For port 22 this points at port 23.limit = next ? next : (p + strlen(p)) — the element boundary. If there is no next port, the limit is the end of the buffer.state = strstr(p, "state=\"open\"") — find the open marker anywhere from here on.if (state != NULL && state < limit) count++; — only count if the marker exists and falls before the next port. For port 22 the marker is inside its own element, so count becomes 1.if (next == NULL) break; p = next; — advance to the next port, or stop.Trace table:
| Iter | p at |
next at |
open marker before limit? |
count |
|---|---|---|---|---|
| 1 | port 22 | port 23 | yes (its own open) |
1 |
| 2 | port 23 | port 443 | no (its state is filtered) |
1 |
| 3 | port 443 | NULL | yes (its own open) |
2 |
| — | loop ends (next == NULL) |
2 |
The trap fixture (filtered directly before open) is where the bound earns its keep: at the filtered port, limit is the start of the open port, and the open marker lands at or after limit, so state < limit is false and it is correctly not counted.
Mistake 1 — Unbounded look-ahead.
if (strstr(p, "state=\"open\"")) count++; with no limit.limit = next <port (or end), and require state < limit.trap test (filtered immediately before open) in your test set; it fails loudly when the bound is missing.Mistake 2 — Dereferencing an unchecked strchr/strstr result.
const char *end = strchr(p, '>'); p = end + 1;end is NULL; end + 1 and the next dereference are undefined behavior.next pointer, or explicitly if (end == NULL) break;.-fsanitize=address and feed truncated input; ASan reports the bad read immediately.Mistake 3 — Matching the value too loosely.
state="open|filtered" (a script-scan combined string) or matching just open as a substring of openfiltered.state="open" (including quotes), which nmap emits for a plain open port.filtered and closed; assert they are not counted.Mistake 4 — Assuming pretty-printed input.
\n and assuming one port per line.<port markers, not by lines.Mistake 5 — Writing to the input buffer.
const input corrupts later reads and may be a const violation (UB).const; use pointer limits instead of mutation.Common failures and how to chase them:
p - xml, next - xml, and state - xml each iteration; if state is at or past next, your bound is missing or wrong.p past a real port. Confirm you advance to next (start of the next <port), not past it. Also check you are not accidentally matching <ports (the container) as if it were <port — strstr("<port") matches <ports too, so make sure your logic only reacts to the state marker, which the container lacks.cc -std=c11 -Wall -Wextra -fsanitize=address,undefined and rerun; the sanitizer prints the exact line and offset of the bad read.nmap -oX out.xml <your-own-lab-host> and diff your count against grep -c 'state="open"' out.xml as a sanity cross-check (this grep is a rough oracle, not ground truth — it ignores element boundaries).Questions to ask when it fails:
strstr/strchr return get NULL-checked before use?limit truly the start of the next element, and am I comparing state < limit?filtered/closed?This section covers both the C memory-safety angle and the security detection/logging angle, since this is a C security lesson.
Memory / UB safety for this parser.
strstr/strchr result before checking it for NULL. NULL + 1 and *NULL are undefined behavior.const char * — treat it read-only; do not null-terminate or edit it in place.strstr; do not assume a fixed length. If you do take a length, prefer bounded scanning and never read at or past buf[len].-Wall -Wextra -fsanitize=address,undefined during development. For a parser fed by files, also fuzz it (e.g. libFuzzer/AFL++) with truncated and garbage inputs; parsers are a classic memory-bug surface.Security & safety — detection and logging. When this parser runs as part of triage, log enough to audit the decision, and nothing sensitive.
Concrete authorized use case. A blue team runs a nightly internal nmap scan of its own subnet, saving -oX reports. Each morning a small C tool parses every report, counts open ports per host, and diffs them against an approved baseline file. Any host with a port not on the baseline raises a ticket. No socket is opened by the parser; it only reads yesterday's authorized scan output.
Professional best-practice habits.
Beginner vs advanced.
| Beginner | Advanced | |
|---|---|---|
| Parsing | strstr token-walk on a small fixture |
Hardened XML parser (e.g. libxml2) with external entities disabled to prevent XXE |
| Scope | Count open ports | Correlate ports to services/versions and to a CVE/inventory feed |
| Trust | Assume report is from your own scan | Verify report provenance and integrity before ingesting |
| Output | A number to the console | Diff-against-baseline, ticketing, and dashboards |
Note two misconceptions to avoid: a report that shows all ports closed does not prove the host is secure, and passing an automated scan does not prove a system is secure — scanners see only what they probe, when they probe it. Never describe any host as "completely secure."
All tasks are lab-only: run them against fixtures you create or against scans of hosts you own or are explicitly authorized to scan (localhost, a container, or an intentionally-vulnerable practice VM). Authorization checklist before any live scan: (1) you own the host or hold written authorization; (2) the target is in an isolated lab network; (3) you have a defined scope and time window; (4) you know how to reset/clean the lab. Cleanup/reset: delete generated *.xml reports and stop/remove any lab container when done. Every task ends defensively — remediate and verify.
Beginner 1 — Count filtered ports.
int count_filtered_ports(const char *xml) alongside the open-port counter.state="filtered".good fixture -> 1.Beginner 2 — Reject the container tag.
<ports> wrapper.<ports> but zero <port ...> elements; assert the count is 0."<host><ports></ports></host>" -> 0.Intermediate 1 — Ignore XML comments.
<!-- ... --> so a commented-out state="open" is not counted.<!--, jump p past the matching --> before continuing; bound safely if --> is missing.state="open" -> 1.strstr(p, "-->") returns NULL if the comment is unterminated — decide what to do (stop safely).Intermediate 2 — Report open ports per host with baseline diff.
<host>, extract its addr and count open ports, then flag hosts whose open set differs from an approved baseline.<host markers as the outer boundary; within each host, count open ports; compare to a small in-code baseline map; print flagged hosts.<host as the outer limit.Challenge — Fuzz-harden the parser.
count_open_ports provably crash-free on arbitrary bytes.-fsanitize=address,undefined; fix any crash or UB found.const; no reads at or past the terminator; document each fix.Main concepts. A saved nmap -oX report is just a NUL-terminated char buffer. You count open ports by hopping from one <port to the next with strstr and, for each element, checking whether state="open" appears before the next port begins — a bounded look-ahead. The asset you protect is your host attack surface; the finding is an unexpected open port measured against a baseline. The parser reads a local file only and never touches the network.
Key syntax/commands. strstr(hay, needle) and strchr(str, ch) return a pointer or NULL; always NULL-check. The guard state != NULL && state < limit with limit = next ? next : end-of-buffer is what prevents over-counting. Build defensively with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined.
Common mistakes. Unbounded look-ahead (over-counts a filtered port before an open one); dereferencing an unchecked pointer on truncated input (undefined behavior); loose matching that counts filtered/closed; assuming pretty-printed, one-port-per-line input; and mutating the const buffer.
What to remember. Never trust input, bound every read, match the exact token, and log the decision (timestamp, source, host, open-port count, correlation id) while logging no secrets. Only run scans against systems you own or are authorized to test, and remember: closed ports and passed scanners do not prove a host is secure — nothing is ever "completely secure."