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.
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.