README.md
Rendering markdown...
# Technical Review: CVE-2026-48866 PoC
```
┌─────────────────────────────────────────────────────────────────────────┐
│ CVE-2026-48866 │
│ Gravity Forms <= 2.10.0.1 — Path Traversal Arbitrary File Deletion │
│ │
│ CVSS 3.1: 9.6 CRITICAL AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H │
│ CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted │
│ Directory — Path Traversal) │
│ CNA: Patchstack │
│ Reported: 2026-04-29 by daroo │
│ Published: 2026-06-01 │
│ Fixed: Gravity Forms 2.10.1 │
└─────────────────────────────────────────────────────────────────────────┘
```
| Reference | URL | Status |
|---|---|---|
| NVD | https://nvd.nist.gov/vuln/detail/CVE-2026-48866 | Verified |
| Patchstack Advisory | https://patchstack.com/database/wordpress/plugin/gravityforms/vulnerability/wordpress-gravity-forms-plugin-2-10-0-1-arbitrary-file-deletion-vulnerability | Verified |
| Gravity Forms Changelog | https://docs.gravityforms.com/gravityforms-change-log/ | Verified |
| Source Code (Mirror) | https://github.com/codewurker/gravityforms | Verified |
| Vulnerable Commit (v2.10.0) | https://github.com/codewurker/gravityforms/commit/86bf7b9ecf14fd4a826a741a1ae180040589ebed | Verified |
| Patched Commit (v2.10.1) | https://github.com/codewurker/gravityforms/commit/cf2ff65133d581cfed1c308adc1621c3af1f8422 | Verified |
| CWE-22 | https://cwe.mitre.org/data/definitions/22.html | Verified |
| MITRE CVE | https://www.cve.org/CVERecord?id=CVE-2026-48866 | Not yet populated |
---
## VALIDITY
**Valid.**
Source code comparison between v2.10.0 ([`86bf7b9`](https://github.com/codewurker/gravityforms/commit/86bf7b9ecf14fd4a826a741a1ae180040589ebed)) and v2.10.1 ([`cf2ff65`](https://github.com/codewurker/gravityforms/commit/cf2ff65133d581cfed1c308adc1621c3af1f8422)) confirms the vulnerability:
1. `delete_physical_file()` in v2.10.0 calls `unlink()` without verifying the resolved path is within the uploads directory.
2. `get_physical_file_path()` uses `str_replace()` to convert URL base to filesystem path, preserving `../` sequences.
3. `get_multifile_value()` and `get_single_file_value()` validate URLs with `GFCommon::is_valid_url()`, which performs format-only validation. `../` passes.
4. The patch in v2.10.1 adds `GFCommon::is_file_in_uploads()` and `GFCommon::get_absolute_path()`, confirming the developers recognized and fixed this exact issue.
The PoC logic follows the correct architecture. Implementation defects reduce its reliability.
---
## CVSS 3.1 Vector Breakdown
Each finding below ties a specific PoC defect to the CVSS metric it undermines.
```
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
AV:N Network — PoC correctly targets admin-ajax.php over HTTP
AC:L Low complexity — PoC correctly requires only form ID + field ID
PR:N No privileges — PoC correctly uses nopriv_gform_submit_form for injection
UI:R User interaction — PoC correctly models admin deletion as the trigger
S:C Changed scope — PoC does not verify scope change (deletion → RCE chain)
C:H Confidentiality — PoC does not test data exfiltration paths
I:H Integrity — PoC targets wp-config.php deletion (integrity destruction)
A:H Availability — PoC verifies availability loss via HTTP health check
```
---
## FINDINGS
### [F-01] Hardcoded form ID prevents exploitation of CVSS 9.6 Critical vuln for most targets
**Severity:** Critical
**CVSS Metric Affected:** AC:L (Attack Complexity: Low)
**Location:** `poc.py:220`
**Description:** The entry detail URL hardcodes `&id=1` regardless of the `--form-id` argument. WordPress sites with Gravity Forms forms having ID != 1 (the majority of real-world deployments, since form IDs increment across all forms) will fail without feedback. The nonce extraction returns nothing, and the code reports "Could not find delete nonce." This converts AC:L to AC:H: the attacker must know the form ID is exactly 1, or manually edit the code.
**Evidence:**
```python
# poc.py:220
entry_detail_url = f'{target}/wp-admin/admin.php?page=gf_entries&view=entry&id=1&lid={latest_entry_id}'
# ^^^^
# hardcoded, ignores --form-id
```
Gravity Forms accepts `--form-id` at line 297 but never passes it to `trigger_deletion()`. The function signature at line 172 omits `form_id`:
```python
def trigger_deletion(session, target, admin_user, admin_pass): # no form_id
```
**Fix:**
```python
def trigger_deletion(session, target, admin_user, admin_pass, form_id):
# ...
entry_detail_url = f'{target}/wp-admin/admin.php?page=gf_entries&view=entry&id={form_id}&lid={latest_entry_id}'
```
---
### [F-02] False positive on unclear response inflates AC:L assumption
**Severity:** Critical
**CVSS Metric Affected:** AC:L (Attack Complexity: Low)
**Location:** `poc.py:157-161`
**Description:** When the server returns HTTP 200 but the body contains none of the known success indicators (`gformRedirect`, `confirmation`, `thank`) or failure indicators (`validation_error`, `validation_message`), the function returns `True`. The exploit proceeds to Phase 2 assuming the poisoned entry exists. Many Gravity Forms configurations return generic HTML that matches none of these patterns: custom confirmation messages, external URL redirects, AJAX disabled, WAF challenge pages returning HTTP 200.
CVSS assumes AC:L: the attacker submits the payload and it works. This defect converts the attack from deterministic to probabilistic.
**Evidence:**
```python
# poc.py:157-161
else:
print(f'[?] Unclear response. Check manually.')
print(f' Response snippet: {resp.text[:500]}')
# May still have worked
return True
```
**Fix:**
```python
else:
print(f'[-] Unclear response. Cannot confirm entry creation.')
print(f' Response snippet: {resp.text[:500]}')
return False
```
---
### [F-03] Deletion targets latest entry, not the poisoned entry
**Severity:** High
**CVSS Metric Affected:** UI:R (User Interaction: Required), trigger phase reliability
**Location:** `poc.py:216`
**Description:** Phase 2 grabs the first entry ID from the entries list and deletes it. This may not be the poisoned entry. If the site has multiple forms, if entries were created between injection and deletion, or if the entries page orders by a different column, the wrong entry gets deleted. The poisoned entry persists in the database, and the target file survives.
The CVSS vector models UI:R as "a privileged user must perform an action." The PoC models this as automated admin login + delete. If the automation deletes the wrong entry, the attack fails to satisfy UI:R for the poisoned entry.
**Evidence:**
```python
# poc.py:216
latest_entry_id = entry_ids[0] # assumes first entry is the poisoned one
```
No filtering by form ID, no verification that the entry contains the malicious URL.
**Fix:** Parse the injection response for the created entry ID, or filter entries by form ID and verify the entry field value contains the traversal URL before deleting.
---
### [F-04] Verification only detects wp-config.php deletion
**Severity:** High
**CVSS Metric Affected:** A:H (Availability: High), ability to confirm impact
**Location:** `poc.py:258-274`
**Description:** `verify_file_exists()` sends an HTTP GET and checks for HTTP 500 or "error establishing a database connection." This only detects `wp-config.php` deletion. For `.htaccess`, plugin files, theme files, or any other target, the function returns `True` (site responding normally) regardless of whether the file was deleted. The operator receives false confirmation that the exploit failed.
The CVSS vector rates A:H because deleting critical files renders the site non-functional. The PoC can only confirm this for one specific file.
**Evidence:**
```python
# poc.py:263-264
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, or different file was targeted)')
return True
```
**Fix:**
```python
if target_file != 'wp-config.php':
print(f'[!] Cannot verify deletion of {target_file} without server-side access.')
return None
```
---
### [F-05] Nonce extraction may match wrong nonce, causing deletion to fail without feedback
**Severity:** High
**CVSS Metric Affected:** UI:R (User Interaction: Required), trigger phase reliability
**Location:** `poc.py:225-236`
**Description:** The delete nonce extraction uses `_wpnonce=([a-f0-9]+).*?delete`, which matches any nonce appearing before the word "delete" in the HTML. WordPress generates different nonces for different actions (edit, delete, bulk delete, duplicate). The matched nonce may target a different action. The delete POST then fails with a nonce mismatch. The code does not detect this because it checks only HTTP status code, not the response body.
**Evidence:**
```python
# poc.py:226
nonce_match = re.search(r'_wpnonce=([a-f0-9]+).*?delete', resp.text)
```
The fallback at line 233 has the same weakness:
```python
nonce_match = re.search(r'name="_wpnonce"\s+value="([a-f0-9]+)"', resp.text)
```
**Fix:** Gravity Forms uses specific nonce actions. Extract the nonce from the delete-specific link:
```python
nonce_match = re.search(r'page=gf_entries.*?delete.*?_wpnonce=([a-f0-9]+)', resp.text)
```
---
### [F-06] No confirmation that poisoned entry was created
**Severity:** High
**CVSS Metric Affected:** AC:L (Attack Complexity: Low), injection reliability
**Location:** `poc.py:101-169`
**Description:** After Phase 1, the code cannot confirm the malicious URL reached the database. If the form has validation rules that reject the payload (required file upload field that expects `$_FILES` data, honeypot field, reCAPTCHA, custom validation via `gform_validation` filter), Gravity Forms discards the submission. Phase 2 then deletes a pre-existing entry, and the operator believes the exploit succeeded.
**Evidence:**
```python
# poc.py:150-152
if 'gformRedirect' in resp.text or 'confirmation' in resp.text.lower() or 'thank' in resp.text.lower():
print(f'[+] Form submitted successfully! Malicious URL should be stored in entry.')
return True
```
"Should be stored" is not confirmation. The CVSS vector assumes the injection succeeds (AC:L). Without confirmation, the attacker cannot distinguish success from failure.
**Fix:** Parse the Gravity Forms AJAX response for entry metadata. The response often includes `entry_id` in JSON or HTML. Use that ID to verify the entry exists and contains the traversal URL before proceeding to Phase 2.
---
### [F-07] RCE escalation chain prerequisites are unstated
**Severity:** Medium
**CVSS Metric Affected:** S:C (Scope: Changed). The CVSS vector models scope change from availability loss to code execution.
**Location:** `README.md:209-224`
**Description:** The "Deletion → RCE Escalation Chain" diagram claims deleting `wp-config.php` leads to "full admin access to site (RCE via themes/plugin editor)." The CVSS vector rates S:C (changed scope) and C:H (confidentiality impact), which implies the vulnerability affects systems beyond the vulnerable component. The escalation chain requires three prerequisites the README omits:
1. The attacker must control a reachable MySQL server.
2. The WordPress installer wizard must be accessible (not blocked by hosting provider, WAF, or IP restriction).
3. The attacker creates a new, empty WordPress installation on their own database. They do not gain access to the existing site's data, users, or content.
The attacker can install themes/plugins via the new admin panel. "Full site takeover" overstates what the attacker controls.
**Evidence:**
```
│ Delete │ │ WordPress shows │ │ Attacker sets │
│ wp-config.php│ ──── │ installer wizard │ ──── │ own DB creds │
└─────────────┘ └──────────────────┘ └────────┬────────┘
▼
┌─────────────────┐
│ Full admin │
│ access to site │
│ (RCE via themes │
│ /plugin editor)│
└─────────────────┘
```
**Fix:** Add prerequisites:
```
**RCE chain requires:**
- Attacker-controlled reachable MySQL server
- WordPress installer accessible (not blocked by hosting/WAF)
- Result: attacker gets a fresh WordPress install on the same domain,
NOT access to existing site data
```
---
### [F-08] Traversal depth assumes default Gravity Forms upload path
**Severity:** Medium
**CVSS Metric Affected:** AC:L (Attack Complexity: Low). Reduces to AC:H for non-default configs.
**Location:** `poc.py:76-85`
**Description:** The default depth of 3 and the hardcoded URL prefix assume the uploads path is `{wp_root}/wp-content/uploads/gravity_forms/`. Sites using custom upload paths, multisite configurations (`BLOGUPLOADDIR`), or plugins that relocate uploads will have different directory depths. The exploit fails without feedback: the `../` sequences either don't escape far enough (file not found) or escape too far (deletes the wrong file).
In multisite mode, `get_physical_file_path()` uses `preg_replace("|^(.*?)/files/gravity_forms/|", BLOGUPLOADDIR . 'gravity_forms/', $url)` instead of `str_replace()`, which changes the base path entirely.
**Evidence:**
```python
# poc.py:85
malicious_url = f'{target}/wp-content/uploads/gravity_forms/{traversal}{target_file}'
```
No option to specify a custom upload URL. No auto-detection of the actual upload root.
**Fix:**
```python
parser.add_argument('--upload-url', help='Full upload URL root (auto-detected if omitted)')
```
```python
if upload_url:
malicious_url = f'{upload_url}/{traversal}{target_file}'
else:
malicious_url = f'{target}/wp-content/uploads/gravity_forms/{traversal}{target_file}'
```
---
### [F-09] REST API fallback has unverified authentication state
**Severity:** Low
**CVSS Metric Affected:** UI:R (User Interaction: Required), trigger reliability
**Location:** `poc.py:202-210`
**Description:** When the entries page returns no entry IDs, the code falls back to the Gravity Forms REST API (`/wp-json/gf/v2/entries`). This endpoint requires authentication and checks `gravityforms_view_entries` capability. The code does not verify the admin session has REST API access. If the endpoint returns 401 or 403, the code catches the exception without reporting the HTTP status and reports "No entries found."
**Evidence:**
```python
# poc.py:203-210
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
```
**Fix:** Check status code and report:
```python
if api_resp.status_code == 200:
# parse entries
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}')
```
---
## Summary
| ID | Severity | CVSS Metric | Impact on Exploitability |
|---|---|---|---|
| F-01 | Critical | AC:L | Exploit fails for forms with ID != 1 (most targets) |
| F-02 | Critical | AC:L | Cannot confirm injection succeeded; false positives |
| F-03 | High | UI:R | Deletion targets wrong entry; poisoned entry survives |
| F-04 | High | A:H | Cannot verify impact for non-wp-config.php targets |
| F-05 | High | UI:R | Wrong nonce causes silent deletion failure |
| F-06 | High | AC:L | No confirmation payload was stored in database |
| F-07 | Medium | S:C | RCE chain prerequisites unstated in documentation |
| F-08 | Medium | AC:L | Default depth fails for non-default upload paths |
| F-09 | Low | UI:R | REST API fallback fails without feedback on auth errors |
The vulnerability is real. The attack chain follows the correct architecture. F-01 and F-02 prevent reliable exploitation against most real-world targets. F-03, F-05, and F-06 reduce confidence in the trigger phase. The CVSS 9.6 Critical rating reflects the vulnerability's potential. This PoC cannot reliably reach it.