Cross-Site Request Forgery (CSRF)
CSRF exploits the fact that the browser automatically attaches ambient credentials (cookies, HTTP basic auth, client TLS certs) to every cross-origin request. The application then trusts the request because the cookie is present. There is no signature on the intent of the request — only on the identity of the user, and that signature is supplied by the browser, not the user. The 2006 Netflix CSRF and 2008 ING Direct CSRF both let an attacker originate wire transfers; in 2024 CSRF chains still rank in OWASP A01 (Broken Access Control).
The Core Concept
Section titled “The Core Concept”The browser’s cookie jar is origin-blind for cross-site requests with SameSite not Strict or Lax. A request from attacker.com to bank.com/transfer carries bank.com’s cookies — the user is logged in, the cookie gets attached.
victim (logged in to bank.com) │ │ visits attacker.com/evil.html ▼ attacker.com → returns <form action="https://bank.com/transfer"> │ │ JS submits the form (POST with body) ▼ bank.com/transfer │ │ cookie header attached automatically: │ Cookie: session=eyJ... ← victim's session ▼ bank.com processes as if victim did itThe mitigation is to require a token the attacker cannot read: a CSRF token bound to the session, or SameSite=Strict cookies, or a custom header (like X-Requested-With) that requires preflight via CORS.
How It Works
Section titled “How It Works”- Victim is authenticated — the target app sets a
sessioncookie with noSameSite(orSameSite=Lax, which still allows top-level GET navigations). - Attacker hosts a trigger page — a
<form>, an<img>, a<script>tag, or a fetch from JS. The trigger constructs a request to the target URL with the attacker’s chosen body. - Browser attaches cookies — the browser, on seeing a request to
bank.com, attaches the cookies forbank.comregardless of which page issued the request. - Application trusts the session — the request passes the auth check and is executed in the victim’s name.
- SameSite (partial defense) —
SameSite=Strictblocks all cross-site requests.SameSite=Lax(default since Chrome 80, 2020) blocks cross-site POSTs but allows top-level GET navigations. A GET-based state-changing endpoint (delete-user via GET) is still exploitable underLax.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
| Hidden auto-submit form | <form method=POST action=...><input type=hidden...><script>document.forms[0].submit()</script> | Easy when SameSite=Strict is set |
| Image tag GET | <img src="https://bank.com/transfer?to=attacker&amount=1000"> | Easy — but works on GET endpoints |
fetch() with credentials: 'include' | Modern bypass for simple CORS — sends cookies for any request without preflight | Medium — needs Content-Type: text/plain to avoid preflight |
| JSON CSRF | Content-Type: application/json triggers preflight → CORS blocks; bypass with text/plain body {}... | Medium — depends on parser flexibility |
| CSRF token leak via XSS | W3 + W4: use XSS to read the CSRF token, then submit a real CSRF | Hard — XSS defeats all CSRF defenses |
| Login CSRF | Force-victim-login to attacker’s account, then their activity is logged under attacker’s account | Medium — works even with SameSite=Strict |
| WebSocket CSRF | Browser attaches cookies to WS upgrade requests too | Hard — WS has no built-in CSRF defense |
Hands-On Commands
Section titled “Hands-On Commands”# Generate a CSRF POC HTML for a target endpoint — x5s / Burp Suite Engager / manual# Manual template:cat > csrf.html <<EOF<html><body><form id="f" action="https://bank.com/transfer" method="POST"> <input type="hidden" name="to" value="attacker"> <input type="hidden" name="amount" value="1000"></form><script>document.getElementById('f').submit()</script></body></html>EOF# Open in browser while logged in to bank.com — transfer fires.
# Burp Suite Pro → right-click request → "Engagement tools → Generate CSRF PoC"# outputs a self-submitting HTML form with a JSON / multipart variant
# Inspect SameSite posture of all cookies on a sitecurl -sI https://bank.com | grep -i 'set-cookie'# Look for: SameSite=Strict|Lax|None and Secure flag
# Test SameSite=Lax bypass for GET endpoints — top-level navigation only# <a href="https://bank.com/delete?confirm=1">click</a> ← works under Lax# <form action="https://bank.com/delete" method="POST"> ← blocked under Lax
# JSON CSRF — body must be `text/plain` to avoid preflightcurl -X POST https://api.target.com/profile \ -H "Content-Type: text/plain" \ -H "Cookie: session=eyJ..." \ --data '{"email":"attacker@evil"}'
# nuclei CSRF token disclosure templatesnuclei -u https://target.com -t ~/nuclei-templates/http/exposures/ -tags csrf
# wfuzz for endpoints that change state on GETffuf -u https://target.com/FUZZ -w /usr/share/wordlists/dirb/common.txt -mc 200,302Detection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — CSRF is handled by rule 920170 (GET or POST Request with a Body Content) and the 920300 protocol family for invalid content types. CSRF defense is more about application design than a single WAF rule. The CRS includes:
- 941100 series — would catch a CSRF payload that contains XSS vector
- 920220 — Request Content-Type is not allowed by policy (used to block
text/plainJSON CSRF)
Suricata SID 2221024 — HTTP CSRF token missing pattern [verify].
Sysmon Event ID 1 — not relevant directly; CSRF is a server-side auth decision.
MITRE ATT&CK T1185 — Browser Security Bypass — CSRF chains with browser hijacking.
MITRE ATT&CK T1550.004 — Web Session Cookie (use alternate authentication material — the attacker uses the victim’s session).
Wazuh rule 31168 — CSRF attempt pattern (when a known token is missing from POST) [verify].
Evasion Techniques
Section titled “Evasion Techniques”JSON CSRF via text/plain. A Content-Type: application/json triggers a CORS preflight, which fails for cross-origin attackers. But many frameworks parse JSON regardless of declared Content-Type — if you send the JSON body with Content-Type: text/plain, no preflight occurs, the cookie is attached, and the framework’s JSON.parse accepts the body. The classic 2017 Netflix JSON CSRF used this.
Bypassing CSRF tokens via XSS. A SameSite=Strict cookie plus a per-request CSRF token is rock-solid — unless the app has stored XSS. With XSS in the same origin, the attacker reads document.querySelector('meta[name=csrf-token]').content and submits the request with the valid token. Defense: still use both defenses; XSS is a separate class.
Login CSRF. Even with SameSite=Strict, an attacker can force the victim’s browser to log in to the attacker’s account. The session cookie is set, but it’s the attacker’s session. The victim types their credit card into the attacker’s account view. Defense: per-request CSRF tokens on login forms too.
WebSocket CSRF. WebSockets do not require CSRF tokens because the upgrade request is HTTP — and cookies attach. Use the Origin header check on the server side: reject if Origin not in allowlist.
| Evasion | Bypasses | Defense |
|---|---|---|
text/plain JSON body | application/json-only CORS | Reject non-application/json content types server-side |
Top-level <a> GET | SameSite=Lax (still allows top-level GET) | State-changing actions must be POST-only |
| XSS-read-CSRF-token | CSRF tokens | Fix XSS, then CSRF is also fixed |
| Login CSRF | Even SameSite=Strict | CSRF token on login forms + SameSite=Strict |
| WS no origin check | Default WebSocket servers | Validate Origin header in upgrade handler |
⚠️ What caught me: A bank’s CSRF defense was a custom header
X-Requested-With: XMLHttpRequest. The backend rejected any state-changing request without it. I assumed this was a real defense because the header requires CORS preflight for cross-origin fetch — but a form submission does not.<form>cannot set custom headers, so I assumed CSRF was impossible. Then I tried WebSocket CSRF: WebSocket upgrade requests allow arbitrary headers from JS, and the bank had a WS endpoint that triggered transfers. TheX-Requested-Withcheck existed on HTTP but the WebSocket handler checked nothing. Lesson: a custom-header defense covers HTTP request modes that go through CORS, but WebSocket upgrades andEventSourceare separate code paths.
Lab Practice
Section titled “Lab Practice”Container: WebGoat (webgoat/goatandwolf) — has a dedicated CSRF lesson in stage 8.
docker run --rm -p 8080:8080 -p 9090:9090 webgoat/goatandwolf# http://localhost:8080/WebGoat user: guest / guestObjective: complete the CSRF lesson at /WebGoat/start.mvc#lesson/Csrf.lesson, then attempt the “JSON CSRF” challenge using the text/plain body bypass.
Expected flag format: flag{csrf_webgoat_json_text_plain_bypass}.
Detection that should fire: ModSecurity rule 920220 (content-type policy) blocks the text/plain POST to the JSON endpoint. Browser-side: SameSite=Strict rejects the cross-origin cookie.
Interview Questions
Section titled “Interview Questions”Conceptual: explain why SameSite=Lax (Chrome default since 2020) was a major CSRF defense improvement but did not eliminate CSRF. Give two CSRF scenarios that still work under Lax.
Technical: walk through what happens in the browser when an attacker submits a <form method=POST enctype=application/json> from attacker.com to bank.com. Why does this not work as a JSON CSRF, and how does the text/plain variant bypass it?
Scenario: a web app uses a double-submit CSRF cookie (cookie + form field both contain the same random token, server compares). Why is this weaker than a server-stored session-bound token, and what is the specific attack that breaks it?
Compare CSRF and XSS in terms of the trust direction. CSRF exploits “the server trusts the browser”; XSS exploits “the browser trusts the server.” What are the security implications of that difference?
What is a Login CSRF and why is it a problem even with SameSite=Strict cookies? Give a concrete attack scenario.
Related Modules
Section titled “Related Modules”→ W1 (OWASP Top 10 Overview) · W3 (Cross-Site Scripting) · W12 (Session Management)