Insecure Design
Theory
Insecure Design entered the list in 2021 and sits at #6 in 2025. Unlike other categories that describe implementation flaws, insecure design means the security control was never built in the first place. You cannot patch your way out of insecure design — it requires re-architecting the feature. The question to ask: "If I assumed an attacker would use this feature, how would I design it differently?"
What Insecure Design Looks Like
- Predictable secrets — reset tokens derived from timestamps, user IDs, or sequential counters instead of cryptographic randomness
- No rate limiting on sensitive actions — login, OTP verification, and password reset endpoints have unlimited retries, allowing brute-force
- Business logic flaws — the application never validates that a business rule holds (e.g., negative quantities, applying a coupon multiple times, gift card abuse)
- Flat trust models — all authenticated users are treated identically regardless of context (IP, device, risk score)
- No abuse cases considered — the design process only modelled happy-path scenarios, never adversarial ones
Predictable Reset Token — Vulnerable Design
# VULNERABLE — reset token is user_id + "0000"; entirely predictable
import random
def generate_reset_token(user_id: int) -> str:
return str(user_id) + "0000" # attacker knows user_id=1 (admin) → token is "10000"
# Also broken: time-based tokens
def generate_reset_token_v2(user_id: int) -> str:
return str(user_id) + str(int(time.time()))[-4:] # predictable within a ~10-second window
No Rate Limiting — Vulnerable Design
# VULNERABLE — no attempt counter, no lockout, no delay
@app.post("/verify-otp")
def verify_otp(otp: str = Form()):
if otp == admin_otp: # 4 digits = 10,000 possibilities
return {"flag": FLAG} # attacker enumerates 0000–9999 in < 1 minute
return {"error": "wrong otp"}
# Brute-force attack:
for i in range(10000):
r = requests.post("/verify-otp", data={"otp": f"{i:04d}"})
if "flag" in r.text:
print(f"OTP: {i:04d}")
break
Fixed Design
import secrets
from datetime import datetime, timedelta
# FIXED — cryptographically random token with expiry
def generate_reset_token(user_id: int) -> str:
token = secrets.token_urlsafe(32) # 256 bits of randomness; unguessable
token_store[token] = {
"user_id": user_id,
"expires": datetime.utcnow() + timedelta(minutes=15) # short expiry window
}
return token
# FIXED — rate limiting on OTP with lockout after 5 attempts
attempt_count = {}
@app.post("/verify-otp")
def verify_otp(request: Request, otp: str = Form()):
ip = request.client.host
attempts = attempt_count.get(ip, 0)
if attempts >= 5:
raise HTTPException(429, "Too many attempts. Try again in 15 minutes.")
if otp != admin_otp:
attempt_count[ip] = attempts + 1
return {"error": "wrong otp"}
attempt_count.pop(ip, None)
return {"flag": FLAG}
Real-World Examples
- Instagram (2019) — Password reset via 6-digit SMS code had no rate limit; 1,000,000 requests/hour were possible. Account takeover at scale. Paid $30,000 bug bounty.
- PayPal (2012) — Password reset flow revealed whether an email was registered in the system, enabling account enumeration for targeted phishing
- Robinhood (2021) — 5 million email addresses and 2 million names exposed via a social-engineering attack on customer support — no design control to limit what support agents could export
- Any "security question" system — "What is your mother's maiden name?" can be answered from LinkedIn/Facebook; fundamentally insecure design regardless of implementation quality
How to Fix — Checklist
- Cryptographic randomness for all secrets — use
secrets.token_urlsafe(32)(Python) orcrypto.randomBytes(32)(Node). Never timestamps, IDs, orrandom - Rate limiting on every authentication/verification endpoint — max 5 attempts, exponential backoff or IP lockout, CAPTCHA after threshold
- Short token expiry — password reset tokens expire in 15 minutes; OTPs expire in 30 seconds
- Threat modelling during design — use STRIDE or abuse cases: for every feature, ask "how would an attacker misuse this?"
- Single-use tokens — invalidate a reset/OTP token immediately after first use, even if still within the expiry window
Challenge 1
Predictable Password Reset Token
The password reset token is derived from the user ID and a known constant, making it predictable. Request a reset for admin and compute the token yourself.
Steps
Reset token =
str(user_id) + '0000'. Admin's user_id is 1. So admin's token is 10000.
Challenge 2
No Rate Limiting on OTP
The 2-step verification OTP is a 4-digit number with no rate limiting. Brute-force all values from 0000–9999. The correct OTP for admin is seeded as 7391.
Steps
Try:
for i in $(seq -w 0 9999); do curl -s -X POST /web/a06/verify-otp -d "otp=$i" | grep -q flag && echo $i && break; done