The Challenge & Rules
3 minBelow is a small, deliberately-vulnerable Flask app (a mini link-sharing site). It contains four planted OWASP bugs. Your job: find each, identify its category, exploit it locally to confirm, then patch it.
- 🥉 Bronze — find and name all four bugs.
- 🥈 Silver — confirm each with a local proof-of-concept.
- 🥇 Gold — patch all four correctly and re-test to prove the fixes hold.
This app is intentionally insecure — run it on your own machine, attack your own instance. The skills are the point; the ethics (Lesson 1, 19) are absolute. Never deploy this or point exploits at anything you don't own.
The Vulnerable App
5 minSave and run this locally. Read it carefully — every bug maps to a lesson you've done (28-35).
# buggy_app.py — DELIBERATELY VULNERABLE. Run locally only. Find 4 bugs. import sqlite3, hashlib from flask import Flask, request, session app = Flask(__name__) app.secret_key = "hardcoded-secret-123" # (bug?) db = sqlite3.connect(":memory:", check_same_thread=False) db.execute("CREATE TABLE users (id INTEGER, name TEXT, pw TEXT, role TEXT)") db.execute("INSERT INTO users VALUES (1,'aisha','5f4dcc3b5aa765d61d8327deb882cf99','user')") db.execute("INSERT INTO users VALUES (2,'admin','21232f297a57a5a743894a0e4a801fc3','admin')") db.execute("CREATE TABLE notes (id INTEGER, owner_id INTEGER, text TEXT)") db.execute("INSERT INTO notes VALUES (1,1,'aisha note'),(2,2,'admin secret')") @app.post("/login") def login(): u, p = request.form["user"], request.form["pw"] pw_hash = hashlib.md5(p.encode()).hexdigest() # (bug?) row = db.execute( f"SELECT id, role FROM users WHERE name='{u}' AND pw='{pw_hash}'").fetchone() # (bug?) if row: session["uid"], session["role"] = row[0], row[1] return "ok" return "no", 401 @app.get("/note/<int:note_id>") def get_note(note_id): row = db.execute("SELECT text FROM notes WHERE id=?", (note_id,)).fetchone() return row[0] if row else ("not found", 404) # (bug?) @app.get("/search") def search(): q = request.args.get("q", "") return f"<h1>Results for {q}</h1>" # (bug?) if __name__ == "__main__": app.run(debug=True) # (bug?)
There are at least four serious OWASP bugs here (and a couple of supporting ones). Identify each, map it to its OWASP category and the lesson that covers it, prove it locally, then patch it. Work the events below in order.
Event 1 · SQL Injection (find & patch)
14 minThe /login route builds its query with an f-string. Confirm the auth bypass, then fix it.
Show the bug + fix
# BUG: A03 SQL Injection (L8-30/31) — f-string query. # PoC (local): POST /login with user="admin'--" & pw=anything → logs in as admin. # FIX: parameterised query row = db.execute( "SELECT id, role FROM users WHERE name = ? AND pw = ?", (u, pw_hash)).fetchone() # Re-test: admin'-- now returns None (no such literal user). ✓
🥈 Category A03; PoC = auth bypass via admin'--; fix = parameterise.
Event 2 · IDOR & Event 3 · XSS
16 minEvent 2 · Broken Access Control / IDOR (8 min)
The /note/<id> route returns any note by id — no ownership check. Confirm aisha can read admin's secret note, then fix it.
Show the bug + fix
# BUG: A01 Broken Access Control / IDOR (L8-28). # PoC: logged in as aisha (uid 1), GET /note/2 → returns "admin secret". # FIX: check ownership against the session user (and role for shared access) @app.get("/note/<int:note_id>") def get_note(note_id): if "uid" not in session: return "login required", 401 row = db.execute("SELECT text, owner_id FROM notes WHERE id=?", (note_id,)).fetchone() if not row or (row[1] != session["uid"] and session.get("role") != "admin"): return "not found", 404 # 404, don't reveal it exists return row[0]
🥈 Category A01; PoC = read another user's note; fix = ownership/role check, 404 on deny.
Event 3 · Reflected XSS (8 min)
The /search route reflects q into HTML unescaped. Confirm a harmless payload runs, then fix it.
Show the bug + fix
# BUG: A07/A03 Reflected XSS (L8-32/33). # PoC: GET /search?q=<script>alert(1)</script> → script runs in the browser. # FIX: escape output (and/or render via an auto-escaping template) import html @app.get("/search") def search(): q = request.args.get("q", "") return f"<h1>Results for {html.escape(q)}</h1>" # <script> → text # (plus: set a CSP header and HttpOnly cookies for defence in depth)
🥈 Category A07/A03; PoC = alert via ?q=; fix = html.escape (+ CSP/HttpOnly).
Event 4 · The Configuration & Crypto Bugs
13 minTwo more bugs hide in plain sight: a deployment misconfiguration and a cryptographic failure. Find both.
Look at app.run(...), the secret_key, and how passwords are hashed. Which OWASP categories apply, and why is each dangerous?
Show the bugs + fixes
# BUG: A05 Security Misconfiguration (L8-34) — debug=True in "production". # Risk: Werkzeug console = remote code execution; tracebacks leak secrets. # FIX: app.run(debug=False) (control via env: FLASK_DEBUG) # BUG: A02 Cryptographic Failures (L8-12/13/29): # (a) hard-coded secret_key → load from os.environ["SECRET_KEY"] # (b) MD5, UNSALTED password hashes → use bcrypt (salted, slow): import bcrypt # store: bcrypt.hashpw(pw.encode(), bcrypt.gensalt()) # verify: bcrypt.checkpw(pw.encode(), stored_hash) # MD5 here also means the seeded "passwords" (md5 of 'password'/'admin') # are instantly reversible via rainbow tables.
🥈 A05 (debug=True) + A02 (hard-coded key, MD5 unsalted). Fixes: debug off via env, key from env, bcrypt.
Grand Final · The Fully-Patched App (Gold)
12 minThe medal event: produce the fully-patched app with all bugs fixed, plus a short report mapping each bug → OWASP category → lesson → fix. Re-run every PoC to confirm each attack now fails while normal use still works.
Show the patched app + report
# fixed_app.py — all four+ OWASP bugs patched import os, sqlite3, bcrypt, html from flask import Flask, request, session app = Flask(__name__) app.secret_key = os.environ["SECRET_KEY"] # A02: key from env app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax") # defence in depth @app.after_request def csp(resp): # A07: CSP safety net resp.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'" return resp # (passwords stored with bcrypt at registration; seeded here accordingly) @app.post("/login") def login(): u, p = request.form["user"], request.form["pw"] row = db.execute("SELECT id, role, pw FROM users WHERE name = ?", (u,)).fetchone() # A03: parameterised if row and bcrypt.checkpw(p.encode(), row[2]): # A02: bcrypt verify session["uid"], session["role"] = row[0], row[1] return "ok" return "no", 401 @app.get("/note/<int:note_id>") def get_note(note_id): # A01: ownership check if "uid" not in session: return "login required", 401 row = db.execute("SELECT text, owner_id FROM notes WHERE id=?", (note_id,)).fetchone() if not row or (row[1] != session["uid"] and session.get("role") != "admin"): return "not found", 404 return row[0] @app.get("/search") def search(): return f"<h1>Results for {html.escape(request.args.get('q',''))}</h1>" # A07 if __name__ == "__main__": app.run(debug=False) # A05: debug OFF
BUG REPORT 1. f-string login query → A03 Injection (L8-30/31) → parameterise 2. /note/<id> no owner chk → A01 Access Control (L8-28) → ownership/role + 404 3. /search reflects q raw → A07 XSS (L8-32/33) → html.escape + CSP 4. debug=True → A05 Misconfig (L8-34) → debug=False via env + hard-coded key + MD5 → A02 Crypto Failures (L8-12/29) → env key + bcrypt Re-test: admin'-- fails, /note/2 as aisha → 404, ?q=<script> → text, errors are generic, passwords are salted+slow. All PoCs now fail. 🥇
🥇 Gold = every bug patched correctly, mapped to category+lesson+fix, and every PoC re-tested to fail.
Recap & Scorecard
3 minYou did the full loop of application security on one app: find the bugs by reading code with the OWASP lens, classify each by category, exploit locally to confirm, and patch with the correct defence — then re-test to prove the fix. The four headline bugs were A03 (SQLi → parameterise), A01 (IDOR → ownership check), A07 (XSS → escape + CSP), and A05 (debug → off), with A02 (hard-coded key + MD5 → env + bcrypt) underneath. This is exactly what a security code review and a pentest report look like. Tally your medals and note which category you almost missed — that's where to sharpen.
Pattern Card
- find
- Read code/config through the OWASP lens; spot the sinks and defaults.
- classify
- Map each bug to its OWASP category and severity.
- exploit (locally)
- A proof-of-concept on your own instance to confirm it's real.
- patch & verify
- Apply the correct defence, then re-run the PoC to prove it fails.
Homework
4 minFully patch the buggy app and produce the bug report (category + lesson + fix + re-test) for all bugs. Then take a web app you built and run the same find→classify→exploit→patch loop on it — you will likely find at least one real OWASP bug. Document the most serious one you fixed.
Sample · real bug found in my own app
Most serious bug I found in my Level 4 blog: A01 Broken Access Control.
The /post/<id>/delete route had @login_required but no author check —
any logged-in user could delete anyone's post by POSTing to the URL.
The UI hid the delete button on others' posts, which made it "look"
fine, but the endpoint enforced nothing.
Find: read every <int:id> route asking "could another user call
this for an object that isn't theirs?"
Classify: A01 (L8-28).
Exploit: as user B, POST /post/5/delete (user A's post) → deleted.
Patch: fetch the post, abort(404) unless post.author_id ==
current_user.id (or role admin).
Verify: re-ran the PoC → 404, post survives; author can still delete.Non-negotiables: the buggy app fully patched with a category/lesson/fix report, plus a real find→patch on your own app.