Initiation aux expressions régulières en PHP

Image non disponible


précédentsommairesuivant

IV. Sécurité

IV-A. Protéger (escape)

Comme souvent, PHP propose une fonction permettant de protéger les chaînes contre les mauvaises utilisations. J'imagine que vous avez entendu parler de fonctions comme mysql_real_escape_string() pour envoyer des chaînes dans des requêtes SQL sans être victime d'injections SQL. De même, des fonctions comme escapeshellcmd() permettent de protéger d'autres aspects du système hôte lorsque vous utilisez des chaînes fournies par votre utilisateur.

Le rôle de preg_quote() est identique : protéger votre système lorsque vous utilisez dans votre regex une chaîne soumise par votre utilisateur. Vous devriez systématiquement l'utiliser lorsque vous utilisez une chaîne soumise par l'utilisateur dans votre "pattern" (le 1° paramètre de preg_match()).

Malheureusement, cela signifie que vous ne pouvez pas laisser le choix à votre utilisateur d'entrer des wildcards directement dans les patterns. Pour y parvenir, par exemple si vous souhaitez laisser à votre utilisateur la possibilité de rechercher tous les fichiers nommés d'une certaine manière, par exemple "img*.jpg", au moyen d'une expression rationnelle, vous devrez utiliser dans le pattern une chaîne soumise par formulaire. C'est là qu'il faut utiliser preg_quote() sous peine d'ouvrir une faille de sécurité. Utiliser preg_quote() sur la chaîne "img*.jpg" va la convertir en "img\*\.jpg" car l'astérisque et le point sont deux caractères spéciaux. La recherche de votre utilisateur ne donnera donc pas le résultat attendu, mais (et c'est ce qui nous intéresse) vous serez protégé contre les attaques.

Pour y remédier, vous devrez soit proposer une interface à votre utilisateur, soit trouver une alternative similaire au BBCode pour le HTML. Ce n'est pas l'objet de ce tutoriel, ainsi je vous laisse méditer sur la question.

La fonction preg_quote() doit être utilisée pour protéger le masque de n'importe quelle fonction preg_...(), dès lors que vous introduisez une chaîne soumise par votre utilisateur. Il n'est pas suffisant de protéger uniquement le masque de preg_match() car les autres fonctions sont également vulnérables.

IV-B. Regex injection

Il existe une technique appelée "regex injection". Le principe est de tirer parti du modificateur "e" utilisé dans certaines regexes afin d'introduire et d'exécuter du code PHP arbitraire, à la manière de l'injection SQL.

Reprenons l'exemple précédent de preg_replace() afin d'illustrer l'utilisation du modificateur 'e'
Sélectionnez
<?php

$pattern  = "#\[b\](.*)\[/b\]#e";

$replacement = "'<strong>'.strtoupper('$1').'</strong>'";

$subjects = array();
$subjects[] = "Initiation aux [b]expressions régulières[/b]";
$subjects[] = "Initiation aux expressions [b]rationnelles[/b]";
$subjects[] = "Initiation aux expressions <strong>rationnelles</strong>";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
    echo "<li><strong>$subject</strong> ?<br />";
    if(preg_match($pattern, $subject))
    {
        echo "Oui : ".preg_replace($pattern, $replacement, $subject);
    }
    else
    {
        echo "Non<br /><br />";
    }
    echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]
#\[b\](.*)\[/b\]#e => N'importe quelle suite de caractères encadrée d'une balise BBCode de mise en gras.

Tester ce script

La regex apparaît avec le modificateur "e", utilisable uniquement avec la fonction preg_replace().
La fonction preg_replace() n'utilise plus seulement '$1' mais "'<b>'.strtoupper('$1').'</b>'" afin de mettre en majuscules la chaîne trouvée.
Cette regex fonctionne mais il y a un inconvénient : elle peut être vulnérable à une injection de code. Dans le cas présent, puisque l'utilisateur ne peut rien introduire dans la chaîne, nous sommes en sécurité. Toutefois, il me semble périlleux d'utiliser une méthode qui, sous certaines conditions, permet à quelqu'un d'exécuter du code PHP de son choix.

En fait, la faille de sécurité (injection de regex) est utilisable uniquement si l'utilisateur a un contrôle sur la chaîne de remplacement. Il est difficile de trouver un exemple représentatif, dans la mesure où ces situations sont très rares.

