Security Advanced

Beyond OWASP Top 10 — Advanced Web App Vulnerabilities and Chains

SSRF chains, deserialization, prototype pollution, CSPP, race conditions, and the subtle bugs that get past automated scanners. With Django-specific exploitation and defense.

DjangoZen Team May 10, 2026 15 min read 2 views

What "advanced" means here

OWASP Top 10 covers the broad categories. Real-world exploitation often combines several lower-severity bugs into one critical chain. Or exploits subtle behaviors that don't fit neatly into a Top 10 category. This tutorial covers the bugs that get past automated scanners and into bug bounty reports.

Each example includes the attack pattern, a vulnerable Django snippet, and the specific defense.

SSRF chains and metadata service abuse

Server-Side Request Forgery: the app fetches a URL provided by the user. Vulnerable code:

def fetch_image(request):
    url = request.POST['url']
    response = requests.get(url)
    return HttpResponse(response.content, content_type='image/png')

Basic SSRF — internal scans

Attacker provides http://127.0.0.1:6379/ and gets Redis output, http://localhost:5432/ and gets PostgreSQL banner, etc. Maps internal services from outside.

SSRF to cloud metadata

The really nasty case. On AWS:

http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name

Returns short-lived AWS credentials for the EC2 instance's IAM role. Attacker now has whatever permissions that role has — often more than you intended.

