Si vous avez déjà vu passer un fichier .php dans votre répertoire uploads alors que vous n’y avez jamais touché, vous avez probablement frôlé (ou subi) une vulnérabilité d’upload.

Sur WordPress 6.9.4 (avril 2026) et PHP 8.1+, la plupart des “gros” incidents que je dépanne autour de l’upload viennent rarement du cœur de WordPress. Ils viennent presque toujours d’un plugin, d’un thème, d’un snippet “pratique” copié-collé, ou d’une intégration AJAX/REST faite trop vite.

La menace

Une vulnérabilité d’upload permet à un attaquant de téléverser un fichier que vous n’aviez pas l’intention d’accepter. Le scénario le plus grave, c’est l’upload d’un fichier exécutable (PHP, PHTML, PHAR, etc.) ou d’un fichier “polyglotte” (une image qui contient aussi du code) puis son exécution sur le serveur. À partir de là, l’attaquant peut :

  • déployer une webshell et exécuter des commandes,
  • voler des secrets (clés API, mots de passe en clair dans des fichiers, tokens),
  • modifier le contenu (SEO spam), injecter des redirections,
  • créer des comptes admin, installer des plugins,
  • persister via des tâches cron, mu-plugins ou fichiers cachés.

Dans la pratique, l’upload vulnérable est l’un des vecteurs les plus fréquents sur les sites WordPress “réels” que je récupère (sites de clients, multisites, sites avec builders). La raison est simple : beaucoup de code d’upload “maison” fait confiance à $_FILES, au nom de fichier, ou à un accept="image/*" côté navigateur. Or, ces signaux sont triviales à falsifier.

Le risque en langage simple : si votre code accepte un fichier sans vérifier ce que c’est réellement et où il sera stocké, vous donnez potentiellement à un inconnu la possibilité de déposer un programme sur votre serveur.

WordPress met déjà des garde-fous (vérification de type, renommage, dossiers datés, etc.), mais vous pouvez les court-circuiter en une ligne si vous contournez les API officielles.

Résumé rapide

  • Ne faites jamais confiance à l’extension, au nom, au champ type de $_FILES, ni à l’attribut HTML accept.
  • Utilisez les API WordPress : wp_handle_upload(), wp_check_filetype_and_ext(), et wp_insert_attachment() si vous créez une pièce jointe.
  • Ajoutez une validation “contenu réel” : pour les images, vérifiez via wp_get_image_editor() ou exif_imagetype() (si disponible) et rejetez les polyglottes douteux.
  • Stockez hors exécution : bloquez l’exécution PHP dans wp-content/uploads (Nginx/Apache), et idéalement servez les fichiers via le webserver sans interpréteur.
  • Protégez l’entrée : capabilities + nonce + limites de taille + quotas + rate limiting.
  • Surveillez : logs, fichiers récents dans uploads, et alertes sur extensions inattendues.

Code vulnérable — ce qu’il ne faut PAS faire

Voici un exemple réaliste : un formulaire “upload d’avatar” ajouté dans un thème, avec un handler sur admin_post_nopriv_*. Je vois souvent ce genre de snippet sur des sites Elementor/Divi où quelqu’un veut un profil utilisateur custom.

<?php
// ⚠️ Exemple volontairement vulnérable : ne pas utiliser.
add_action('admin_post_nopriv_upload_avatar', 'mon_upload_avatar_vulnerable');
add_action('admin_post_upload_avatar', 'mon_upload_avatar_vulnerable');

