Bug Bounty Academy

Cours · classe de vulnérabilité

Upload de fichiers

Validation, stockage et service des fichiers : de la XSS SVG à la RCE.

~20 min de lecture

Objectifs

À la fin de ce cours, tu sauras :

  • identifier les quatre étapes du cycle de vie d'un fichier envoyé (réception, validation, stockage, service) et le risque propre à chacune ;
  • analyser pourquoi une validation fondée sur l'extension, le Content-Type ou les premiers octets ne suffit pas ;
  • démontrer un défaut d'upload avec une preuve inoffensive, acceptée par les programmes ;
  • évaluer la sévérité selon ce que le fichier permet réellement (servi, interprété, traité) ;
  • recommander une architecture d'upload robuste, de la validation au domaine de service isolé.

Pourquoi c'est important en bug bounty

Presque toutes les applications acceptent des fichiers : avatars, pièces jointes, justificatifs, imports CSV, documents à signer. Chaque fonctionnalité d'upload est un point d'entrée de contenu arbitraire dans le système, et les conséquences varient énormément selon l'endroit où le fichier finit.

Les rapports d'upload couvrent toute l'échelle de sévérité :

  • Faible à moyenne : fuite de métadonnées EXIF (géolocalisation d'un utilisateur), fichier HTML servi sur un domaine de stockage isolé, absence de limite de taille.
  • Moyenne à élevée : SVG ou HTML servi depuis le domaine principal, ce qui équivaut à une XSS stockée touchant tous ceux qui ouvrent le fichier.
  • Critique : fichier écrit dans un répertoire où le serveur web l'interprète comme du code, ou écrasement d'un fichier de l'application via un nom de fichier contrôlé.

Ce qui distingue un rapport payé d'un rapport fermé : la démonstration de ce que le fichier fait une fois en place. « J'ai pu envoyer un fichier .html » est informatif ; « le fichier est servi en text/html sur app.acme.test et son contenu s'exécute dans l'origine de l'application » est une vulnérabilité.

Le mécanisme

Un upload sûr exige que quatre décisions soient correctes. Une seule erreur suffit à ouvrir une faille.

ÉtapeQuestion que le serveur doit trancherErreur typique
RéceptionQuelle taille, combien de fichiers, quel débit ?Aucune limite : saturation du disque ou de la mémoire
ValidationCe fichier est-il réellement du type attendu ?Confiance dans l'extension ou le Content-Type envoyés par le client
StockageOù et sous quel nom l'écrire ?Nom fourni par l'utilisateur, répertoire servi et interprété par le serveur web
ServiceComment le renvoyer au navigateur ?Même origine que l'application, type déduit du nom, pas de Content-Disposition
UPLOAD DE FICHIERSDu formulaire d'upload à l'interprétation du fichier
💡 Mécanisme clé : Le danger ne vient pas de l'envoi lui-même mais de ce que le serveur ou le navigateur fait du fichier ensuite : l'interpréter comme du code, l'afficher dans l'origine de l'application ou le traiter avec une bibliothèque vulnérable.

Les trois informations sur lesquelles repose souvent une validation naïve sont toutes contrôlées par le client :

  • le nom de fichier (et donc l'extension) est un simple champ de la requête multipart ;
  • le Content-Type de la partie multipart est une déclaration du navigateur, modifiable librement ;
  • les premiers octets (« magic bytes ») prouvent seulement que le fichier commence comme une image, pas qu'il n'est que cela.

Seule une décision prise côté serveur, sur le contenu réel, combinée à un stockage et un service sûrs, protège réellement.

Question de réflexion : pourquoi vérifier les magic bytes ne suffit-il pas ?

Parce qu'un fichier peut être valide pour plusieurs formats à la fois (fichier « polyglotte ») : il commence par une signature d'image légitime mais contient plus loin un contenu interprétable par un autre programme. Si le serveur stocke ensuite ce fichier sous une extension qui déclenche une interprétation, ou le sert avec un type actif, la vérification initiale n'a rien empêché. La vraie protection consiste à re-encoder l'image (ce qui ne conserve que les pixels) et à contrôler l'extension finale et le type de service côté serveur.

Où chercher

Toute fonctionnalité qui accepte un fichier ou une URL de fichier mérite une cartographie :

  • avatars et images de profil, bannières, logos d'organisation ;
  • pièces jointes de tickets de support, de messagerie interne, de commentaires ;
  • imports : CSV, XLSX, XML, JSON, archives ZIP ;
  • documents : justificatifs PDF, contrats, factures téléversées ;
  • éditeurs riches qui insèrent des images (souvent un endpoint d'upload séparé, moins contrôlé) ;
  • API mobiles, parfois plus permissives que le formulaire web pour la même fonctionnalité.

Pour chaque upload, note les signaux suivants :

  • l'URL de service du fichier : même domaine que l'application (app.acme.test/uploads/...) ou domaine dédié (acme-usercontent.test, bucket de stockage) ?
  • le nom final : conservé tel quel, préfixé, ou remplacé par un identifiant aléatoire ?
  • les en-têtes de réponse au téléchargement : Content-Type, Content-Disposition, X-Content-Type-Options ;
  • le traitement : miniatures générées, aperçus de documents, extraction de texte, conversion de format.

Méthode de test

Travaille avec ton propre compte de test, dans le périmètre autorisé, et intercepte les requêtes avec ton proxy.

1. Établir la référence. Envoie un fichier légitime (une petite image PNG) et observe la requête, la réponse et l'URL finale.

POST /api/profile/avatar HTTP/1.1
Host: app.acme.test
Content-Type: multipart/form-data; boundary=----fc
 
------fc
Content-Disposition: form-data; name="file"; filename="avatar.png"
Content-Type: image/png
 
<octets de l'image>
------fc--
HTTP/1.1 201 Created
Content-Type: application/json
 
{"url":"https://app.acme.test/uploads/avatars/avatar.png"}

Premier constat : le nom d'origine est conservé et le fichier est servi depuis le domaine principal. Ce sont deux points d'attention.

2. Tester la validation, une variable à la fois. Modifie séparément l'extension, le Content-Type déclaré, puis le contenu, et note quelles combinaisons sont acceptées. L'objectif est de comprendre sur quoi le serveur décide, pas d'envoyer du code.

3. Observer le service. Pour chaque fichier accepté, télécharge-le et regarde les en-têtes. La question décisive : le navigateur va-t-il l'afficher comme une page active dans l'origine de l'application ?

HTTP/1.1 200 OK
Content-Type: image/svg+xml

Un SVG servi ainsi sur app.acme.test, sans Content-Disposition: attachment ni politique de sécurité restrictive, est rendu comme un document actif dans l'origine de l'application.

4. Examiner le nom de fichier. Vérifie si des caractères de chemin dans filename modifient l'emplacement d'écriture. Teste uniquement avec un nom de fichier inoffensif et unique dans un répertoire que tu peux observer, sans jamais viser un fichier existant de l'application.

5. Examiner le traitement. Si l'application génère des aperçus ou convertit des documents, note la bibliothèque ou le service utilisé quand il est visible (en-têtes, métadonnées du fichier produit). Les failles de ces composants relèvent souvent de vulnérabilités connues : signale la version observée et l'exposition, sans chercher à les exploiter.

Variantes et défenses courantes

  • Liste noire d'extensions : elle échoue parce qu'il existe toujours une extension oubliée, une variante de casse ou une configuration serveur qui interprète un suffixe inattendu. Seule une liste blanche courte est défendable.
  • Validation côté client (attribut accept, contrôle JavaScript) : utile pour l'ergonomie, sans aucune valeur de sécurité, puisque la requête peut être envoyée directement.
  • Contrôle du Content-Type déclaré : il reflète ce que le client affirme, pas ce que le fichier est.
  • Renommage partiel : ajouter un préfixe aléatoire tout en conservant l'extension d'origine laisse intact le risque d'interprétation.
  • Stockage sur un bucket : excellent, à condition que le bucket soit servi depuis un domaine distinct et que les objets ne soient pas listables publiquement.
  • Fichiers SVG : ce sont des documents XML pouvant contenir du script. Les accepter comme « images » sans les assainir ni les servir en pièce jointe crée une XSS stockée.
  • Archives ZIP : leur extraction côté serveur pose deux risques classiques, les chemins relatifs dans les entrées de l'archive (écriture hors du répertoire prévu) et les archives à très fort taux de compression (épuisement des ressources).
  • Formats bureautiques et XML : un parseur XML mal configuré qui résout les entités externes peut exposer des fichiers du serveur (XXE). La correction consiste à désactiver la résolution des entités externes.
Question de réflexion : un HTML stocké sur un domaine séparé est-il une vulnérabilité ?

En général, non, ou au mieux de faible sévérité. Le principe même d'un domaine de contenu utilisateur (comme acme-usercontent.test) est que le script qui s'y exécute n'a pas accès aux cookies ni au DOM de app.acme.test, grâce à la politique de même origine. Le rapport devient pertinent seulement si ce domaine partage des cookies avec l'application (cookies posés sur le domaine parent), s'il est autorisé par une politique CORS de l'application, ou s'il sert à du phishing crédible sous une marque de confiance.

Prouver l'impact sans nuire

Les programmes attendent une preuve minimale, réversible et inoffensive :

  • Fichier marqueur : un fichier texte contenant une chaîne unique (par exemple frenchcyber-test-7f3a) suffit à prouver l'écriture à un emplacement inattendu ou le service avec un mauvais type.
  • SVG ou HTML dans l'origine principale : une preuve visuelle qui affiche document.domain dans une boîte de dialogue démontre l'exécution dans la bonne origine. Rien de plus : aucune lecture de cookie, aucun envoi de données.
  • Métadonnées : télécharge ta propre photo de test contenant des coordonnées GPS fictives et montre qu'elles restent lisibles dans le fichier servi aux autres utilisateurs.
  • Nom de fichier : montre que ton fichier marqueur a été écrit dans un répertoire différent de celui prévu, sans jamais toucher un fichier existant.

Ce qui est interdit dans tous les programmes sérieux : déposer un fichier capable d'exécuter des commandes sur le serveur, écraser ou supprimer des fichiers de l'application, laisser un fichier actif accessible à d'autres utilisateurs. Si tu suspectes qu'un fichier pourrait être interprété côté serveur, arrête-toi au fichier marqueur, décris le comportement observé et laisse l'équipe confirmer. Supprime ensuite tes fichiers de test et mentionne-le dans le rapport.

Évaluer la sévérité

ScénarioVecteur CVSS 3.1Score
Métadonnées GPS des photos de profil conservées et lisibles publiquementAV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N5.3
SVG servi sur le domaine principal, ouvert par la victime via un lien (compte requis pour l'upload)AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N5.4
Pièce jointe HTML d'un ticket rendue dans le back-office par un agent de supportAV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N8.7
Fichier interprété comme du code par le serveur web, compte standard requisAV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H8.8

Le scope passe à « changé » quand le contenu envoyé s'exécute dans le navigateur d'un autre utilisateur : le composant vulnérable (le serveur d'upload) diffère du composant impacté (la session de la victime). Comme toujours, la grille du programme prime sur le score brut.

Corriger

Voici un endpoint Express vulnérable : nom d'origine conservé, extension non vérifiée, stockage dans un répertoire servi par l'application.

// VULNÉRABLE
const storage = multer.diskStorage({
  destination: "public/uploads",
  filename: (req, file, cb) => cb(null, file.originalname),
});
app.post("/api/avatar", multer({ storage }).single("file"), (req, res) => {
  res.json({ url: `/uploads/${req.file.originalname}` });
});
app.use(express.static("public"));

Version corrigée : taille limitée, type détecté sur le contenu, image re-encodée, nom aléatoire, stockage hors du répertoire statique, service avec des en-têtes stricts.

// CORRIGÉ
import { randomUUID } from "node:crypto";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";
 
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);
 
app.post("/api/avatar", upload.single("file"), async (req, res) => {
  const detected = await fileTypeFromBuffer(req.file.buffer);
  if (!detected || !ALLOWED.has(detected.mime)) return res.status(415).end();
 
  // Re-encodage : ne conserve que les pixels, supprime métadonnées et contenu parasite
  const clean = await sharp(req.file.buffer).rotate().webp({ quality: 85 }).toBuffer();
  const key = `avatars/${randomUUID()}.webp`;
  await objectStorage.put(key, clean, { contentType: "image/webp" });
 
  res.json({ url: `https://acme-usercontent.test/${key}` });
});

Côté service des fichiers, applique systématiquement :

  • un domaine dédié au contenu utilisateur, sans cookies de l'application ;
  • X-Content-Type-Options: nosniff pour empêcher le navigateur de deviner un type actif ;
  • Content-Disposition: attachment pour tout ce qui n'est pas une image affichée en ligne ;
  • une CSP restrictive sur le domaine de contenu (default-src 'none') ;
  • pour les SVG nécessaires, un assainissement strict ou une conversion en image matricielle.

Défense en profondeur : analyse antivirus asynchrone, quotas par utilisateur, journalisation des uploads, traitement des documents dans un service isolé sans accès réseau, parseurs XML configurés sans résolution d'entités externes.

Erreurs qui font rejeter un rapport

  • « L'upload accepte des fichiers .html » sans montrer où et comment le fichier est servi : informatif.
  • XSS sur le domaine de stockage isolé présentée comme une XSS sur l'application principale.
  • Absence de preuve d'exécution : un fichier accepté n'est pas un fichier interprété.
  • Fichiers de test laissés en place, ou visibles par d'autres utilisateurs.
  • Preuve agressive (fichier capable d'exécuter des commandes) : violation des règles, rapport fermé et risque d'exclusion du programme.
  • Signaler une limite de taille absente sans démontrer d'impact réel, souvent exclu des politiques.

Checklist du hunter

  • Où le fichier est-il servi : même origine ou domaine isolé ?
  • Le nom final est-il contrôlé par l'utilisateur ?
  • Sur quoi repose la validation : extension, type déclaré, contenu ?
  • Quels en-têtes accompagnent le téléchargement (Content-Type, Content-Disposition, nosniff, CSP) ?
  • Les SVG, HTML, XML et archives sont-ils acceptés ? Comment sont-ils traités ?
  • Les métadonnées des images sont-elles supprimées ?
  • Une preuve marqueur ou visuelle inoffensive suffit-elle à démontrer l'impact ?
  • As-tu supprimé tes fichiers de test et décrit précisément ce que tu as laissé ou retiré ?

Pour aller plus loin