Safe Penetration Testing Labs · intermediate · ~12 min

Detecting failed-login bursts

**What you will learn** - Recognize the log signature of a brute-force or password-spraying attack: many `Failed password` lines from one source IP in a short window. - Parse each line of a sample SSH/`auth.log` to extract the source IP address of a failed login. - Aggregate per-IP failure counts in C using a small fixed array of `{ ip, count }` records (a simple hash-free counting table). - Apply a configurable **threshold** to turn raw counts into actionable alerts, the same count-and-flag logic that `fail2ban` uses. - Handle the C string and memory concerns this task creates: bounded copies, no buffer overflows, correct cleanup, and defensive validation of untrusted log text. - Practice all of this safely on a static, authorized sample file — never on live systems you do not own.

Overview

When someone tries to break into an account by guessing passwords, they rarely guess once. They guess hundreds or thousands of times. Every one of those attempts leaves a footprint in the system's authentication log. Detecting failed-login bursts is the skill of reading that log, counting how often each source IP fails, and raising an alert when one IP crosses a suspicious threshold.

This builds directly on Parsing server logs, the prerequisite lesson. There you learned to read a log file line by line and pull fields out of each line. Here we use those same parsing skills for a security purpose: instead of just printing fields, we aggregate them — we keep a running count per IP address and compare it against a limit.

The data we work with is the authentication log. On a typical Linux server this is /var/log/auth.log (Debian/Ubuntu) or /var/log/secure (RHEL/Fedora). The SSH daemon writes a line every time a login is attempted. A failed password attempt looks like this:

May 14 09:31:07 webhost sshd[4421]: Failed password for invalid user admin from 203.0.113.45 port 51920 ssh2

The two pieces a detector cares about are the literal phrase Failed password (the event type) and the IP after the word from (the source). Count those per IP, flag the loud ones, and you have the core of an intrusion-detection rule.

Key terms used in this lesson:

  • Brute-force attack — trying many passwords against one account until one works.
  • Password spraying — trying a few common passwords against many accounts (looks similar in logs: lots of failures, often from one IP).
  • Source IP — the network address the attempt came from.
  • Threshold — the count above which we treat an IP as suspicious.
  • Detector / IDS rule — code that reads logs and emits alerts. We are writing a tiny one.

Why it matters

Credential attacks are one of the most common ways real systems get compromised. Any server with SSH, RDP, or a web login exposed to the internet starts receiving automated password-guessing traffic within minutes of going online. The attacker's goal is a single correct guess; the defender's goal is to notice the thousands of wrong guesses that come first and shut the source down before the lucky one lands.

That is exactly what failed-login detection does. It converts a stream of individual, unremarkable log lines into one clear signal: this IP is attacking us. From that signal you can block the IP, rate-limit it, alert an on-call engineer, or feed it to a SIEM (Security Information and Event Management) dashboard.

Learning to build this by hand matters even though tools like fail2ban exist, because:

  • It teaches you what those tools actually do, so you can configure and trust them.
  • The same count-and-threshold pattern appears everywhere in security monitoring: failed API calls, 404 bursts, DNS query floods, repeated 401s.
  • Doing it in C forces you to handle untrusted input carefully — a skill that is the security work. A log parser that overflows a buffer on a malicious log line is itself a vulnerability.

Core concepts

1. The brute-force log signature

Definition. A brute-force signature is the recognizable pattern a password-guessing attack leaves in an authentication log: a high volume of failed authentications, concentrated on a single source IP, over a short time.

Plain explanation. One person logging in usually fails zero, one, maybe two times before they get it right or give up. An attack script fails constantly — it does not get tired and does not stop at a typo. So the giveaway is not any single failure; it is the rate and concentration of failures.

How it appears internally. Each attempt is one log line written by sshd. A burst is just many such lines close together with the same IP after from.

