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.

KC Ramo · DjangoZen Team May 10, 2026 19 min read 436 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 library — requests 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)

Closing the gap between the check and the fetch

The helper above validates one address, then hands the hostname to requests, which resolves it again when it connects: that second lookup is the window a rebinding defense has to close. socket.gethostbyname() also returns only one IPv4 address, so IPv6 records are never checked. Treat the in-process check as a first filter and layer behind it:

  • Check every resolved address from socket.getaddrinfo(), unwrap IPv4-mapped IPv6 addresses, and reject the hostname if any record is non-public. is_global covers most non-public ranges; multicast needs its own check.
  • Send user-directed fetches through an egress proxy that enforces the same rules at connect time (Stripe's open-source Smokescreen is built for this). The proxy connects to the address it validated, so the time-of-check/time-of-use gap disappears.
def resolve_public_addresses(hostname: str, port: int) -> list[str]:
    try:
        infos = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP)
    except socket.gaierror:
        return []
    addresses = []
    for *_, sockaddr in infos:
        ip = ipaddress.ip_address(sockaddr[0].split('%', 1)[0])
        if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
            ip = ip.ipv4_mapped
        if not ip.is_global or ip.is_multicast:
            return []  # one bad record rejects the whole answer
        addresses.append(str(ip))
    return addresses

On AWS, also require IMDSv2 and set the metadata response hop limit to 1 so containers cannot reach the service through the node, and keep the instance role least-privilege.

Regression tests for the SSRF guard

Mock DNS so tests are deterministic and never touch the network:

@pytest.mark.parametrize('answer', [
    '127.0.0.1', '10.0.0.12', '169.254.169.254', '::1', '::ffff:10.0.0.12', '203.0.113.10',
])
def test_non_public_answers_are_rejected(answer):
    fake = [(socket.AF_INET6 if ':' in answer else socket.AF_INET,
             socket.SOCK_STREAM, 6, '', (answer, 443))]
    with mock.patch('socket.getaddrinfo', return_value=fake):
        assert resolve_public_addresses('files.example.com', 443) == []

Note the last case: 203.0.113.0/24 and the other documentation ranges are not global, so the guard rejects them. For the positive test, use an address from a public allocation you control.

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(...) uses JSON by default, so it cannot execute code; but if SECRET_KEY leaks, anyone can forge signed values (cookies, tokens, password-reset links), so rotate it immediately

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

Where pickle hides in a Django project

Few teams write pickle.loads(request.body), but pickle still flows through a typical stack. Django's cache backends pickle stored values, so anything that can write to an unauthenticated Redis or memcached can place bytes the application will later unpickle; a task queue configured with a pickle serializer behaves the same way. Require authentication and network isolation for caches and brokers, pin Celery to JSON (CELERY_ACCEPT_CONTENT = ['json']), and load model artifacts only when their hash matches one recorded at build time. In CI, Bandit flags the common sinks (B301 for pickle, B506 for yaml.load); run it on every pull request and require a linked review for each suppression.

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.

Atomic conditional updates and constraints

For a simple counter, move the check into the UPDATE itself:

def redeem_for_user(user, code: str) -> bool:
    with transaction.atomic():
        updated = (Coupon.objects
                   .filter(code=code, uses_remaining__gt=0)
                   .update(uses_remaining=F('uses_remaining') - 1))
        if updated == 0:
            return False
        coupon = Coupon.objects.get(code=code)
        Redemption.objects.create(coupon=coupon, user=user)
        apply_discount(user, coupon)
    return True

Under PostgreSQL's default READ COMMITTED isolation, an UPDATE that waits on another transaction's row lock re-checks its WHERE clause against the committed row, so only as many requests succeed as there were uses. Back it with constraints that hold no matter which code path, admin action or data migration touches the table: models.CheckConstraint(condition=Q(uses_remaining__gte=0), name=...) on Coupon and a UniqueConstraint(fields=['coupon', 'user'], ...) on Redemption. A duplicate then raises IntegrityError, the whole atomic block (including the decrement) rolls back, and the view returns 409.

Testing for races

Django's TestCase wraps each test in a transaction that threads with their own connections cannot see, so concurrency tests need TransactionTestCase, and they must run on PostgreSQL because SQLite's whole-database write lock hides the bug. Start ten threads that each call redeem_for_user() for a one-use coupon, synchronize them with a threading.Barrier, close each thread's connection in a finally block, and assert that exactly one call returned True.

Races are probabilistic, so repeat the test several times in CI.

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

Django-side hardening

  • Keep ALLOWED_HOSTS explicit. request.get_host() and build_absolute_uri() validate against it and raise DisallowedHost (a 400, logged on django.security.DisallowedHost).
  • Leave USE_X_FORWARDED_HOST at False unless your proxy always overwrites the header; the same applies to SECURE_PROXY_SSL_HEADER.
  • Build absolute URLs for emails and canonical links from a setting such as SITE_URL = 'https://app.example.com', not from the request.
  • Mark personalized views with @never_cache or @cache_control(private=True), and declare legitimate header dependencies with @vary_on_headers.

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 and dot-dot sequences correctly (URL-encoded input is already decoded by Django before your view sees it).

Better: never let the client name the file

The strongest fix removes the user-controlled path entirely: store uploads in a FileField, fetch with get_object_or_404(Document, pk=pk, owner=request.user), and return FileResponse(doc.file.open('rb'), as_attachment=True, filename=doc.original_name). Authorization and path safety now come from the same query, FileSystemStorage refuses paths outside its root with SuspiciousFileOperation, and as_attachment=True stops uploaded HTML or SVG rendering inline on your domain. Keep a parametrized test expecting a 4xx for names with parent-directory segments or absolute paths.

