SSRF chains, deserialization, prototype pollution, CSPP, race conditions, and the subtle bugs that get past automated scanners. With Django-specific exploitation and defense.
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.
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')
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.
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.
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.
169.254.169.254 at allrequests with custom session that blocks private IPsimport 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)
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.
pickle.loads(...) on user input or any external sourceyaml.load(...) (vs safe yaml.safe_load(...)) — same riskmarshal.loads(...)numpy.load(allow_pickle=True)joblib.load(...) on untrusted filessigning.loads(...) is safe if the SECRET_KEY hasn't leaked; but if it has, any signed cookie can be a vectoryaml.safe_load() for YAML, never yaml.load()Concurrent requests violate assumptions. The classic example: a coupon code that should only be used once.
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.
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.
The unifying lesson: any business rule that says "only X of these can happen" needs either DB-level locking or atomic operations.
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
Object.create(null) for user-input maps (no prototype chain)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).
The cache stores a manipulated response that other users then receive.
X-Forwarded-Host: evil.com)Vary header)The classic ?file=../../../../etc/passwd. Mostly handled by frameworks but still appears in specific endpoints:
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.
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.
defusedxml for XML parsingMentioned 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."
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.
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.
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.
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.
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.
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.
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.
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.