← Blog

"AppSec Series #3: OWASP Top 10 Deep Dive — Every Vulnerability With Attack Code and Fix"

The OWASP Top 10 is the universal language of application security. Master every vulnerability with real attack scenarios, vulnerable code, secure fixes, and detection techniques.

reading now
views
comments

Series Navigation

Part 2: Secure Coding Fundamentals

Part 4: Authentication and Authorization


A01 — Broken Access Control

Most common vulnerability. Users can access data or actions they shouldn't.

Attack Scenario — IDOR (Insecure Direct Object Reference)

GET /api/orders/1042          ← user's own order
GET /api/orders/1043          ← another user's order — should be forbidden

Vulnerable code:

@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
    order = Order.query.get(order_id)  # No ownership check!
    return jsonify(order.to_dict())
    # Any logged-in user can access any order by changing the ID

Secure code:

@app.route('/api/orders/<int:order_id>')
@login_required
def get_order(order_id):
    order = Order.query.filter_by(
        id=order_id,
        user_id=current_user.id  # Always scope to current user
    ).first_or_404()
    return jsonify(order.to_dict())

Attack Scenario — Privilege Escalation

# ❌ Role set by client
@app.route('/register', methods=['POST'])
def register():
    user = User(
        email=request.json['email'],
        role=request.json.get('role', 'user')  # Attacker sends role='admin'
    )

# ✅ Role set by server only
@app.route('/register', methods=['POST'])
def register():
    user = User(
        email=request.json['email'],
        role='user'  # Always set server-side
    )

Detection:

# Automated IDOR detection with ffuf
# Enumerate order IDs as different user
ffuf -u https://app.com/api/orders/FUZZ \
     -H "Cookie: session=USER_B_SESSION" \
     -w numbers.txt \
     -mc 200    # 200 = access granted (should be 403)

A02 — Cryptographic Failures

Sensitive data exposed due to weak or missing encryption.

Attack — Weak Password Hashing

import hashlib, md5

# ❌ MD5 — cracked instantly with rainbow tables
stored_hash = hashlib.md5(password.encode()).hexdigest()

# ❌ SHA-256 without salt — still vulnerable to rainbow tables
stored_hash = hashlib.sha256(password.encode()).hexdigest()

# ❌ SHA-256 with salt — better, but still too fast
import os
salt = os.urandom(16)
stored_hash = hashlib.sha256(salt + password.encode()).hexdigest()

# ✅ bcrypt — designed for passwords, slow by design
import bcrypt
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))
# Verify
bcrypt.checkpw(password.encode('utf-8'), hashed)  # returns True/False

# ✅ Argon2 — winner of Password Hashing Competition (PHC), even better
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2)
hashed = ph.hash(password)
ph.verify(hashed, password)  # raises exception on mismatch

Attack — Sensitive Data in Transit

# ❌ HTTP (plaintext) — anyone on network sees everything
requests.get('http://api.example.com/user/profile')

# ✅ Always HTTPS — enforce it
import ssl
import requests

# Enforce TLS verification (never disable in production)
response = requests.get(
    'https://api.example.com/user/profile',
    verify=True  # verify=False is a critical vulnerability
)
# Force HTTPS redirect
server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    ssl_protocols TLSv1.2 TLSv1.3;  # disable TLS 1.0, 1.1
    ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
    ssl_prefer_server_ciphers on;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}

Attack — Encryption at Rest

# ❌ Sensitive data stored plaintext in database
class User(db.Model):
    ssn = db.Column(db.String(11))  # '123-45-6789' in plaintext

# ✅ Encrypt at application level before storing
from cryptography.fernet import Fernet
import base64, os

class EncryptionService:
    def __init__(self, key: bytes):
        self.fernet = Fernet(key)

    def encrypt(self, plaintext: str) -> str:
        return self.fernet.encrypt(plaintext.encode()).decode()

    def decrypt(self, ciphertext: str) -> str:
        return self.fernet.decrypt(ciphertext.encode()).decode()