function mon_upload_avatar_vulnerable() {
	// ⚠️ Pas de nonce : CSRF possible (un visiteur connecté peut être forcé à uploader)
	// ⚠️ Pas de vérification de droits : tout le monde peut tenter un upload

	if ( empty($_FILES['avatar']) ) {
		wp_die('Aucun fichier.');
	}

	$f = $_FILES['avatar'];

	// ⚠️ Confiance dans le nom et l'extension : facilement falsifiable
	$ext = pathinfo($f['name'], PATHINFO_EXTENSION);
	if ( ! in_array(strtolower($ext), array('jpg','jpeg','png','gif'), true) ) {
		wp_die('Extension refusée.');
	}

	// ⚠️ Dépôt direct dans uploads, sans passer par wp_handle_upload()
	$uploads = wp_upload_dir();
	$dest = trailingslashit($uploads['basedir']) . 'avatars/' . basename($f['name']);

	wp_mkdir_p( dirname($dest) );

	// ⚠️ move_uploaded_file sans contrôle MIME réel, sans renommage sécurisé
	if ( ! move_uploaded_file($f['tmp_name'], $dest) ) {
		wp_die('Échec upload.');
	}

	// ⚠️ On renvoie une URL basée sur le nom original
	$url = trailingslashit($uploads['baseurl']) . 'avatars/' . basename($f['name']);
	wp_redirect( $url );
	exit;
}

Comment l’attaque fonctionne (sans “recette” d’exploitation)

Le problème vient de trois hypothèses fausses :

  • “L’extension dit la vérité” : un fichier peut s’appeler photo.jpg tout en contenant du PHP, ou un contenu hybride.
  • “Le navigateur respecte accept=image/*” : un attaquant n’est pas obligé d’utiliser votre formulaire.
  • “uploads est un répertoire sûr” : sur beaucoup d’hébergements, uploads est accessible publiquement, et parfois des mauvaises configs permettent l’exécution de scripts (ou des contournements via .htaccess mal géré, handlers, etc.).

Edge case que j’ai déjà vu : un site derrière un plugin de cache qui sert des fichiers “tels quels” et un serveur qui exécute encore des .phtml. Résultat : un fichier “image” finit exécutable via une extension alternative.

Autre point : ce handler accepte nopriv. Donc même sans compte, on peut tenter des uploads. Ajoutez à ça l’absence de nonce : un utilisateur connecté peut se faire piéger via CSRF.

Code sécurisé — la bonne implémentation

Objectif : même fonctionnalité (upload d’un avatar), mais avec une validation complète et une surface d’attaque réduite. Je pars sur un endpoint admin_post (classique), mais les mêmes principes s’appliquent à REST, AJAX ou un formulaire Elementor/Divi.

Principes retenus

  • Autorisation : capability + restriction “propriétaire” (l’utilisateur ne modifie que son avatar).
  • Anti-CSRF : nonce obligatoire.
  • Upload via WordPress : wp_handle_upload() (qui s’appuie sur _wp_handle_upload() et la logique core).
  • Validation de type : wp_check_filetype_and_ext() + liste blanche stricte.
  • Validation de contenu image : tentative d’ouverture via l’éditeur d’images (GD/Imagick) et re-encodage (mesure très efficace contre les polyglottes).
  • Stockage : sous-dossier dédié dans uploads, mais avec blocage serveur de l’exécution (section suivante).
  • Journalisation : log minimal en cas de rejet (sans stocker de données sensibles).

Code complet (plugin ou mu-plugin recommandé)

Piège courant : coller ce code dans functions.php d’un thème qui sera remplacé. Pour ce type de sécurité, je le mets presque toujours dans un mu-plugin (chargé tôt, non désactivable par l’admin compromis) ou dans un plugin dédié.

<?php
/**
 * Plugin Name: Upload avatar sécurisé
 * Description: Exemple d'upload sécurisé (WP 6.9.4+, PHP 8.1+).
 */

defined('ABSPATH') || exit;

add_action('admin_post_upload_avatar_secure', 'mon_upload_avatar_secure');

