Elementor Pro
Unauthenticated Arbitrary File Upload to Remote Code Execution
This blog post is about an unauthenticated arbitrary file upload vulnerability in the Elementor Pro plugin that leads to remote code execution. The flaw lives in the Forms module’s File Upload field, where the extension check and the file-move step run in two separate loops with different handling of empty file entries. By submitting two file parts for the same field, an unauthenticated attacker skips the extension blocklist entirely and writes a PHP file into a public directory. Patchstack has issued mitigation rules to protect against exploitation of this vulnerability.
This vulnerability was discovered and reported to Patchstack by Tin Pham aka TF1T.
✌️ Our users are protected from this vulnerability. Are yours?
Identify vulnerabilities in your plugins and get recommendations for fixes.
Request auditProtect your users, improve server health and earn additional revenue.
Patchstack for hostsAbout the Elementor Pro plugin
Elementor Pro is the premium extension of Elementor, one of the most widely used WordPress page builders. Among many other features, it adds a Forms widget that lets site owners build contact, job-application, and support-ticket forms directly in the editor, including a File Upload field so visitors can attach documents.
The security vulnerability
In version 4.2.1 and below, the plugin’s Forms module handles an uploaded file in two independent steps: it validates the file’s extension in one loop, and it moves the uploaded file to a public directory in a second loop. These two loops treat empty file entries differently, and that mismatch is the whole bug.
Two loops, two different rules
The File Upload form field is implemented in modules/forms/fields/upload.php. When a form is submitted, the field’s validation() method walks over the submitted file entries and checks each one’s extension against both an allowed list and a blocklist of disallowed types. Separately, process_field() walks over the same entries and moves each valid upload into the public forms directory.
The problem is that these two loops disagree about what to do with an empty file entry (an upload part whose filename is blank, which PHP reports as UPLOAD_ERR_NO_FILE). The validation loop and the processing loop have different early-exit logic for these empty entries, so a carefully shaped multi-part upload can be seen one way by the validator and another way by the mover.
The intended defense is an extension check. Each uploaded file is validated by is_file_type_valid(), which both requires the extension to be in an allowed list and rejects anything on a hardcoded blocklist:
// modules/forms/fields/upload.php - is_file_type_valid()
$file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$file_types_meta = explode( ',', $field['file_types'] );
$file_types_meta = array_map( 'trim', $file_types_meta );
$file_types_meta = array_map( 'strtolower', $file_types_meta );
$file_extension = strtolower( $file_extension );
return ( in_array( $file_extension, $file_types_meta ) && ! in_array( $file_extension, $this->get_blacklist_file_ext() ) );
The blocklist explicitly covers PHP and other executable extensions, so a straightforward .php upload is normally rejected:
// modules/forms/fields/upload.php - get_blacklist_file_ext()
$blacklist = [
'php', 'php3', 'php4', 'php5', 'php6', 'phps', 'php7',
'phtml', 'shtml', 'pht', 'swf', 'html', 'asp', 'aspx',
'cmd', 'csh', 'bat', 'htm', 'hta', 'jar', 'exe', 'com',
// ...
];
That check is sound. It simply never runs. Putting the two loops side by side shows why – the distance between a working defense and an unauthenticated RCE is one keyword:
// modules/forms/fields/upload.php - validation()
foreach ( $files[$id] as $index => $file ) {
// not uploaded
if ( ! $field['required'] && UPLOAD_ERR_NO_FILE === $file['error'] ) {
return; // <-- leaves the whole method; every later entry goes unchecked
}
// ...
if ( ! $this->is_file_type_valid( $field, $file ) ) {
$ajax_handler->add_error( $id, esc_html__( 'This file type is not allowed.', 'elementor-pro' ) );
}
}
// modules/forms/fields/upload.php - process_field()
foreach ( $files[$id] as $index => $file ) {
if ( UPLOAD_ERR_NO_FILE === $file['error'] ) {
continue; // <-- skips only this entry; the loop carries on
}
// ... the file is moved into the public uploads directory
}
An empty first entry makes validation() return before it ever type-checks the .php entry that follows it. process_field(), meanwhile, only continues past that empty entry and moves the .php one anyway. The validator reports a clean submission because it stopped reading; the mover proceeds because it did not.
The move step is the sink. It builds the destination name from PHP’s uniqid(), keeps the attacker’s extension, and moves the file into the public forms directory:
// modules/forms/fields/upload.php - process_field()
$file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$uploads_dir = $this->get_ensure_upload_dir();
$filename = uniqid() . '.' . $file_extension;
$filename = wp_unique_filename( $uploads_dir, $filename );
$new_file = trailingslashit( $uploads_dir ) . $filename;
$move_new_file = Plugin::instance()->php_api->move_uploaded_file( $file['tmp_name'], $new_file );
Note that the submitted filename is discarded entirely – only the extension survives into the name the file is stored under. That is why the extension is the whole game here. A classic double-extension attempt like shell.php.jpg is harmless, because it is saved as <uniqid>.jpg, and an uploaded .htaccess is equally inert, because it becomes <uniqid>.htaccess rather than a directory config file. The only thing that matters is getting the final extension past the check, and the loop mismatch does exactly that.
By submitting two file parts for the same field, an empty first entry ([0], blank filename) followed by the .php payload ([1]), the extension blocklist check is skipped for the second entry while the move step still processes it. The .php file lands in wp-content/uploads/elementor/forms/, a public, web-accessible directory, letting a fully unauthenticated visitor place executable PHP on the server and, by requesting the file directly, achieve remote code execution.
The attack surface
The only prerequisite is that the target site has at least one published Elementor page containing a Form widget with a File Upload field. This is an extremely common, everyday configuration: job-application forms, “attach a photo/ID/receipt” forms, and support-ticket attachments all use it. The field’s “Required” toggle being off is its default state, so no hardened or unusual setting is needed.
Every value the request needs is visible in the public page HTML to any unauthenticated visitor: the post_id, the form_id (Elementor’s internal element id for the Form widget), and the upload field’s form_fields[{FIELD_ID}] input name. The upload is handled through the elementor_pro_forms_send_form AJAX action with no cookies and no nonce required.
The uploaded file is written as wp-content/uploads/elementor/forms/<uniqid>.php, where <uniqid> is the output of PHP’s uniqid() function (13 hex characters: 8 encode the Unix epoch second, 5 encode the microseconds).
Recovering the uploaded filename
The upload response does not return the file path, so on its own the attacker must recover the uniqid() filename. Because uniqid() is not random but time-based, this is very cheap:
- Timing brute-force. The 8 hex “seconds” digits are given directly by the server’s own
Date:response header. Only the 5 hex “microsecond” digits need brute-forcing, and even that space can be narrowed to the request’s own round-trip window by recording the moment right before and after the exploit POST, then falling back to a full-second sweep if needed. - Zero brute-force via email. Elementor Pro’s default form notification template (
[all-fields]) renders every submitted field, including a line with the exact uploaded file’s URL. Where a form also has a second “autoresponder” email action enabled (common on exactly the job-application and support-ticket forms that carry file-upload fields), that email is sent back to the attacker-controlled submitted address, disclosing the precise upload URL with no brute-forcing at all.
What to do
Update Elementor Pro to version 4.2.2 or later. The fix brings the two loops into agreement about what an empty file entry means, so an entry can no longer slip past the validator while still being picked up by the mover. Current versions additionally re-check the extension inside process_field() itself, immediately before the file is moved, so the blocklist now guards the sink directly rather than only the validation pass.
Because this vulnerability is unauthenticated and leaves a file behind on disk, updating closes the hole but does not undo an attempt that already succeeded. Sites that ran a vulnerable version with a public File Upload form should also review wp-content/uploads/elementor/forms/ for anything that is not one of the document or image types their forms actually accept, in particular files ending in .php.
Patchstack customers are protected against this vulnerability.
Conclusion
This is a textbook desynchronization flaw: the code that decides whether an upload is allowed and the code that acts on the upload walk the same data with different rules. Neither loop is wrong on its own, and reading either one in isolation shows nothing alarming – the bug exists only in the gap between them. An empty file part is enough to make the validator and the mover disagree, and that disagreement turns a restricted file-upload field into an unauthenticated remote code execution primitive. The root-cause fix is to have both loops share the exact same view of which entries are real uploads and which are empty, so an entry can never pass the mover without first passing the validator.
Timeline
🤝 You can help us make the Internet a safer place
Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.
Get started for freeProtect your users too! Improve server health and earn added revenue with proactive security.
Patchstack for hostsReport vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.
Learn more