encryption = EncryptionService(key=os.environ['ENCRYPTION_KEY'].encode())

class User(db.Model):
    _ssn_encrypted = db.Column('ssn', db.Text)

    @property
    def ssn(self):
        return encryption.decrypt(self._ssn_encrypted)

    @ssn.setter
    def ssn(self, value):
        self._ssn_encrypted = encryption.encrypt(value)

A03 — Injection

User input is interpreted as code or commands.

SQL Injection

# ❌ Vulnerable
def search_users(search_term):
    query = f"SELECT * FROM users WHERE name LIKE '%{search_term}%'"
    return db.execute(query)
    # search_term = "' UNION SELECT username,password FROM admin_users --"
    # Returns admin credentials!

# ✅ Parameterised
def search_users(search_term):
    return db.execute(
        "SELECT * FROM users WHERE name LIKE %s",
        (f"%{search_term}%",)
    )

Manual SQL injection test:

# Test for SQL injection using sqlmap
sqlmap -u "https://app.com/search?q=test" \
       --dbs \           # enumerate databases
       --tables \        # enumerate tables
       --batch           # auto answer prompts

# Manual test payloads:
' OR '1'='1
' OR 1=1--
'; SELECT sleep(5)--    # time-based blind injection test

Command Injection

import subprocess, shlex

# ❌ Command injection
@app.route('/ping')
def ping():
    host = request.args.get('host')
    result = os.system(f"ping -c 1 {host}")
    # host = "google.com; cat /etc/passwd" → runs both commands

# ✅ Use subprocess with list form — arguments never passed to shell
@app.route('/ping')
def ping():
    host = request.args.get('host', '')
    # Whitelist: only allow valid hostnames
    if not re.match(r'^[a-zA-Z0-9.-]+$', host):
        return jsonify({'error': 'Invalid host'}), 400

    result = subprocess.run(
        ['ping', '-c', '1', host],  # list form — no shell interpretation
        capture_output=True,
        text=True,
        timeout=5
    )
    return jsonify({'output': result.stdout})

LDAP Injection

# ❌ LDAP injection
def authenticate_ldap(username, password):
    ldap_filter = f"(&(uid={username})(userPassword={password}))"
    # username = "*)(uid=*))(|(uid=*" → bypasses authentication

# ✅ Escape special LDAP characters
from ldap3.utils.conv import escape_filter_chars

def authenticate_ldap(username, password):
    safe_username = escape_filter_chars(username)
    safe_password = escape_filter_chars(password)
    ldap_filter = f"(&(uid={safe_username})(userPassword={safe_password}))"

A04 — Insecure Design

Security not considered in the design phase.

Example — Password Reset Design Flaw

❌ Insecure Design:
User requests reset → App emails reset link → Link works for 7 days
Problems:
- Link in email history forever
- 7 days is too long — attacker has time to use stolen email access
- No confirmation the link was used

✅ Secure Design:
User requests reset → App emails time-limited token (15 min)
                   → Token is single-use (invalidated after use)
                   → Old token invalidated if new reset requested
                   → User notified of reset request via separate channel
import secrets
from datetime import datetime, timedelta

class PasswordResetToken(db.Model):
    token_hash = db.Column(db.String(64), unique=True)  # store hash, not raw token
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    expires_at = db.Column(db.DateTime)
    used_at = db.Column(db.DateTime, nullable=True)

def create_reset_token(user_id: int) -> str:
    # Invalidate any existing tokens for this user
    PasswordResetToken.query.filter_by(
        user_id=user_id, used_at=None
    ).delete()

    raw_token = secrets.token_urlsafe(32)  # cryptographically secure

    reset = PasswordResetToken(
        token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
        user_id=user_id,
        expires_at=datetime.utcnow() + timedelta(minutes=15)  # short expiry
    )
    db.session.add(reset)
    db.session.commit()
    return raw_token  # send this to user

