Assurer la de votre revient à vous assurer qu’il restera en vie le plus longtemps possible, et qu’il ne vous échappera pas. Adoptez les bons réflexes en comparant un mauvais code (bad idea) et un bon code (good idea).

Cet article sera mis à jour au fil des nouvelles mauvaises idées que je croiserai sur le net, au taff ou ailleurs.

Monitorer son jeu

Bad idea: ne pas monitorer

Si vous ne surveillez pas votre jeu web, alors celui-ci peut partir à la dérive sans que vous ne vous en aperceviez. Vous devez être alerté si un joueur prend trop d’avance sur les autres, si votre base de données semble soudainement vide, ou si des erreurs (logs) apparaissent dans votre jeu web.

Good idea: monitorer (automatiquement)

Cette surveillance (monitoring) peut se faire manuellement (vous allez vous-même voir chaque jour si vous avez des logs d’erreur, ou vous surveillez toutes les heures si le classement du jeu n’a pas totalement été chamboulé), ou automatiquement (c’est plus simple!). Investir un peu de temps dans la création d’une tâche CRON (par exemple) qui va vérifier que le serveur SQL répond, que vous n’avez pas (trop) de logs d’erreur d’un coup, et que le classement des joueurs est stable peut s’avérer payant le jour où un soucis survient: vous serez vite prévenu, et vous pourrez vite prendre des mesures avant que les choses ne dégénèrent.

Le spam

Bad idea: un CAPTCHA contre le spam

Le spam n’est pas forcément l’oeuvre de robots. Des humains peuvent aussi spammer (ou troller). Il existe même des services qui “résolvent” les captchas pour vous: vous achetez un crédit de 1000 captchas, et des humains (dans un pays du tiers monde souvent) résolvent les captchas que votre robot de spam leur envoie.

Good idea: analyser, modérer

Si le CAPTCHA est excellent contre les “flood” (c’est à dire, un envoie massif de messages automatiques, peu importe leur qualité), le meilleur moyen de lutte contre le spam reste la présence d’un modérateur humain. Vous pouvez aussi analyser le contenu du message, et bannir certains mots, certaines expressions, ou interdire de poster (sur votre forum par exemple) un lien pointant vers autre chose que votre jeu web. C’est souvent suffisant.

“Prod” et “Dev”

Bad idea: éditer directement son jeu en ligne

Si vous éditez directement les sources de votre jeu en ligne (via FileZilla par exemple), alors vous risquez d’une part de perdre votre jeu du jour au lendemain faute de backup, mais vous allez aussi risquer de bloquer votre jeu (qui sera inutilisable pour vos joueurs le temps que vous finissiez votre développement) et enfin, vous laisserez fuiter des messages d’erreur très instructifs à vos joueurs, qui peuvent être des pirates.

Good idea: développer en local sur votre poste

Vous devez installer une stack web locale (WAMP par exemple pour PHP & MySQL) c’est à dire sur votre ordinateur. Vous pourrez alors, d’une part, mettre des backups en place, mais aussi utiliser les outils de débug comme XDebug, et enfin, les messages d’erreur éventuels lors de vos tests resteront sur votre ordinateur. Une fois votre jeu prêt (ou votre mise à jour prête), vous pourrez procéder à son “déploiement en prod”, c’est à dire à la mise à jour de votre jeu en ligne à partir de ces sources locales.

Stocker les mots de passe

Bad idea: stockage en clair

$pdo->prepare('INSERT INTO comptes (nom, mot_de_passe) VALUES (?, ?)')->execute(array($playerName, $playerPassword));
$pdo->prepare('SELECT * FROM comptes WHERE nom = ? AND mot_de_passe = ?')->execute(array($playerName, $playerPassword));

Non.

Bad idea: inventer son système de cryptage

$pdo->prepare('INSERT INTO comptes (nom, mot_de_passe) VALUES (?, ?)')->execute(array($playerName, md5($playerPassword)));
$pdo->prepare('SELECT * FROM comptes WHERE nom = ? AND mot_de_passe = ?')->execute(array($playerName, md5($playerPassword)));

Non car le MD5 de deux mots de passe identiques sera toujours le même: en lisant la BDD, je sais quels joueurs ont les mêmes mots de passe. De plus, il existe des sites web permettant (parfois) de retrouver le mot de passe à partir du MD5.

Good idea: saler et hasher le mot de passe avec les fonctions de cryptographie native

