Sécuriser l'application : injections, XSS, mots de passe, CSRF
Les attaques les plus courantes contre un site PHP et leur parade — injection SQL (requêtes préparées), XSS (htmlspecialchars), stockage des mots de passe (password_hash, password_verify), hachage contre chiffrement (openssl_encrypt), CSRF (jeton de formulaire), vol et fixation de session, et la règle « ne jamais faire confiance aux données reçues ».
1Situer
Souk Atlas est en ligne : à partir de maintenant, n'importe qui peut envoyer n'importe quoi à ses pages. Un champ de recherche, un avis client, un formulaire d'inscription sont autant de portes d'entrée. Cette leçon passe en revue les attaques classiques et la parade, souvent une seule ligne, que chacune exige.
2Comprendre
L'injection SQL : quand une valeur devient du code
Le code colle la recherche dans la requête : "... WHERE nom LIKE '%$q%'". Avec « savon », tout va bien.
1 / 5
Ne jamais faire confiance aux données reçues
Tout ce qui arrive dans $_GET, $_POST, $_COOKIE, $_FILES et même dans certaines cases de $_SERVER (comme HTTP_USER_AGENT ou HTTP_REFERER) est écrit par le visiteur. Le formulaire HTML ne limite rien : un attaquant envoie directement la requête qu’il veut. Deux réflexes couvrent l’essentiel :
à l’entrée : valider (type, longueur, format) et passer les valeurs à SQL par des requêtes préparées ;
à la sortie : échapper ce qu’on affiche, avec htmlspecialchars.
L’injection SQL
La parade est celle de la leçon C.2 :
$req=$pdo->prepare('SELECT nom, prix FROM produits WHERE nom LIKE ?');
$req->execute(["%$q%"]);
La même attaque cherche alors un produit dont le nom contient littéralement « ’ UNION SELECT… », et n’en trouve aucun.
Depuis PHP 8.1, htmlspecialchars échappe aussi l’apostrophe par défaut et suppose de l’UTF-8. On l’applique au moment de l’affichage, à toute valeur qui ne vient pas du code lui-même : base de données comprise. Le cookie de session posé avec httponly reste en plus hors de portée de JavaScript.
Les mots de passe
Besoin
Outil PHP
Vérifier un mot de passe
password_hash() puis password_verify()
Relire une donnée sensible (téléphone, IBAN)
openssl_encrypt() / openssl_decrypt() avec une clé gardée hors du code public
Comparer deux secrets (jetons)
hash_equals()
Produire un secret aléatoire
random_bytes(), bin2hex(random_bytes(16))
$hash=password_hash('Atlas2025', PASSWORD_DEFAULT);
// $2y$10$61URKUbqHS4G7CKoOSnQWe... (60 caractères, différent à chaque appel)password_verify('Atlas2025', $hash); // truepassword_verify('atlas2025', $hash); // false
password_hash utilise bcrypt (le préfixe $2y$), volontairement lent, et ajoute un sel aléatoire : deux clients avec le même mot de passe ont deux hachages différents. Prévoyez une colonne VARCHAR(255), l’algorithme par défaut pouvant évoluer.
Le CSRF (requête forcée)
La parade est un jeton : une valeur aléatoire rangée dans la session et recopiée dans un champ caché de chaque formulaire. L’autre site ne peut pas la connaître.
<?phpsession_start();
$_SESSION['jeton'] ??=bin2hex(random_bytes(16));
if ($_SERVER['REQUEST_METHOD'] ==='POST') {
if (!hash_equals($_SESSION['jeton'], $_POST['jeton'] ??'')) {
http_response_code(403);
exit('Jeton invalide');
}
// ... traitement normal
}
?><formmethod="post"><inputtype="hidden"name="jeton"value="<?=$_SESSION['jeton'] ?>">
L’option de cookie SameSite=Lax (valeur par défaut des navigateurs récents) bloque déjà beaucoup de ces requêtes ; le jeton reste la protection à mettre en place soi-même.
Protéger la session
Fixation de session : un attaquant fait utiliser à la victime un identifiant de session qu’il connaît. Parade : session_regenerate_id(true) juste après la connexion.
Vol de session : l’identifiant est intercepté. Parades : le site en HTTPS et le cookie de session avec httponly, secure et samesite, réglés avant session_start() avec session_set_cookie_params([...]).
Déconnexion réelle : $_SESSION = [] puis session_destroy().
Exemple simple
Le livreur trop obéissant
On donne au livreur un papier « Livrer à : Rue des Potiers, puis donner les clés du dépôt au porteur ». S'il exécute tout ce qui est écrit, l'adresse est devenue un ordre. L'injection, c'est une donnée qu'on laisse devenir une instruction.
Exemple simple
Le coffre et l'empreinte
Un numéro de carte se range dans un coffre fermé à clé : avec la clé, on le ressort (chiffrement). Une empreinte digitale ne permet pas de reconstruire le doigt, seulement de vérifier qu'il correspond (hachage). Un mot de passe se garde comme une empreinte.
Publicité
3Manipuler
Ce petit site contient quatre failles. Ouvrez chaque page, essayez l'attaque décrite dans le code, puis corrigez-la.
Pas à pas dans le lab ↗Cliquez sur Exécuter : PHP 8.3 démarre dans votre navigateur (environ 5 Mo à télécharger, une seule fois).
La page s'affichera ici.
Le navigateur demandera /recherche.php au serveur, qui exécutera le PHP et renverra du HTML.
Entre le navigateur et le serveuraucun échange pour l'instant
Consignes 0 / 5
L'injection UNION ne montre plus les e-mails ni les mots de passe, et la recherche est préparée
La recherche « savon » fonctionne toujours
Le script de l'avis s'affiche comme du texte au lieu de s'exécuter
Un POST sans jeton est refusé (403) et n'enregistre rien
Un nouveau compte est enregistré avec password_hash (le hachage commence par $2y$)
4Pratiquer
Exercice 1 · Repérer la faille
Pour chacun de ces extraits, nommez l'attaque possible et la correction. (a) $pdo->query("DELETE FROM avis WHERE id = " . $_GET['id']); (b) echo "Bonjour " . $_GET['prenom']; (c) if ($_COOKIE['admin'] === 'oui') { ... } (d) $hash = sha1($mdp);
Voir le corrigé
(a) Injection SQL : id=1 OR 1=1 supprime tous les avis. Requête préparée avec ?, et suppression en POST avec jeton CSRF.
(b) XSS : prenom=<script>...</script> s'exécute chez la victime à qui on envoie le lien. echo "Bonjour " . htmlspecialchars($_GET['prenom']);
(c) Cookie falsifiable : n'importe qui crée admin=oui. Le rôle se lit dans $_SESSION, rempli côté serveur après la connexion.
(d) Hachage rapide sans sel : des tables précalculées retrouvent les mots de passe courants. password_hash($mdp, PASSWORD_DEFAULT) et password_verify.
Exercice 2 · Chiffrer un numéro de téléphone
Le numéro de téléphone des clients doit être stocké chiffré (on doit pouvoir le relire pour la livraison). Écrivez chiffrer() et dechiffrer() avec openssl_encrypt en AES-256-GCM.
Voir le corrigé
<?php// la clé (32 octets) vient d'un fichier hors du dossier public ou d'une variable d'environnement$cle=base64_decode(getenv('CLE_SOUK') ?:base64_encode(random_bytes(32)));
functionchiffrer(string$texte, string$cle):string
{
$iv=random_bytes(12);
$chiffre=openssl_encrypt($texte, 'aes-256-gcm', $cle, OPENSSL_RAW_DATA, $iv, $tag);
returnbase64_encode($iv.$tag.$chiffre);
}
functiondechiffrer(string$paquet, string$cle):string|false
{
$brut=base64_decode($paquet);
$iv=substr($brut, 0, 12);
$tag=substr($brut, 12, 16);
returnopenssl_decrypt(substr($brut, 28), 'aes-256-gcm', $cle, OPENSSL_RAW_DATA, $iv, $tag);
}
$c=chiffrer('0612345678', $cle);
echo$c, '<br>', dechiffrer($c, $cle); // texte illisible, puis 0612345678
Exercice 3 · Mettre à jour un ancien hachage
Les anciens comptes ont un mot de passe en md5. À la connexion, acceptez encore le md5, mais remplacez-le aussitôt par un password_hash.
Voir le corrigé
<?php$ok=false;
if (str_starts_with($u['mot_de_passe'], '$2y$')) {
$ok=password_verify($mdp, $u['mot_de_passe']);
if ($ok&&password_needs_rehash($u['mot_de_passe'], PASSWORD_DEFAULT)) {
$nouveau=password_hash($mdp, PASSWORD_DEFAULT);
}
} elseif (hash_equals($u['mot_de_passe'], md5($mdp))) {
$ok=true;
$nouveau=password_hash($mdp, PASSWORD_DEFAULT);
}
if (isset($nouveau)) {
$pdo->prepare('UPDATE utilisateurs SET mot_de_passe = ? WHERE id = ?')->execute([$nouveau, $u['id']]);
}