Safe Penetration Testing Labs · intermediate · ~15 min
By the end of this lesson you will be able to: - Read an in-memory `auth.log` buffer line by line in C11 using only standard library functions. - Classify each log line as a success, a failure, or noise, and explain why the exact substring you match (`Failed password`) matters for accuracy. - Extract the source IP that follows `from ` and stops before ` port `, safely and without buffer overruns. - Count failures per source IP by grouping, then report how many IPs meet or exceed a detection threshold. - Turn that count into a defensive signal: what to log about a suspected brute-force burst, and what you must never log. - Verify your detector rejects noise (successful logins, malformed lines) and accepts real bursts, all inside an isolated lab.
Security objective. The asset you are protecting is account access on a Linux host — specifically the SSH service (sshd) that lets users log in remotely. The threat is a brute-force attack: an attacker who repeatedly guesses passwords, hoping one works. Your job in this lesson is detection, not attack. You will build a small C program that reads the authentication log, spots the tell-tale burst of Failed password events coming from one source IP, and flags it so a human defender can respond.
Brute-force simply means trying many passwords in quick succession. Against a weak password it can succeed in minutes, so catching the attempt early — while it is still failing — is what gives defenders time to react (block the IP, force a password reset, enable rate limiting).
Where this fits: Linux records authentication events in /var/log/auth.log (Debian/Ubuntu/Kali) or /var/log/secure (RHEL/Fedora). A brute-force run leaves a very visible fingerprint there — dozens or hundreds of Failed password for ... from <ip> lines from the same address within seconds. A Security Operations Center (SOC) analyst reads exactly these lines to separate a real attack from background noise. Every off-the-shelf tool that does this (fail2ban, a SIEM correlation rule, an IDS signature) starts from the same core idea you are about to implement.
How this builds on your prerequisites. From C strings you already know a C string is a char * ending in a '\0', and that functions like strstr, strncmp, and strchr search and slice those bytes without allocating. You will lean on all three here. From The main function you know how int main(void) returns a status code and how a program's entry point drives the work; the detector you write is a plain function that main calls with a fixed test buffer. Crucially, the log bytes are a static buffer bundled with the harness — your code never opens /var/log/auth.log and never touches the network. That keeps the whole exercise safe and reproducible.
Triage — deciding what is a real attack and what is harmless — is the first job a SOC does after an alert fires. A busy internet-facing SSH server can log thousands of failed logins a day just from automated internet scanning. The skill that matters professionally is not "a failed login happened" (that is constant) but "this source IP failed this many times in this window, which is abnormal." That is precisely the grouped-count-versus-threshold logic in this lesson.
In authorized professional work this shows up everywhere:
auth.log to reconstruct when the brute force started, which IPs took part, and whether any attempt eventually succeeded (an Accepted line right after a burst of Failed lines from the same IP is a red flag).Because the logic is simple string parsing, it is a perfect first exercise for learning to build detections that are precise (few false positives) and honest about their limits.
Definition. /var/log/auth.log (Debian/Ubuntu/Kali) is a plain-text file where the system records authentication events — logins, sudo use, SSH sessions. On RHEL/Fedora the same information lives in /var/log/secure.
Plain explanation. Every time someone (or something) tries to log in, a line is appended. You read it top to bottom; each line is one event.
How it works. sshd writes a line per attempt through the system logger. A failed SSH password attempt looks like:
Jan 23 12:01:02 host sshd[1234]: Failed password for invalid user root from 192.0.2.7 port 5413 ssh2
When / when not. Use it for authentication-related detection. Do not treat it as tamper-proof: an attacker with root can edit or delete it, which is why production systems ship logs off-host to a central collector. In this lab the buffer is static and read-only.
Pitfall. Log formats vary slightly by OS and sshd version. Matching a substring that is stable across versions (Failed password) is safer than matching the whole line.
Definition. Classifying means deciding what category each line belongs to before you count it.
Plain explanation. Only some lines are failed passwords. Accepted publickey and Accepted password are successes. Session-open messages, sudo lines, and cron noise are irrelevant. Count only what you mean to count.
How it works. Search each line for the exact substring "Failed password". If it is absent, skip the line. This is a substring test with strstr.
When / when not. For a first-pass burst detector, substring matching is enough. When you need airtight parsing (feeding a SIEM), you would parse structured fields instead of free text.
Pitfall. strstr matches anywhere in the line. A crafted username could contain the text Failed password. In this lab the buffer is trusted; in production you would anchor the match to the message portion after sshd[...]:.
Definition. The source IP is the address the attempt came from — the token right after from and right before port.
Plain explanation. Find from , start reading after it, and stop at the next space (the one before port). The bytes in between are the IP.
How it works. strstr(line, "from ") gives you a pointer to from ; advance 5 bytes to reach the address; then copy characters until you hit a space or newline, always leaving room for the terminating '\0'.
When / when not. This boundary rule works for both IPv4 (192.0.2.7) and IPv6 addresses as printed by sshd. It fails on malformed lines that lack from — which you must handle by skipping, not crashing.
Pitfall. Copying into a fixed char ip[64] without a length cap is a classic overflow. Always bound the copy and terminate the string.
Definition. Grouping means accumulating a per-IP counter; thresholding means comparing that counter to a chosen limit.
Plain explanation. Keep a small table of {ip, count}. Each Failed password line: find the IP's row (or add one), increment its count. At the end, count how many rows reached threshold.
How it works. A linear scan of the table with strcmp is perfectly fine for a lab-sized buffer. Real tools use a hash map, but the logic is identical.
When / when not. Thresholds trade off sensitivity vs false positives. Too low and normal retries alert; too high and a slow brute force slips through. Tune against real baseline traffic.
Pitfall. Double-counting. If you add a fresh row every time an IP appears instead of finding the existing row, every IP looks unique and no threshold is ever crossed.
UNTRUSTED TRUST BOUNDARY TRUSTED (defender)
+-----------------------+ | +----------------------------+ +------------------+
| Attacker on internet | | | sshd on the host | | auth.log buffer |
| guesses passwords |=====|====>| (entry point: TCP :22) |====>| (static, in lab)|
| from source IP X | network | writes one line/attempt | +---------+--------+
+-----------------------+ boundary +----------------------------+ |
v
+-----------------------------+
| YOUR detector (this lesson)|
| count Failed-password per IP|
| flag IPs >= threshold |
+--------------+--------------+
|
v
+-----------------------------+
| Alert + defensive log entry |
| (SOC analyst acts on it) |
+-----------------------------+
Asset protected : account access on the host (the sshd login).
Entry point : sshd listening on TCP port 22.
Trust boundary : the network edge — data from source IPs is untrusted input.
Insecure assumption an attacker exploits: "anyone reaching :22 may keep guessing forever."
Knowledge check.
auth.log, appearing right after a long burst of failures from the same IP, would tell you the brute force succeeded — and why would that raise the incident's severity?The whole detector is built from four standard-library string operations plus a bounded copy. All are declared in <string.h>.
#include <string.h>
/* 1. Does this line contain the failure marker?
strstr returns a pointer to the first match, or NULL if absent. */
char *marker = strstr(line, "Failed password");
if (marker == NULL) {
/* not a failed-password line -> skip it */
}
/* 2. Find where the IP starts: just after "from ". */
char *f = strstr(line, "from ");
if (f != NULL) {
char *ip_start = f + 5; /* skip the 5 bytes of "from " */
/* 3. The IP ends at the next space or end-of-line. */
size_t len = 0;
while (ip_start[len] != ' ' &&
ip_start[len] != '\n' &&
ip_start[len] != '\0') {
len++;
}
/* 4. Copy it into a fixed buffer, NEVER exceeding its size, then terminate. */
char ip[64];
if (len >= sizeof ip) len = sizeof ip - 1; /* clamp: leave room for '\0' */
memcpy(ip, ip_start, len);
ip[len] = '\0';
}
Key points:
strstr(haystack, needle) returns NULL when the needle is absent — always check before dereferencing.f + 5 works because "from " is exactly five bytes; count them yourself rather than guessing.len >= sizeof ip clamp is what prevents a buffer overflow if a malformed line has no delimiter. sizeof ip - 1 reserves the last byte for '\0'.strchr(p, '\n') to find each line end, or copy each line into a temporary buffer before processing.The /var/log/auth.log file is the first place a defender looks after a suspicious event. It records authentication activity on the system.
A brute-force attack against SSH leaves a clear trail: many Failed password for ... lines from the same source IP, all within a short window.
(Brute-force means an attacker tries many passwords in quick succession, hoping one works.)
This exercise focuses on the detection side. The steps are:
Jan 23 12:01:02 host sshd[1234]: Failed password for invalid user root from 192.0.2.7 port 5413
Jan 23 12:01:04 host sshd[1235]: Failed password for invalid user admin from 192.0.2.7 port 5414
Jan 23 12:01:08 host sshd[1240]: Accepted publickey for gilos from 10.0.0.5 port 22001
The bytes you process are static and bundled in the harness. The function never opens a real log file.
Write a function that:
Failed password for ... from <ip> lines per IP.threshold times.Accepted publickey is a success, not a failure. The string you want is Failed password.port that follows it. Use that boundary to extract it.The example builds the detection in three stages: an intentionally sloppy version that miscounts and can overflow, the corrected secure version, and checks that prove the fix works. Everything runs on a static in-memory buffer — no files, no network.
/* WARNING: intentionally vulnerable — use only in a local, isolated,
authorized lab. Do not deploy.
Two bugs on purpose:
(A) counts "password" anywhere, so it also counts "Accepted password"
(a SUCCESS) as if it were a failure -> false positives.
(B) copies the IP into a fixed buffer with no length check -> a line
missing the " port " delimiter overruns ip[] (buffer overflow). */
#include <stdio.h>
#include <string.h>
static int bad_detect(const char *log) {
int hits = 0;
const char *p = log;
while (*p) {
if (strstr(p, "password")) { /* BUG A: too loose */
const char *f = strstr(p, "from ");
if (f) {
char ip[8]; /* far too small */
int i = 0;
const char *s = f + 5;
while (*s != ' ') { /* BUG B: no bound, no newline/EOF check */
ip[i++] = *s++; /* overruns ip[] */
}
ip[i] = '\0';
hits++;
}
}
const char *nl = strchr(p, '\n');
if (!nl) break;
p = nl + 1;
}
return hits;
}
#include <stdio.h>
#include <string.h>
#define MAX_IPS 256
#define IP_LEN 64
#define LINE_LEN 1024
/* One row per distinct source IP. */
struct ip_count {
char ip[IP_LEN];
int count;
};
/* Find the row for `ip`, or create it. Returns index, or -1 if table full. */
static int find_or_add(struct ip_count *tbl, int *n, const char *ip) {
for (int i = 0; i < *n; i++) {
if (strcmp(tbl[i].ip, ip) == 0) return i; /* group: reuse row */
}
if (*n >= MAX_IPS) return -1; /* table full: refuse */
strncpy(tbl[*n].ip, ip, IP_LEN - 1);
tbl[*n].ip[IP_LEN - 1] = '\0';
tbl[*n].count = 0;
return (*n)++;
}
/* Extract the source IP from one line into `out` (size IP_LEN).
Returns 1 on success, 0 if the line has no usable "from <ip>". */
static int extract_ip(const char *line, char *out) {
const char *f = strstr(line, "from ");
if (f == NULL) return 0;
const char *s = f + 5; /* skip "from " */
size_t len = 0;
while (s[len] != ' ' && s[len] != '\n' && s[len] != '\0') {
len++;
}
if (len == 0) return 0;
if (len >= IP_LEN) len = IP_LEN - 1; /* clamp: no overflow */
memcpy(out, s, len);
out[len] = '\0';
return 1;
}
/* Count how many distinct source IPs produced at least `threshold`
"Failed password" events in the log buffer. */
int detect_bursts(const char *log, int threshold) {
struct ip_count tbl[MAX_IPS];
int n = 0;
const char *p = log;
while (*p != '\0') {
/* Copy the current line into a bounded temporary. */
char line[LINE_LEN];
const char *nl = strchr(p, '\n');
size_t linelen = nl ? (size_t)(nl - p) : strlen(p);
if (linelen >= LINE_LEN) linelen = LINE_LEN - 1; /* clamp */
memcpy(line, p, linelen);
line[linelen] = '\0';
/* Classify: only exact "Failed password" lines count. */
if (strstr(line, "Failed password") != NULL) {
char ip[IP_LEN];
if (extract_ip(line, ip)) {
int idx = find_or_add(tbl, &n, ip);
if (idx >= 0) tbl[idx].count++;
}
}
if (nl == NULL) break;
p = nl + 1;
}
int flagged = 0;
for (int i = 0; i < n; i++) {
if (tbl[i].count >= threshold) flagged++;
}
return flagged;
}
int main(void) {
/* Static in-lab log: 192.0.2.7 fails 3x (a burst), 10.0.0.5 succeeds,
198.51.100.9 fails once (noise). No real hosts; TEST-NET ranges only. */
const char *log =
"Jan 23 12:01:02 host sshd[1234]: Failed password for invalid user root from 192.0.2.7 port 5413 ssh2\n"
"Jan 23 12:01:04 host sshd[1235]: Failed password for invalid user admin from 192.0.2.7 port 5414 ssh2\n"
"Jan 23 12:01:06 host sshd[1236]: Failed password for gilos from 192.0.2.7 port 5415 ssh2\n"
"Jan 23 12:01:08 host sshd[1240]: Accepted publickey for gilos from 10.0.0.5 port 22001 ssh2\n"
"Jan 23 12:02:00 host sshd[1250]: Failed password for invalid user test from 198.51.100.9 port 3000 ssh2\n";
/* ACCEPT good input: with threshold 3, exactly one IP (192.0.2.7). */
int flagged = detect_bursts(log, 3);
printf("threshold 3 -> flagged IPs: %d (expect 1)\n", flagged);
/* REJECT the success line: the Accepted publickey from 10.0.0.5
must never be counted as a failure. A high threshold flags nobody. */
int none = detect_bursts(log, 99);
printf("threshold 99 -> flagged IPs: %d (expect 0)\n", none);
/* Sensitivity check: threshold 1 flags the two IPs that ever failed. */
int lenient = detect_bursts(log, 1);
printf("threshold 1 -> flagged IPs: %d (expect 2)\n", lenient);
return 0;
}
Expected output
threshold 3 -> flagged IPs: 1 (expect 1)
threshold 99 -> flagged IPs: 0 (expect 0)
threshold 1 -> flagged IPs: 2 (expect 2)
Compile and run in your lab with:
gcc -std=c11 -Wall -Wextra -fsanitize=address,undefined -o detect detect.c
./detect
The -fsanitize=address,undefined flags make the vulnerable Stage-1 copy crash loudly if you run it, proving the overflow is real; the Stage-2 code runs clean.
Walking through detect_bursts on the Stage-3 log:
struct ip_count tbl[MAX_IPS]; int n = 0; — an empty table of {ip, count} rows. n is how many rows are in use.while (*p != '\0') walks the buffer one line at a time. strchr(p, '\n') finds the end of the current line; linelen is its length, clamped to fit the temporary line[LINE_LEN].memcpy(line, p, linelen); line[linelen] = '\0'; copies just this line and terminates it, so strstr never runs past the line.strstr(line, "Failed password") is the classifier. Success lines (Accepted publickey) return NULL here and are skipped — that is why the success from 10.0.0.5 is never counted.extract_ip finds from , jumps 5 bytes, and copies until the space before port, clamped to IP_LEN. For line 1 that yields 192.0.2.7.find_or_add searches the table with strcmp. First time it sees 192.0.2.7 it adds a row; the next two times it reuses that row — this is the grouping that prevents double-counting.for counts rows whose count >= threshold.Trace with threshold = 3:
| Line | Marker present? | Extracted IP | Table after step |
|---|---|---|---|
| 1 Failed … 192.0.2.7 | yes | 192.0.2.7 | {192.0.2.7:1} |
| 2 Failed … 192.0.2.7 | yes | 192.0.2.7 | {192.0.2.7:2} |
| 3 Failed … 192.0.2.7 | yes | 192.0.2.7 | {192.0.2.7:3} |
| 4 Accepted … 10.0.0.5 | no | — (skipped) | {192.0.2.7:3} |
| 5 Failed … 198.51.100.9 | yes | 198.51.100.9 | {192.0.2.7:3, 198.51.100.9:1} |
Final pass with threshold = 3: 192.0.2.7 has 3 (>=3, flagged), 198.51.100.9 has 1 (not flagged). Result: 1. With threshold = 1 both rows qualify → 2. With threshold = 99 neither qualifies → 0.
Mistake 1 — matching "password" instead of "Failed password".
if (strstr(line, "password")).Accepted password (a successful login) also contains password, so successes are counted as failures. Your "brute force" alert fires on legitimate users — a false positive that erodes trust in the detector."Failed password" substring.Accepted password and assert it is not counted (Stage-3 does this with the threshold 99 -> 0 check).Mistake 2 — unbounded IP copy (buffer overflow).
while (*s != ' ') ip[i++] = *s++; into a small char ip[8].ip[] and corrupts the stack — undefined behavior and a real security bug even in a parser.'\n', or '\0', clamp len to IP_LEN - 1, and always write '\0'.-fsanitize=address; it aborts on the overrun immediately.Mistake 3 — adding a new row per occurrence (double counting inverted).
strncpy(tbl[n].ip, ip, ...); tbl[n].count = 1; n++; every time.Failed password becomes its own row, so no IP ever accumulates past 1 and no threshold is crossed — the detector silently detects nothing.find_or_add searches first and reuses an existing row.Mistake 4 — reading past the current line.
strstr/strchr on the raw p and assuming they stop at the line boundary.strstr happily crosses '\n', so from on a later line can attach to the marker on an earlier line.Mistake 5 — assuming logs are trustworthy.
auth.log as ground truth of everything that happened.The count is always 0.
from offset or delimiter is wrong.find_or_add) rather than creating a new one each time.The count is too high.
password loosely and counting Accepted password. Switch to the full "Failed password" marker and re-run the success-line test.strstr is running on a single terminated line, not the whole buffer.Segfault or AddressSanitizer abort.
len >= IP_LEN clamp and the '\n'/'\0' stop conditions are present.strstr's return value before dereferencing — a NULL from a missing from will crash if you add 5 to it blindly.Last line ignored.
'\n', strchr returns NULL. Make sure your loop still processes that last chunk (the secure code uses strlen(p) in that case) before breaking.Questions to ask when it fails.
strstr actually find the marker, or am I dereferencing NULL?from, no port) get skipped safely instead of crashing?A fast way to localize bugs: temporarily printf("[%s] len=%zu -> ip=%s\n", classified?"FAIL":"skip", linelen, ip) per line, run once, then remove the prints.
This lesson has two safety dimensions: C memory safety and security detection/logging.
C memory safety for this parser.
extract_ip clamps len to IP_LEN - 1; the line copy clamps to LINE_LEN - 1; find_or_add uses strncpy and then forces a terminator. Never copy attacker-influenced text into a fixed buffer without a length cap.memcpy/strncpy of a slice, write '\0' yourself — strncpy does not terminate when the source is too long.strstr and strchr return NULL; adding an offset to NULL is undefined behavior.malloc a dynamic table, pair every malloc with free and check for NULL.-Wall -Wextra -fsanitize=address,undefined while developing; it catches overruns and bad reads at the exact line.Security & safety: detection and logging. When your detector flags a burst, the alert it emits is itself security data. Log it carefully.
What to log for each flagged burst:
sshd, and the account names tried if you track them).What to NEVER log:
Which events signal abuse: a high failure-per-IP count in a short window; failures spread across many usernames from one IP (username enumeration); and — most serious — a burst of failures immediately followed by an Accepted line from the same IP, which suggests the brute force succeeded.
How false positives arise: a user or app with a stale cached password can retry rapidly and look like an attacker; a NAT gateway or corporate proxy makes many legitimate users share one source IP, inflating its count; and vulnerability scanners run by your own team generate bursts. Tune thresholds against a real baseline and maintain an allowlist for known-good sources so the signal stays trustworthy.
Authorized real-world use case. A SOC deploys a lightweight agent (or a SIEM rule) that reads auth.log on each server, counts Failed password events per source IP over a rolling window, and raises a ticket when an IP crosses the threshold. An analyst then decides whether to block the IP at the firewall, confirm the targeted accounts are safe, and check for a following Accepted line. Tools like fail2ban automate exactly this loop on a single host; the detector you wrote is its beating heart.
Professional best-practice habits.
Beginner vs advanced.
| Aspect | Beginner | Advanced |
|---|---|---|
| Data | one static buffer | live tail of rotating logs, multiple hosts |
| Matching | substring Failed password |
structured field parse, anchored to the message |
| Storage | linear {ip,count} array |
hash map + time-windowed / decaying counters |
| Window | whole buffer | rolling N-minute window per IP |
| Response | print a flag | enrich (geo/ASN), correlate, auto-ticket, rate-limit |
| Trust | logs assumed intact | tamper detection, off-host collection, integrity checks |
Always remember: passing this or any automated check does not prove a system is secure, and nothing is ever "completely secure" — a detector reduces time-to-notice, it does not eliminate the threat.
All tasks run on a static, in-memory log buffer in an isolated lab. Do not point any of this at a system you do not own or are not explicitly authorized to test.
Beginner 1 — Count all failed passwords.
int count_failed(const char *log) returning the number of lines containing "Failed password".Accepted lines.3.strstr per line; increment on a non-NULL match.Accepted password line is not counted before trusting the number.Beginner 2 — Extract every source IP safely.
int print_failed_ips(const char *log) that prints the source IP of each failed-password line and returns how many it printed.extract_ip; skip lines with no from .192.0.2.7 three times and 198.51.100.9 once; returns 4.char ip[64]; clamp the copy; always terminate.extract_ip shape from the lesson; stop at space/newline/EOF.from .-fsanitize=address and confirm a line missing port does not overrun.Intermediate 1 — Per-IP burst detector.
int detect_bursts(const char *log, int threshold) (as in the lesson) returning the number of distinct IPs with at least threshold failures.threshold 3 → 1; threshold 1 → 2; threshold 99 → 0.{ip,count} table; handle a full table by refusing new rows.find_or_add before incrementing.Intermediate 2 — Enumeration signal.
user_threshold different usernames.for (and optional invalid user ) and from; store a small per-IP username set.root, admin, test.Challenge — Windowed detector with a defensive report.
threshold failures within any window_seconds span, then emit a one-line defensive alert per flagged IP (source IP, count, first/last timestamp, correlation id) — with NO secrets.Mon DD HH:MM:SS timestamp to seconds-of-day; keep per-IP failure times; slide a window; produce the alert text your SOC would log.Main concepts. auth.log records SSH authentication events; a brute force shows up as many Failed password ... from <ip> lines from one source IP in a short window. Detection = classify each line, extract the source IP, group failures per IP, and flag IPs that reach a threshold. This is the core SOC triage routine and the heart of tools like fail2ban.
Key syntax / commands.
strstr(line, "Failed password") — classify (returns NULL if absent; always check).strstr(line, "from ") + 5 — locate the IP start; copy until space/'\n'/'\0', clamped to the buffer size, then terminate.{ip, count} table with find_or_add to group, then a final pass counting count >= threshold.gcc -std=c11 -Wall -Wextra -fsanitize=address,undefined.Common mistakes. Matching password (counts successes) instead of Failed password; unbounded IP copy (buffer overflow); adding a new row per occurrence instead of grouping; letting strstr cross line boundaries; trusting logs as tamper-proof.
What to remember. Precision beats volume — count this IP, this many times, not "a failure happened." Bound every copy and terminate every string. Log the who/what/when/decision/correlation-id of an alert; never log passwords, tokens, keys, or unneeded PII. Tune thresholds against real baselines (NAT and self-run scanners cause false positives). Decoding a log is not the same as proving an attack succeeded, passing an automated check does not prove a system is secure, and nothing is ever "completely secure." Only ever run this against systems you own or are explicitly authorized to test.