Les 5 failles de sécurité que le PHP legacy finit toujours par collectionner

Injection SQL, dépendances EOL, XSS, erreurs avalées : les 5 failles que je retrouve dans presque chaque audit de PHP legacy, et pourquoi elles s'accumulent.

Vous avez déjà lu l'article. "Les 10 failles PHP à éviter absolument." Un snippet vulnérable, le même snippet corrigé juste en dessous, et la morale en conclusion : adoptez un framework moderne. C'est très bien quand on apprend. Ça ne ressemble en rien à ce qu'on trouve en ouvrant un vrai legacy.

Parce qu'un code qui tourne en production depuis dix ans, ce n'est pas un manuel d'exercices. C'est une sédimentation. Des choix faits vite un vendredi soir, des dépendances figées "parce que ça marche", des bouts de code recopiés sans qu'on les relise jamais.

Ce que je vais décrire, ce sont les cinq familles de failles que je retrouve, audit après audit, en ouvrant ce genre de code. Toutes adossées à un cas réel : un projet PHP/Laravel mature, autour de 44 000 lignes, en production depuis des années. Anonymisé, mais les chiffres sont les vrais.

Et surtout, la vraie histoire : pourquoi un legacy non maintenu finit toujours par les collectionner toutes, et pourquoi c'est leur combinaison, pas chaque faille prise à part, qui fait le danger.

Faille 1 : l'injection SQL cachée dans un filtre maison

La première, c'est la plus directe. Une requête SQL construite à la main, avec une variable collée dans la chaîne au lieu d'être passée en paramètre lié.

Sur ce projet, le cas était presque élégant dans sa dangerosité. Le mécanisme qui filtre les données par compte, le garde-barrière censé garantir que le compte A ne voit jamais les données du compte B, était écrit comme ça :

$query->whereRaw('('.$table.'.account_id = '.$accountId.')');

Une concaténation directe. Pas de paramètre lié, pas d'échappement. Et ce filtre s'appliquait à tous les modèles de l'application. Un seul point faible, répliqué partout : si la valeur concaténée devient manipulable, c'est la porte ouverte aux données des autres comptes. Dans une appli multi-tenant, c'est la pire fuite possible.

Pourquoi un legacy accumule ce genre de chose ? Parce que ce code a été écrit avant que la requête paramétrée soit un réflexe partout. Et les ORM laissent toujours une porte de sortie, un whereRaw, un DB::raw, pour les cas où le framework ne suffit pas. Cette porte contourne aussi les protections que le framework offre par défaut.

Le signal qui doit vous alerter : un grep sur whereRaw, DB::raw, db_query(, ou n'importe quelle variable de requête ($_GET, $_POST) collée dans du SQL. Ce que vous trouvez n'est pas forcément exploitable, mais ça mérite une lecture ligne à ligne. C'est le chemin le plus court vers vos données.

Faille 2 : les dépendances en fin de vie, CVE en libre accès

Celle-là ne se cache même pas. Elle est écrite noir sur blanc dans votre composer.lock.

Sur ce projet, une seule commande a sorti le chiffre : 72 vulnérabilités connues, réparties sur 19 paquets. Pas des failles théoriques : des CVE publiques, documentées, avec parfois un mode d'emploi en ligne.

Le palmarès donnait le ton. Un générateur de PDF en version 0.8.4 traînait une exécution de code à distance (CVE-2022-28368). Un client HTTP en retard d'une version majeure laissait fuiter des informations sensibles dans les en-têtes. Et au-dessus de tout ça, le langage lui-même : PHP 7.3, sans le moindre correctif de sécurité depuis novembre 2022. Quand le runtime est en fin de vie, plus aucun patch n'arrive, quoi qu'il se passe.

Et pourquoi on en arrive là ? Le composer.lock qu'on n'ose plus toucher. On le gèle "parce que ça marche", et chaque mois de gel, de nouvelles CVE sont publiées sur les versions exactes que vous faites tourner. Le code ne bouge pas ; le risque, lui, monte. Et on n'ose plus le dégeler, parce qu'une mise à jour majeure sans tests pour la rattraper, c'est un saut dans le vide. On y revient à la dernière faille.

Le signal est le plus simple de toute la liste : composer audit. Une commande, et vous savez. Le coût, en revanche, est particulier : une CVE connue, ce n'est pas une faille à découvrir, c'est une faille avec un manuel d'exploitation déjà publié.

Trier 72 vulnérabilités sans paniquer, savoir lesquelles sont vraiment exploitables dans votre contexte, je l'ai détaillé dans un article dédié au tri des CVE. Ici, ce qui compte, c'est juste qu'elles sont là, et qu'elles s'empilent toutes seules.

Faille 3 : la sortie non échappée qui attend son XSS

Changement de registre : on passe du back au front, là où les données s'affichent.

Le moteur de templates échappe les variables par défaut. C'est le comportement sain : tout ce qui part vers le navigateur est neutralisé, impossible d'y glisser une balise active. Sauf qu'il existe une syntaxe pour désactiver cet échappement, {!! !!}, pour les cas où on a vraiment besoin d'afficher du HTML brut.

Sur ce projet, j'ai compté 782 occurrences de cette syntaxe dans les templates. 782. À ce stade, ce n'est plus un cas particulier, c'est une habitude.

Chaque endroit où {!! !!} affiche une donnée venue d'une saisie utilisateur est un vecteur de XSS potentiel. Pas les 782, beaucoup affichent du contenu de confiance. Mais il suffit d'une poignée mal placées pour ouvrir la porte au vol de session, au défacement, ou à de l'hameçonnage interne.

D'où viennent ces 782 ? De l'effet boule de neige du copier-coller. Le premier {!! !!} est posé pour un cas légitime : afficher du HTML stocké, par exemple. Puis il est recopié ailleurs par mimétisme, sans qu'on se redemande à chaque fois si la donnée est de confiance. Et sur ce code, plus personne n'est repassé derrière. On arrive à 782.

Le signal : un grep sur {!! !!}, ou sur les echo de variables sans htmlspecialchars. Le tri demande de l'oeil (quelle donnée vient de l'utilisateur ?), mais le repérage, lui, est mécanique.

