Accueil > Développement > Intégration > La sécurité dans un jeu PHP/MySQL

La sécurité dans un jeu PHP/MySQL

Protéger son jeu des vils tricheurs

vendredi 15 septembre 2006, par Haiken

La sécurité dans un jeu en PHP/MySQL est un élément primordial. Si votre site n’est pas sécurisé, c’est la porte ouverte à toutes sortes de tricheries de la part de joueurs moins honnêtes que les autres... Et qui dit triche, dit plaisir de jeu perdu pour les victimes !
Il est également primordial de penser à la sécurité avant même de commencer à programmer. Plus tard, vous aurez bien d’autres préoccupations, et il risque d’être difficile de revenir en arrière. Par contre, quelques bonnes pratiques respectées tout au long du développement vous éviterons bien des soucis par la suite !

Il n’est pas question dans cet article de faire l’inventaire de toutes les failles possibles de sécurité pour un site en PHP. Je conseille à ce sujet le très bon Guide Sécurité PHP du PHP Security Consortium, après avoir bien sûr parcouru la rubrique sécurité du manuel PHP.
Cet article a pour but de vous donner les bases qui vous permettront de développer l’esprit tranquille, le tout illustré d’exemples concrets applicables aux jeux web.

Configuration et utilisation des variables

La configuration de php, stockée dans le php.ini est importante puisqu’elle conditionne le comportement des variables prédéfinies. Utilisez une configuration par défaut, elle est le fruit de l’expérience de la communauté PHP. Evitez d’utiliser des fonctionnalités spécifiques, qui pourraient ne pas être disponibles sur n’importe quel serveur ! On peut citer register_globals, magic_quotes_gpc, short_open_tags, ...
Et bien sûr, si vous êtes plusieurs à développer, utilisez tous la même configuration.

Voici les points vraiment très importants :

  • register_globals doit être positionné à Off. Lorsque vous utilisez des variables de session, utilisez toujours $_SESSION, idem pour les cookies, variables passées en GET et POST : $_COOKIE, $_GET et $_POST.
  • Positionnez error_reporting à E_ALL, afin d’afficher toutes les erreurs, warnings et notice. Un bon moyen est d’appeler error_reporting(E_ALL) au début de chaque script. Si vous avez le moindre avertissement, c’est potentiellement une faille de sécurité !
  • Evitez d’utiliser le include($page), même si vous contrôlez avec une liste de pages autorisées.
  • Si vous avez un espace d’administration, contrôlez son accès avec un mot de passe (.htaccess)
  • Si vous offrez la possibilité d’uploader un fichier, contrôlez son contenu, cela pourrait être... un script php, qui pourrait être exécuté par le serveur !
  • De manière générale, évitez de re-développer des fonctionnalités déjà existantes, comme la gestion des sessions. Sauf si vous êtes un véritable expert, il y a de fortes chances pour que vous laissiez passer certains aspects importants (la sécurité en est un), et cela pour un gain pas forcément réel.
  • Sur la même note, si vous utilisez des outils externes (forum phpBB, punBB, ...) assurez-vous de le maintenir à jour régulièrement afin de combler les failles de sécurité. Si vous décidez de gérer vous-même votre hébergement (serveur dédié), sachez que c’est un métier à part entière, et un métier complexe. Là encore, agissez prudemment, n’utilisez que des outils stables et sécurisés, et entourez-vous de personnes compétentes sur le sujet.
  • Pour éviter les injections SQL, assurez-vous de toujours échapper les variables utilisées dans les requêtes, surtout si elles proviennent de l’utilisateur. La fonction addslashes est généralement utilisée, mais il est préférable d’utiliser celle correspondant à votre base de données : mysql_real_escape_string pour MySQL (qui échappe en plus le caractère 0) Attention aux magic quotes : les variables peuvent être déjà échappées ou non selon la configuration php. Le manuel PHP fournit une solution sécurisée les prenant en compte :
    <?php
    // Protège la variable
    function quote_smart($value)
    {
      // Stripslashes
      if (get_magic_quotes_gpc()) {
        $value = stripslashes($value);
      }
      // Protection si ce n'est pas une valeur numérique ou une chaîne numérique
      if (!is_numeric($value)) {
        $value = "'" . mysql_real_escape_string($value) . "'";
      }
      return $value;
    }

    // Connexion
    $link = mysql_connect('mysql_host', 'mysql_user', 'mysql_password')
    OR die(mysql_error());

    // Fabrication d'une requête sécurisée
    $query = sprintf("SELECT * FROM users WHERE user=%s AND password=%s",
    quote_smart($_POST['username']),
    quote_smart($_POST['password']));

    mysql_query($query);
    ?>

