
A/B Docker lab + PoC for CVE-2026-32475 (Elementor Pro Forms unauthenticated arbitrary file upload -> RCE via validation/move loop desync)
A/B Docker lab that reproduces CVE-2026-32475 (CVSS 9.x, Patchstack; Elementor Pro Forms File Upload field, affected ≤ 4.2.1, fixed 4.2.2 on 2026-08-19; reported by Tin Pham / TF1T). Unauthenticated, no user interaction.
Authorized testing / education only. This lab runs entirely on
127.0.0.1in disposable Docker containers. Do not point the PoC at systems you are not explicitly authorized to test.build.shfetches the Elementor Pro source from a public GPL mirror; no premium code is redistributed in this repository.
modules/forms/fields/upload.php. Two loops iterate the same reshaped
$_FILES['form_fields'][$id] but treat an empty part (UPLOAD_ERR_NO_FILE) differently:
// validation() — line ~269
foreach ( $_FILES['form_fields'][ $id ] as $index => $file ) {
if ( ! $field['required'] && UPLOAD_ERR_NO_FILE === $file['error'] ) {
return; // <-- abandons ALL remaining validation of this field
}
...
if ( ! $this->is_file_type_valid( $field, $file ) ) { // extension allow/deny list
$ajax_handler->add_error( $id, 'This file type is not allowed.' );
}
}
// process_field() — line ~418
foreach ( $_FILES['form_fields'][ $id ] as $index => $file ) {
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // <-- skips ONLY the empty part, keeps going
}
$filename = uniqid() . '.' . pathinfo( $file['name'], PATHINFO_EXTENSION ); // attacker ext, no recheck
move_uploaded_file( $file['tmp_name'], trailingslashit( $uploads_dir ) . $filename );
}
Send one optional upload field with two parts — an empty one first, then the PHP
payload. validation() hits the empty part and returns before ever checking the .php;
process_field() continues past the empty part and moves the .php to
wp-content/uploads/elementor/forms/<uniqid()>.php. The get_blacklist_file_ext() blocklist
(php,phtml,pht,shtml,…) only runs inside validation(), so it is completely bypassed.
Fully unauth: wp_ajax_nopriv_elementor_pro_forms_send_form (ajax-handler.php:295).
get_ensure_upload_dir() drops an .htaccess in the forms dir containing only
Options -Indexes + Header set Content-Disposition attachment. That is a browser download
hint — on Apache + mod_php the .php still executes server-side; the attacker (curl) simply
receives the command output with a download header. The forms module contains no unlink
cleanup, so the shell persists.
A published page with an Elementor Pro Form widget that has a File Upload field whose
Required = No (the ! $field['required'] branch). Field config is read server-side from
_elementor_data, so the attacker cannot toggle it — but "optional attachment" fields are common.
Stored name = uniqid() = %08x%05x = (unix-second)(microsecond). The second is leaked
exactly by the HTTP Date response header; only the microsecond (0–999999) is unknown →
a bounded ≤10⁶ online GET brute. poc.py --recover anchors the microsecond on the response
arrival time (co-located / NTP-synced ⇒ seconds–minutes; here: ~26k requests) and turns the
write into remote code execution.
| Service | Port | Elementor Pro |
|---|---|---|
wp-vuln | http://127.0.0.1:8975 | 3.6.4 — vulnerable (authentic tree; validation() desync identical to ≤4.2.1) |
wp-patched | http://127.0.0.1:8976 | 3.6.4 with the 4.2.2-equivalent fix (return → continue, aligning the loops) |
Stack: wordpress:php7.4-apache (mod_php) + MariaDB 10.6 + Elementor (free) 3.6.8 + Elementor Pro 3.6.4.
Each variant has an admin (admin/labpass) and a published page "CVE-2026-32475 Lab"
(post_id=5, form_id=frm00001, upload field field_cv, Required=No).
Version note. No clean 4.2.1/4.2.2 source is publicly redistributable, so the lab runs the authentic 3.6.4 tree, whose
validation()/process_field()desync is byte-identical to the code CVE-2026-32475 describes (the bug is long-latent; 4.2.2 aligned the two loops). The A/B "patched" build applies exactly that alignment.
Requires Docker + Docker Compose and outbound network (to pull images, Elementor free, and the
Elementor Pro source mirror). build.sh clones the authentic Elementor Pro tree automatically.
git clone https://github.com/dinosn/cve-2026-32475-elementor-pro-lab
cd cve-2026-32475-elementor-pro-lab
bash build.sh # clone plugin + compose up + install WP/Elementor + create form page (both variants)
bash verify.sh # A/B proof: vuln writes+executes a shell; patched rejects. Cleans up.
# fully-remote unauthenticated RCE (auto-extracts post_id/form_id/field_id from the page):
python3 poc.py -t http://127.0.0.1:8975 --page-id 5 --recover -c "id; uname -a"
# just prove the blocklist bypass (leave the shell for inspection):
python3 poc.py -t http://127.0.0.1:8975 --post-id 5 --form-id frm00001 --field-id field_cv
docker-compose.yml — 2× (WordPress + MariaDB), vuln:8975 / patched:8976elementor-pro-vuln/ — authentic Elementor Pro 3.6.4 (vulnerable); generated by build.sh, git-ignoredelementor-pro-patched/ — same tree, validation() return→continue; generated by patch_pro.sh, git-ignoredevidence/verify_transcript.txt — a captured passing verify.sh runinstall_wp.sh — wp-cli install of WP + Elementor free + activate Pro + create form page + fix uploads permssetup_page.php — builds the Elementor form page with an optional upload fieldpatch_pro.sh — derives the patched tree + prints the one-line diffpoc.py — unauth desync upload + uniqid() filename recovery + RCEverify.sh — A/B proof with an independent filesystem oracle (proves execution, not just write)build.sh — one-shot buildcd /root/cve-2026-32475-lab && docker compose down -v