Faille 4 : les erreurs qu'on avale sans bruit

Les trois premières sont des classiques. Celle-ci est plus sournoise, parce qu'elle ne ressemble pas à une faille de sécurité au premier coup d'oeil.

Le pattern : du code qui attrape une exception, et n'en fait rien. Le catch est là, il rassure à la lecture, mais il avale l'erreur en silence.

Sur ce projet, le cas était presque caricatural. Dans le flux de paiement, un bloc attrapait \Exeption, avec une faute de frappe, \Exeption au lieu de \Exception. Résultat : ce catch n'attrape pas la classe qu'il croit attraper. Selon le contexte, soit il laisse filer l'erreur, soit il masque autre chose. Dans les deux cas, des échecs de paiement passaient inaperçus depuis des années.

En quoi c'est une faille de sécurité ? Parce que la sécurité repose entièrement sur la détection. Ce que vous n'attrapez pas, vous ne le voyez pas : un paiement qui échoue, mais aussi une tentative d'exploitation, un comportement anormal, une exception levée par quelqu'un qui sonde votre appli. Une appli qui avale ses erreurs ne peut pas non plus vous prévenir qu'on est en train de la tester. Le silence est l'allié de l'attaquant.

Le signal est le plus difficile à capter de toute la liste. Aucun scanner ne marque ça "critique" spontanément : syntaxiquement, le code est valide. Il faut de l'analyse statique poussée, et un oeil derrière. Ce \Exeption, c'est justement PHPStan monté en niveau qui l'a fait remonter, un outil basique serait passé à côté. Le coût : on découvre le problème le jour où le client appelle.

Faille 5 : aucun filet, donc rien ne se corrige

La dernière n'est pas une faille dans le code. C'est l'absence de quelque chose. Et c'est elle qui rend les quatre autres permanentes.

Sur ce projet : 11 fichiers de test pour 132 fichiers source. Un ratio de 0,08. Et zéro test sur le flux de paiement, la partie la plus critique de l'appli, celle qui touche à l'argent.

Voilà le mécanisme, et c'est le coeur de l'histoire. Sans tests, corriger une faille devient un pari. Vous voulez patcher l'injection SQL, mettre à jour le générateur de PDF, réparer le \Exeption ? Chaque correctif risque de casser autre chose en production, et vous n'avez aucun moyen de le vérifier avant de déployer. Alors on reporte. On note "à faire plus tard". Et plus tard n'arrive jamais.

C'est la faille qui empêche de corriger les failles.

Le cercle est vicieux : moins on a de tests, moins on ose toucher au code ; moins on touche au code, plus la dette s'épaissit, et plus écrire ces tests un jour coûtera cher.

Et c'est là que tout se noue. La sécurité d'un legacy, ce n'est pas une liste de bugs indépendants qu'on coche un par un. C'est un système : le runtime en fin de vie ne reçoit plus de patch, les dépendances accumulent les CVE, l'absence de tests interdit de bouger, donc les correctifs sont reportés, donc la dette de sécurité compose mois après mois. Les cinq failles se tiennent par la main.

C'est aussi pour ça que le coût ne saute pas aux yeux : il s'accumule en silence, exactement comme la dette technique qu'on finit par chiffrer. Tant que rien n'explose, tout va bien. Le jour où ça explose, c'est rarement au bon moment.

Bref

Un projet n'a pas forcément les cinq. J'ai vu des legacy à la structure saine qui n'en cumulaient que deux ou trois. Mais un code laissé sans maintenance finit par les collectionner, parce que c'est le même abandon qui les nourrit toutes.

Et le vrai message n'est pas "vous allez vous faire pirater demain". Ce serait du marchand de peur, et ce serait faux. Le vrai message est plus inconfortable : vous ne savez pas lesquelles de ces failles votre code a, ni lesquelles traiter en premier. Le médecin qui lit une radio ne hurle pas, il vous dit ce qu'il voit, et dans quel ordre s'en occuper.

C'est exactement la question que pose un audit.