CSRF & Clickjacking
Understand and exploit Cross-Site Request Forgery and Clickjacking — two attacks that abuse browser trust — and implement robust defenses for each.
Learning Objectives
- → Explain how CSRF exploits cookie-based authentication
- → Craft CSRF PoC forms for GET and POST requests
- → Implement CSRF tokens, SameSite cookies, and double-submit cookie patterns
- → Exploit clickjacking to hijack button clicks via iframes
- → Implement frame-busting and X-Frame-Options defenses
Cross-Site Request Forgery (CSRF)
How CSRF Works
CSRF forces a logged-in victim's browser to send an unintended request:
1. Alice logs into bank.com → session cookie stored
2. Alice visits evil.com (still logged in)
3. evil.com's page auto-submits a form to bank.com/transfer
4. Browser includes Alice's session cookie → bank processes transfer as Alice
The bank sees a valid session cookie — it can't distinguish:
- Alice intentionally transferring $1000
- Alice's browser auto-submitting the form from evil.com
Basic CSRF PoC (GET)
<!-- On evil.com — transfers $1000 from victim's account -->
<img src="https://bank.com/transfer?to=attacker&amount=1000">
<!-- GET with image: browser loads image URL with cookies attached -->
<!-- Works if transfer is done via GET request (very bad practice) -->
CSRF PoC (POST)
<!-- Auto-submitting form on attacker's page -->
<html>
<body onload="document.getElementById('form').submit()">
<form id="form" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
</body>
</html>
CSRF with JSON Body
JSON content type triggers CORS preflight — harder to CSRF:
// This won't work cross-origin without CORS permission:
fetch('https://bank.com/transfer', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({to: 'attacker', amount: 1000})
})
// BUT — if the endpoint also accepts text/plain:
fetch('https://bank.com/transfer', {
method: 'POST',
headers: {'Content-Type': 'text/plain'}, // no preflight!
body: '{"to":"attacker","amount":1000}' // JSON body
})
Bypassing CSRF Token Validation
Common mistakes:
1. Token not validated on server → just send any string
2. Token validated only for POST, not PUT/PATCH/DELETE
3. Token in URL (leaks via Referer header)
4. Token predictable: sequential numbers, timestamp-based
5. Token not tied to session — reuse someone else's token
6. CSRF token check skipped when method is GET
# Test: remove the CSRF token entirely → if request succeeds, broken
# Test: use a valid token from your account on victim's action → if works, not session-bound
CSRF Defense
1. CSRF Tokens (Synchronizer Token Pattern)
# Django — built-in CSRF middleware (enabled by default)
# In views:
from django.views.decorators.csrf import csrf_protect, csrf_exempt
@csrf_protect # explicitly add protection
def transfer(request):
if request.method == 'POST':
# Django verifies X-CSRFToken header or csrfmiddlewaretoken POST field
process_transfer()
# In templates:
<form method="POST">
{% csrf_token %} <!-- generates: <input name="csrfmiddlewaretoken" value="TOKEN"> -->
...
</form>
# For AJAX:
headers: {'X-CSRFToken': getCookie('csrftoken')}
2. SameSite Cookie Attribute (Best Defense)
Set-Cookie: session=abc; SameSite=Strict; HttpOnly; Secure
SameSite=Strict: Cookie NOT sent on ANY cross-site request
→ CSRF impossible (evil.com request has no session cookie)
SameSite=Lax: Cookie sent on GET top-level navigation but NOT on:
→ POST forms from other origins (form CSRF blocked)
→ sub-requests (img, iframe, fetch from other origins)
← Link clicks from other sites still send cookie
SameSite=None: Cookie sent everywhere (requires Secure)
→ CSRF possible — need CSRF token
3. Double Submit Cookie
# Set CSRF cookie
response.set_cookie('csrf', random_token, samesite='Strict')
# Client includes in both cookie and POST body (or header)
POST /transfer
Cookie: session=abc; csrf=TOKEN123
Body: csrf_token=TOKEN123&amount=1000
# Server verifies: csrf cookie == csrf body value
# Attacker can't read the cookie from another origin → can't forge match
4. Referer/Origin Header Validation
def validate_origin(request):
origin = request.headers.get('Origin') or request.headers.get('Referer', '')
if not origin.startswith('https://bank.com'):
return HttpResponseForbidden('Invalid origin')
# Note: some proxies strip Referer — fallback needed
Clickjacking
How Clickjacking Works
Attacker overlays an invisible iframe over a fake button:
<!-- Attacker's page -->
<style>
iframe {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
opacity: 0; /* invisible! */
z-index: 999; /* on top of fake button */
}
.fake-button {
position: absolute; top: 200px; left: 300px;
}
</style>
<div class="fake-button">Click here for a prize!</div>
<iframe src="https://bank.com/confirm-transfer?to=attacker&amount=1000"></iframe>
Victim clicks the "prize" button → actually clicks "Confirm Transfer" in the invisible iframe.
Dragjacking & Likejacking
Dragjacking: Drag-and-drop of file onto attacker page → actually drags file into iframe upload form
Likejacking: Invisible Facebook Like button overlaid on fake content
→ victim "likes" attacker page without knowing
Clickjacking Defense
1. X-Frame-Options Header
X-Frame-Options: DENY # Never allow iframing
X-Frame-Options: SAMEORIGIN # Only same-origin iframes
X-Frame-Options: ALLOW-FROM https://... # Deprecated — poor browser support
2. CSP frame-ancestors (Recommended)
Content-Security-Policy: frame-ancestors 'none' # same as DENY
Content-Security-Policy: frame-ancestors 'self' # same origin only
Content-Security-Policy: frame-ancestors 'self' https://trusted.com
Difference: frame-ancestors in CSP is more powerful — works in more browsers.
3. JavaScript Frame-Busting (Fragile)
// Break out of iframe
if (top !== self) {
top.location = self.location;
}
// BUT: easily bypassed with sandbox attribute:
<iframe sandbox="allow-forms allow-scripts" src="victim.com">
// sandbox prevents top.location reassignment!
// Robust version:
if (top !== self) {
document.body.style.display = 'none';
top.location.replace(self.location.href);
}
// Still fragile — use X-Frame-Options/CSP instead
Django: Full CSRF + Clickjacking Protection
# settings.py — enabled by default:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # HSTS, HTTPS
'django.middleware.clickjacking.XFrameOptionsMiddleware', # X-Frame-Options
'django.middleware.csrf.CsrfViewMiddleware', # CSRF
...
]
X_FRAME_OPTIONS = 'DENY' # or 'SAMEORIGIN'
# SecurityMiddleware also sets:
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True # legacy — CSP is better
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
Create a Flask app with a /transfer endpoint (no CSRF protection), then: (1) build an attacker HTML page with auto-submitting form to /transfer, (2) test it with a logged-in browser, (3) add Django/Flask CSRF token protection, (4) test that the PoC now fails, (5) verify SameSite=Strict on the session cookie blocks the attack even without a CSRF token.
Why does SameSite=Strict prevent CSRF?
What is the double-submit cookie pattern?
Using a local Flask app with a /admin/delete-account endpoint: (1) create an attacker page with an invisible iframe over a 'Click here to win' button, (2) set iframe opacity to 0 and position over the button, (3) demonstrate that clicking the 'win' button triggers /admin/delete-account, (4) add X-Frame-Options: DENY and verify the iframe is blocked, (5) replace with CSP frame-ancestors 'none' and test.
What iframe attribute prevents frame-busting JS from working?
Test a web app for CSRF token validation weaknesses: (1) remove the CSRF token entirely — does it work? (2) use an empty string as the token, (3) use another user's valid token, (4) change request method from POST to GET (some frameworks only check POST), (5) try PUT/PATCH/DELETE methods, (6) document each finding and the correct fix.
Why must CSRF tokens be tied to the user's session?