Christian Wenz propose une démonstration dans son article Regular Expression Injection. Je me permets de reformuler son code afin de vous proposer une version qui me semble plus adaptée : utiliser la fonction md5() plutôt que strtolower(). C'est l'appel à ces fonctions qui rend le modificateur "e" obligatoire lorsque nous utilisons preg_replace().

users.xml : les mots de passe sont tous deux 'secret' en MD5
Sélectionnez
<?xml version="1.0"?>
<userdata>
    <user>
        <username>administrator</username>
        <password>1e6947ac7fb3a9529a9726eb692c8cc5</password>
    </user>
    <user>
        <username>John Doe</username>
        <password>1e6947ac7fb3a9529a9726eb692c8cc5</password>
    </user>
</userdata>
Démonstration de l'injection de regex
Sélectionnez
<?php

//Script original par Christian Wenz, XML original par Amit Klein

$pattern = "#(<username\s*>\s*$username\s*</username>\s*"
            ."<password\s*>)".md5($password)."(</password>)#e";

$replacement = "'\\1'.md5('$newpassword').'\\2'";

// Données en entrée
$username = (isset($_POST['username'])) ? $_POST['username'] : '';
$password = (isset($_POST['password'])) ? $_POST['password'] : '';
$newpassword = (isset($_POST['newpassword'])) ? $_POST['newpassword'] : '';

?>

<!-- Formulaire de saisie des modifications -->
<form method="post" action="<?php echo basename(__FILE__); ?>">
    <label>
        Nom d'utilisateur :
        <input type="text" name="username" value="<?php echo htmlentities($username); ?>" />
    </label><br />
    <label>
        Mot de passe actuel :
        <input type="password" name="password" value="<?php echo htmlentities($password); ?>" />
    </label><br />
    <label>
        Nouveau mot de passe :
        <input type="password" name="newpassword" value="<?php echo htmlentities($newpassword); ?>" />
    </label><br />
    <input type="submit" value="Modifier le mot de passe" />
</form>

<?php

// Application des modifications
if(!empty($_POST))
{
    // Récupération du XML
    $users = file_get_contents('users.xml');

    // Remplacement du mot de passe
    $users = preg_replace(
        $pattern,
        $replacement,
        $users, // subject
        -1, // limit
        $nb_of_changes // count
    );

    // Si le remplacement a fonctionné, on écrit le nouveau XML
    if($nb_of_changes)
    {
        file_put_contents('users.xml', $users);
    }
}

?>

Décomposition de la regex : [?]
#(>username\s*<\s*$username\s*>/username<\s*>password\s*<)".md5($password)."(>/password<)#e => Les balises XML "username" et "password" contenant les valeurs saisies par l'utilisateur et un nombre variable d'espaces.

La vulnérabilité est visible lorsque vous essayez ce genre de nouveau mot de passe :
  • '.die('Arrêt du script').'
  • '.system('reboot').'
  • '.eval('echo "Informations de debug:<br />"; echo "<pre>"; print_r($_POST); echo "</pre>"; exit;').'

Il n'est pas possible de passer plus d'une commande PHP avec cette vulnérabilité. Cependant, s'il n'est pas désactivé sur le serveur, le construct PHP eval() permet d'exécuter toutes les commandes que nous voulons.


Une solution sûre consiste à ne pas utiliser le modificateur "e" et à déléguer les traitements à une autre fonction (plutôt que de tout faire dans preg_replace()), appelée "fonction de callback". C'est rendu possible par preg_replace_callback().

Alternative sécurisée au premier exemple
Sélectionnez
<?php

function str_enhance($match)
{
    return "<strong>".strtoupper($match[1])."</strong>";
}

$pattern  = "#\[b\](.*)\[/b\]#";

$subjects = array();
$subjects[] = "Initiation aux [b]expressions régulières[/b]";
$subjects[] = "Initiation aux expressions [b]rationnelles[/b]";
$subjects[] = "Initiation aux expressions <strong>rationnelles</strong>";

echo "Le masque <strong>$pattern</strong> correspond-il à :<br /><ul>";
foreach($subjects as $subject)
{
    echo "<li><strong>$subject</strong> ?<br />";
    if(preg_match($pattern, $subject))
    {
        echo "Oui : ".preg_replace_callback($pattern, "str_enhance", $subject);
    }
    else
    {
        echo "Non<br /><br />";
    }
    echo "</li>";
}
echo "</ul>";

?>

Décomposition de la regex : [?]
#\[b\](.*)\[/b\]# => N'importe quelle suite de caractères encadrée d'une balise BBCode de mise en gras.