Legitimate user:                 Attacker (brute force):
Failed password ... from 10.0.0.7   Failed password ... from 203.0.113.45
Accepted password ... from 10.0.0.7 Failed password ... from 203.0.113.45
                                    Failed password ... from 203.0.113.45
   (1 failure, then success)        Failed password ... from 203.0.113.45
                                    Failed password ... from 203.0.113.45
                                       (many failures, same IP, no success)

When to rely on it / when not. Counting failures per IP is reliable against naive scanners. It is weaker against attackers who spread attempts across many IPs (a botnet) or who go slow ("low and slow"). Real detectors combine per-IP counts with per-account counts and time windows.

Pitfall. A few failures are normal (fat-fingered passwords, expired credentials, misconfigured cron jobs). Setting the threshold to 1 or 2 produces constant false alarms.

Knowledge check (explain in your own words): Why is a single failed login a poor brute-force indicator, while five hundred failures from one IP in a minute is a strong one?

2. Extracting the source IP from a line

Definition. Field extraction is pulling the one substring you need (here, the IP after from) out of a longer line of text.

Plain explanation. Each line is just characters. To find the IP, locate the marker word from, then copy the token that follows it up to the next space.

How it works internally. strstr(line, "Failed password") tells you the line is a failure. strstr(line, " from ") gives you a pointer to where the IP starts; you then copy characters until you hit a space.

... Failed password for admin from 203.0.113.45 port 51920 ssh2
                            ^^^^^^
                            " from "  ->  start copying here -> 203.0.113.45
                                          stop at the space  ^

When to use / not. strstr + manual copy is fine for a fixed, known log format. For varied or hostile formats, prefer a stricter parser or a vetted regex library — but never trust a regex to also bound your buffer; you still must.

Pitfall. Copying "until the space" without checking the destination size is a classic buffer overflow. Always copy with a hard length cap.

Knowledge check (predict the output): Given the line Failed password for root from 198.51.100.9 port 22 ssh2, what exact text does "copy after from up to the next space" produce?

3. Per-IP aggregation (the counting table)

Definition. Aggregation is collapsing many records into per-key totals — here, one running count per distinct IP string.

Plain explanation. Keep a small array of { ip, count } slots. For each failed line, find the slot whose ip matches; if found, count++; if not found, add a new slot with count = 1.

Structure (memory layout of the table):

index   ip (char[40])        count
  0   | "203.0.113.45"     |  517 |
  1   | "198.51.100.9"     |    3 |
  2   | "10.0.0.7"         |    1 |
  ...                       (unused slots)

How it works internally. This is a linear-probe lookup over an array: O(n) per line for n distinct IPs. For a few hundred IPs it is instant; for millions you would use a hash table. The principle is identical.

When to use / not. A fixed array is perfect for a teaching detector and for capped inputs. When the number of distinct IPs is unbounded and large, switch to a hash map so lookups stay O(1).

Pitfall. Forgetting to cap the number of slots: if more distinct IPs appear than your array can hold, you must not write past the end. Decide a policy (drop, or grow) and enforce it.

Knowledge check (find-the-bug): A loop does table[count].ip = strdup(ip); count++; but never checks count < MAX_IPS. What goes wrong on a log with thousands of distinct IPs, and which memory-safety category is this?

4. Thresholding and alerting

Definition. A threshold is the count value at or above which an IP is reported as suspicious.

Plain explanation. After counting, walk the table once. For each IP whose count >= threshold, emit an alert. Everything below threshold is treated as noise.

How it works internally. Pure comparison — if (table[i].count >= threshold). The intelligence is entirely in choosing a good threshold and (in real systems) a time window.

When to use / not. Static thresholds are simple and predictable. Adaptive/anomaly thresholds catch slow attacks but add false positives and complexity. Start static.

Pitfall. A threshold with no time window flags an IP that failed 50 times over six months. Real rules count failures per window (e.g., 5 in 60 seconds), then reset.

      counts                         threshold = 20
 IP A:  517  ===========================|====>  ALERT
 IP B:    3  =|.........................|        ok
 IP C:    1  |..........................|        ok