function mon_upload_avatar_secure() {
	// 1) Vérifier la session et le nonce (anti-CSRF)
	if ( ! is_user_logged_in() ) {
		wp_die('Accès refusé.', 403);
	}

	// Le champ nonce doit être présent dans le formulaire : wp_nonce_field('upload_avatar_secure', 'upload_avatar_nonce')
	$nonce = isset($_POST['upload_avatar_nonce']) ? sanitize_text_field(wp_unslash($_POST['upload_avatar_nonce'])) : '';
	if ( ! wp_verify_nonce($nonce, 'upload_avatar_secure') ) {
		wp_die('Nonce invalide.', 403);
	}

	$user_id = get_current_user_id();

	// 2) Vérifier les droits (capability) — adaptez à votre cas
	// Pour un avatar perso, on exige au minimum upload_files.
	if ( ! current_user_can('upload_files') ) {
		wp_die('Droits insuffisants.', 403);
	}

	// 3) Vérifier que le fichier est présent
	if ( empty($_FILES['avatar']) || ! is_array($_FILES['avatar']) ) {
		wp_die('Aucun fichier reçu.', 400);
	}

	$file = $_FILES['avatar'];

	// 4) Garde-fous basiques sur l'upload PHP
	if ( ! isset($file['error']) || is_array($file['error']) ) {
		wp_die('Upload invalide.', 400);
	}

	if ( (int) $file['error'] !== UPLOAD_ERR_OK ) {
		wp_die('Erreur upload (code ' . (int) $file['error'] . ').', 400);
	}

	// 5) Limite de taille (ex: 2 Mo) — ajustez selon vos besoins
	$max_bytes = 2 * 1024 * 1024;
	if ( isset($file['size']) && (int) $file['size'] > $max_bytes ) {
		wp_die('Fichier trop volumineux.', 413);
	}

	// 6) Validation extension + mime via WP (ne pas faire confiance à $_FILES['type'])
	// Liste blanche stricte
	$allowed_mimes = array(
		'jpg|jpeg' => 'image/jpeg',
		'png'      => 'image/png',
		'gif'      => 'image/gif',
		'webp'     => 'image/webp',
	);

	// wp_check_filetype_and_ext() inspecte le fichier et recoupe extension/mime.
	$check = wp_check_filetype_and_ext($file['tmp_name'], $file['name'], $allowed_mimes);

	if ( empty($check['ext']) || empty($check['type']) ) {
		mon_log_upload_rejete('type_invalide', $user_id, $file);
		wp_die('Type de fichier refusé.', 415);
	}

	// 7) Upload via l'API WP (gère le renommage, collisions, etc.)
	require_once ABSPATH . 'wp-admin/includes/file.php';

	$overrides = array(
		'test_form' => false, // On ne dépend pas de $_POST['action'] et du referer
		'mimes'     => $allowed_mimes,
	);

	$uploaded = wp_handle_upload($file, $overrides);

	if ( isset($uploaded['error']) ) {
		mon_log_upload_rejete('wp_handle_upload:' . $uploaded['error'], $user_id, $file);
		wp_die('Upload refusé.', 400);
	}

	// 8) Validation "contenu réel" + neutralisation polyglotte :
	// on tente d'ouvrir l'image, puis on la re-encode en PNG (ou JPEG) dans un nouveau fichier.
	$sanitized_path = mon_sanitiser_image_par_reencodage($uploaded['file']);

	if ( is_wp_error($sanitized_path) ) {
		// Supprimer le fichier initial uploadé
		@unlink($uploaded['file']);
		mon_log_upload_rejete('reencodage:' . $sanitized_path->get_error_message(), $user_id, $file);
		wp_die('Image invalide.', 415);
	}

	// On remplace le fichier uploadé par la version re-encodée
	if ( $sanitized_path !== $uploaded['file'] ) {
		@unlink($uploaded['file']);
		$uploaded['file'] = $sanitized_path;

		$uploads = wp_upload_dir();
		$relative = str_replace(trailingslashit($uploads['basedir']), '', $sanitized_path);
		$uploaded['url'] = trailingslashit($uploads['baseurl']) . str_replace(DIRECTORY_SEPARATOR, '/', $relative);
	}

	// 9) Déplacer dans un sous-dossier dédié (optionnel, mais pratique)
	$final = mon_deplacer_dans_dossier_avatars($uploaded['file'], $uploaded['url']);
	if ( is_wp_error($final) ) {
		@unlink($uploaded['file']);
		wp_die('Erreur de stockage.', 500);
	}

	// 10) Enregistrer l'URL en user meta (ou utilisez un champ ACF, etc.)
	update_user_meta($user_id, 'mon_avatar_url', esc_url_raw($final['url']));

	// 11) Redirection sûre
	$redirect = ! empty($_POST['redirect_to']) ? esc_url_raw(wp_unslash($_POST['redirect_to'])) : admin_url('profile.php');
	wp_safe_redirect($redirect);
	exit;
}

