OWASP Top 10:2025 Course — Module 9 of 10. Course home · Previous: Module 8 — Software/Data Integrity Failures · Next: Module 10 — Mishandling of Exceptional Conditions
Learn first: what is a Security Logging Failure
A09:2025 is the category that makes every other category worse: if an attack leaves no trace, you can’t detect it, investigate it, or even know it happened. This module runs the exact same brute-force attack twice — once against an app with zero logging, once against the same app with one structured JSON log line per attempt plus a watcher that alerts in real time. The attack itself doesn’t change at all; only your ability to see it does.
Stage 1 (no logging): Stage 2 (structured logging):
attack.py ──▶ app attack.py ──▶ app ──▶ login_attempts.log
│ │
server console ▼
stays EMPTY watcher.py (tails log)
account taken over, │
zero trace failures/window > threshold?
│
*** ALERT ***
The attack succeeds in both cases in this lab — the difference is entirely about whether anyone finds out.
Scenario briefing
You’re the on-call engineer at a company whose login app was just quietly compromised. You’ve been asked to figure out, after the fact, whether the incident response team would have had anything to go on — and then to fix that gap.
Lab setup
- Download: Download the lab (zip)
- Prerequisites: Docker Engine 20+, Docker Compose v2, Python 3.9+ on your host (the attack and watcher scripts run on the host, against the containerized apps).
- Start it:
cd owasp-a09-detection-lab docker compose up -d --build - Check it worked:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/should print200.
Missions
Mission 1 — attack the silent app. Brute-force the admin account and check what the server noticed.
Show solution
Solution — Mission 1
python3 app/attack.py 0 http://127.0.0.1:5000/login
[attack] tried 'lab-admin-pass' -> SUCCESS (200)
[attack] credentials found: admin:lab-admin-pass
Now check the server: docker compose logs no-logging — only the Flask startup banner. Nothing about ten login attempts, nothing about the one that succeeded. The account is compromised and there is no record of it.
Mission 2 — watch the same attack get caught live. Start the watcher and the logging-enabled app, then re-run the attack.
Show solution
Solution — Mission 2
python3 -u app/watcher.py --threshold 5 --window 30 &
python3 app/attack.py 0.3 http://127.0.0.1:5001/login
[watcher] failure #5 in window for 172.18.0.1 -> 'admin'
*** ALERT: possible brute-force attack ***
source_ip : 172.18.0.1
failures : 5 within 30s
The exact same attack now raises an alert mid-attack, before the attacker’s final (correct) guess lands.
Mission 3 — read the structured log. Confirm what got recorded for the successful login.
Show solution
Solution — Mission 3
tail -1 app/login_attempts.log
{"timestamp": "...", "event": "login_attempt", "username": "admin", "source_ip": "...", "outcome": "success", "failure_streak": 0}
The attack script itself also prints the recovered credential pair directly: credentials found: admin:lab-admin-pass — submit FLAG{a09-detected-brute-force-admin-lab-admin-pass} once you’ve confirmed that line in the attack output against the logging-enabled app.
Mission 4 — add logging to the silent app. Port the structured JSON logging from app_with_logging.py into app_no_logging.py.
Show solution
Solution — Mission 4
log_entry = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"event": "login_attempt",
"username": username,
"source_ip": request.remote_addr,
"outcome": "success" if ok else "failure",
"failure_streak": streak,
}
with open(LOG_FILE, "a") as f:
f.write(json.dumps(log_entry) + "\n")
Restart the service, run python3 lab.py verify — the attack still succeeds (this module isn’t about stopping the attack, that’s Module 7), but now it leaves a trail a watcher can act on.
Hints
- Start the watcher before running the attack — it tails the log file like
tail -f, so it needs to already be running to catch new lines as they arrive. attack.pytakes an optional delay as its first argument;0(no delay) is fine for a quick test,0.3makes the alert easier to watch land in real time.- Delete
login_attempts.logbetween runs if you want a clean failure-streak count from zero.
Fix it + re-test
Add one structured log line per security-relevant event (not a free-text message — JSON with a consistent schema is what a watcher or SIEM can actually parse), covering at minimum: timestamp, actor (username/IP), event type, and outcome. Then build or buy the correlation layer (watcher.py here, a real SIEM rule in production) that turns "five failures in 30 seconds from one source" into an alert, not just a line in a file nobody reads.
Detect it
This module’s lab literally is the detection answer: a sliding-window velocity check, per (source_ip, username) pair, is a minimal but real brute-force detector. Push the same structured events to a central log pipeline so the detection survives even if the attacker gets far enough to delete local files.
Quick self-check
- Why is "the attack succeeded either way" the whole point of this module, rather than a failure of the lab?
- Why log every attempt (including failures) instead of only logging successful logins?
- What’s the practical difference between a log line and an alert?
Show answers
Answers
- This module isolates one variable — visibility — from the attack’s success; it proves that detection and prevention are separate problems, and that you can (and should) build detection even for attacks you haven’t fully prevented yet.
- A string of failures followed by a success is the actual signature of a brute-force attack; logging only successes would make a compromised account look identical to a normal login, with no way to tell the difference after the fact.
- A log line is a record that something can be reviewed later, usually after the fact; an alert is an active notification the moment a pattern crosses a threshold, in time to respond while the attack is still happening.