def use_reset_token(raw_token: str, new_password: str) -> bool:
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
    reset = PasswordResetToken.query.filter_by(
        token_hash=token_hash,
        used_at=None  # not already used
    ).first()

    if not reset:
        return False
    if datetime.utcnow() > reset.expires_at:
        return False

    # Mark as used immediately (prevent replay)
    reset.used_at = datetime.utcnow()
    change_password(reset.user_id, new_password)
    db.session.commit()
    return True

A05 — Security Misconfiguration

Systems running with insecure default settings.

# ❌ Common misconfigurations
DEBUG = True               # exposes stack traces to users
SECRET_KEY = 'dev'         # weak, predictable secret key
SQLALCHEMY_ECHO = True     # logs all SQL to stdout (may contain data)

# ❌ Default admin credentials
# admin/admin, admin/password, root/root

# ❌ Unused features enabled
SWAGGER_UI = True          # exposes API docs in production
CORS_ORIGINS = '*'         # allows any origin

# ✅ Production configuration
import os

class ProductionConfig:
    DEBUG = False
    TESTING = False
    SECRET_KEY = os.environ['SECRET_KEY']          # 32+ random bytes
    SQLALCHEMY_ECHO = False
    CORS_ORIGINS = ['https://app.example.com']     # explicit whitelist
    SESSION_COOKIE_SECURE = True                   # HTTPS only
    SESSION_COOKIE_HTTPONLY = True                 # no JS access
    SESSION_COOKIE_SAMESITE = 'Lax'               # CSRF protection
# Detect misconfigurations with Scout Suite (AWS/Azure/GCP)
pip install scoutsuite
scout aws --report-dir ./scout-report

# Detect web server misconfigs with nikto
nikto -h https://your-app.com

# Check TLS configuration
sslyze --regular your-app.com:443

A06 — Vulnerable and Outdated Components

Using libraries with known CVEs.

# Python — check with safety
pip install safety
safety check

# Node.js — built-in audit
npm audit
npm audit fix

# Java — OWASP Dependency Check
mvn org.owasp:dependency-check-maven:check

# All languages — Snyk (free for open source)
npm install -g snyk
snyk test
snyk monitor   # continuous monitoring

# Container images — Trivy
trivy image python:3.9
trivy image node:16-alpine

A07 — Identification and Authentication Failures

Broken login, weak passwords, no MFA.

# ❌ No brute force protection
@app.route('/login', methods=['POST'])
def login():
    user = User.query.filter_by(email=request.json['email']).first()
    if user and user.check_password(request.json['password']):
        return jsonify({'token': generate_token(user)})
    return jsonify({'error': 'Invalid credentials'}), 401
    # Attacker tries 1,000,000 passwords — no lockout

# ✅ Rate limiting + account lockout
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(app, key_func=get_remote_address)

@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute")  # rate limit per IP
def login():
    email = request.json.get('email', '')
    password = request.json.get('password', '')

    # Check account lockout
    user = User.query.filter_by(email=email).first()
    if user and user.locked_until and user.locked_until > datetime.utcnow():
        return jsonify({'error': 'Account temporarily locked'}), 429

    if user and user.check_password(password):
        user.failed_attempts = 0
        db.session.commit()
        return jsonify({'token': generate_token(user)})
    else:
        if user:
            user.failed_attempts += 1
            if user.failed_attempts >= 5:
                user.locked_until = datetime.utcnow() + timedelta(minutes=15)
            db.session.commit()
        # Same error for wrong user OR wrong password (prevents user enumeration)
        return jsonify({'error': 'Invalid credentials'}), 401

A08 — Software and Data Integrity Failures

Unsigned code, compromised CI/CD pipelines.

# ❌ Insecure CI/CD — anyone can push malicious code
- name: Deploy
  run: curl https://raw.githubusercontent.com/random-user/script.sh | bash
  # This runs arbitrary code from the internet

# ✅ Pin dependencies to exact versions and verify hashes
# package-lock.json (Node) or requirements.txt with hashes (Python)
# pip install --require-hashes -r requirements.txt
# requirements.txt with cryptographic hashes
Django==4.2.0 \
    --hash=sha256:c36e2... \
    --hash=sha256:f8a3b...

