Safe Penetration Testing Labs · intermediate · ~10 min
By the end of this lesson you will be able to: - Recognise the **Common Log Format (CLF)** and name every field on a request line. - Read an access log **one line at a time** with `fgets` and extract structured fields (IP, timestamp, request, status, size) safely in C11. - Handle the awkward parts of the format — bracket-grouped timestamps and quote-grouped request lines — without buffer overflows. - Turn parsed fields into a **defensive signal**: spot 404 sweeps, error spikes, and noisy source IPs that hint at probing. - Log your analysis output with the right fields for an incident record, and know what must never be written to a log. - Do all of this only on logs from systems you own or are authorized to review.
Security objective. The asset you protect here is your own web server's access log and the story it tells. A log is evidence. The threat is an attacker probing your site — requesting hidden paths, hammering a login, or scanning for known-vulnerable URLs — and the defensive skill is turning a wall of raw text into countable, reviewable facts so you can detect that activity early. You are not attacking anything; you are reading records your own server already wrote.
Every mainstream web server (Apache, Nginx, and many proxies) records each HTTP request as a single line of text. The dominant shape is the Common Log Format (CLF) and its close relative the Combined format. One request, one line, a fixed field order. That regularity is exactly what makes a small C program a good tool: you can walk each line, pick out the fields you care about, and count things.
This lesson builds directly on your two prerequisites. From fgets for safe line reading you already know how to pull one line into a fixed buffer without overrunning it — that is the backbone of any log reader, because logs can be gigabytes and you never load the whole file at once. From C strings you know that a C string is a char array ending in \0, and that functions like strchr, strncmp, and sscanf operate on those arrays. Here we combine both: fgets gives us a line, string functions carve fields out of it.
Where this fits: parsing is the raw material for the next skills in this track. Once you can reliably split a line into fields, detecting failed logins (the next lesson) and rate-based alerting become simple counting problems. Parse first, decide second.
In real, authorized security work, log parsing is one of the highest-leverage skills you can have, and it shows up daily:
/.git/, long query strings — depends on reliable field extraction.Writing a parser yourself (rather than only using a SIEM) teaches you what the fields actually are and where they break — malformed lines, injected control characters, spoofed fields. That understanding is what separates someone who runs tools from someone who can trust and debug them. And because you're only ever reading logs from systems you own or are contracted to assess, it's a skill you can practise safely from day one.
Definition. CLF is a plain-text, one-line-per-request format written by web servers. The field order is fixed.
Plain explanation. Think of each line as a tiny fixed-column record with no header. You always know field 1 is the IP, the bracketed chunk is the time, the quoted chunk is the request.
How it works. A canonical line:
127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326
| Field | Example | Meaning |
|---|---|---|
| Client IP | 127.0.0.1 |
Who made the request (may be a proxy) |
| Identity | - |
RFC 1413 ident, almost always - |
| User | frank |
Authenticated username, or - |
| Timestamp | [10/Oct/2000:13:55:36 -0700] |
Bracket-grouped, contains a space |
| Request line | "GET /apache_pb.gif HTTP/1.0" |
Quote-grouped, contains spaces |
| Status | 200 |
HTTP response code |
| Size | 2326 |
Bytes sent, or - |
When / when not. CLF (or Combined, which adds referrer and user-agent) is what you'll parse for HTTP. It is not the format for syslog, JSON logs, or firewall logs — check before assuming field positions.
Pitfall. The timestamp and the request line each contain spaces. Splitting naively on every space shreds those fields. You must treat [...] and "..." as single units.
Definition. Reading one line into a fixed buffer, processing it, then reusing the buffer for the next line.
Plain explanation. You never need the whole file in memory. fgets(buf, sizeof buf, fp) reads up to one line (or sizeof buf - 1 bytes) and null-terminates it — the safe pattern from your prerequisite.
How it works. Loop while (fgets(line, sizeof line, fp)). Each iteration you get one record. If a line is longer than the buffer, fgets returns the first chunk and the rest comes next call — something to guard against.
When / when not. Always stream logs; never fread a multi-gigabyte file into one allocation.
Pitfall. Forgetting that fgets keeps the trailing \n. Strip it or your field comparisons may fail.
strchr, strncmp, sscanfDefinition. Standard-library string tools that locate delimiters and copy or compare bytes.
Plain explanation. strchr(s, '[') finds the timestamp start; strchr(s, '"') finds the request start. sscanf can pull the two integers (status, size) after the closing quote. strncmp checks a token like " 404 " without walking off the end.
Pitfall. sscanf("%s") and strcpy are unbounded — a hostile or truncated line can overflow your destination. Always bound the copy (%NNs, strncpy + manual \0, or snprintf).
Definition. Aggregating parsed fields into counts that indicate probing or abuse.
Plain explanation. One 404 is nothing. Two hundred 404s from one IP in a minute is a directory sweep. A cluster of 401/403 on a login path is credential guessing. Parsing gives you the fields; counting per-IP or per-status per-window gives you the signal.
Pitfall — the false positive. A broken link, a search-engine crawler, or your own uptime monitor also generate repetitive requests. A signal is a hypothesis, not a verdict; confirm with context before acting.
TRUST BOUNDARY (network edge)
Internet | Your infrastructure
|
[ Clients ] | [ Web server ] --writes--> [ access.log ]
legit ----->|--> Apache/Nginx |
attacker -->| | v
(probes, | | (entry point: [ Your log parser (C) ]
scanners) | | HTTP requests) |
| v
| [ Analyst review / alerts ]
Assets: access.log integrity + the truthful record it holds
Entry points: attacker-controlled request fields land IN the log
(URL path, method, user-agent) — treat log CONTENT as untrusted input
Trust boundary: the network edge; also the boundary between
"raw log bytes" and "your parser's buffers"
Knowledge check.
The core loop is stream a line, then carve fields. Key building blocks:
#include <stdio.h>
#include <string.h>
char line[8192]; /* one CLF line fits comfortably */
FILE *fp = fopen(path, "r"); /* open for reading, check for NULL */
while (fgets(line, sizeof line, fp)) { /* safe: never writes past the buffer */
line[strcspn(line, "\n")] = '\0'; /* strip trailing newline in place */
char *ts = strchr(line, '['); /* timestamp begins at '[' */
char *req = strchr(line, '"'); /* request line begins at first '"' */
/* Pull the two trailing integers (status, size) with a BOUNDED scan.
%*[^\"] skips to the closing quote; then two ints. */
int status = 0;
long size = 0;
char *after = req ? strrchr(line, '"') : NULL; /* closing quote */
if (after && sscanf(after + 1, " %d %ld", &status, &size) >= 1) {
/* status now holds the HTTP code, e.g. 200 or 404 */
}
}
Annotations:
strcspn(line, "\n") returns the index of the first newline (or end); assigning '\0' there trims it without a separate strlen.strchr / strrchr return a pointer into line or NULL. Always check for NULL before dereferencing.sscanf, always bound %s conversions (e.g. %255s) and prefer %d/%ld for numbers. Never use bare %s into a fixed buffer.sizeof line (not a hard-coded number) keeps the buffer size and the fgets limit in sync.Most web servers write access logs in the Common Log Format (CLF). Each request becomes one line. Here is an example:
127.0.0.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326
A single line packs several fields together, in this order:
127.0.0.1)- (not provided)frank)[10/Oct/2000:13:55:36 -0700])"GET /apache_pb.gif HTTP/1.0")200)2326)A small C parser can pull these fields out one line at a time:
fgets.This is useful for incident analysis — but only on logs from your own server.
Below: an insecure parser, the secure version, and checks that prove the fix. All examples run on a local file you create yourself — no network, no live server.
/* insecure_parse.c — DO NOT DEPLOY. Demonstrates the overflow to AVOID. */
#include <stdio.h>
int main(void) {
FILE *fp = fopen("access.log", "r");
char line[8192];
char ip[16]; /* far too small, and no bound below */
while (fgets(line, sizeof line, fp)) { /* fp not checked for NULL! */
sscanf(line, "%s", ip); /* UNBOUNDED: a long IP field overflows ip[] */
printf("ip=%s\n", ip);
}
return 0; /* fp never fclose'd */
}
Why it's dangerous: sscanf("%s") writes as many bytes as the field has, with no limit — a crafted or corrupted log line overruns ip[16] (a stack buffer overflow, undefined behaviour). fp is used without a NULL check, so a missing file dereferences NULL. The file is never closed. Log content is attacker-influenced, so this is a real exposure, not a theoretical one.
/* secure_parse.c — bounded, checked, cleaned up. C11. */
#include <stdio.h>
#include <string.h>
/* Extract fields from one CLF line into out-params.
Returns 1 on a usable parse, 0 otherwise. Never writes past a buffer. */
static int parse_clf(const char *line, char *ip, size_t ip_sz, int *status) {
/* Bounded copy of the leading IP token (stop at first space). */
size_t i = 0;
while (line[i] && line[i] != ' ' && i + 1 < ip_sz) { ip[i] = line[i]; i++; }
ip[i] = '\0';
if (i == 0) return 0; /* no IP -> unusable */
/* Status is the integer right after the CLOSING quote of the request. */
const char *close = strrchr(line, '"');
if (!close) return 0; /* malformed: no request line */
if (sscanf(close + 1, " %d", status) != 1) return 0;
if (*status < 100 || *status > 599) return 0; /* not a real HTTP code */
return 1;
}
int main(void) {
FILE *fp = fopen("access.log", "r");
if (!fp) { perror("fopen access.log"); return 1; } /* checked */
char line[8192];
long total = 0, not_found = 0;
while (fgets(line, sizeof line, fp)) {
line[strcspn(line, "\n")] = '\0';
char ip[64];
int status;
if (!parse_clf(line, ip, sizeof ip, &status)) continue; /* skip junk */
total++;
if (status == 404) {
not_found++;
/* Defensive signal: log the observation (see Security notes). */
printf("NOTFOUND src=%s status=%d\n", ip, status);
}
}
if (ferror(fp)) { perror("read"); fclose(fp); return 1; }
fclose(fp); /* cleanup */
printf("summary: %ld requests, %ld were 404\n", total, not_found);
return 0;
}
Create a small fixture and run it. This is safe: you author the file yourself.
# Build both
cc -std=c11 -Wall -Wextra -fsanitize=address secure_parse.c -o secure_parse
# GOOD line (real 404) + BAD lines (malformed, oversized IP, no quotes)
printf '%s\n' \
'127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /admin HTTP/1.0" 404 12' \
'10.0.0.5 - - [10/Oct/2000:13:55:37 -0700] "GET /ok HTTP/1.0" 200 99' \
'this-line-has-no-quotes-and-no-status' \
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA no-quote 404' \
> access.log
./secure_parse
Expected behaviour: the two well-formed lines are counted (summary: 2 requests), the /admin 404 is flagged (NOTFOUND src=127.0.0.1 status=404), and the two malformed lines are skipped rather than crashing or overflowing. Running the insecure version against the oversized-IP line under -fsanitize=address instead reports a stack-buffer-overflow — that contrast is the proof that bounding the copy fixed a real bug. Verifying that bad input is rejected and good input is accepted is the mitigation-verification step; a parser that never rejects anything is not actually validating.
Walking the SECURE parse_clf on the line:
127.0.0.1 - - [.../-0700] "GET /admin HTTP/1.0" 404 12
while loop copies characters until it hits a space or fills the buffer (i + 1 < ip_sz leaves room for the terminator). It stops at the space after 127.0.0.1.ip[i] = '\0' closes the string. If i == 0 (line started with a space) we bail — no usable IP.strrchr(line, '"') returns a pointer to the last ", i.e. the closing quote of "GET /admin HTTP/1.0". Using the last quote is what lets the status parse start in the right place even though the URL could contain characters we don't model.strrchr returned NULL (no quote at all), we return 0 — this is how the this-line-has-no-quotes fixture line is rejected.sscanf(close + 1, " %d", status) reads past the quote and any spaces, capturing 404. The leading space in " %d" skips whitespace. If it can't read an integer, we return 0.100..599 is the only valid HTTP status range; anything else means we mis-parsed, so we reject. This is validation, not decoration.Trace of key values for two lines:
| Input line (abbrev) | ip |
strrchr finds |
status |
Return |
|---|---|---|---|---|
127.0.0.1 ... "GET /admin..." 404 12 |
127.0.0.1 |
closing " |
404 | 1 (flagged) |
10.0.0.5 ... "GET /ok..." 200 99 |
10.0.0.5 |
closing " |
200 | 1 |
this-line-has-no-quotes... |
(copied) | NULL |
— | 0 (skipped) |
AAAA... no-quote 404 |
truncated to 63 chars | NULL |
— | 0 (skipped) |
In main, each returned-1 line increments total; a status == 404 also increments not_found and prints a one-line observation. The oversized-IP line is safely truncated by the bound and then rejected for lacking a quote — no overflow, exactly the outcome the insecure version failed to guarantee.
1. Splitting on every space.
sscanf(line, "%s %s %s %s", a,b,c,d) to grab "the fields".[ for time, " for request, and the closing " for the trailing integers."HTTP/1.0", you split wrong. Test against a line with a multi-word field.2. Unbounded copies.
sscanf(line, "%s", ip); or strcpy(ip, field);.%63s, or a manual loop with i + 1 < size, or snprintf).-fsanitize=address -Wall -Wextra; it flags the overflow immediately.3. Not checking fopen.
fp right after fopen with no NULL test.NULL; fgets(line, n, NULL) is undefined.if (!fp) { perror(...); return 1; }.4. Treating a signal as a verdict.
5. Trusting the client IP as identity.
X-Forwarded-For can be spoofed.Common errors and how to chase them:
close and the substring after it. Switch strchr to strrchr for the closing quote.total: 0). Check whether fgets is even reading — print the first line. A missing newline strip or a wrong path (fopen returned NULL, but you didn't check) are the usual causes. perror tells you which.cc -std=c11 -Wall -Wextra -fsanitize=address,undefined. ASan pinpoints the exact buffer and line of an overflow; UBSan catches bad integer/pointer ops. Re-run against your malformed fixture.fgets in two calls. Detect it — if the buffer is full and the last char isn't \n, you're mid-line; drain to the next newline before treating it as a new record.printf raw log fields to a terminal unfiltered (they can contain escape sequences); sanitize or hex-dump suspicious values.Questions to ask when it fails:
fopen succeed? (Always check first.)Memory safety (C). Every field you extract from a log line comes from untrusted content — the URL path, method, and user-agent are attacker-controlled and land verbatim in the line. Treat the whole line as hostile input:
strcpy/bare %s into a fixed buffer.fopen, check fgets's return, check sscanf's return count before trusting out-params.fclose; check ferror after the loop.-fsanitize=address,undefined -Wall -Wextra.Detection & logging — what to record. When your parser flags something, write a structured, one-line observation with:
rule=404-sweep count=213 window=60s).What to NEVER log. Do not write passwords, session cookies, Authorization headers or bearer tokens, API keys, private keys, full payment card numbers (PANs), or unneeded personal data. These often appear inside query strings or headers in raw traffic — if you pull request fields, redact secrets before writing your own output, or you turn your analysis log into a new leak.
Events that signal abuse. Bursts of 404 to non-existent paths (directory/vuln scanning), repeated 401/403 on auth endpoints (credential guessing), one IP requesting many distinct sensitive paths (/.git/, /admin, /wp-login.php), and unusually long or encoded URLs.
How false positives arise. Search-engine crawlers, uptime/health monitors, a freshly broken deploy (mass 404 from your own users), CDN or proxy IPs aggregating many real users, and security scanners you scheduled. A good detection records enough context (user-agent, timing, target set) that a human can dismiss a false positive quickly — and every rule needs a benign-cause review before it pages anyone.
Authorized use case. You run a small web app and want an early warning for path-probing. You copy your own access.log to a workstation (or read it in place), run your parser to count 404s per source IP over rolling windows, and print any IP above a threshold for a human to review. Everything is on infrastructure you own; nothing is sent anywhere.
Professional best-practice habits:
perror) rather than silently.Beginner vs advanced:
| Beginner | Advanced | |
|---|---|---|
| Scope | One file, count 404s and distinct IPs | Rolling time-window counts, per-IP + per-path aggregation |
| Formats | CLF only | CLF, Combined, and JSON logs; handle X-Forwarded-For carefully |
| Output | Print a summary | Structured findings feeding a review queue / SIEM, with correlation ids |
| Robustness | Skip malformed lines | Handle over-long lines, injected control bytes, encoding, and log rotation |
| Ethics | Own logs only | Written authorization + scope for any client engagement |
All tasks are lab-only: work on log files you create yourself or on logs from a system you own. Each ends by remediating and verifying, not attacking.
Authorization checklist (before any lab): (1) You own the system or have written authorization and scope. (2) The lab is local — your own file, localhost, or an isolated VM/container. (3) No third-party or production data. (4) You have a cleanup/reset step.
Beginner 1 — Field extractor.
fgets; use bounded copies; print ip=... status=... size=....access.log you author; output one summary line per record.strcpy, no bare %s; must compile with -Wall -Wextra.".fgets, strrchr, bounded sscanf.Beginner 2 — 404 counter.
404.Intermediate 1 — Per-IP 404 tally.
count ip lines.Intermediate 2 — Windowed spike detector.
possible=crawler?).strptime.Challenge — Robust, redacting parser.
-fsanitize=address,undefined -Wall -Wextra; must reject malformed input without crashing.\n-escapes, a fake Authorization: Bearer ... in the URL) and confirm each is handled.rm access.log), and confirm no captured log data or findings containing real IPs/PII persist outside the lab.Main concepts. Web servers write access logs in the Common Log Format: one request per line, fixed field order (IP, ident, user, [timestamp], "request", status, size). The timestamp and request line contain spaces, so you must anchor on [, ", and the closing " rather than splitting on whitespace. Stream the file with fgets — never load it whole — and treat every field as untrusted input because attackers control what goes into the URL, method, and headers that end up in the line.
Key syntax/commands. fgets(line, sizeof line, fp) to read; line[strcspn(line,"\n")]='\0' to trim; strrchr(line,'"') to find the request end; bounded sscanf(close+1, " %d", &status) for the status; range-check 100..599. Build and test with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined.
Common mistakes. Splitting on every space; unbounded copies (strcpy, bare %s) that overflow; skipping the fopen/fgets/sscanf return checks; treating a 404 spike as a verdict rather than a hypothesis; trusting the client IP as identity.
What to remember. Parse first, decide second. Bound every copy. Reject malformed lines instead of guessing. Log findings with timestamp, source, resource, result, decision, and a correlation id — and never log passwords, tokens, cookies, keys, full card numbers, or unneeded PII. Only ever analyse logs from systems you own or are authorized to review, and confirm a mitigation works by testing that it rejects bad input and accepts good input.