Il y a un dossier qui traîne sur mes disques depuis 2010. Il s'appelle tofema. Dedans, il y a 85 fichiers PHP et le site communautaire d'une team de Transformice — TTMF, la Team Transformice Fansite.
J'avais 15 ou 16 ans. Je ne savais pas coder. J'ai quand même écrit un forum complet : sujets, réponses, pagination, statuts de modération, marquage lu/non-lu, un espace d'administration, un module de recrutement avec votes, et même un système de statistiques de visite maison que j'avais appelé « Big Brother ».
Et puis le site s'est fait hacker.
Pendant des années, je suis resté avec cette histoire sans jamais vraiment savoir ce qui s'était passé. J'avais des souvenirs vagues : quelque chose de cassé, des pages modifiées, une remise en état dans la panique. Mais aucune explication technique.
Ce mois-ci, j'ai rouvert le dossier. Je l'ai remonté en local, je l'ai fait analyser ligne par ligne, et j'ai enfin la réponse. Elle est plus intéressante que je ne l'imaginais — et pas seulement pour le hack.
Remonter un site de 2010 en local
Premier obstacle : le code date d'avant PHP 7. Il utilise l'extension mysql_*, supprimée en 2015. Sur un PHP moderne, chaque page renvoie une erreur fatale dès la première requête.
C'était le premier vrai constat : l'extension était appelée depuis 55 fichiers, et ces appels sont tellement imbriqués dans la logique qu'il était illusoire de les remplacer un par un. Réécrire tout ça à la main aurait pris des jours et cassé des choses.
J'ai donc écrit un shim — une couche de compatibilité qui réimplémente l'ancienne API par-dessus mysqli, chargée automatiquement avant chaque script. Résultat : zéro modification des 55 fichiers concernés, le site tourne.
Autre surprise : les fichiers sont physiquement encodés en ISO-8859-1. Pas en UTF-8. À chaque fois qu'un outil moderne les ouvre, il détruit les accents (é devient un caractère de remplacement). J'ai corrompu trois fichiers comme ça avant de comprendre, et j'ai dû les réparer octet par octet. Le code de 2010 porte son époque jusque dans son encodage.
Troisième obstacle, et le plus instructif : aucun dump de la base de données n'existait. J'ai donc reconstruit les 10 tables uniquement à partir des requêtes SQL disséminées dans le code. Avec une contrainte amusante : inscription.php faisait un INSERT positionnel sans liste de colonnes —
$sql = 'INSERT INTO ttmf_membres VALUES ("" , "'.$pseudo.'", MD5("'.$password.'"), "", "", "3", "0", "'.time().'", "'.$mail.'", "0", "", "", "", "", "")';
Quinze valeurs dans l'ordre, sans nom de colonne. Pour que l'inscription fonctionne encore, il fallait que les 15 premières colonnes du schéma reconstruit tombent exactement dans cet ordre. Ce genre de détail te rappelle qu'on écrivait du code pour aller vite, pas pour être relu.
Une fois tout ça en place : le site fonctionne. Les 21 pages répondent, les accents s'affichent, on peut se connecter, poster un message, créer un sujet.
Et là, je pouvais enfin faire ce que je n'avais jamais fait en 2010 : regarder le code froidement.
Ce que j'ai trouvé
J'ai lancé une analyse statique complète : 85 fichiers, 5 284 lignes, et l'extraction de chaque appel mysql_query en traçant les variables depuis les entrées utilisateur jusqu'aux requêtes.
Les chiffres d'abord.
| Indicateur | Valeur |
|---|---|
| Fichiers PHP | 85 |
| Lignes de code | 5 284 |
| Requêtes SQL | 235 |
mysql_real_escape_string | 0 |
| Requêtes préparées (PDO) | 0 |
Sessions PHP (session_start) | 0 |
| Jetons anti-CSRF | 0 |
Blocs try / catch | 0 |
Un seul chiffre suffit à résumer le hack : zéro échappement, pour 235 requêtes.
En classant chaque requête selon son exposition réelle, j'arrive à :
- 41 requêtes critiques — l'entrée utilisateur arrive brute dans la requête, ou passe par un cookie falsifiable
- 33 requêtes à exposition élevée — via une variable intermédiaire jamais filtrée
- 75 requêtes atteignables sur 235, soit 32 %
Un tiers du code SQL du site était manipulable par n'importe qui.
La faille qui explique probablement tout
Celle-ci, je l'ai testée — en lecture seule, sur ma base locale, pas sur de vrais utilisateurs.
$query = mysql_query('SELECT * FROM ttmf_membres WHERE pseudo = "'.$_GET['membre'].'"');
profil.php prend le pseudo directement dans l'URL et le colle dans la requête. Pas d'échappement. La requête peut donc être détournée pour renvoyer autre chose que ce qui était prévu.
Et la table ttmf_membres contient — entre autres — mail et password_crypt.
Autrement dit : depuis une simple URL, on pouvait faire sortir de la base les hashes de mots de passe de tous les comptes. Le tout sans laisser de trace visible, parce qu'une requête détournée qui réussit affiche une page parfaitement normale. C'est ce qui rend l'injection SQL si discrète : il n'y a pas de message d'erreur, juste des données qui ne sont pas les bonnes.
La faille que j'avais complètement oubliée
Celle-ci m'a fait arrêter de lire pendant une minute. Elle est pire que la précédente, et je ne l'avais jamais soupçonnée.
Dans modification_profil.php, voici la structure :
include('form.php'); // charge la session
echo '<div id="Texte">';
if ($_GET['action'] == "modifier")
{
if (isset($_POST['genre']))
{
// ... UPDATE ttmf_membres SET genre, mail, signature WHERE pseudo = ...
}
}
// ... et seulement APRÈS :
if(!isset($_GET['membre'])) { /* erreur */ }
else
{
if ($_COOKIE['pseudo'] == $_GET['membre']) { /* affiche le formulaire */ }
}
L'écriture se fait avant la vérification d'identité.
J'ai testé : un POST anonyme, sans le moindre cookie, sur modification_profil.php?membre=X&action=modifier — et le profil du membre X a été modifié en base. Mail, genre, signature. Sans compte, sans mot de passe, sans rien.
Ce n'est pas un problème de cryptographie, ni de SQL, ni de framework. C'est un problème d'ordre. La vérification existe, elle est là, elle est même correcte — elle arrive juste vingt lignes trop tard.
Le cookie qui contenait le mot de passe
Troisième découverte, et c'est celle qui explique la persistance de l'attaque.
Quand on se connectait, le site posait deux cookies :
setcookie('pseudo', $pseudo, time() + 365*24*3600);
setcookie('password', $password_db, time() + 365*24*3600);
$password_db n'est pas un jeton de session. C'est le hash MD5 du mot de passe, tel qu'il est stocké en base. Le cookie est le mot de passe.
Conséquences, toutes vérifiées :
- Le cookie vit 365 jours
- Il est relu à 14 endroits différents dans le code
- Un cookie volé donne un accès permanent — changer son mot de passe ne l'invalide pas
- Et comme la faille SQL permettait justement d'extraire ces hashes, la chaîne complète était ouverte
En forgeant simplement un cookie avec le bon pseudo et le bon hash, on obtient une session valide. Testé : /admin/index.php s'ouvre, menu d'administration complet compris.
Ce qui, en revanche, tenait bon
Je dois être honnête sur ce point, parce que j'avais une idée reçue fausse et l'analyse l'a corrigée.
connexion.php résiste. J'étais persuadé qu'on pouvait se connecter avec une chaîne du genre " OR "1"="1 dans le champ pseudo. C'est faux : cette page utilise htmlspecialchars(), qui échappe les guillemets. J'ai testé cinq variantes d'attaque sur cette page — toutes ont échoué.
Même chose pour admin/profil.php : la vérification if ($grade >= $grade_modo) est bien présente et bien placée. Un POST anonyme avec grade=6 ne change rien. La protection tient.
Donc non, on ne devenait pas administrateur en tapant une requête dans un formulaire. Mais on pouvait voler les hashes, ou modifier les profils sans rien demander — ce qui revient au même en pratique, et explique peut-être mieux pourquoi le hack a été si difficile à comprendre : il ne passait pas par là où je regardais.
Aucune porte dérobée
Dernier point, rassurant : j'ai cherché un webshell, du code obfusqué, un eval(base64_decode(...)), une porte dérobée laissée par l'attaquant. Rien. Aucun code malveillant dans ce snapshot. Le nettoyage a été fait — ou bien ce dossier est postérieur à l'incident.
La chaîne, reconstituée
En assemblant tout, l'attaque prend cette forme :
graph TD
A["profil.php : 41 requêtes<br/>injectables, non échappées"] -->|"requête détournée"| B["Fuite des hashes MD5<br/>tous les comptes"]
C["modification_profil.php<br/>écriture AVANT vérification"] -->|"POST anonyme"| D["Modification du profil<br/>de n'importe qui"]
B -->|"MD5 sans sel : cassable"| E["Mot de passe en clair"]
B -->|"cookies forgés"| F["Session de n'importe<br/>quel membre"]
E --> G["Accès /admin"]
F --> G
D --> G
G --> H["admin/text.php :<br/>HTML brut sur l'index"]
H --> I["Défiguration du site"]
style A fill:#c0392b,color:#fff
style C fill:#c0392b,color:#fff
style I fill:#c0392b,color:#fff
Aucune étape de cette chaîne ne demandait de compétence particulière. Un scanner automatique — et il y en avait des milliers en 2010 qui balayaient le web à la recherche exactement de ce motif — pouvait trouver l'entrée tout seul.
Et le MD5 sans sel, en 2010, c'était une rainbow table et quelques secondes.
Le post-mortem
Maintenant la partie désagréable : regarder ce que ce code dit de la façon dont je travaillais.
Ce qui n'allait pas
Je ne factorisais pas. Le chiffre est sans appel : 513 lignes strictement identiques entre plusieurs fichiers, soit 10 % du projet. La même fonction pagination() écrite trois fois. La fonction smileys() recopiée dans trois fichiers. Le tableau des grades ('Banni', 'Averti', …, 'Admin') recopié cinq fois — avec, au passage, trois orthographes différentes du même grade selon le fichier. Le bug était déjà là, latent, attendant qu'on le remarque.
Pire exemple : j'avais écrit un dossier beta/ pour faire une « version 3 » du site. Au lieu de versionner, j'ai recopié dix fichiers à l'identique. Résultat : deux bases de code qui ont divergé, dont une abandonnée en chantier.
95 % de mes fichiers ne contenaient aucune fonction. Sept fonctions au total, pour 5 284 lignes. Tout était écrit en ligne, bout à bout.
87 % de mes requêtes ne vérifiaient pas leur résultat. 31 or die sur 235 requêtes, aucun try/catch. Et 23 endroits qui affichaient carrément mysql_error() brut à l'écran — ce qui, en plus d'être laid, offrait le schéma de la base à l'attaquant. C'est exactement comme ça qu'on passe d'un scanner aveugle à une attaque chirurgicale.
Je dupliquais les requêtes. 37 requêtes imbriquées dans des boucles. listemembre.php exécutait 23 requêtes pour afficher une page, dont 7 dans une boucle. Avec 20 membres, invisible. Avec 2 000, la page ne répond plus.
Je commentais au lieu de corriger. Le plus parlant, retrouvé tel quel dans modification_profil.php :
/* If ($password != $password_db)
{ echo 'L\'ancien mot de passe est incorrect'; // Ca s'affiche dans tous les cas, problème de conditions
} */
J'avais diagnostiqué le bug dans le commentaire. J'avais écrit noir sur blanc ce qui n'allait pas. Et j'ai commenté au lieu de corriger.
Et il reste des traces de panique. echo 'RIRE'; dans un fichier. Un fichier message_.php jamais référencé nulle part. 330 lignes de code enfermées dans de gros blocs commentés. Un fichier qui affichait encore un tableau de bord de debug.
Ce qui allait bien
Je ne vais pas non plus cracher sur ce que ce gamin de 15 ans a réussi à faire.
J'avais trouvé, seul, une architecture correcte. Le site a deux points de départ — un ancien et un plus récent — parce que j'avais entrepris de refondre ma propre structure. La v1 mélangeait tout dans un fichier. La v2 découpait :
script/include.php → amorçage (connexion, authentification, gabarit)
script/init.php → <head> et navigation
script/menu_gauche.php → colonne latérale
script/close_page.php → fermeture
C'est le principe du « gabarit + fragments » que les frameworks ont standardisé par la suite. Personne ne me l'avait enseigné. Je l'ai déduit.
Je séparais déjà les traitements de l'affichage. Tous mes fichiers *_envoi.php écrivaient en base puis redirigeaient, sans jamais rien afficher. C'est le motif POST/Redirect/GET, et c'est la bonne façon de faire — pour éviter qu'un rafraîchissement reposte le même message.
Mon schéma de données tenait debout. Dix tables, des relations cohérentes, une table dédiée au suivi de lecture, une autre pour les statistiques avec déduplication par IP, page et membre. J'ai pu le reconstruire entièrement à partir des seules requêtes — ce qui prouve qu'il était cohérent.
J'avais construit des choses ambitieuses. Un système de statistiques maison, un espace d'administration, une modération avec statuts, un module de recrutement avec candidatures et votes, une page de maintenance avec exception pour l'admin. Et des easter eggs personnels — dont un message tendre codé en dur entre deux pseudos, que j'avais complètement oublié.
Et j'ai fait un travail de fourmi. Un des fichiers contient 126 str_ireplace enchaînés pour transformer les émoticônes textuels en images. C'est laid, c'est inefficace, c'est illisible. Mais c'est le travail de quelqu'un qui tenait à son projet.
Les cinq leçons
Voilà ce que j'aurais eu besoin qu'on me dise en 2010. Et ce que je répète maintenant à chaque fois que je vois du code qui touche à des données.
1. Une entrée utilisateur n'est jamais une donnée
C'est la leçon centrale, et elle tient en une phrase : tout ce qui vient de l'extérieur est potentiellement du code.
En 2010, les tutoriels m'avaient appris à échapper pour l'affichage — et je le faisais, j'utilisais htmlspecialchars 26 fois. Mais personne ne m'avait dit d'échapper pour le SQL. J'avais protégé la sortie et laissé l'entrée grande ouverte.
Aujourd'hui, on ne raisonne même plus comme ça : on utilise des requêtes paramétrées, et la question ne se pose plus. Le SQL n'est jamais une chaîne de caractères à assembler. C'est la seule vraie réponse.
2. Un cookie n'est pas une preuve d'identité
Stocker le hash du mot de passe dans un cookie, c'est considérer qu'un identifiant permanent peut servir de session. C'est faux pour trois raisons : il ne se révoque pas, il vit trop longtemps, et sa compromission est silencieuse.
Une session, ça se révoque. En changeant son mot de passe, on doit pouvoir déconnecter l'attaquant. Chez moi, c'était structurellement impossible.
3. L'ordre des vérifications est la sécurité
La faille la plus grave de ce site n'était ni cryptographique ni SQL. C'était une vérification placée après l'écriture.
On peut connaître par cœur toutes les bonnes pratiques et les appliquer dans le mauvais ordre : le résultat est un site ouvert. La règle est simple — on contrôle, puis on agit. Jamais l'inverse.
4. Ne te répète pas, sinon tu ne corriges qu'un bug sur trois
Ces 513 lignes dupliquées ne sont pas juste une question d'élégance. Le tableau des grades recopié cinq fois avec trois orthographes différentes est un bug qui attend son heure : le jour où on corrige « Veteran » en « Vétéran », on oublie trois fichiers sur cinq.
Chaque duplication est une correction future que tu vas rater.
5. La relecture ne peut pas venir de toi seul
Tous mes défauts — duplication, ordre des contrôles, debug commenté, echo 'RIRE' oublié — ont un point commun : un seul passage de relecture les aurait attrapés.
Aucun n'était difficile à voir. Ils étaient juste invisibles pour moi, parce que je venais de les écrire et que je savais ce qu'ils étaient censés faire.
C'est la leçon que je trouve la plus importante, et celle qui m'a coûté le plus cher.
Épilogue : ce que l'IA change à l'archéologie
Un mot sur la méthode, parce qu'elle n'aurait pas été possible il y a quelques années.
Faire ce travail à la main m'aurait pris des semaines. Lire 5 284 lignes d'un code que j'ai écrit à 15 ans, extraire 235 requêtes, tracer les flux de données, tester les failles une par une — c'est le genre de tâche qu'on abandonne au bout de deux heures.
Ce qui a changé, c'est de pouvoir déléguer la lecture. Faire parcourir les 85 fichiers, extraire chaque appel SQL par équilibrage de parenthèses, tracer les variables contaminées, classer chaque requête par exposition, puis vérifier empiriquement les hypothèses sur le site remonté en local.
Et honnêtement, le plus utile n'a pas été l'analyse statique — c'est le test. C'est en essayant réellement que j'ai découvert que connexion.php résistait (contrairement à ce que je croyais depuis seize ans) et que modification_profil.php était ouverte sans authentification (ce que je n'avais jamais soupçonné).
Seize ans à raconter une histoire approximative. Dix minutes à la vérifier.
Ce que je retiens
Je ne suis pas en colère contre ce code. Je le trouve même touchant.
Ce n'était pas de la négligence : c'était l'état de l'art de 2010, tel qu'on me l'avait montré. mysql_query interpolé, c'était dans tous les tutoriels français de l'époque. password_hash n'existait pas encore (PHP 5.5, 2013). PDO était absent des cours. Les guillemets n'étaient même pas échappés par htmlspecialchars par défaut.
J'ai écrit le code que mon époque m'avait appris. Et mon époque avait tort.
Ce qui me frappe, avec le recul, c'est la trajectoire. J'ai livré une v1 dans un fichier monolithique, puis j'ai jugé mon propre travail mal structuré et j'ai entrepris de le refondre. Cette capacité à se relire et à se reprendre, c'est exactement ce qui sépare un développeur qui progresse d'un développeur qui accumule des fonctionnalités.
Et l'ironie finale : ce hack m'a appris en une nuit ce qu'aucun tutoriel ne m'avait enseigné. Qu'une entrée est une arme. Qu'un cookie n'est pas une identité. Qu'un ordre d'exécution est une décision de sécurité.
C'est une leçon chère. Mais seize ans plus tard, elle est toujours là, et elle a façonné la façon dont j'écris du code aujourd'hui.
Si tu es tombé sur cet article parce que tu débutes : échappe tes entrées, ne mets jamais un secret dans un cookie, et contrôle avant d'agir. Le reste, tu l'apprendras. Ça, c'est le socle.
Et garde ton vieux code. Un jour, tu le rouvriras — et tu verras à quel point tu as avancé.
Le code source de ce site, son analyse complète et l'environnement local pour le faire tourner sont documentés dans son dossier d'origine. Aucune porte dérobée n'a été retrouvée : le snapshot est propre.