# Install — fails if hash doesn't match
pip install --require-hashes -r requirements.txt

A09 — Security Logging and Monitoring Failures

Not detecting or logging security events.

# What MUST be logged for security:

# 1. All authentication events
def login(email, password):
    result = authenticate(email, password)
    if result.success:
        security_log.info("auth.login.success", user_id=result.user.id,
                          ip=request.remote_addr, timestamp=datetime.utcnow().isoformat())
    else:
        security_log.warning("auth.login.failure", email=email,
                             ip=request.remote_addr, reason=result.reason)

# 2. Access control failures
def get_document(doc_id):
    doc = Document.query.get(doc_id)
    if doc.owner_id != current_user.id:
        security_log.warning("authz.access.denied",
                             user_id=current_user.id,
                             resource=f"document:{doc_id}",
                             ip=request.remote_addr)
        abort(403)

# 3. Admin actions
def delete_user(user_id):
    security_log.info("admin.user.delete",
                      admin_id=current_user.id,
                      target_user_id=user_id,
                      ip=request.remote_addr)

A10 — Server-Side Request Forgery (SSRF)

Server fetches a URL provided by an attacker, reaching internal services.

# ❌ SSRF vulnerability
import requests

@app.route('/fetch-url')
def fetch_url():
    url = request.args.get('url')
    response = requests.get(url)  # attacker provides: http://169.254.169.254/latest/meta-data/
    return response.text
    # On AWS: returns IAM credentials! Full account compromise.

# Attacker payloads:
# http://169.254.169.254/latest/meta-data/iam/security-credentials/  (AWS metadata)
# http://localhost:8080/admin   (internal admin panel)
# http://internal-db:5432      (internal database)
# file:///etc/passwd           (local file read)

# ✅ Validate and whitelist URLs
from urllib.parse import urlparse
import ipaddress

ALLOWED_DOMAINS = {'api.trusted.com', 'cdn.trusted.com'}

def is_safe_url(url: str) -> bool:
    try:
        parsed = urlparse(url)

        # Must be HTTPS
        if parsed.scheme != 'https':
            return False

        # Must be in whitelist
        if parsed.hostname not in ALLOWED_DOMAINS:
            return False

        # Resolve hostname and check it's not private
        import socket
        ip = socket.gethostbyname(parsed.hostname)
        ip_obj = ipaddress.ip_address(ip)

        # Block private/loopback/link-local IPs
        if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
            return False

        return True
    except Exception:
        return False

@app.route('/fetch-url')
def fetch_url():
    url = request.args.get('url')
    if not is_safe_url(url):
        return jsonify({'error': 'URL not allowed'}), 400
    response = requests.get(url, timeout=5)
    return response.text

OWASP Top 10 — Interview Quick Reference

Vulnerability One-Line Definition Key Defence
A01 Broken Access Control Users access resources they shouldn't Check ownership on every request
A02 Crypto Failures Sensitive data unprotected HTTPS, bcrypt/Argon2, encrypt at rest
A03 Injection Input interpreted as code Parameterised queries, avoid shell commands
A04 Insecure Design Security not in architecture Threat model, secure design patterns
A05 Misconfiguration Default/insecure settings Harden configs, disable debug in prod
A06 Vulnerable Components Outdated deps with CVEs npm audit, Snyk, dependency scanning
A07 Auth Failures Weak login, no MFA Rate limiting, lockout, MFA
A08 Integrity Failures Unsigned code, compromised pipeline Hash pinning, signed artifacts
A09 Logging Failures Attacks not detected Log all security events, SIEM
A10 SSRF Server fetches attacker URLs URL whitelist, block private IPs

What's Next

In Part 4 we go deep on authentication and authorisation — OAuth 2.0 flows, JWT vulnerabilities and secure implementation, session management, MFA, and the most common auth bugs that lead to account takeovers.

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000