Skip to content

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

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

  1. Victim is authenticated — the target app sets a session cookie with no SameSite (or SameSite=Lax, which still allows top-level GET navigations).
  2. 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.
  3. Browser attaches cookies — the browser, on seeing a request to bank.com, attaches the cookies for bank.com regardless of which page issued the request.
  4. Application trusts the session — the request passes the auth check and is executed in the victim’s name.
  5. SameSite (partial defense) — SameSite=Strict blocks 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 under Lax.
TechniqueDescriptionDetection 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 preflightMedium — needs Content-Type: text/plain to avoid preflight
JSON CSRFContent-Type: application/json triggers preflight → CORS blocks; bypass with text/plain body {}...Medium — depends on parser flexibility
CSRF token leak via XSSW3 + W4: use XSS to read the CSRF token, then submit a real CSRFHard — XSS defeats all CSRF defenses
Login CSRFForce-victim-login to attacker’s account, then their activity is logged under attacker’s accountMedium — works even with SameSite=Strict
WebSocket CSRFBrowser attaches cookies to WS upgrade requests tooHard — WS has no built-in CSRF defense
Terminal window
# 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 site
curl -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 preflight
curl -X POST https://api.target.com/profile \
-H "Content-Type: text/plain" \
-H "Cookie: session=eyJ..." \
--data '{"email":"attacker@evil"}'
# nuclei CSRF token disclosure templates
nuclei -u https://target.com -t ~/nuclei-templates/http/exposures/ -tags csrf
# wfuzz for endpoints that change state on GET
ffuf -u https://target.com/FUZZ -w /usr/share/wordlists/dirb/common.txt -mc 200,302

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/plain JSON 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].

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.

EvasionBypassesDefense
text/plain JSON bodyapplication/json-only CORSReject non-application/json content types server-side
Top-level <a> GETSameSite=Lax (still allows top-level GET)State-changing actions must be POST-only
XSS-read-CSRF-tokenCSRF tokensFix XSS, then CSRF is also fixed
Login CSRFEven SameSite=StrictCSRF token on login forms + SameSite=Strict
WS no origin checkDefault WebSocket serversValidate 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. The X-Requested-With check 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 and EventSource are separate code paths.

Container: WebGoat (webgoat/goatandwolf) — has a dedicated CSRF lesson in stage 8.

Terminal window
docker run --rm -p 8080:8080 -p 9090:9090 webgoat/goatandwolf
# http://localhost:8080/WebGoat user: guest / guest

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

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.

→ W1 (OWASP Top 10 Overview) · W3 (Cross-Site Scripting) · W12 (Session Management)