Il est généralement recommandé de désactiver les magic quotes, et de systématiquement
échapper les variables utilisées dans une requête SQL (cela simplifie également
leur sortie sur la page html). Pour désactiver les magic quotes quelle que soit
la configuration, on peut placer ce bout de code au début de chaque page :

set_magic_quotes_runtime(FALSE);
if(get_magic_quotes_gpc()){
        function remove_magic_quotes(&$var) {
                if(is_array($var)){
                        array_walk($var, "remove_magic_quotes");
                } else if (is_string($var)) {
                        $var=stripslashes($var);
                }
        }
       
        remove_magic_quotes($_POST);
        remove_magic_quotes($_GET);
        remove_magic_quotes($_REQUEST);
        remove_magic_quotes($_COOKIE);
}

Pour éviter les problèmes d’injection SQL, on peut aussi se tourner vers les requêtes préparées, supportées entre autres dans la nouvelle extension PDO inclue dans PHP 5.1.

Contrôle des données fournies par l’utilisateur

Si vous n’en avez jamais entendu parler, c’est que vous n’avez lu aucun article sur la sécurité.
Les données fournies en provenance de l’utilisateur, qu’elle proviennent d’un formulaire de saisie (variable $_POST), ou qu’elles soient passées en paramètre de la requête HTTP ($_GET), peuvent aisément être falsifiées. Il faut donc impérativement contrôler TOUTE valeur que vous récupérez du navigateur web, et vérifier que ces valeurs sont bien fournies !

Une bonne pratique est de lister, pour chaque action, les variables nécessaires, ainsi que des contraintes sur leur contenu (ceux qui ont suivi des cours se remémoreront la notion de "pré-condition"). Si une valeur est facultative pour l’utilisateur, mais que vous en avez besoin pour un traitement, pensez à l’initialiser avec une valeur par défaut.

Exemple pour l’action "inscription d’un joueur" :

$_POST[’nom_perso’] : texte, min 1 caractère, max 100 caractères, obligatoire
$_POST[’description’] : texte, min 0 caractère, max 1000 caractères, obligatoire
$_POST[’force’] : nombre, min 1, max 20, obligatoire
$_POST[’endurance’] : nombre, min 1, max 20, obligatoire
force + endurance = 20

Toutes ces contraintes devront être vérifiées avant de pouvoir effectuer l’inscription. Si une contrainte n’est pas respectée, à vous de décider de renvoyer une erreur à l’utilisateur ("Nom du perso obligatoire") ou bien de considérer cela comme une tentative de tricherie ! S’il n’est pas possible en navigant normalement de fournir une certaine valeur, alors c’est très probablement une tentative de triche. Exemple, vous avez sur une page 3 liens :

  • avancer.php ?direction=1
  • avancer.php ?direction=2
  • avancer.php ?direction=3

Il n’y a que 3 directions possibles, et il n’existe nulle part sur le site de lien avec une direction différente de 1, 2 ou 3. Si l’on vous fournit une valeur différente, alors c’est que l’utilisateur a modifié le lien manuellement !

Les contraintes listées précedemment pourraient se traduire ainsi :