Syntax notes

The building blocks are standard C string and I/O functions, plus a small struct for the table.

#include <string.h>   // strstr, strncpy, strcmp, strlen
#include <stdio.h>    // fopen, fgets, fclose, printf

#define MAX_IPS   256   // capacity of the counting table
#define IP_LEN     40   // big enough for IPv4 and IPv6 text + NUL

typedef struct {
    char ip[IP_LEN];    // source IP as text, always NUL-terminated
    int  count;         // failures seen from this IP
} IpCount;

// Find the marker, then the IP after it:
char *p = strstr(line, " from ");      // NULL if not present
if (p) p += strlen(" from ");           // advance past " from " to the IP

// Bounded copy — NEVER strcpy here:
strncpy(dst, src, IP_LEN - 1);          // copy at most IP_LEN-1 chars
dst[IP_LEN - 1] = '\0';                 // strncpy may not NUL-terminate

Key points:

  • fgets reads one line at a time into a fixed buffer and never overflows it (it stops at buffer size). Always use it instead of gets.
  • strstr returns a pointer into the line (or NULL); it does not copy anything.
  • strncpy is bounded but does not guarantee a terminating NUL — you must add it. This is the single most common strncpy bug.

Lesson

What a brute-force attack looks like

A brute-force login attack means an attacker tries many passwords in a row, hoping one works. In the logs, this shows up as a clear pattern:

  • Many failed logins
  • From a single source IP address
  • In a short period of time

That burst of failures is the signal a defender looks for.

How a detector works

A defensive detector follows a simple loop:

  1. Read an authentication log. These look like /var/log/auth.log on Linux, the file that records login attempts.
  2. Count the failures for each source IP address.
  3. Flag any IP whose count rises above a chosen threshold (the limit that separates normal activity from suspicious activity).

This counting-and-flagging idea is the foundation of real tools such as fail2ban, which automatically blocks IPs that fail too many times.

Safety note

The exercises in this lesson parse a static sample file — a fixed copy of log data prepared for practice.

Never run these tools against live logs from systems you do not own.

Code examples

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#define MAX_IPS   256   /* max distinct source IPs we track */
#define IP_LEN     40   /* fits IPv4/IPv6 text + NUL terminator */
#define LINE_LEN 1024   /* max log line length we accept */

typedef struct {
    char ip[IP_LEN];
    int  count;
} IpCount;

/* Copy the IP token that follows " from " in `line` into `out`.
   Returns 1 on success, 0 if this is not a failed-login line. */
static int extract_failed_ip(const char *line, char *out, size_t out_sz)
{
    if (strstr(line, "Failed password") == NULL)
        return 0;                       /* not a failure event */

    const char *p = strstr(line, " from ");
    if (p == NULL)
        return 0;                       /* malformed: no source field */
    p += strlen(" from ");              /* point at first IP char */

    size_t i = 0;
    /* copy until space/end, leaving room for the NUL */
    while (p[i] != '\0' && p[i] != ' ' && i < out_sz - 1) {
        out[i] = p[i];
        i++;
    }
    out[i] = '\0';                       /* always terminate */
    return (i > 0);                      /* reject empty token */
}

/* Add one failure for `ip`. Returns 0 on success, -1 if table is full. */
static int bump(IpCount *table, int *n, const char *ip)
{
    for (int i = 0; i < *n; i++) {       /* linear search for existing IP */
        if (strcmp(table[i].ip, ip) == 0) {
            table[i].count++;
            return 0;
        }
    }
    if (*n >= MAX_IPS)
        return -1;                       /* capacity guard: never overflow */
    strncpy(table[*n].ip, ip, IP_LEN - 1);
    table[*n].ip[IP_LEN - 1] = '\0';     /* strncpy may not terminate */
    table[*n].count = 1;
    (*n)++;
    return 0;
}