Tester ce script

Alternative sécurisée au second exemple
Sélectionnez
<?php

// Script original par Christian Wenz, XML original par Amit Klein

function change_password($match)
{
    global $newpassword;
    return $match[1].md5($newpassword).$match[2];
}

$pattern = "#(<username\s*>\s*$username\s*</username>\s*"
            ."<password\s*>)".md5($password)."(</password>)#";

// Données en entrée
$username = (isset($_POST['username'])) ? $_POST['username'] : '';
$password = (isset($_POST['password'])) ? $_POST['password'] : '';
$newpassword = (isset($_POST['newpassword'])) ? $_POST['newpassword'] : '';

?>

<!-- Formulaire de saisie des modifications -->
<form method="post" action="<?php echo basename(__FILE__); ?>">
    <label>
        Nom d'utilisateur :
        <input type="text" name="username" value="<?php echo htmlentities($username); ?>" />
    </label><br />
    <label>
        Mot de passe actuel :
        <input type="password" name="password" value="<?php echo htmlentities($password); ?>" />
    </label><br />
    <label>
        Nouveau mot de passe :
        <input type="password" name="newpassword" value="<?php echo htmlentities($newpassword); ?>" />
    </label><br />
    <input type="submit" value="Modifier le mot de passe" />
</form>

<?php

// Application des modifications
if(!empty($_POST))
{
    // Récupération du XML
    $users = file_get_contents('users.xml');

    // Remplacement du mot de passe
    $users = preg_replace_callback(
        $pattern,
        "change_password", // callback
        $users, // subject
        -1, // limit
        $nb_of_changes // count
    );

    // Si le remplacement a fonctionné, on écrit le nouveau XML
    if($nb_of_changes)
    {
        file_put_contents('users.xml', $users);
    }
}

?>

Décomposition de la regex : [?]
#(>username\s*<\s*$username\s*>/username<\s*>password\s*<)".md5($password)."(>/password<)# => Les balises XML "username" et "password" contenant les valeurs saisies par l'utilisateur et un nombre variable d'espaces.

IV-C. Le modificateur D

Suite à un commentaire de Hardened-PHPPMOPB-45-2007:PHP ext/filter Email Validation Vulnerability dans leur "mois des bugs PHP", j'ajoute un commentaire sur le modificateur "D".

Le problème survient lorsque l'on valide une chaîne avec l'ancrage $ (fin de chaîne) sans utiliser le modificateur "D" (comme le font tous mes exemples ci-dessus). Dans cette situation, le signe dollar $ est soit la fin de la chaîne soit le caractère précédent si c'est un caractère de fin de ligne "\n".

 
Sélectionnez
$strings = array();
$strings[] = "premier";
$strings[] = "second\n";

foreach($strings as $string)
{
    if(preg_match('/^[a-z]+$/', $string))
    {
        echo '<span style="background: lightgreen;">'.nl2br($string)." est valide</span>";
    }
    else
    {
        echo '<span style="background: red;">'.nl2br($string)." n'est pas valide</span>";
    }

    echo '<br />';
}

En exécutant ce code, on voit que les deux chaînes passent la validation alors que l'on s'attend à n'avoir que des lettres grâce à notre masque [a-z]+. Cependant, puisque le modificateur "D" n'est pas donné, le caractère de fin de ligne "\n" est également autorisé s'il est seul et en fin de chaîne.

Cela peut causer des problèmes par exemple dans le cas de l'envoi d'un e-mail (possibilité d'injection de headers), d'emploi dans une requête SQL (possibilité d'injection SQL), dans un fichier log (possibilité d'enregistrement d'une fausse ligne), dans un appel à popen (possibilité d'exécution de deux commandes au lieu d'une seule), etc.

Le remède est simple : utiliser le modificateur "D".

 
Sélectionnez
$strings = array();
$strings[] = "premier";
$strings[] = "second\n";

foreach($strings as $string)
{
    if(preg_match('/^[a-z]+$/D', $string))
    {
        echo '<span style="background: lightgreen;">'.nl2br($string)." est valide</span>";
    }
    else
    {
        echo '<span style="background: red;">'.nl2br($string)." n'est pas valide</span>";
    }

    echo '<br />';
}

Cette fois, la seconde chaîne est invalidée par l'expression rationnelle, tel que l'on peut s'y attendre.


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2006 Guillaume Rossolini. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.