Same on GCP (http://metadata.google.internal/computeMetadata/v1/...), Azure, and Hetzner Cloud.

SSRF to DNS rebinding

Attacker controls a DNS record. First lookup returns a public IP (passes your URL validation), second lookup (when requests actually fetches) returns 169.254.169.254. Defeats simple URL validation.

Defenses

  • Allowlist destinations instead of blocklist sources
  • Resolve DNS once and connect to that IP — defeats rebinding
  • Use IMDSv2 on AWS — requires a session token in the request, harder to abuse via SSRF
  • Network-level egress filtering — application server should not be able to reach 169.254.169.254 at all
  • Hardened HTTP libraryrequests with custom session that blocks private IPs
import ipaddress
import socket
import requests
from urllib.parse import urlparse

def is_safe_destination(url: str) -> bool:
    parsed = urlparse(url)
    if parsed.scheme not in ('http', 'https'):
        return False
    if not parsed.hostname:
        return False
    # Resolve once
    try:
        ip = socket.gethostbyname(parsed.hostname)
    except socket.gaierror:
        return False
    ip_obj = ipaddress.ip_address(ip)
    if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
        return False
    if ip_obj.is_multicast or ip_obj.is_reserved:
        return False
    # Block AWS/GCP/Azure metadata addresses explicitly
    if ip in ('169.254.169.254', 'fd00:ec2::254'):
        return False
    return True

def fetch_image_safely(request):
    url = request.POST['url']
    if not is_safe_destination(url):
        return HttpResponseBadRequest('URL not allowed')
    response = requests.get(url, timeout=5, allow_redirects=False)
    # IMPORTANT: don't follow redirects; redirect target could bypass the check
    if response.is_redirect:
        return HttpResponseBadRequest('Redirects not allowed')
    return HttpResponse(response.content)

Insecure deserialization

Loading untrusted data into Python objects. The classic vulnerability:

# NEVER do this
import pickle
data = pickle.loads(request.body)

pickle.loads executes arbitrary code from the input. An attacker who can send any bytes gets RCE.

Variants to watch for

  • pickle.loads(...) on user input or any external source
  • yaml.load(...) (vs safe yaml.safe_load(...)) — same risk
  • marshal.loads(...)
  • numpy.load(allow_pickle=True)
  • joblib.load(...) on untrusted files
  • Django's signing.loads(...) is safe if the SECRET_KEY hasn't leaked; but if it has, any signed cookie can be a vector

Defenses

  • Use JSON for untrusted serialization
  • yaml.safe_load() for YAML, never yaml.load()
  • For Python objects across services, use a typed format like Protocol Buffers or msgpack with strict schemas
  • Where pickle is required (internal caches, etc.), ensure inputs are signed and verified

Race conditions in business logic

Concurrent requests violate assumptions. The classic example: a coupon code that should only be used once.

Vulnerable

def redeem_coupon(request):
    code = request.POST['code']
    coupon = Coupon.objects.get(code=code)
    if coupon.uses_remaining > 0:
        coupon.uses_remaining -= 1
        coupon.save()
        apply_discount(request.user, coupon)
        return JsonResponse({'success': True})
    return JsonResponse({'success': False, 'error': 'No uses left'}, status=400)

Send 10 simultaneous redemption requests with a coupon that has 1 use left → all 10 see uses_remaining > 0 before any writes the decrement. All 10 get the discount.

Fixed with database-level locking

from django.db import transaction

def redeem_coupon(request):
    code = request.POST['code']
    with transaction.atomic():
        coupon = Coupon.objects.select_for_update().get(code=code)
        if coupon.uses_remaining > 0:
            coupon.uses_remaining -= 1
            coupon.save()
            apply_discount(request.user, coupon)
            return JsonResponse({'success': True})
        return JsonResponse({'success': False, 'error': 'No uses left'}, status=400)

select_for_update() locks the row for the duration of the transaction. Concurrent transactions queue.

Other race condition patterns to check

  • Withdraw / transfer money — concurrent requests both seeing sufficient balance
  • Increment counters without atomic operations
  • "First N" promotions — "first 100 customers get free product" where N can be exceeded
  • OAuth state validation — race between state generation and check
  • File upload + processing — file replaced between checks

The unifying lesson: any business rule that says "only X of these can happen" needs either DB-level locking or atomic operations.

Prototype pollution (in JavaScript-heavy apps)

If your app uses Node.js anywhere (build pipeline, SSR, serverless functions), prototype pollution is a real risk. Attacker manipulates __proto__ of an object, which then affects all objects in the runtime.

// Vulnerable merge function
function merge(target, source) {
    for (let key in source) {
        if (typeof source[key] === 'object') {
            merge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
}

// Attacker sends: {"__proto__": {"admin": true}}
// Now every object in the JS runtime has admin=true

Defenses

  • Use libraries that prevent prototype pollution (e.g., lodash 4.17.21+ has guards)
  • Use Object.create(null) for user-input maps (no prototype chain)
  • Schema validation on inputs

Django-only apps without JS runtime aren't affected, but most modern web apps have some JS layer (frontend build, SSR, custom server-side rendering).

Web cache poisoning

The cache stores a manipulated response that other users then receive.

Pattern

  1. Attacker sends a request that includes a malicious header (e.g., X-Forwarded-Host: evil.com)
  2. App reflects the header into the response (e.g., generating an absolute URL)
  3. Cache stores the manipulated response keyed on URL alone (not on the header)
  4. Subsequent users hitting the same URL get the malicious response

Defenses

  • Cache key should include all headers that affect the response (Vary header)
  • Don't reflect untrusted headers into responses
  • Test caching with malicious headers: PortSwigger's Param Miner is the go-to tool

Path traversal in modern apps

The classic ?file=../../../../etc/passwd. Mostly handled by frameworks but still appears in specific endpoints:

  • File upload + download endpoints that use user-provided filenames
  • API endpoints that take a path parameter
  • Template engines that include user-controlled paths

Defense pattern

from pathlib import Path

ALLOWED_DIR = Path('/var/app/uploads/').resolve()

def download(request, filename):
    requested = (ALLOWED_DIR / filename).resolve()
    # Critical: check requested is INSIDE ALLOWED_DIR after resolution
    try:
        requested.relative_to(ALLOWED_DIR)
    except ValueError:
        return HttpResponseForbidden('Outside allowed directory')
    if not requested.exists():
        raise Http404()
    return FileResponse(open(requested, 'rb'))

The .resolve() + .relative_to() pattern handles symlinks, dotdot sequences, and URL encoding correctly.

XML External Entity (XXE)

If your app parses XML from users, XXE is the canonical bug:

# Vulnerable
from xml.etree import ElementTree as ET
tree = ET.fromstring(request.body)

The attacker's XML:

<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<root>&xxe;</root>

If the parser resolves external entities (some Python parsers don't by default; others do), reads /etc/passwd or makes SSRF calls.

Defense

  • Use defusedxml for XML parsing
  • Disable DTD entirely if possible
  • Don't accept XML if JSON would work

Insecure direct object references (IDOR) — the persistent classic

Mentioned in tutorial 6 but worth deep dive: when authorization is missing, the wrong object loaded by ID is the vulnerability.

The variant attackers love: multi-step IDOR chains.

Step 1: GET /api/orders/  → returns orders for current user
Step 2: GET /api/orders/<order_id>/items → returns items for any order if order_id known
Step 3: GET /api/items/<item_id>/full → returns full item details for any item

The middle endpoint might check user ownership; the last might not because it's "just retrieving by ID."

Defense

Every endpoint that takes a resource ID must verify the current user has access. Not relative to the previous step — absolutely.

@login_required
def item_detail(request, item_id):
    item = get_object_or_404(
        OrderItem,
        id=item_id,
        order__user=request.user  # Verify ownership via JOIN
    )
    return JsonResponse(item.to_dict())

Audit every endpoint individually. Don't trust higher-level controls.

Open redirects — small bug, big impact

Vulnerable code:

def login(request):
    next_url = request.GET.get('next', '/')
    if user_authenticated:
        return redirect(next_url)

Attacker sends https://yourdomain.com/login/?next=https://evil.com/. After login, user is redirected to attacker site, which now has high-trust context (came from your domain after auth) to phish further.

Defense

from django.utils.http import url_has_allowed_host_and_scheme

def login(request):
    next_url = request.GET.get('next', '/')
    if not url_has_allowed_host_and_scheme(
        url=next_url,
        allowed_hosts={request.get_host()},
        require_https=request.is_secure()
    ):
        next_url = '/'
    if user_authenticated:
        return redirect(next_url)

Always validate redirect targets. Django's built-in helper does this correctly.

Closing — the defensive mindset

Each of these bugs has a pattern: untrusted input → trusted context. The defense is always: validate at the boundary, scope by user/permission, use safe APIs, defense in depth.

Automated scanners find some of these (mostly the classics — SQLi, XSS, basic SSRF). They miss the chains, the multi-step IDORs, the business logic races. Those require manual review and threat modeling.

Tutorial 6 covers how WAFs and runtime defenses help catch what code review misses, and how attackers bypass those defenses.