function valider_inscription($variables) {
        if (!isset($variables['nom_perso'])) return array('TRICHE', 'nom_perso non défini');
        if (!is_string($variables['nom_perso'])) return array('TRICHE', 'nom_perso non textuel');
        if (strlen($variables['nom_perso'])<1 || strlen($variables['nom_perso'])>100) return array('VALID', 'Le nom du perso doit comprendre entre 1 et 100 caractères');

        if (!isset($variables['description'])) return array('TRICHE', 'description non défini');
        if (!is_string($variables[' description '])) return array('TRICHE', ' description non textuel');
        if (strlen($variables[' description '])>1000) return array('VALID', 'La description du perso ne doit pas excéder 1000 caractères');

        if (!isset($variables['force'])) return array('TRICHE', 'force non défini');
if (!is_numeric(($variables['force'])) return array('VALID', 'La force doit être un nombre');
if ($variables['force']<1 || $variables['force']>20) return array('VALID', 'La force doit être comprise entre 1 et 20');

        if (!isset($variables['endurance'])) return array('TRICHE', 'endurance non défini');
if (!is_numeric(($variables['endurance'])) return array('VALID', 'L'endurance doit être un nombre');
if ($variables['endurance']<1 || $variables['endurance']>20) return array('VALID', 'L'endurance doit être comprise entre 1 et 20');
       
if ($variables['force']+$variables['endurance']!=20) return array('VALID', 'Le cumul de la force et de l'endurance doit valoir 20');
       
return array('OK', '');
}

Cette fonction pourra être utilisée ainsi :

list($erreur, $message)=valider_inscription($_POST);
if ($erreur=="TRICHE") {
        //selon votre politique, logger la tentative, afficher un message à l'utilisateur, etc
} else if ($erreur=="VALID") {
        //Renvoyer le formulaire avec le message d'erreur $message à l'utilisateur
} else {
        //OK, effectuer l'inscription en utilisant $_POST['nom_perso'], $_POST['force'], etc
}

A propos de la politique concernant la triche, il est utile d’enregistrer quelque part toutes les tentatives de triche. Lorsqu’une entrée ne correspond pas à ce qu’elle devrait être, on peut enregistrer cela sur le disque, avec la requête lancée, la page appelée, l’IP, ainsi que toutes les variables de session (numéro de compte). Ce fichier peut même entre envoyé à un administrateur par mail régulièrement. Cela permet de voir immédiatement une tentative de piratage, son auteur, et de prendre immédiatement les mesures qui s’imposent.
On peut faire la même chose pour les requêtes SQL ayant échoué. A chaque requête lancée, on va vérifier que l’exécution s’est déroulée correctement (mysql_query() renvoit false en cas d’échec). Si ce n’est pas le cas, alors on va envoyer un mail à l’administrateur avec toutes les infos citées précédemment. En plus de permettre de contrôler que toutes les requêtes sont bonnes, cela permet quelquefois de détecter une tentative de hack par injection SQL.

Voici deux exemples qui vous convaincront de la nécessité de bien contrôler ses variables.

1) Imaginons que vous ayez une fonctionnalité de don d’argent à un autre personnage.
L’utilisateur choisit un destinataire, puis saisit la somme qu’il veut lui céder dans un formulaire. Imaginez d’abord que vous contrôliez bien que la somme soit un nombre, mais que vous ne testiez pas les bornes. Votre traitement ressemble à :

UPDATE Perso set argent=argent-somme where id_perso=$donneur
UPDATE Perso set argent=argent+somme where id_perso=$destinataire

Imaginez qu’un joueur malin saisisse une somme négative... cela aura pour effet de retirer de l’argent au destinataire, et d’en ajouter au donneur !

2) Votre jeu se déroule sur une carte, chaque personnage se trouvant une case. Votre script d’attaque, attaquer.php, prend en paramètre un nombre correspondant au personnage visé, id_perso_vise. Lorsque l’utilisateur choisit d’attaquer, le site lui présente une liste de personnages qui sont à sa portée, c’est-à-dire à moins de 3 cases, accompagné d’un lien attaquer.php ?id_perso_vise=xxxx
Si vous ne contrôlez pas que le personnage visé est bien à moins de 3 cases dans le script attaquer.php, alors un joueur pourrait modifier l’url, et attaquer n’importe quel perso où qu’il soit !

Il existe des cas très subtils, par exemple prenons si une variable $force contient la chaîne "15+250" (saisie par l’utilisateur) :

  • Dans un calcul PHP, $force sera converti en nombre, la conversion récupère tous les chiffres jusqu’au premier caractère non numérique è if ($force<20) renverra true
  • Si alors vous faites une requête : UPDATE Perso set force=$force, alors la valeur 265 sera insérée dans la table ! è il faut vérifier l’utilisateur a bien saisi un nombre (is_numeric). Il est aussi possible de systématiquement convertir le nombre en entier avec intval() par exemple, mais cela ne permet pas de détecter la tentative de triche.

Concurrence d’accès aux données

Maintenant que les données en entrées des traitement sont bien contrôlées, nous allons nous intéresser aux traitements aux-mêmes.

Reprenons notre exemple de don d’argent. Cette action est effectuée par un script donner.php qui prend en paramètre le destinataire du don, ainsi que la somme - entier positif, qui doit être inférieur au montant disponible sur le compte bancaire du joueur. Le numéro du donateur (joueur connecté) est bien entendu conservé dans une variable de session.

La structure générale du script est la suivante :

//Validation : on vérifie que le joueur possède assez d'argent
$r=mysql_query("SELECT argent from Perso where id_perso=".$_SESSION["id_perso']);
list($argent_dispo)=mysql_fetch_row($r);
if ($somme > $argent_dispo) {
        // renvoyer un message d'erreur à l'utilisateur indiquant qu'il n'a pas assez d'argent
}
...

//Traitement : on transfert l'argent d'un perso à l'autre
mysql_query("UPDATE Perso set argent=argent-".$_POST['somme']." where id_perso=".$_SESSION['id_perso']);
mysql_query("UPDATE Perso set argent=argent+".$_POST['somme']." where id_perso=$destinataire");

Tout va bien jusqu’à présent, notre code fonctionne. Maintenant, imaginons qu’il survienne un délai notable entre la validation et le traitement. Les serveurs web gèrent plusieurs processus ou requêtes simultanément. Un script peut donc être interrompu provisoirement, avant de reprendre son exécution plus tard. Vous pourriez également avoir à effectuer des calculs longs et compliquées entre la validation et la mise à jour de la base de données.

Si un joueur envoie alors plusieurs fois de suite la même requête http (avec le même contenu), ce qui peut aisément être effectué en cliquant plusieurs fois de suite, très rapidement, sur le bouton de validation, alors nous avons plusieurs requêtes qui arrivent simultanément au serveur web. Notre serveur web sait exécuter plusieurs scripts (processus) simultanément, et leur exécution peut être effectuée simultanément - surtout si par exemple, le serveur dispose de plusieurs CPU ; mais aussi parce que le système peut décider de suspendre un processus pour en démarrer un autre.

Considérons donc le cas où 2 appels identiques (mêmes paramètres) à donner.php sont arrivés au serveur web simultanément. Nous allons les appeler A et B. Le donateur (joueur n°42) dispose de 50 pièces sur son compte. Il souhaite en donner 30 au joueur n°123, qui en possède initialement 20.
Le séquencement des opérations peut être le suivant :

Le système démarre l’appel A au script
A effectue la phase de validation è ok, le joueur veut donner 30 et dispose de 50
Le système interromps l’appel A et démarre l’appel B au script.
B effectue la phase de validation è ok, le joueur veut donner 30 et dispose de 50
B effectue le traitement, il retire 30 à A et les verse à B è joueur 42 dispose de 20, joueur 123 dispose de 50
B se termine
A reprend son exécution
B effectue le traitement, il retire 30 à A et les verse à B è joueur 42 dispose de -10, joueur 123 dispose de 80 !

C’est ce genre de problème qui a conduit des informaticiens, il y a quelques dizaines d’années, à inventer les bases de données et la notion de transaction. Je ne vais pas rentrer dans les détails ici (internet fournit une large documentation sur le sujet), sachez juste que les transactions ne sont pas supportées par le format de table par défaut de MySQL, MyISAM, mais qu’elles le sont avec InnoDB.
Les transactions permettent de verrouiller les données manipulées, et de s’assurer qu’elles n’ont pas été modifiées entre le début et la fin de la transaction. Si nous avions encapsulé toutes nos requêtes SQL dans une même transaction, nous aurions eu l’assurance que le 2ème appel n’aurait pas abouti.

Outre ce cas d’école, il existe de nombreuses situations où vous pourriez avoir des problèmes.

Prenons l’exemple d’une gestion des points d’action (PA). Le joueur gagne 1 PA par heure. Pour gérer ce système, on stocke la date/heure (timestamp) de dernière connexion pour chaque joueur. Lorsqu’il se connecte, on calcule le temps écoulé depuis le dernier gain de PA, et on lui redonne autant de PA que d’heures écoulées. Notre script pourrait ressembler à :

$r=mysql_query("SELECT timestamp_gain from Perso where id_perso=".$_SESSION['id_perso']);
list($timestamp_gain)=mysql_fetch_row($r));
if (time()-$timestamp_gain>3600) { //si plus de 3600 secondes (1h) se sont écoulées depuis la dernière connexion
        $gain = floor((time()-$timestamp_gain)/3600); //calculer le nombre d'heures écoulées
        mysql_query("UPDATE Perso set PA=PA+$gain, timestamp_gain= timestamp_gain+3600*$gain_timestamp where id_perso=$id_perso");
}

Maintenant, imaginez si deux appels à ce script arrivent simultanément au serveur web... Quel est le risque ? Que le joueur perçoive 2 fois trop de PA !

Pour éviter cette situation délicate, il n’existe guère de salut en dehors des transactions. Vous pourriez effectuer toute l’opération en une seule requête (la requête est compliquée mais c’est parfaitement faisable), mais ce n’est valable que pour ce cas précis. Vous pourriez aussi gérer vous-même une section critique à l’aide de sémaphores UNIX, de la fonction flock() ou la fonction GET_LOCK() de MySQL. Oubliez tout de suite, c’est une très mauvaise idée. D’une part, parce que (hormis avec flock), vous devenez dépendant de fonctions qui ne seront pas forcément disponibles sur toute plate-forme, mais surtout parce que vous êtes en train de réinventer la roue ! La gestion des transactions est un problème compliqué, fiez-vous plutôt aux implémentations fiables et éprouvées qui existent déjà, vous gagnerez du temps et de la sueur ! :-)

Pour clôre ce chapitre (et vous faire des frayeurs :p), appliquez le comportement que l’on vient de voir à une action d’attaque d’un adversaire : le joueur inflige des dommages à un adversaire. Si le nombre de Points de Vie de l’adversaire bascule en dessous de zéro, l’adversaire meurt. Plusieurs joueurs peuvent même attaquer un même adversaire simultanément. Que peut-il se passer en cas d’attaques simultanées sur un même perso ?

Multi-comptes

Vous avez maintenant toutes les cartes en main pour sécuriser votre site. Il reste cependant un problème potentiel : le multi-comptes. En effet, dans un jeu traditionnel, comme un jeu de carte, une personne ne peut jouer qu’un seul joueur. Dans un jeu sur internet, il en va autrement. Une même personne peut très facilement se créer plusieurs comptes sur votre jeu !
Selon le type de jeu, cela aura un impact plus ou moins important. Dans un jeu de guerre, où le nombre de joueurs influence les chances de victoire, cela peut être catastrophique. A moindre échelle, un autre compte peut être utiliser pour affaiblir l’ennemi avant de l’attaquer avec son vrai personnage, pour espionner ou même infiltrer son ennemi.

Il faut savoir qu’il est presque impossible, au niveau technique, d’empêcher le multi-comptes. Les principales techniques de détection, basées sur l’e-mail, l’adresse IP ou les cookies, peuvent être contournées par une personne suffisamment expérimentée. De même, il peut arriver que plusieurs personnes jouent réellement à partir du même poste (familles, écoles, cyber-cafés, ...). Aucune technique de détection n’est donc véritablement fiable.

En conséquence, il est important d’évaluer les impacts et les risques provoqués par une personne gérant plusieurs comptes sur votre jeu. De là, vous devrez en déduire une politique à adapter face à ce problème.
Selon le type et les règles du jeu, cela pourrait être :

  • autorisation explicite de faire du multi-comptes (aucun d’impact)
  • autorisation implicite (très faible impact)
  • interdiction explicite sans répression.
  • interdiction explicite avec répression, ce qui suppose la mise en place d’un système de détection. On peut joueur sur le niveau de tolérance, sur l’éducation plutôt que la répression, s’aider de la dénonciation, etc. Le système de détection n’étant pas fiable, certains jeux mettent même en place des équipes chargées d’enquêter et juger les cas litigieux.

Le meilleur moyen de limiter le multi-comptes reste sans aucun doute de faire en sorte qu’il n’ait aucun impact sur le jeu... Ce qui n’est pas toujours forcément évident. C’est donc un aspect qui devrait être pris en compte dès la conception du jeu !

Pour conclure, retenez surtout qu’il n’y a pas de solution toute faite. Il faudra la choisir en fonction de votre jeu, de votre public et... de votre intuition ! ;-)


Je remercie Merrick, VYS et DT pour leurs remarques et relecture de cet article :-)