$pdo->prepare('INSERT INTO comptes (nom, mot_de_passe) VALUES (?, ?)')->execute(array($playerName, password_hash($playerPassword)));
$hashedPassword = $pdo->prepare('SELECT * FROM comptes WHERE nom = ?')->execute(array($playerName));
if (password_verify($playerPassword, $hashedPassword['mot_de_passe'])) {
  // OK
} else {
  echo "Mauvais login ou mauvais mot de passe";
}

Un pirate pouvant lire votre base de données ne doit pas être capable de voler le compte d’un joueur. Vous devez donc également password_hash-er les tokens de changement de mot de passe, ainsi que les identifiants de session (s’ils sont stockés en base de données).

Vérifier côté serveur, aider côté client

Bad idea: le HTML ou le JS pour la sécurité

<input type="number"/>Vous ne pouvez entrer qu'un nombre!

Le pirate peut modifier son navigateur (ou plus simplement, la page web affichée) pour retirer les restrictions sur les input. Il peut même ne pas être passé par votre formulaire.

Pour cette même raison, il est impossible de protéger un “client lourd”: une application installée sur l’ordinateur d’un utilisateur et nécessitant une “licence” sera toujours piratable, comme le démontre mon exemple de crackage d’une application exécutable (.exe) via OllyDbg.

Good idea: valider les données côté serveur

<input type="number"/>Veuillez entrer un nombre
<?php
// page de récupération du formulaire
$nombre = filter_input(INPUT_GET, 'number', FILTER_VALIDATE_INT);
if ($nombre > 0) {
  // Ok
} else {
  echo "Veuillez entrer un entier strictement positif!";
}

La fonction filter_input propose de nombreuses options pour valider les données reçues par le serveur. N’oubliez pas vos propres checks une fois la donnée récupérée (ici, le check vérifiant que le nombre est positif).

Injection SQL

Bad idea: concaténer des chaînes de caractères

$playerInfos = $pdo->query("SELECT * FROM players WHERE id = " . $_GET['idPlayer']);

Dans un tel cas, le risque est de voir le joueur (le pirate) passer autre chose qu’un nombre en paramètre de la page. Si, par exemple, il appelle la page avec ?idPlayer=0%20OR%20TRUE, alors la requête deviendra SELECT * FROM players WHERE id = 0 OR TRUE, car PHP fera d’abord la concaténation de chaînes de caractères. Une fois exécutée par PDO (c’est à dire, par le serveur MySQL), cette query ne sera plus du tout celle que vous attendiez.

L’injection SQL est l’une des pires failles de sécurité. Ce n’est pas pour rien qu’elle est la 1ere faille de sécurité d’après le Top 10 de l’OWASP pour 2017.

Bad idea: utiliser les requêtes préparées de travers

Si vous avez entendu parler de l’injection SQL, on vous aura sûrement répondu que la solution est d’utiliser des requêtes préparées. En fait, pas tout à fait. Il vous faut utiliser des requêtes SQL paramétrées (et seules les requêtes préparées le permettent). Si vous utilisez des requêtes préparées tout en conservant la concaténation de chaînes de caractères, alors vous serez toujours injectable.

$statement = $pdo->prepare("SELECT * FROM players WHERE id = " . $_GET['idPlayer']);
$playerInfos = $statement->execute();

Good idea: utiliser des requêtes préparées et paramétrées

La solution ici est simple: d’un côté, vous avez une requête statique, avec des “placeholder”, et de l’autre, vous aurez les paramètres de votre query, c’est à dire les valeurs que ces placeholders doivent prendre. Ainsi, le serveur SQL recevra d’abord la requête statique, puis il remplacera lui-même les valeurs des placeholders par les valeurs de vos variables, en gérant tout seul les échappements, comme un grand. Plus aucune injection SQL ne sera possible.

$statement = $pdo->prepare("SELECT * FROM players WHERE id = ?");
$playerInfos = $statement->execute(array($_GET['idPlayer']));

Les placeholders peuvent être nommés si une même valeur est réutilisée plusieurs fois dans la requête.

$statement = $pdo->prepare("SELECT * FROM units WHERE id_player = :idPlayer UNION SELECT * FROM buildings WHERE id_player = :idPlayer");
$unitsAndBuildings = $statement->execute(array('idPlayer' => $_GET['idPlayer']));

Pensez aussi à réduire les droits de l’utilisateur SQL de votre jeu web au strict minimum (un DROP TABLE ou DROP DATABASE dans le code de votre jeu web ne doit pas marcher car votre utilisateur SQL ne doit pas avoir ces droits).

XSS

Bad idea: utiliser directement echo

<a href="...">Cliquez ici pour attaquer le joueur <?php echo $enemy['name']; ?></a>