/**
 * Re-encode une image pour neutraliser les contenus cachés.
 * Retourne le chemin du fichier final (peut être identique si déjà conforme), ou WP_Error.
 */
function mon_sanitiser_image_par_reencodage(string $path) {
	// Vérification rapide existence
	if ( ! file_exists($path) || ! is_readable($path) ) {
		return new WP_Error('fichier_inaccessible', 'Fichier inaccessible.');
	}

	// Ouvrir via l'éditeur WP (GD/Imagick). Si ça échoue, ce n'est pas une image valide.
	$editor = wp_get_image_editor($path);
	if ( is_wp_error($editor) ) {
		return new WP_Error('image_invalide', 'Impossible de lire l’image.');
	}

	// Option : limiter les dimensions pour éviter les bombes de décompression
	$size = $editor->get_size();
	if ( ! empty($size['width']) && ! empty($size['height']) ) {
		$max_dim = 2048;
		if ( $size['width'] > $max_dim || $size['height'] > $max_dim ) {
			$editor->resize($max_dim, $max_dim, false);
		}
	}

	// Re-encodage dans un nouveau fichier PNG (format "sûr" pour avatar)
	$dest = preg_replace('~.[a-zA-Z0-9]+$~', '', $path) . '-sanitized.png';

	$saved = $editor->save($dest, 'image/png');
	if ( is_wp_error($saved) ) {
		return new WP_Error('save_failed', 'Échec du re-encodage.');
	}

	// Permissions cohérentes avec WP
	@chmod($dest, 0644);

	return $dest;
}

/**
 * Déplace le fichier vers uploads/avatars/ avec un nom non contrôlé par l'utilisateur.
 */
function mon_deplacer_dans_dossier_avatars(string $file_path, string $file_url) {
	$uploads = wp_upload_dir();

	$avatars_dir = trailingslashit($uploads['basedir']) . 'avatars';
	wp_mkdir_p($avatars_dir);

	// Nom généré : évite les collisions, supprime l'influence du nom original
	$hash = wp_hash($file_path . '|' . microtime(true));
	$dest = trailingslashit($avatars_dir) . 'avatar-' . $hash . '.png';

	if ( ! @rename($file_path, $dest) ) {
		// Fallback si rename échoue (cross-filesystem)
		if ( ! @copy($file_path, $dest) ) {
			return new WP_Error('move_failed', 'Déplacement impossible.');
		}
		@unlink($file_path);
	}

	@chmod($dest, 0644);

	$dest_url = trailingslashit($uploads['baseurl']) . 'avatars/' . basename($dest);

	return array(
		'path' => $dest,
		'url'  => $dest_url,
	);
}

/**
 * Log minimal (évitez d'enregistrer le chemin tmp complet si vous le jugez sensible).
 */
function mon_log_upload_rejete(string $reason, int $user_id, array $file) {
	if ( defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
		$name = isset($file['name']) ? (string) $file['name'] : '';
		error_log('[upload_avatar] rejet user_id=' . $user_id . ' reason=' . $reason . ' name=' . $name);
	}
}

Explication “simple” : ce qui change

Vous ne laissez plus le navigateur ou l’utilisateur décider du type de fichier. Vous faites vérifier le fichier par WordPress, puis vous tentez de le lire comme une image. Si l’éditeur d’images n’arrive pas à l’ouvrir, vous jetez le fichier.

Ensuite, vous re-encodez l’image. C’est le point qui élimine énormément de charges cachées : vous ne servez plus “le fichier fourni”, vous servez “une image reconstruite”.

Explication “technique” : pourquoi ces mesures tiennent dans la durée

Compatibilité Divi 5 / Elementor / Avada

Le point d’intégration, c’est le formulaire :

  • Elementor : si vous utilisez “Form” + action personnalisée, faites pointer vers admin-post.php?action=upload_avatar_secure et ajoutez un champ HTML caché pour le nonce (via un shortcode qui rend wp_nonce_field()). Le handler ci-dessus reste identique.
  • Divi 5 : même approche avec un module Form. J’ai souvent vu des formulaires Divi envoyés vers une page “merci” sans nonce. Ajoutez le nonce et gardez le traitement côté serveur dans un plugin.
  • Avada : Fusion Form permet de poster vers une URL. Utilisez admin-post.php et un champ caché nonce, ou encapsulez le formulaire dans un shortcode.