int main(int argc, char **argv)
{
    const char *path = (argc > 1) ? argv[1] : "sample_auth.log";
    int threshold    = (argc > 2) ? atoi(argv[2]) : 5;
    if (threshold < 1) threshold = 1;    /* validate untrusted arg */

    FILE *f = fopen(path, "r");
    if (f == NULL) {
        perror("fopen");                 /* report why it failed */
        return 1;
    }

    IpCount table[MAX_IPS];
    int n = 0;
    char line[LINE_LEN];
    char ip[IP_LEN];
    long dropped = 0;                     /* IPs we could not track */

    while (fgets(line, sizeof line, f) != NULL) {
        if (extract_failed_ip(line, ip, sizeof ip)) {
            if (bump(table, &n, ip) != 0)
                dropped++;
        }
    }
    fclose(f);                            /* release the file handle */

    printf("Distinct source IPs with failures: %d\n", n);
    if (dropped > 0)
        printf("WARNING: table full, %ld additional IP(s) untracked\n",
               dropped);

    printf("--- Alerts (threshold = %d) ---\n", threshold);
    int alerts = 0;
    for (int i = 0; i < n; i++) {
        if (table[i].count >= threshold) {
            printf("ALERT brute-force suspected: %s (%d failures)\n",
                   table[i].ip, table[i].count);
            alerts++;
        }
    }
    if (alerts == 0)
        printf("No IP reached the threshold.\n");

    return 0;
}

What it does. It opens an authentication log, reads it line by line, and for every line containing Failed password it extracts the IP after from, increments that IP's counter in a fixed table, and finally prints every IP whose failure count meets the threshold.

Expected output. Given a sample log where 203.0.113.45 has 7 failures, 198.51.100.9 has 3, and a threshold of 5:

Distinct source IPs with failures: 2
--- Alerts (threshold = 5) ---
ALERT brute-force suspected: 203.0.113.45 (7 failures)
No IP reached the threshold.

(The "No IP reached the threshold" line prints only if zero alerts fire; with one alert above, it would not appear — shown here to illustrate both branches.)

Edge cases handled: missing file (perror + exit), lines without from, oversized lines (fgets truncates safely), empty IP token (rejected), more than MAX_IPS distinct IPs (counted as dropped, never an overflow), and a nonsensical threshold argument (clamped to ≥ 1).

Line by line

We trace the program on this two-line sample, with threshold = 5:

May 14 09:31:07 web sshd[44]: Failed password for admin from 203.0.113.45 port 51920 ssh2
May 14 09:31:08 web sshd[44]: Failed password for admin from 203.0.113.45 port 51921 ssh2
  1. main start. path defaults to sample_auth.log (or argv[1]). threshold is read from argv[2] or defaults to 5, then clamped so it is never below 1.
  2. fopen. Opens the file for reading. If it returns NULL, perror prints e.g. fopen: No such file or directory and we exit with status 1.
  3. Table init. table holds up to 256 IpCount slots; n (used count) starts at 0; dropped at 0.
  4. First fgets. Reads line 1 into line. extract_failed_ip runs: strstr(line, "Failed password") matches, strstr(line, " from ") points just before 203.0.113.45; we advance past from and copy 2 0 3 . 0 . 1 1 3 . 4 5, stopping at the space, then write '\0'. Returns 1 with ip = "203.0.113.45".
  5. bump (first time). Linear search finds no match (n == 0), capacity check passes, so slot 0 gets ip = "203.0.113.45", count = 1, and n becomes 1.
  6. Second fgets. Reads line 2; extract_failed_ip again yields "203.0.113.45".
  7. bump (second time). Linear search finds slot 0 matches, so table[0].count becomes 2; no new slot.
  8. EOF. fgets returns NULL; loop ends. fclose(f) releases the handle.

State of memory at this point:

slot ip count
0 203.0.113.45 2
  1. Report. Prints Distinct source IPs with failures: 1. dropped is 0, so no warning.
  2. Threshold pass. For slot 0, 2 >= 5 is false, so no alert fires; alerts stays 0, and we print No IP reached the threshold. If the same IP had appeared 5+ times, that comparison would be true and we would print the ALERT line instead.

The value that drives the result is table[0].count: it is the running total that the final comparison tests against threshold.

Common mistakes

Mistake 1: Unbounded copy of the IP token (strcpy / no length cap)

/* WRONG */
char ip[16];
char *p = strstr(line, " from ") + 5;
strcpy(ip, p);   /* copies the WHOLE rest of the line, incl. "port ... ssh2" */

Why it is wrong. strcpy copies until a NUL. The rest of the log line is far longer than 16 bytes, so this writes past ip — a stack buffer overflow, classic undefined behavior and a real exploit primitive. It also captures port 51920 ssh2, not just the IP.

Corrected. Copy character by character with a hard cap and stop at the first space (as in the lesson's extract_failed_ip), or use strncpy plus an explicit NUL. Recognize it when a tool crashes only on long lines, or when AddressSanitizer reports a stack-buffer-overflow.

Mistake 2: Assuming strstr(line, " from ") is never NULL

/* WRONG */
char *p = strstr(line, " from ");
p += 6;   /* CRASH if p was NULL */

Why it is wrong. Some lines (Accepted password, daemon noise, truncated lines) have no from. Adding to NULL and dereferencing is undefined behavior. Corrected: always if (p == NULL) return 0; before using it.

Mistake 3: Threshold of 1 (or no threshold)

/* WRONG: alerts on every single failure */
if (table[i].count >= 1) alert();

Why it is wrong. Normal users mistype passwords. A threshold of 1 buries the real attack in false positives, so operators ignore the alerts — "alert fatigue." Corrected: pick a meaningful threshold (commonly 5–10) and, in production, count per time window.

Mistake 4: strncpy without manual NUL termination

/* WRONG */
strncpy(table[n].ip, ip, IP_LEN);   /* no room left for NUL if ip is long */

Why it is wrong. If the source is exactly IP_LEN chars, strncpy fills the buffer and leaves it unterminated; later strcmp/printf read past the end. Corrected: strncpy(dst, src, IP_LEN - 1); dst[IP_LEN - 1] = '\0';

Debugging tips

Compiler warnings — turn them on first. Build with gcc -Wall -Wextra -Wpedantic -std=c11. This catches the most common slips here: implicit declaration of strstr/strncpy (missing #include <string.h>), atoi without <stdlib.h>, and signed/unsigned comparison mismatches in the copy loop.

Runtime crashes — use the sanitizers. Compile with gcc -fsanitize=address,undefined -g and run on your sample log. ASan pinpoints the exact buffer overflow or NULL dereference with file and line, which is far faster than guessing. valgrind ./a.out sample_auth.log is an alternative that also flags reads of uninitialized memory.

Common errors and what they mean:

  • Segfault only on some logs → almost always a missing NULL check after strstr, or an overflow on an unusually long line. Add the guards from Mistake 1 and 2.
  • Counts are too high / IPs look garbled (203.0.113.45 port) → you copied past the space; your stop condition is wrong.
  • Two identical IPs counted separately → trailing characters differ (a stray \n or space copied in). Confirm you stop at the first space and NUL-terminate.
  • fopen returns NULL → wrong path or permissions; perror("fopen") tells you which.

Logic errors — test deliberately. Make a tiny fixture: one IP failing 7 times, another failing twice. Run with threshold = 5; only the first IP should alert. Then run with threshold = 2; both should alert. If results differ, print every table[i].ip/count before the threshold loop to see the raw aggregation.

Questions to ask when it does not work: Did I check strstr for NULL? Is every copied string NUL-terminated? Is my stop-at-space condition correct? Does my capacity guard run before I write a new slot? Am I comparing >= (not >) to the threshold?

Memory safety

Authorization and ethics. Run these programs only against a static sample log you are authorized to use — your own lab file, a CTF dataset, or a copy you generated. Never point a parser at live logs on systems you do not own or administer, and never use failed-login data to retaliate against an IP. Detection is defensive; the output of this tool is an alert to a human, not an attack.

Threat model for the parser itself (the parser is part of the attack surface):

Asset:           the monitoring host running the detector
Trust boundary:  the log file is UNTRUSTED input
                 (an attacker controls the username, and sometimes
                  more, that ends up in log lines)
Entry point:     each line read by fgets
Risk:            a hostile/oversized line overflowing a buffer turns
                 your defensive tool into a vulnerability

Because log content is partly attacker-influenced, the C memory-safety concerns for this topic are front and center:

  • Bounds. Every copy must be length-capped. Use fgets (bounded) not gets; copy the IP with an explicit cap and NUL terminator. An overflow here is exploitable.
  • NUL termination. strncpy does not guarantee it; always set the last byte to '\0'. Unterminated strings cause out-of-bounds reads in strcmp/printf.
  • Capacity guard. Check n < MAX_IPS before writing a new table slot, or count overflow IPs as dropped. Writing slot 256 of a 256-slot array is out-of-bounds.
  • NULL pointers. strstr and fopen can return NULL; check both before use.
  • Initialization. Only print table[i] for i < n; uninitialized slots contain garbage.
  • Resource cleanup. fclose the file; if you later strdup/malloc IP strings, free them. A long-running monitor that leaks memory eventually dies.

Detection & logging guidance. Log what fired the alert: source IP, failure count, the time window, and the rule/threshold used. Do not log passwords, password hashes, session tokens, or full credential strings — those are secrets and logging them creates a new breach. Write alerts to an append-only, access-controlled log so an attacker who gains a foothold cannot quietly erase the evidence of their own brute-force.

Testing the mitigation. To verify your bounded parser is actually safe, build a hostile fixture line that is far longer than LINE_LEN and one whose username field contains spaces and from, run under -fsanitize=address, and confirm: no overflow report, the IP is extracted correctly, and counts are right. If ASan stays silent and counts are correct, the bounds handling works.

Real-world uses

Where this exact pattern runs in production:

  • fail2ban scans /var/log/auth.log (and many other logs), counts failures per IP within a time window, and inserts a firewall rule to ban IPs that cross the threshold — the count-and-flag logic of this lesson, plus a ban action and a window.
  • sshd / PAM can lock accounts after repeated failures; the same counting idea, applied per account instead of per IP.
  • SIEM platforms (Splunk, Elastic Security, Wazuh) ship correlation rules like "≥ N failed logins from one source in M minutes → alert," feeding SOC dashboards.
  • Cloud providers (AWS GuardDuty, Azure Sentinel) generate "SSH brute force" and "password spray" findings from the same signal at scale.
  • Web apps apply identical logic to repeated HTTP 401/403s or failed API keys to drive rate-limiting and CAPTCHA challenges.

Professional best-practice habits for this topic:

Beginner rules:

  • Treat every log line as untrusted; bound every copy and check every pointer.
  • Make the threshold and log path configurable, not hard-coded.
  • Use clear names (extract_failed_ip, IpCount) and comment only the non-obvious lines.
  • Always close files and free anything you allocate.
  • Test on a small fixture with known counts before trusting real output.

Advanced rules:

  • Count per time window, not over all history, and decay or reset counts so old failures do not accumulate forever.
  • Combine signals: per-IP and per-account counts, plus geovelocity and whether a success eventually followed.
  • Replace the linear array with a hash table once distinct IPs grow large, to keep lookups O(1).
  • Emit structured, machine-parsable alerts (JSON) to an append-only sink, with no secrets in the payload.
  • Add allow-lists for known scanners/monitoring so you do not page on yourself.

Practice tasks

Beginner 1 — Count total failures. Objective: read a sample auth log and print the total number of Failed password lines. Requirements: open the file with fopen, read with fgets, use strstr to detect the phrase, fclose at the end. Validate that fopen succeeded. Example: a 100-line log with 23 failures prints Total failed logins: 23. Constraints: do not load the whole file into memory; process line by line. Hint: this is the simplest detector — no IPs yet, just a counter. Concepts: file I/O, strstr. (Mirrors the Count failed logins exercise.)

Beginner 2 — Threshold decision. Objective: write int is_bruteforce(int failed_count, int threshold) that returns 1 if failed_count >= threshold, else 0. Requirements: handle threshold <= 0 by treating it as 1; no I/O needed. Example: is_bruteforce(7, 5) → 1; is_bruteforce(3, 5) → 0. Constraints: pure function, no globals. Hint: it is a single comparison plus a guard. Concepts: thresholding, input validation. (Mirrors the Brute-force threshold reached? exercise.)

Intermediate 1 — Per-IP counts. Objective: extend the beginner work to print each source IP and its failure count. Requirements: extract the IP after from with a bounded copy, aggregate into a fixed IpCount table[MAX_IPS], guard the capacity, NUL-terminate every IP. Print IP count for each distinct IP. Example input line: ... Failed password for root from 198.51.100.9 port 22 ssh2. Example output: 198.51.100.9 4. Constraints: no buffer may overflow on any input. Hint: reuse extract_failed_ip and bump patterns; test with the sanitizer. Concepts: field extraction, aggregation, bounds safety.

Intermediate 2 — Alert above threshold. Objective: read the log, aggregate per IP, and print only IPs at or above a threshold passed on the command line. Requirements: parse argv for path and threshold; validate the threshold; print ALERT <ip> (<count> failures) for each flagged IP and a clear message when none qualify. Example: ./detect sample_auth.log 5 prints alerts only for IPs with ≥ 5 failures. Constraints: still process line by line; still bound all copies. Hint: combine the two intermediate pieces with the final threshold loop. Concepts: argument parsing, validation, thresholding.

Challenge — Windowed detection. Objective: flag an IP only if it reaches the threshold within a sliding time window (e.g., 5 failures in 60 seconds), using the timestamp at the start of each line. Requirements: parse the Mon DD HH:MM:SS timestamp to seconds, keep per-IP recent-failure timestamps, and alert when enough fall inside the window; drop timestamps that age out. Bound all storage. Example: 5 failures from one IP within 60s alerts; the same 5 spread over an hour does not. Constraints: no unbounded growth — cap stored timestamps per IP. Hint: a small ring buffer of recent timestamps per IP works; compare newest minus oldest to the window. Concepts: time parsing, windowing, per-IP state, careful bounds. (Do not just count all-time totals — the window is the point.)

Summary

  • The signal. A brute-force attack shows up as many Failed password lines from one source IP in a short time. A single failure is noise; a concentrated burst is the alert.
  • The pipeline. Read the auth log line by line (fgets), detect failures (strstr(line, "Failed password")), extract the IP after from with a bounded copy, aggregate per IP in a fixed { ip, count } table, then flag every IP whose count meets the threshold. This is the core of fail2ban and SIEM brute-force rules.
  • Key syntax to remember. strstr returns a pointer or NULL (check it); strncpy does not guarantee a NUL terminator (add it); always guard table capacity with n < MAX_IPS; compare with >= to the threshold; fclose what you fopen.
  • Common mistakes. Unbounded strcpy of the IP (overflow), dereferencing a NULL from strstr, a threshold of 1 (false-alarm flood), and forgetting per-time-window counting in production.
  • Security mindset. The log is untrusted input, so the parser must be memory-safe or it becomes the vulnerability. Log the alert details (IP, count, window) but never secrets. Practice only on authorized, static sample logs — detection is defensive.

Practice with these exercises