๐Ÿ›ก OWASP Lab
A04:2021 High

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) or crypto.randomBytes(32) (Node). Never timestamps, IDs, or random
  • 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