XML External Entity (XXE)

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

XML can declare entities. A parser that resolves external entities can be made to read local files or send requests on the server's behalf, and a parser that expands nested entities can be made to exhaust memory. Python's standard xml.etree does not resolve external entities but is still exposed to entity expansion, and other parsers behave differently depending on version and settings. Rather than reasoning about each parser's defaults, parse all untrusted XML with defusedxml.

Defense

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

Hardened parsing in practice

With the standard library, call defusedxml.ElementTree.fromstring(body, forbid_dtd=True) and catch defusedxml.DefusedXmlException plus ParseError as a 400. With lxml, build the parser explicitly: XMLParser(resolve_entities=False, no_network=True, load_dtd=False). Review indirect XML too: SVG uploads, office documents and SAML responses.

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.

Make the scoped query the default path

Put the ownership rule on the queryset once, and in Django REST Framework apply it in get_queryset(), which the detail, update and delete actions all use:

class OrderItemQuerySet(models.QuerySet):
    def for_user(self, user):
        if not user.is_authenticated:
            return self.none()
        return self.filter(order__user=user)

class OrderItemViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = OrderItemSerializer
    def get_queryset(self):
        return OrderItem.objects.for_user(self.request.user)

Return 404 rather than 403 so responses do not confirm which IDs exist, restrict writable foreign-key fields in serializers to the user's own objects, and remember that UUIDs slow enumeration but are not authorization. The cheapest regression test creates two users and asserts that the second one gets a 404 for the first one's object.

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.

Why the Top 10 is only the start

The OWASP Top 10 covers the most common risks, but real attackers do not stop there, and a mature security posture has to account for the vulnerabilities beyond the headline list. These advanced issues are often less about a single obvious flaw and more about subtle logic errors, unexpected interactions between features, and edge cases the developers never considered. Moving beyond the Top 10 means thinking like an attacker who probes how your specific application behaves under unusual conditions, not just checking boxes against a generic list. The Top 10 is the floor of web security awareness, not the ceiling.

Business logic vulnerabilities

Some of the most damaging flaws are not technical bugs at all but logic errors — the application does exactly what it was coded to do, but the workflow itself can be abused. Manipulating the order of steps, replaying an action that should happen once, exploiting a discount or limit that was not properly enforced server-side: these bypass technical controls entirely because they live in the business rules. They are invisible to scanners because there is no malformed input, only legitimate requests in an unintended sequence. Defending against them requires understanding how your application could be misused and enforcing every important rule on the server, never trusting the client to follow the intended flow.

Chaining vulnerabilities together

Advanced attacks rarely rely on a single flaw; they chain several minor issues into a serious compromise. An information disclosure that leaks an internal detail, combined with a weak access check, combined with a feature that can be abused, can together achieve what none could alone. This is why issues that seem low-severity in isolation matter — an attacker assembles them. Thinking in chains changes how you triage: a small leak is not just a small leak but a potential link in a path to something worse. Defenders who only fix high-severity findings in isolation miss the combinations that turn a handful of minor issues into a breach.

The defensive mindset

Protecting against advanced vulnerabilities is less about a checklist and more about a mindset of skeptical, adversarial thinking applied throughout development. Question what could go wrong with each feature, assume inputs are hostile, enforce invariants server-side, minimize what each component trusts, and review for how features interact, not just whether each works. This mindset, applied continuously, catches the subtle and chained issues that no automated tool will flag. Beyond the Top 10, security becomes a way of thinking about your own system as an attacker would — and that perspective, built into how you design and review, is the real advanced defense.

A secure code review checklist

Scanners are weak at every class in this tutorial, so review has to do the work. Grep hits are starting points: each one needs a human to trace whether request data can reach it.

ClassSearch forAcceptable fix
SSRFrequests., httpx., urlopen(, webhooks, "import from URL"Allowlist, or resolver check plus egress proxy; no redirects; limits
Deserializationpickle, yaml.load(, joblib.load(, allow_pickle=TrueJSON with schema, safe_load(), hash-pinned artifacts
Races.get(), field check, .save() on counters or balancesConditional update(), select_for_update(), constraints
Cache poisoningHeaders echoed into output, public caching of personalized viewsStrict ALLOWED_HOSTS, SITE_URL, Vary, private
Path traversalopen(, FileResponse(, path joins with request dataLookup by ID, or resolve() plus relative_to()
IDORget_object_or_404(Model, pk=...) without an owner filterfor_user() querysets, scoped get_queryset()
Open redirectredirect( with request dataurl_has_allowed_host_and_scheme() in one shared helper

For every feature pull request, also ask which regression test fails if the protection is removed.

Detecting probing in production

Fixes prevent the bug; logging tells you whether someone is looking for it. Have each guard emit a structured warning with a stable event name (ssrf_destination_blocked, xml_rejected) on a myproject.security logger, and route it together with Django's django.security logger to alerting. Alert on repeated blocked destinations from one account, bursts of 404s across many IDs on detail routes, and IntegrityError on one_redemption_per_user above baseline.

In CI, run python manage.py check --deploy against production settings, Bandit, a dependency audit, and a tests/security/ suite grouped by class. Verify each new security test by temporarily removing its fix and watching it fail: a security test that has never failed proves nothing.

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.