Vu par le navigateur d’un joueur, votre serveur génère une page HTML que le navigateur va interpréter. Donc, si vous intégrez du code dynamique là dedans, alors vous devez vous assurer qu’il ne sera pas interpréter de travers. Dans cet exemple, si l’ennemi en question se nomme <script>alert('XSS');</script> alors la page web générée par votre serveur contiendra <a href="...">Cliquez ici pour attaquer le joueur <script>alert('XSS');</script></a>. Le navigateur l’interprétera, et le joueur qui veut vous attaquer verra une boîte d’alerte marquée “XSS”.

Afficher une alerte est inutile en pratique. Cela ne sert qu’à montrer l’existence d’une faille XSS. Un vrai pirate s’en servira pour faire faire des actions au joueur contre son gré (changer de mot de passe pour un connu du pirate, changer l’email du compte, supprimer le compte, transférer des ressources, etc).

Good idea: utiliser htmlentities

Cette fonction de PHP permet d’échapper les caractères HTML, et donc, de s’assurer qu’un texte sera considéré comme du texte et non interprété comme du HTML. Précisez l’encodage s’il ne s’agit pas de celui par défaut. Créez une fonction intermédiaire vous facilitera la tâche.

<?php
function html(string $text): void {
    echo htmlentities($text, ENT_HTML5, 'UTF-8');
} ?>
<a href="...">Cliquez ici pour attaquer le joueur <?php html($enemy['name']); ?></a>

URL injection

Bad idea: utiliser htmlentities partout

<a href="attack.php?enemy=<?php echo htmlentities($enemy['name']); ?>">Attaquer <?php echo htmlentities($enemy['name']); ?></a>

Dans ce cas, l’URL est injectable. En effet, si le pirate s’appelle fake&moi=0, alors le code deviendra <a href="attack.php?enemy=fake&amp;moi=0">Attaquer.... Le navigateur de la victime accèdera donc à la page attack.php?enemy=fake&amp;moi=0 et le serveur cherchera l’ennemi enemy=fake, qui n’existe pas. Le pirate sera donc inattaquable!

Good idea: cumuler urlencode pour les URL et htmlentities pour HTML

<a href="<?php echo htmlentities('attack.php?enemy=' . urlencode($enemy['name'])); ?>">Attaquer <?php echo htmlentities($enemy['name']); ?></a>

Si une variable $x doit être “insérée” (echo ou le . de la concaténation) dans un flux de donnée contenant du type Y (du HTML par exemple), alors vous devez utiliser la fonction d’échappement de Y. Dans l’exemple ci-dessus, le nom de l’ennemi est d’abord concaténé avec une URL, donc il est échappé avec urlencode. Ensuite, le tout est mis dans du HTML, et on l’échappe donc avec htmlentities.

Identifier un élément de jeu

Bad idea: l’identifier par un nom “humain”

<a href="<?php echo htmlentities('attack.php?enemy=' . urlencode($enemy['name'])); ?>">Attaquer <?php echo htmlentities($enemy['name']); ?></a>

Quand vous devez faire référence à un élément de jeu d’une page à l’autre (un bâtiment du jeu, un joueur, un item, une ville dans la carte, etc), n’utilisez pas le nom de cet objet: il peut ne pas être unique, il peut être de longueur arbitraire, il peut changer au fil du temps, et il peut même poser des soucis d’encodage.

Good idea: un identifiant numérique unique

<a href="<?php echo htmlentities('attack.php?id_enemy=' . urlencode($enemy['id'])); ?>">Attaquer <?php echo htmlentities($enemy['name']); ?></a>

L’identifiant numérique n’a pas de raison de changer (même si votre jeu passant de l’anglais au français). De plus, il présentera moins de risque de sécurité (c’est un nombre, donc vous pouvez facilement vérifier si id_enemy a une valeur acceptable). Enfin, n’oubliez pas de rajouter un UNIQUE INDEX (ou PRIMARY INDEX) dans votre base de donnée pour assurer l’unicité de cet identifiant numérique.

Du javascript dynamique

Bad idea: créer un code javascript dynamique

<script><?php echo $javascriptCode;</script>

Ceci ne peut pas être sécurisé.

Good idea: créer du JSON, et l’interpréter dans un Javascript statique

<script>
const JSON_DATA = <?php echo json_encode($javascriptData); ?>;
JSON.parse(JSON_DATA).forEach(...);
</script>

Ici, vous construisez d’abord un JSON contenant vos données dynamiques, vous l’envoyez dans le flux Javascript (donc, en l’échappant via json_encode) puis votre code Javascript, statique, traite son contenu. Il n’y a donc pas d’interprétation dynamique d’un code Javascript, seulement la lecture d’un objet de données JSON.