Piège courant : “le builder met en cache le formulaire”. Si vous injectez un nonce statique dans le HTML et que vous cachez la page, vous cassez la validation (ou pire : vous réutilisez un nonce trop longtemps). Solution : excluez la page du cache, ou générez le nonce via un endpoint non caché, ou limitez la mise en cache aux visiteurs non connectés.

Configuration serveur

Votre code peut être propre, mais si le serveur exécute des scripts dans wp-content/uploads, vous gardez un risque résiduel. Je durcis systématiquement ce point.

Apache (.htaccess) : bloquer l’exécution PHP dans uploads

À placer dans wp-content/uploads/.htaccess (créez le fichier s’il n’existe pas). Attention : sur certains hébergements, Apache ignore les .htaccess (Nginx) ou interdit certaines directives. Testez.

# Bloquer l'exécution des scripts dans uploads
<IfModule mod_php.c>
  php_flag engine off
</IfModule>

# Bloquer des extensions dangereuses (défense en profondeur)
<FilesMatch ".(php|phtml|phar|phpd+|cgi|pl|asp|aspx)$">
  Require all denied
</FilesMatch>

# Empêcher l'indexation de répertoire
Options -Indexes

Nginx : location pour uploads

À mettre dans le vhost Nginx (ou un include). Le but : refuser toute exécution de scripts dans /wp-content/uploads/.

location ^~ /wp-content/uploads/ {
  # Ne pas exécuter de scripts
  location ~* .(php|phtml|phar|phpd+|cgi|pl|asp|aspx)$ {
    return 403;
  }

  add_header X-Content-Type-Options "nosniff" always;
  add_header Content-Security-Policy "default-src 'none'" always;
}

wp-config.php : désactiver l’éditeur et limiter la surface

Ce n’est pas spécifique à l’upload, mais en incident réel ça réduit les dégâts post-exploitation.

<?php
// Désactiver l'éditeur de fichiers thème/plugin dans l'admin
define('DISALLOW_FILE_EDIT', true);

// Option "coup de massue" si vous ne déployez jamais via l'admin
// define('DISALLOW_FILE_MODS', true);

Headers HTTP utiles

Les headers ne “réparent” pas un upload vulnérable, mais ils réduisent certaines escalades (MIME sniffing, exécution inattendue côté navigateur).

  • X-Content-Type-Options: nosniff
  • Content-Security-Policy (au minimum sur /uploads si possible)

Si vous gérez les headers via WordPress, vous pouvez ajouter nosniff au niveau global (si votre stack ne le fait pas déjà) :

<?php
add_action('send_headers', function () {
	// Défense en profondeur : évite certains contournements côté navigateur
	header('X-Content-Type-Options: nosniff');
}, 20);

Vérifier si votre site est vulnérable

Je sépare toujours “diagnostic fichier” et “diagnostic code”. L’un sans l’autre vous laisse des angles morts.

Scan rapide des extensions suspectes dans uploads (WP-CLI)

Sur le serveur :

# Adapter le chemin si nécessaire
cd /var/www/html

