Insecure Design
Theory
A04 Insecure Design is a new category in OWASP 2021. 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.
Hint
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.
Hint
Try:
for i in $(seq -w 0 9999); do curl -s -X POST /web/a04/verify-otp -d "otp=$i" | grep -q flag && echo $i && break; done