Il n’est pas nécessaire ici d’échapper l’ensemble du contenu de la balise script via htmlentities car la norme HTML dit que le contenu d’une balise script n’est pas interprété. json_encode est conçu pour échapper correctement toute occurrence qui pourrait fermer le parser HTML “trop tôt” (par exemple, si $javascriptData contient </script>, alors json_encode l’échappera en le transformant en <\/script>).

L’injection XSS via Javascript

Bad idea: .innerHTML

<script>
const JSON_DATA = JSON.parse(document.querySelector('.username').innerText);
document.body.innerHTML = JSON_DATA.helloWorld;
</script>

Peu importe d’où vient le contenu du JSON_DATA: la chaîne de caractère qu’il contient sera interprétée comme du HTML après son insertion via document.innerHTML.

Good idea: .innerText ou createTextNode

<script>
const JSON_DATA = JSON.parse(document.querySelector('.username').innerText);
document.body.innerText = JSON_DATA.helloWorld;
</script>
<script>
const JSON_DATA = JSON.parse(document.querySelector('.username').innerText);
document.querySelector('.destination').appendChild(document.createTextNode(JSON_DATA.helloWorld));
</script>

Les failles XSRF

Bad idea: GET sur un formulaire

<form action="delete-account.php" method="GET">
<label>Compte à supprimer: <input type="text" name="playerName"/></label>
...
</form>

Si un attaquant m’envoie un email contenant le code HTML <img src="delete-account.php?playerName=admin"/> (ou s’il l’insère dans sa signature, sur le forum du jeu ou même sur un autre forum sans aucun lien), alors mon navigateur chargera la page delete-account.php?playerName=admin. Comme je suis loggé dans le jeu et que j’en suis admin, alors le serveur supprimera le compte indiqué en paramètre playerName.

Good idea: POST, token XSRF et confirmation par mot de passe

Une requête GET ne doit pas changer l’état du serveur. Donc, toutes les actions dans le jeu (déplacement, construction d’une unité, modification du profil, modération d’un message, etc) doivent être faites par des formulaires en POST, et le serveur doit vérifier que la requête reçue est en POST. De plus, il faut aussi vérifier que l’utilisateur n’est pas passé par un formulaire d’un autre site web (l’action aurait alors pû être involontaire, car le pirate avait hacké cet autre site par exemple). Enfin, si l’action est très critique (suppression de compte, changement de mot de passe ou d’email, etc) alors il faut demander son mot de passe à l’utilisateur.

<form action="delete-account.php" method="POST">
<label>Compte à supprimer: <input type="text" name="playerName"/></label>
<label>Votre mot de passe: <input type="password" name="passcheck"/></label>
...
</form>
<?php
if (!password_verify(filter_input(INPUT_POST, 'password'), $hashedPassword['mot_de_passe'])
    || !filter_input(INPUT_POST, 'playerName')
    || ($_SERVER['HTTP_ORIGIN'] ?? null) !== 'monjeuweb.com')) {
  echo "I'm sorry, I'm afraid I can't let you do that";
}

Protéger l’utile

Bad idea: tout protéger drastiquement

Obfusquer les CSS et les JS, protéger les vidéos et les sons par DRM, crypter les sprites PNG, mettre les statistiques des unités du jeu dans un coffre-fort,…

Good idea: protéger ce qui est privé, laisser ce qui est publique.

Vous devez protéger de manière systématique le code de votre jeu (vous devez toujours utiliser des requêtes paramétrées, vous devez toujours échapper le code HTML via htmlentities, etc) mais certaines informations de votre jeu seront nécessairement publiques: les CSS, les scripts JS, les sprites PNG, etc sont des ressources publiques qu’il est inutile d’obfusquer (voir les fiches thématiques du ministère sur l’identification des données à protéger). De même, si toute la “sécurité de votre gameplay” (le fait qu’un joueur ne puisse pas gagner à coup sûr) repose sur le secret des formules de calcul du jeu, alors votre jeu sera peut-être déserté dès qu’un joueur aura fait le rétro-engineering de votre jeu.

Si votre jeu est assez riche en gameplay, avec une communauté de joueurs qui s’entend bien (qui est intéressante) alors votre jeu sera à l’abris du retro-engineering. Ce dernier ne fait peur qu’aux jeux trop simplistes, ou sans communauté digne de ce nom. L’intérêt de votre jeu doit être dans le gameplay en lui-même, pas dans le fait que ses formules soient secrètes.