# Chercher des extensions exécutables courantes dans uploads
find wp-content/uploads -type f ( -iname "*.php" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.php*" ) -print

# Chercher des fichiers récemment modifiés (ex: 7 jours)
find wp-content/uploads -type f -mtime -7 -print

Détecter des fichiers “doubles extensions” et noms bizarres

find wp-content/uploads -type f -regextype posix-extended -regex '.*.(jpg|png|gif|webp).(php|phtml|phar)$' -print

# Caractères non ASCII dans les noms (souvent utilisé pour tromper)
find wp-content/uploads -type f -name "*[! -~]*" -print

Requêtes SQL utiles (pièces jointes)

Si votre upload crée des attachments, vérifiez les MIME anormaux :

wp db query "
SELECT ID, post_title, post_mime_type, guid
FROM wp_posts
WHERE post_type = 'attachment'
AND post_mime_type NOT LIKE 'image/%'
AND post_mime_type NOT IN ('application/pdf','text/plain')
ORDER BY ID DESC
LIMIT 50;
"

Ce n’est pas une preuve d’attaque (vous pouvez avoir des ZIP, DOCX, etc.), mais c’est un bon point de départ.

Ce qu’il faut chercher dans les logs

  • POST répétés vers admin-post.php, admin-ajax.php ou un endpoint REST custom, avec un volume anormal.
  • Réponses 200/302 sur des routes d’upload alors que l’utilisateur n’est pas censé uploader.
  • Accès directs à /wp-content/uploads/... sur des extensions non attendues.
  • Erreurs liées à PHP dans uploads (si vous en voyez, c’est déjà un signal critique).

Tableau de diagnostic (symptômes terrain)

Symptôme Cause probable Vérification Solution
Fichiers .php/.phtml dans uploads Upload non filtré ou plugin vulnérable find wp-content/uploads -iname "*.php*" Supprimer, corriger code, bloquer exécution serveur
Images “cassées” après upload Re-encodage échoue (GD/Imagick manquant), ou fichier non-image Logs PHP + test wp_get_image_editor() Installer Imagick/GD, améliorer message d’erreur, rejeter proprement
Uploads acceptés sans connexion Endpoint public (nopriv) ou REST sans permission_callback Revue des hooks/actions REST Exiger auth + capability + nonce
Fichiers renommés “bizarrement” Collision, filtre sanitize_file_name, ou code custom Comparer nom original vs nom final Générer un nom serveur (hash), stocker le nom original séparément si besoin
Après correction, le bug “persiste” Cache (page, objet, CDN) servant un ancien formulaire/nonce Tester sans cache, purger CDN Exclure page, régénérer nonces, purger caches

Erreurs de sécurité fréquentes

Erreur Risque Solution
Valider uniquement l’extension (.jpg) Upload de contenu non-image, polyglotte, contournement wp_check_filetype_and_ext() + validation contenu (éditeur d’image)
Faire confiance à $_FILES['type'] MIME falsifié Détecter côté serveur via WP + libs image
Déplacer le fichier avec move_uploaded_file() dans un chemin web-accessible Exposition directe, exécution si mauvaise config serveur wp_handle_upload() + blocage exécution dans uploads
Endpoint REST sans permission_callback strict Upload anonyme ou par rôles faibles Exiger auth + capability + validation nonce si applicable
Oublier le nonce (ou le rendre statique via cache) CSRF, contournement des intentions utilisateur wp_nonce_field(), exclure du cache, ou endpoint nonce dynamique
Copier le code au mauvais endroit (thème au lieu de plugin) Perte du patch au prochain déploiement, régression Mettre en plugin ou mu-plugin, versionner
Oublier un point-virgule / parenthèse dans un snippet Fatal error, site indisponible (DoS involontaire) Tester en staging, activer WP_DEBUG en préprod
Utiliser un hook inadapté (ex: traiter l’upload sur init) Bypass de contrôles, exécution trop tôt/trop tard Utiliser admin_post_*, REST, ou AJAX dédiés
Tester directement en production sans sauvegarde Perte de données, rollback compliqué Staging + sauvegarde + plan de rollback
PHP trop ancien sur un serveur “hérité” Comportements inattendus, libs image cassées, sécurité dégradée Vérifier version PHP Monter à PHP 8.1+ (recommandé), idéalement plus récent si possible
Suivre un ancien tutoriel incompatible WordPress 6.9.x Fonctions obsolètes, patterns dangereux Audit du code, tests Revenir aux API core actuelles et aux pratiques 6.9.4+

Checklist de durcissement

  • Endpoints d’upload : capability + nonce + rate limiting.
  • Whitelist MIME/extension via wp_check_filetype_and_ext().
  • Pour les images : re-encodage (GD/Imagick) + limite dimensions.
  • Nom final côté serveur (hash/UUID), jamais le nom utilisateur.
  • Permissions fichiers : typiquement 0644 (fichiers) et 0755 (dossiers).
  • Bloquer l’exécution de scripts dans wp-content/uploads (Apache/Nginx).
  • Surveiller uploads : alertes sur nouvelles extensions, fichiers récents.
  • Limiter les types uploadables au strict besoin (évitez “tout fichier”).
  • Mettre à jour WordPress, thème, plugins ; supprimer plugins inactifs.
  • Activer des sauvegardes externes + tests de restauration.

Que faire si le site est déjà compromis ?

  1. Isoler : mettez le site en maintenance (ou restreignez par IP) pour stopper l’hémorragie. Si vous suspectez une exécution active, coupez temporairement l’accès public.
  2. Sauvegarder pour analyse : faites une copie des fichiers et de la base (snapshot). Ne “nettoyez” pas avant d’avoir une preuve, sinon vous perdez la chronologie.
  3. Identifier le point d’entrée : cherchez un endpoint d’upload, un plugin récemment ajouté, un formulaire builder, un handler admin_post_nopriv, ou un route REST trop permissive.
  4. Supprimer les artefacts évidents : fichiers exécutables dans uploads, fichiers récemment modifiés dans wp-content, mu-plugins inconnus. Faites-le en comparant avec une source saine (repo Git, zip officiel).
  5. Réinstaller le cœur : remplacez les fichiers WordPress par ceux de la version 6.9.4 (ou plus récente) depuis wordpress.org/download. Ne touchez pas à wp-config.php sans raison.
  6. Réinstaller les plugins/thèmes depuis des sources officielles : supprimez puis réinstallez. Les fichiers “modifiés” dans un plugin sont un classique de persistance.
  7. Rotation des secrets : changez mots de passe (WP, FTP/SSH, DB), clés SALT, tokens API. Les SALT se régénèrent (puis déconnexion globale) : developer.wordpress.org — Security keys.
  8. Auditer les comptes : supprimez les admins inconnus, vérifiez les emails, forcez reset mots de passe, vérifiez les sessions.
  9. Vérifier la base : cherchez des injections dans wp_options (autoload), des cron suspects, des URLs de redirection. Exemple WP-CLI :
# Cron events
wp cron event list --fields=hook,next_run,recurrence

# Options autoload lourdes ou suspectes
wp db query "SELECT option_name, LENGTH(option_value) AS bytes FROM wp_options WHERE autoload='yes' ORDER BY bytes DESC LIMIT 30;"
  1. Fermer la faille : patcher le code d’upload, verrouiller serveur, déployer WAF si possible.
  2. Contrôler après remise en ligne : surveillez logs 48-72h, mettez des alertes sur changements de fichiers.

Ne sous-estimez pas la persistance : si l’attaquant a eu exécution, il peut avoir déposé plusieurs portes. J’ai souvent retrouvé des backdoors dans des images, des fichiers de cache, ou des “must-use plugins” discrets.

Conseils de maintenance et compatibilité

Sur WordPress 6.9.4, vous avez intérêt à traiter l’upload comme un “service” isolé plutôt qu’un bout de code dispersé. Sur des stacks complexes (Elementor + plugins marketing + CDN), la cohérence est votre meilleure défense.

Pattern “service” (DI légère) pour centraliser la politique d’upload

Si vous avez plusieurs formulaires (avatar, documents, médias privés), centralisez la whitelist et la validation.

<?php
// Exemple minimaliste : une classe service sans framework.
final class Mon_Upload_Policy {

	public function allowed_mimes(): array {
		return array(
			'jpg|jpeg' => 'image/jpeg',
			'png'      => 'image/png',
			'webp'     => 'image/webp',
		);
	}

	public function max_bytes(): int {
		return 2 * 1024 * 1024;
	}

	public function validate_tmp_file(string $tmp, string $name): array {
		$check = wp_check_filetype_and_ext($tmp, $name, $this->allowed_mimes());
		if ( empty($check['ext']) || empty($check['type']) ) {
			return array('ok' => false, 'message' => 'Type refusé.');
		}
		return array('ok' => true, 'message' => '');
	}
}

Ça évite le patch “à moitié” : je vois souvent un site où l’upload avatar est patché, mais l’upload “pièce jointe formulaire” (builder) ne l’est pas.

Impact perf/SEO

  • Le re-encodage coûte du CPU. Sur des sites à fort trafic, ajoutez du rate limiting et évitez de re-encoder des images déjà optimisées si vous contrôlez la source (mais gardez une validation forte).
  • Si vous servez des avatars, pensez au cache CDN, mais ne cachez pas vos nonces.
  • Évitez de stocker des fichiers utilisateurs non nécessaires : moins de surface, moins de coûts.

Back-compat et régressions

Deux sources de régression reviennent :

  • Un plugin de sécurité/caching qui modifie les headers ou la gestion des uploads.
  • Un hébergement qui change de stack (Apache → Nginx) et rend vos .htaccess inutiles.

Après toute migration, testez explicitement : upload autorisé (fichier valide), upload refusé (mauvais type), upload refusé (trop gros), et vérifiez qu’aucun fichier exécutable ne peut être servi depuis uploads.

Ressources

FAQ

Est-ce que WordPress “protège déjà” contre les mauvais fichiers ?

Partiellement. Si vous utilisez wp_handle_upload() et laissez WordPress vérifier les types, vous avez une base solide. Le problème, c’est le code custom qui contourne ces fonctions, et les cas limites (polyglottes, mauvaises configs serveur).

Pourquoi re-encoder l’image au lieu de juste vérifier le MIME ?

Parce que la vérification MIME/extension ne garantit pas que le fichier n’embarque pas autre chose. Le re-encodage reconstruit une image propre à partir des pixels décodés, ce qui neutralise beaucoup de contenus cachés.

Le re-encodage suffit-il à 100% ?

Non. C’est une défense très efficace, mais vous devez garder : whitelist stricte, permissions, blocage exécution dans uploads, et contrôle d’accès sur l’endpoint.

Puis-je autoriser les PDF en upload “sécurisé” ?

Oui, mais ne les traitez pas comme des images. Validez via wp_check_filetype_and_ext(), limitez la taille, stockez avec un nom serveur, et servez-les avec des headers adaptés. Et si c’est “privé”, ne les laissez pas accessibles publiquement via une URL devinable.

Que faire si mon formulaire est géré par Elementor/Divi/Avada et que je ne contrôle pas l’upload ?

Deux options : (1) remplacer l’upload du builder par un endpoint custom (recommandé), ou (2) auditer/mettre à jour le module d’upload et ajouter un WAF + règles serveur. Dans les dépannages, je finis souvent par revenir à un handler custom pour reprendre le contrôle.

Dois-je utiliser la Media Library (attachments) pour un avatar ?

Pas obligatoire. Pour un avatar, stocker un fichier dans uploads/avatars et enregistrer l’URL en user meta est souvent suffisant. Si vous avez besoin de métadonnées, de tailles générées, ou d’un workflow éditorial, alors oui, utilisez wp_insert_attachment().

Comment éviter qu’un attaquant spamme mon endpoint d’upload ?

Exigez une session (ou une auth forte), ajoutez du rate limiting (au niveau reverse proxy/WAF si possible), limitez la taille et mettez des quotas. Les nonces ne suffisent pas contre un attaquant authentifié.

J’ai bloqué .php dans uploads, suis-je tranquille ?

Vous avez réduit un gros risque, mais non. Il reste : contournements via autres extensions, mauvaises configs, vulnérabilités de lecture/SSRF, et l’impact “stockage” (ex: PDF malicieux). Bloquer l’exécution est indispensable, pas suffisant.

Pourquoi mon upload marche en local mais pas en prod ?

Souvent : limites PHP (upload_max_filesize, post_max_size), absence d’Imagick/GD, permissions filesystem, ou cache qui sert un nonce périmé. Vérifiez aussi que vous appelez wp_handle_upload() après avoir inclus wp-admin/includes/file.php.

Quel est le piège le plus fréquent quand on patch un upload ?

Patch partiel : vous sécurisez un formulaire, mais un autre endpoint (REST/AJAX/builder) reste ouvert. Centralisez la politique d’upload et faites un inventaire de tous les points d’entrée.