En bref : lorsqu'un navigateur téléverse un fichier, il envoie une requête POST au format multipart/form-data, dans laquelle les champs du formulaire et le contenu des fichiers se suivent sous forme de parties distinctes. PHP décompose lui-même cette requête, écrit chaque partie de fichier dans un fichier temporaire du upload_tmp_dir, consigne les informations dans $_FILES et ne démarre votre script qu'ensuite. Le script déplace le fichier avec move_uploaded_file() ; tout ce qui n'est pas déplacé est supprimé par PHP à la fin de la requête.
Le trajet d'un fichier, du navigateur jusqu'au script
Pour les décideurs, un point est essentiel : un upload n'est pas une opération qui concerne la seule application. Une grande partie du travail s'effectue dans l'interpréteur PHP, avant même que le code du CMS ou de la boutique n'intervienne. C'est pourquoi les failles de cette partie de PHP touchent chaque site web, qu'il dispose ou non d'un formulaire d'upload.
Le déroulement en un coup d'œil :
- Le formulaire avec
method="POST"etenctype="multipart/form-data"est envoyé. - Le serveur web accepte la requête et la transmet à PHP, selon le mode de fonctionnement via PHP-FPM, mod_php ou une autre interface.
- PHP vérifie les limites telles que
post_max_sizeet découpe le contenu à l'aide d'une chaîne de séparation, appelée boundary. - Les champs normaux se retrouvent dans
$_POST; les parties de fichiers sont écrites dans des fichiers temporaires et décrites dans$_FILES. - Ce n'est qu'à ce moment que le script démarre, contrôle le fichier et le déplace vers sa destination.
- À la fin de la requête, PHP supprime tous les fichiers temporaires qui n'ont pas été déplacés.
multipart/form-data : la structure d'une requête d'upload
Une requête d'upload est du texte brut contenant des données de fichier intégrées. L'en-tête Content-Type indique la boundary, et dans le contenu, cette chaîne sépare les différentes parties. Chaque partie possède ses propres en-têtes avec le nom du champ et, le cas échéant, le nom du fichier. Un exemple inoffensif, fortement abrégé :
POST /bewerbung HTTP/1.1
Content-Type: multipart/form-data; boundary=----trenner42
------trenner42
Content-Disposition: form-data; name="vorname"
Erika
------trenner42
Content-Disposition: form-data; name="lebenslauf"; filename="lebenslauf.pdf"
Content-Type: application/pdf
%PDF-1.7 ... Dateiinhalt ...
------trenner42--
Toutes les informations de ce texte proviennent de l'expéditeur, y compris le nom du fichier et le Content-Type de la partie. PHP les reprend dans $_FILES, mais n'en vérifie pas la vraisemblance.
Le tableau $_FILES
Pour un champ nommé lebenslauf, $_FILES['lebenslauf'] contient, selon le manuel PHP, les clés suivantes :
| Clé | Contenu | Digne de confiance ? |
|---|---|---|
name |
nom d'origine du fichier chez l'expéditeur | non |
full_path |
chemin communiqué par le navigateur, depuis PHP 8.1.0 | non |
type |
type MIME selon le navigateur, non vérifié par PHP | non |
size |
taille en octets | déterminée par PHP |
tmp_name |
chemin du fichier temporaire sur le serveur | attribué par PHP |
error |
code d'erreur du traitement de l'upload | défini par PHP |
Pour les champs dont le nom contient des crochets, par exemple bilder[], chaque clé contient un tableau avec une entrée par fichier. L'application parcourt alors, par exemple, $_FILES['bilder']['error'] et accède à tmp_name et name par le même index.
Un formulaire peut en outre envoyer un champ caché MAX_FILE_SIZE avant le champ de fichier. Selon le manuel, il ne s'agit que d'une indication pour le navigateur, facile à contourner. Vous ne pouvez compter que sur les limites imposées par le serveur : dans PHP, dans le serveur web et dans l'application elle-même.
Limites : les directives php.ini pour les uploads
Ces directives pilotent le traitement des uploads. Les valeurs par défaut proviennent du manuel PHP et s'appliquent lorsqu'aucun php.ini ne les remplace.
| Directive | Valeur par défaut | Modifiable dans |
|---|---|---|
file_uploads |
1 |
configuration système uniquement |
upload_tmp_dir |
vide (répertoire temporaire du système) | configuration système uniquement |
upload_max_filesize |
2M |
php.ini, pool, configuration de répertoire |
post_max_size |
8M |
php.ini, pool, configuration de répertoire |
max_file_uploads |
20 |
php.ini, pool, configuration de répertoire |
enable_post_data_reading |
1 |
php.ini, pool, configuration de répertoire |
post_max_size doit être supérieur à upload_max_filesize, et memory_limit devrait en règle générale, selon le manuel, être supérieur à post_max_size. Si upload_tmp_dir n'est pas accessible en écriture, PHP se rabat sur le répertoire temporaire du système. Avec open_basedir, le répertoire temporaire doit être autorisé, sinon les uploads échouent. Les valeurs pratiques et leurs conséquences sont décrites dans l'article Limiter judicieusement post_max_size et upload_max_filesize.
Évaluer correctement les codes d'erreur
Le champ error indique ce qui s'est passé pendant le traitement. Les constantes vont de UPLOAD_ERR_OK (0) à UPLOAD_ERR_EXTENSION (8), en passant par UPLOAD_ERR_INI_SIZE (1), UPLOAD_ERR_FORM_SIZE (2), UPLOAD_ERR_PARTIAL (3), UPLOAD_ERR_NO_FILE (4), UPLOAD_ERR_NO_TMP_DIR (6) et UPLOAD_ERR_CANT_WRITE (7).
<?php
declare(strict_types=1);
$datei = $_FILES['lebenslauf'] ?? null;
if ($datei === null) {
exit('Keine Upload-Daten, eventuell wurde post_max_size überschritten.'); // Aucune donnée d'upload, post_max_size a peut-être été dépassé.
}
$meldung = match ($datei['error']) {
UPLOAD_ERR_OK => null,
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Datei zu groß.',
UPLOAD_ERR_NO_FILE => 'Keine Datei gewählt.',
default => 'Upload fehlgeschlagen.',
};
if ($meldung === null && is_uploaded_file($datei['tmp_name'])) {
$ziel = '/srv/app/uploads/' . bin2hex(random_bytes(16)) . '.pdf';
move_uploaded_file($datei['tmp_name'], $ziel);
}
L'exemple ne montre que la mécanique. Le contrôle du type de fichier, le stockage en dehors de la racine web et le blocage de l'exécution de scripts sont décrits dans le guide Mettre en œuvre les uploads de fichiers PHP en toute sécurité.
Uploads et PHP2Shell : ce que signifient les mesures transitoires
Les failles PHP2Shell CVE-2026-109618 et CVE-2026-109619 concernent le traitement des requêtes dans l'interpréteur PHP. C'est pourquoi php.net cite, parmi les mesures transitoires, notamment post_max_size = 8M (sans uploads) ou 32M (avec uploads), upload_max_filesize en dessous, ainsi que session.upload_progress.enabled=0. Ces valeurs relèvent du php.ini, du pool FPM ou de la configuration du serveur ; tous les workers doivent ensuite redémarrer.
Des auteurs spécialisés proposent en outre de bloquer au niveau du proxy ou du WAF les requêtes multipart/form-data en dehors des véritables chemins d'upload. Il s'agit d'une mesure non officielle, avec le risque de casser des formulaires ; l'article Filtrer les requêtes d'upload en périphérie décrit les compromis. Aucune de ces mesures ne remplace la mise à jour vers PHP 8.5.12, 8.4.27, 8.3.36 ou 8.2.35.
Comment PHP SOS aide-t-il concrètement ?
- Nous déterminons lesquels de vos sites ont réellement besoin d'uploads, et sur quels chemins.
- Nous définissons les limites et les mesures transitoires pool par pool, redémarrons tous les workers et testons les formulaires.
- Nous recherchons des fichiers exécutables dans les répertoires d'upload et bloquons l'exécution de scripts dans le cadre du durcissement de serveur.
- Nous installons la mise à jour de PHP et documentons le niveau de correctifs de chaque site.
Questions fréquentes
Où PHP enregistre-t-il d'abord les fichiers téléversés ?
Dans un répertoire temporaire. Par défaut, il s'agit du répertoire temporaire du système ; upload_tmp_dir permet de le modifier. Si le script ne déplace pas le fichier, PHP le supprime à la fin de la requête.
Puis-je me fier au champ type de $_FILES ?
Non. Le type MIME est communiqué par le navigateur, et selon le manuel PHP ne le vérifie pas. name et full_path proviennent eux aussi de l'expéditeur. Le type réel du fichier se détermine côté serveur, à partir du contenu.
Pourquoi $_FILES est-il vide alors qu'un fichier a été envoyé ?
Le plus souvent, la requête entière dépassait post_max_size. PHP vide alors entièrement $_POST et $_FILES, sans code d'erreur dans le tableau. D'autres causes possibles : un enctype manquant dans le formulaire ou file_uploads désactivé.
Interdire les uploads dans l'application protège-t-il contre PHP2Shell ?
Non. Le traitement des données de la requête a lieu dans PHP, avant l'exécution du code de l'application. Ce qui est efficace, c'est la mise à jour de PHP et le redémarrage de tous les workers ; des limites plus strictes ne sont qu'une mesure transitoire.
Quelle est la différence entre upload_max_filesize et post_max_size ?
upload_max_filesize limite chaque fichier pris isolément, post_max_size l'ensemble du contenu de la requête, fichiers et champs compris. post_max_size doit donc être supérieur à upload_max_filesize.